Как остановить «уплощение» объектов в JSON
Исправьте уплощение JSON в JavaScript с replacer, reviver, toJSON и context.source, чтобы восстанавливать Date, Map, Set и BigInt.
JSON.stringify преобразует Date, вызывая её метод toJSON, который возвращает строку в формате ISO 8601, а у JSON.parse соответствующего шага нет — поэтому значение возвращается строкой, пока вы сами не преобразуете его с помощью reviver’а.
Обычно это всплывает одинаково: закэшированный объект нормально записывается в localStorage, нормально оттуда достаётся, а затем .getFullYear() выбрасывает ошибку или ячейка таблицы отрисовывает Invalid Date. Данные никогда не были повреждены. Просто где-то между двумя вызовами значение перестало быть Date.
В этой статье разбираются обе половины пути: toJSON и replacer на выходе, reviver на обратном пути, а также третий аргумент reviver’а для значений, которые теряют точность ещё до того, как вы их увидите. Предполагается, что основы вы уже знаете; если хотите начать с них, см. how to read and write JSON in JavaScript. Этот материал начинается со второго аргумента.
Ключевые выводы
JSON.stringifyсериализуетDateчерезtoJSONв ISO-строку, аJSON.parseвозвращает эту строку без изменений, пока reviver не преобразует её обратно.- Если reviver вернёт
undefined, соответствующий ключ исчезнет из результата, поэтому каждому reviver’у нужен завершающийreturn valueдля ключей, которые он не обрабатывает. - Reviver выполняется для каждой пары ключ-значение, дочерние элементы — раньше родительских, а затем ещё раз для всего разобранного значения под ключом
"". MapиSetсериализуются в{}, поэтому их восстановление требует replacer’а и reviver’а, написанных как согласованная пара.- Третий аргумент reviver’а — объект контекста, свойство
sourceкоторого содержит исходный текст JSON, что позволяет прочитать большое целое число какBigIntдо того, какNumberего округлит.
Как выглядит сломанный цикл JSON-преобразования?
const session = { user: "ada", lastLogin: new Date("2024-03-01T09:30:00Z") };
const wire = JSON.stringify(session);
// '{"user":"ada","lastLogin":"2024-03-01T09:30:00.000Z"}'
const back = JSON.parse(wire);
typeof back.lastLogin; // "string"
back.lastLogin.getFullYear(); // TypeError
Исходящее преобразование в порядке. Проблема во входящем: оно понятия не имеет, чем эта строка была раньше.
Что переживает JSON-сериализацию, а что нет?
В грамматике JSON нет места для большей части того, что содержит объект JavaScript, поэтому всё, что в неё не укладывается, преобразуется или отбрасывается. MDN документирует полный набор правил сериализации для JSON.stringify; третий столбец ниже — это то, что вы реально получаете обратно после разбора.
| Значение | Что пишет JSON.stringify | Что возвращает JSON.parse |
|---|---|---|
Date | ISO-строку через toJSON | строку |
Map, Set, WeakMap, WeakSet | {} | пустой объект |
undefined, функция, символ в объекте | свойство пропускается | свойство отсутствует |
| Те же значения внутри массива | null | null |
NaN, Infinity | null | null |
BigInt | выбрасывает TypeError | — |
| Экземпляр класса | обычный объект из перечислимых собственных свойств | обычный объект, прототип потерян |
| Свойство с ключом-символом | игнорируется | отсутствует |
| Циклическая ссылка | выбрасывает TypeError | — |
Обёрнутые Number, String, Boolean | распакованный примитив | примитив |
Две строки заслуживают особого внимания. Map и Set превращаются в {}, потому что JSON.stringify обходит собственные перечислимые свойства объекта, а их записи там не хранятся. И внутри объекта undefined, функции и символьные значения пропускаются полностью, тогда как внутри массива те же значения становятся null — так что индексы сохраняются, даже если значения нет.
toJSON решает, что будет записано
Когда у значения есть метод toJSON, JSON.stringify пишет то, что этот метод вернул, и игнорирует сам объект. Пример с toJSON на MDN также показывает, что методу передаётся ключ, под которым лежит значение, — так что один и тот же объект может сериализоваться по-разному в зависимости от того, где он встречается.
class Money {
constructor(amount, currency) {
this.amount = amount;
this.currency = currency;
}
format() {
return `${(this.amount / 100).toFixed(2)} ${this.currency}`;
}
toJSON() {
return { __type: "Money", amount: this.amount, currency: this.currency };
}
}
JSON.stringify({ total: new Money(4599, "EUR") });
// '{"total":{"__type":"Money","amount":4599,"currency":"EUR"}}'
Поле __type — это дискриминатор, который будет искать reviver. Его запись — исходящая половина контракта.
Reviver в JSON.parse срабатывает на обратном пути
Reviver — это второй аргумент JSON.parse, и он вызывается для каждой пары ключ-значение, полученной при разборе. Пример обхода на MDN показывает порядок: сначала идут самые глубокие значения, затем то, что их содержит, и последний вызов охватывает весь результат под ключом "".
JSON.parse('{"a":1,"b":{"c":2,"d":{"e":3}}}', (key, value) => {
console.log(JSON.stringify(key));
return value;
});
// "a", "c", "e", "d", "b", ""
А теперь правило, которое незаметно уничтожает данные: если reviver вернёт undefined, этот ключ исчезнет из объекта; сделайте так на корневом вызове — и весь разбор вернёт undefined.
const json = '{"user":"ada","lastLogin":"2024-03-01T09:30:00.000Z"}';
// Destructive: every unhandled key falls off the end and is deleted.
JSON.parse(json, (key, value) => {
if (key === "lastLogin") return new Date(value);
});
// undefined
// Correct: the fallback return keeps everything else intact.
JSON.parse(json, (key, value) =>
key === "lastLogin" ? new Date(value) : value,
);
// { user: "ada", lastLogin: Date 2024-03-01T09:30:00.000Z }
Ни один из вариантов не выбрасывает исключение. Именно это делает первый опасным: потеря проявляется как отсутствующее поле или сырая ISO-строка в отрисованном выводе, а не как стек вызовов, — то есть как дефект, который session replay обнаруживает задолго до того, как о нём напишут в баг-репорте.
Как восстановить экземпляры классов и Map?
Восстановление настоящего экземпляра требует обеих половин пути: toJSON пишет тег типа рядом с данными, а reviver проверяет этот тег и передаёт оставшиеся поля в конструктор.
const reviver = (key, value) =>
value && value.__type === "Money"
? new Money(value.amount, value.currency)
: value;
JSON.parse('{"total":{"__type":"Money","amount":4599,"currency":"EUR"}}', reviver)
.total.format(); // "45.99 EUR"
Тот же приём работает для встроенных типов, у которых нет toJSON. Map уходит наружу как массив записей через replacer и возвращается через reviver, который распознаёт массив массивов.
const flags = new Map([["beta", true], ["darkMode", false]]);
const text = JSON.stringify({ flags }, (key, value) =>
value instanceof Map ? Array.from(value.entries()) : value,
);
// '{"flags":[["beta",true],["darkMode",false]]}'
const restored = JSON.parse(text, (key, value) =>
Array.isArray(value) && value.every(Array.isArray) ? new Map(value) : value,
);
restored.flags.get("beta"); // true
Такая проверка формы — это догадка, и она даёт осечку на пустых массивах: [].every(Array.isArray) равно true, поэтому обычный [] в любом месте полезной нагрузки вернётся как пустой Map. Тег типа, вроде того, что пишет Money, убирает угадывание.
Replacer и reviver — это одно соглашение о формате передачи данных. Измените одну сторону в одиночку — и цикл преобразования сломается.
Replacer: фильтрация на выходе
Replacer — это второй аргумент JSON.stringify, и он бывает двух видов. Как функция он выполняется для каждой пары ключ-значение, и возврат undefined опускает свойство. Как массив он работает как белый список, в котором учитываются только строковые и числовые элементы, а всё остальное, что вы туда положите, включая символы, не оказывает никакого эффекта.
const account = { id: 7, email: "ada@example.com", password: "hunter2" };
JSON.stringify(account, (key, value) => (key === "password" ? undefined : value));
// '{"id":7,"email":"ada@example.com"}'
JSON.stringify(account, ["id", "email"]);
// '{"id":7,"email":"ada@example.com"}'
Тот же приём отбрасывает известный ключ с обратной ссылкой, который иначе заставил бы JSON.stringify выбросить TypeError на цикле. Универсальному сериализатору, устойчивому к циклам, нужен WeakSet посещённых объектов; отбрасывание одного конкретного ключа решает только тот случай, о котором вы знаете.
Важна одна деталь порядка выполнения: toJSON срабатывает до того, как replacer увидит значение, поэтому для Date аргумент value в replacer’е уже является ISO-строкой, тогда как this[key] — по-прежнему исходный объект.
JSON.stringify({ lastLogin: new Date() }, function (key, value) {
// Must be a regular function: an arrow function has no `this` binding here.
return key === "lastLogin" ? this[key].getTime() : value;
});
Третий аргумент, space, влияет только на форматирование. Запросите больше 10 пробелов — всё равно получите 10, а строка отступа длиннее 10 символов будет урезана до первых 10.
Чтение исходного текста через context.source
Третий аргумент reviver’а — объект контекста, создаваемый заново для каждого вызова, чьё свойство source содержит исходный текст JSON для этого значения. Этот аргумент появляется только для примитивов; для объекта или массива его нет. Это предложение TC39 о доступе к исходному тексту в JSON.parse, которое достигло Stage 4 и вошло в ECMAScript 2026, утверждённый Ecma International 30 июня 2026 года.
Оно решает проблему потери, которая происходит до того, как какой-либо reviver смог бы вмешаться: к моменту, когда вы получаете value, большое целое число уже округлено до double.
const wire = '{"orderId": 9007199254740993}';
JSON.parse(wire).orderId;
// 9007199254740992 <- precision already gone
JSON.parse(wire, (key, value, context) =>
key === "orderId" ? BigInt(context.source) : value,
).orderId;
// 9007199254740993n
value — это результат с потерей точности. context.source — то, что реально было передано. Прежде чем полагаться на это, проверьте доступность в целевых средах выполнения.
Подведём итоги
Сериализация — это контракт, который вы пишете дважды: один раз в toJSON или replacer’е, второй раз в reviver’е, который понимает, что произвела первая половина. Пройдитесь по объектам, которые вы кладёте в localStorage или в слой кэширования, найдите те, что несут Date, Map, Set или экземпляры классов, и дайте каждому тег типа и соответствующую ветку в reviver’е. Затем проверьте, что каждый уже существующий у вас reviver заканчивается запасным return value.
Частые вопросы
Избавляет ли structuredClone от необходимости в reviver'е?
Нет, потому что structuredClone создаёт копию в памяти, а не строку JSON, поэтому её нельзя записать в localStorage или в тело запроса. Он действительно сохраняет Date, Map, Set и циклические ссылки, но выбрасывает DataCloneError на функциях и не копирует цепочку прототипов, так что экземпляр класса всё равно приходит как обычный объект без своих методов. Для восстановления экземпляров из текста reviver по-прежнему нужен.
Что использовать: toJSON или функцию-replacer?
Используйте toJSON, когда формат передачи принадлежит самому типу: метод живёт в классе, поэтому любая сериализация этого значения выдаёт одну и ту же форму без каких-либо действий со стороны вызывающего кода. Используйте replacer, когда правило относится к одному месту вызова — например, удаление поля password или преобразование Map из библиотеки, которую вы не контролируете. toJSON выполняется первым, поэтому replacer получает то, что вернул toJSON.
Выполняется ли reviver и для элементов массива?
Да. Индексы массива передаются reviver'у как строки, поэтому первый элемент приходит с ключом '0', а затем сам массив передаётся выше под своим собственным ключом. Возврат undefined для элемента удаляет этот элемент, а не сдвигает остальные, оставляя дыру, при этом длина массива не меняется. Reviver'ам для массивов нужен тот же запасной return value, что и reviver'ам для объектов.
Как типизировать reviver для JSON.parse в TypeScript?
JSON.parse в TypeScript возвращает any независимо от того, что делает reviver. Стандартная библиотека объявляет reviver со строковым ключом, значением типа any и возвращаемым типом any, поэтому reviver, восстанавливающий Date или экземпляры классов, не даёт компилятору никакой дополнительной информации. Аннотируйте результат явным типом в месте вызова или пропустите разобранное значение через валидатор схемы, прежде чем доверять его форме.
Complete picture for complete understanding
Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.
Star on GitHub12k