12k
All articles

Как остановить «уплощение» объектов в JSON

Исправьте уплощение JSON в JavaScript с replacer, reviver, toJSON и context.source, чтобы восстанавливать Date, Map, Set и BigInt.

OpenReplay Team
OpenReplay Team
Как остановить «уплощение» объектов в JSON

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
DateISO-строку через toJSONстроку
Map, Set, WeakMap, WeakSet{}пустой объект
undefined, функция, символ в объектесвойство пропускаетсясвойство отсутствует
Те же значения внутри массиваnullnull
NaN, Infinitynullnull
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 или экземпляры классов, не даёт компилятору никакой дополнительной информации. Аннотируйте результат явным типом в месте вызова или пропустите разобранное значение через валидатор схемы, прежде чем доверять его форме.

Open-source session replay

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

We use cookies to improve your experience. By using our site, you accept cookies.