Co JSON.stringify ukrycie pomija, konwertuje i odmawia zserializowania
Dowiedz się, które wartości JavaScript są pomijane lub modyfikowane przez JSON.stringify, jak toJSON, zamienniki i funkcje odwracające to naprawiają oraz kiedy structuredClone jest lepszym narzędziem.
JSON.stringify rzadko zgłasza problemy. Gdy napotka wartość, której nie może przedstawić, zazwyczaj ją pomija lub przekształca w coś innego i kontynuuje pracę, zwracając w pełni poprawny JSON. W ten sposób błędy pojawiają się na kilka kroków od swojej przyczyny: dane znikają podczas serializacji, ale problem ujawnia się później w kodzie, który w ogóle nie wie, że doszło do serializacji. Ten przewodnik opisuje, co ginie, co zmienia typ oraz co powoduje błędy, a następnie przedstawia wbudowane narzędzia (toJSON, zamienniki, funkcje przywracające oraz structuredClone), które umożliwiają świadome kontrolowanie każdego przypadku.
Atrybuty, które znikają bez śladu
Rozważmy obiekt sesji zawierający mieszankę zwykłych danych, funkcję zwrotną, wyraźnie niezdefiniowane pole oraz symbol:
const session = {
userId: 42,
role: "admin",
onExpire: () => console.log("session expired"),
lastActivity: undefined,
tempToken: Symbol("temp"),
};
console.log(JSON.stringify(session));
// {"userId":42,"role":"admin"}
Wynik zawiera tylko dwie z pięciu właściwości. onExpire, lastActivity oraz tempToken zniknęły, bez żadnego wyjątku, ostrzeżenia ani informacji w uzyskanej ciągu znaków wskazującej na usunięcie czegokolwiek. Wewnątrz obiektu funkcja JSON.stringify pomija wszelkie właściwości, których wartość to undefined, funkcja lub Symbol. Żadna z tych wartości nie ma reprezentacji w formacie JSON, więc zamiast zakończyć pracę błędem, serializator zwraca ważny JSON dla tego, co pozostało.
Skutek jest subtelny. Klient, który analizuje tę ciąg znaków i oczekuje obecności lastActivity, nawet jeśli ma wartość undefined, nie znajdzie jej. Klucz nie jest pusty; od początku w ogóle nie trafił do wyniku. Kod, który rozróżnia „brakujący” od „obecny, ale niezdefiniowany”, na przykład za pomocą "lastActivity" in obj lub Object.keys, będzie teraz zachowywał się inaczej po drugiej stronie.
Tablice zachowują się inaczej
Ty same wartości niesprawdzane otrzymują różne traktowanie wewnątrz tablicy:
console.log(JSON.stringify([undefined, function () {}, 1]));
// [null,null,1]
Tutaj stają się null, zamiast zniknąć. Tablica nie może usunąć elementu bez przesunięcia indeksów wszystkich pozostałych elementów, dlatego serializator zachowuje to miejsce i wypełnia je najbliższym odpowiednikiem „niczego” w JSON. Podstawowa przyczyna jest identyczna, jednak występują dwa różne, niewidoczne zachowania w zależności od tego, czy wartość znajdowała się w obiekcie, czy w tablicy. Dwa powiązane przypadki krawędziowe podążają tą samą linią: NaN i Infinity są serializowane jako null, a wywołanie JSON.stringify bezpośrednio na undefined lub funkcji zwraca undefined zamiast ciągu znaków.
Data wracają jako ciągi znaków
Wydaje się, że daty przetrwają całą transmisję, ale tylko w połowie:
const record = { createdAt: new Date() };
const json = JSON.stringify(record);
console.log(json); // {"createdAt":"2026-09-07T14:30:00.000Z"}
const restored = JSON.parse(json);
console.log(restored.createdAt instanceof Date); // false
console.log(typeof restored.createdAt); // "string"
Sama informacja o dacie pozostaje nienaruszona; znajduje się bezpośrednio w wyniku jako ciąg tekstowy w formacie ISO 8601. To, co zostaje utracone, to typ danych. Funkcja JSON.parse nie może stwierdzić, że dany ciąg tekstowy kiedyś reprezentował obiekt typu Date, a nie tylko wygląda jak taki tekst, dlatego zwraca ciąg tekstowy, który pozostaje nim, dopóki coś go ponownie nie przekonwertuje.
To ma znaczenie już wtedy, gdy kod wywołuje metodę związana z datą po całym procesie przetwarzania. Na przykład wyrażenie record.createdAt.getFullYear() powoduje błąd, nie dlatego, że dane są błędne, ale dlatego, że ich typ zmienił się w tajemnicy podczas przetwarzania. Problem ten często występuje przy odpowiedziach API, wartościach przywracanych z localStorage oraz wiadomościach przekazywanych przez kolejki.
Błędy spowodowane strukturami cyklicznymi
Nie każdy błąd zachowuje się cicho. Niektóre obiekty w ogóle nie mogą zostać zserializowane, a silnik języka wyraźnie to komunikuje:
const parent = { name: "parent" };
const child = { name: "child", parent };
parent.child = child;
JSON.stringify(parent); // TypeError: Converting circular structure to JSON
Aby utworzyć wynik, JSON.stringify przemierza drzewo obiektów. Gdy jakiś obiekt w tym drzewie prowadzi z powrotem do jednego ze swoich przodków, to przechodzenie nigdy by się nie zakończyło. Zamiast rekurencyjnie działać bez ograniczeń, silnik wykrywa ten cykl i natychmiast rzuca błąd TypeError. To jeden z nielicznych przypadków, gdy serializator naprawdę zawodzi, i to z dobrego powodu: nie ma możliwości podania częściowej ani przybliżonej odpowiedzi, ponieważ drzewo cykliczne rzeczywiście nie może zostać spłaszczone do drzewa JSON. Wartości BigInt to kolejny wyraźny przypadek; one również powodują błąd TypeError, chyba że sam je przekonwertujesz.
Kontrolowanie wyniku za pomocą toJSON
Część wbudowanych typów sama decyduje o tym, jak powinny wyglądać w formacie JSON, dlatego Date zamienia się w ciąg ISO zamiast pustego obiektu. Każdy obiekt może przyjąć takie samo zachowanie, definiując metodę toJSON. Gdy taka metoda istnieje, JSON.stringify wywołuje ją i serializuje wartość zwróconą przez nią, zamiast właściwości samego obiektu:
class Money {
constructor(cents) {
this.cents = cents;
}
toJSON() {
return { amount: this.cents / 100, currency: "USD" };
}
}
const price = new Money(3499);
console.log(JSON.stringify({ price })); // {"price":{"amount":34.99,"currency":"USD"}}
Bez toJSON instancja Money byłaby zapisywana według swojej wewnętrznej struktury, {"cents":3499}, co ujawniałoby szczegóły implementacji, na których inne części systemu nie powinny polegać. Dzięki temu mechanizmowi obiekt sam określa swoją publiczną reprezentację. Date wykorzystuje dokładnie ten mechanizm: to Date.prototype.toJSON generuje ciąg ISO.
Należy pamiętać, że jest to proces jednokierunkowy. Analiza wyniku daje zwykły obiekt zawierający amount i currency, a nie instancję Money; odbudowa klasy to zadanie narzędzia typu reviver opisanego dalej.
Narzędzia zastępujące i odbudowujące: kształtowanie obu kierunków
JSON.stringify przyjmuje opcjonalny drugi argument – funkcję zastępującą, która jest wywoływana dla każdego klucza i wartości przed ich zapisaniem. Zwrócenie undefined powoduje usunięcie danej pozycji, natomiast zwrócenie jakiejkolwiek innej wartości zastępuje ją. Dzięki temu można w prosty sposób filtrować dane poufne podczas wyjścia:
const user = { id: 1, name: "Priya", passwordHash: "a1b2c3..." };
const safe = JSON.stringify(user, (key, value) => {
return key === "passwordHash" ? undefined : value;
});
console.log(safe); // {"id":1,"name":"Priya"}
JSON.parse oferuje odwrotną funkcjonalność: narzędzie odbudowujące, które jest wywoływane dla każdego klucza i wartości po analizie. Jest to oficjalne rozwiązanie problemu z Date przedstawionego wcześniej:
const restored = JSON.parse(json, (key, value) => {
if (key === "createdAt") return new Date(value);
return value;
});
console.log(restored.createdAt instanceof Date); // true
Warto znać dwa istotne szczegóły. Funkcje odzyskiwania danych działają od dołu do góry, więc wartości wewnątrz struktur są już odzyskiwane podczas przetwarzania ich rodzica. Ponadto sprawdzenie samego nazwy klucza pozwala dopasować go na dowolnej głębokości, więc pole createdAt znajdujące się w jakimś zagnieżdżonym obiekcie również zostanie przekonwertowane; jeśli tego nie chcesz, sprawdź także format wartości.
Żadna z tych kwestii nie stanowi skomplikowanego aspektu API. Są to zamierzone sposoby precyzyjnego kontrolowania serializacji i są znacznie bardziej niezawodne niż usuwanie właściwości przed przekształceniem w ciąg znaków lub ręczne modyfikowanie obiektów po ich parsowaniu.
Map i Set tracą wszystko
Rozwijający, którzy oczekują, że JSON zachowa dowolnego rodzaju obiekty, często napotykają tu problemy:
const tags = new Set(["urgent", "billing"]);
console.log(JSON.stringify({ tags })); // {"tags":{}}
Dla serializatora Set nie jest ani tablicą, ani zwykłym obiektem z wyliczalnymi własnymi właściwościami, dlatego staje się pustym obiektem, a wszystkie wartości, które zawierał, są cicho odrzucane. Map spotyka ten sam los. Jeśli któryś z nich musi przetrwać, należy go wyraźnie przekształcić przed konwertowaniem na ciąg znaków, na przykład poprzez rozszerzenie go do tablicy:
const json = JSON.stringify({ tags: [...tags] }); // convert Set to array before stringifying
Dla Map wyrażenia [...map] lub Object.fromEntries(map) tworzą formy możliwe do serializacji, a mechanizm odwracający proces może odbudować oryginalną kolekcję po powrocie.
Kopie głębokie: użyj structuredClone
Przez lata JSON.parse(JSON.stringify(obj)) był powszechnym sposobem na głębokie klonowanie obiektu. Działa to jedynie przez przypadek i ma wszystkie opisane powyżej ograniczenia: funkcje oraz wartości undefined znikają, daty zamieniają się w łańcuchy tekstowe, kolekcje opróżniają się, a cykliczne odniesienia powodują błędy.
Współczesne przeglądarki i Node.js oferują specjalnie stworzoną alternatywę:
const clone = structuredClone(original);
structuredClone wykonuje prawdziwą kopię głęboką przy użyciu algorytmu strukturalnego klonowania. Zachowuje wartości typu Date, Map i Set, a także radzi sobie z odniesieniami cyklicznymi – wszystko to, co metoda oparta na JSON albo zniekształca, albo odrzuca. Ma jednak swoje ograniczenia: rzutuje błąd DataCloneError w przypadku funkcji i węzłów DOM, a instancje klas wracają jako zwykłe obiekty bez ich prototypu. Gdy celem jest po prostu skopiowanie danych, structuredClone jest niemal zawsze lepszym wyborem. JSON.stringify nigdy nie został zaprojektowany do klonowania; był po prostu na tyle wygodny, że ludzie używali go w tym celu.
Główne wnioski
JSON.stringifyserializuje tylko tę część wartości JavaScript, którą JSON może reprezentować, a nie dowolne wartości JavaScript.
undefined, funkcje i symbole są pomijane; w tablicach stają się one null.BigInt powodują błędy; struktury Map i Set są w tle serializowane do postaci {}.toJSON, do filtrowania wyniku – zamiennika, a do odbudowy typów – funkcji odzyskiwania.structuredClone, gdy chcesz uzyskać kopię, a funkcję JSON.stringify traktuj jako filtr kształtujący format, którego braki musisz bezpośrednio obsłużyć, aby pola, kolekcje i typy nie znikały potajemnie pomiędzy różnymi częściami systemu.Literatura pokrewna
- Poza rozmiarem pakietu: odkrywanie tego, co naprawdę spowalnia twoją aplikację webową — Dlaczego redukcja liczby kilobajtów rzadko naprawia wolną aplikację oraz jak śledzić rzeczywisty czas oczekiwania na serwerach, w procesach typu waterfall, przy wykorzystaniu mechanizmów hydration, skryptów i obrazów od dostawców zewnętrznych.
- Co naprawdę gwarantuje async/await i co zostawia dla ciebie — Zrozumienie tego, co faktycznie wstrzymuje działanie instrukcji await, sposoby unikania zapytań seriizowanych oraz powody, dla których błędy, anulowanie zadań, ustalanie kolejności i próby ponowne wymagają rozwiązań wykraczających poza mechanizm async/await.