Главная / Статьи / Что тихо удаляет JSON.stringify, что преобразует и что отказывается сериализовать

Что тихо удаляет JSON.stringify, что преобразует и что отказывается сериализовать

Узнайте, какие значения JavaScript игнорирует или изменяет функция JSON.stringify, как функции toJSON, заменители и восстановители решают эту проблему, и когда structuredClone является более подходящим инструментом.

1602 слов

JSON.stringify редко выдает ошибки. Когда он сталкивается с значением, которое не может представить, он обычно пропускает его или преобразует во что-то другое и продолжает работу, возвращая полностью корректный JSON. Именно так ошибки появляются на несколько шагов дальше от своей причины: данные исчезают в процессе сериализации, но сбой проявляется позже в коде, который даже не подозревает о том, что происходила сериализация. В этом руководстве перечислено, что теряется, что меняет тип и что вызывает ошибки, а затем показаны встроенные инструменты (toJSON, заменители, функции восстановления и structuredClone), которые позволяют управлять каждым случаем намеренно.

Свойства, исчезающие без следа

Рассмотрим объект сессии, содержащий смесь обычных данных, функцию-обратный вызов, явно неопределенное поле и символ:

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"}

В результирующем выводе присутствуют только два из пяти свойств. Свойства onExpire, lastActivity и tempToken отсутствуют; при этом не возникает ни исключений, ни предупреждений, и в полученной строке нет ничего, что указывало бы на удаление каких-либо элементов. Внутри объекта функция JSON.stringify игнорирует все свойства, значения которых равны undefined, функции или объектам типа Symbol. У этих типов значений нет представления в формате JSON, и вместо того чтобы сброситься с ошибкой, сериализатор возвращает допустимый JSON-формат для оставшихся элементов.

Последствия этого незаметны. Пользователь, который парсит этот строковый объект и ожидает наличия поля lastActivity, даже если его значение равно undefined, не обнаружит его. Ключ не пуст; он изначально вообще не попадал в результат обработки. Код, который различает случаи «отсутствия» поля и его наличия с значение undefined, например с использованием выражения "lastActivity" in obj или метода Object.keys, теперь будет вести себя иначе.

Массивы ведут себя по-другому

Те же неподдерживаемые значения получают разное обработку внутри массива:

console.log(JSON.stringify([undefined, function () {}, 1]));
// [null,null,1]

Здесь они становятся null, вместо того чтобы исчезнуть. Массив не может убрать элемент, не сместив индексы всех следующих за ним элементов, поэтому сериализатор сохраняет это место и заполняет его тем, что в JSON считается аналогом «ничего». Коренная причина одинакова, но в зависимости от того, находилась ли ценность в объекте или в массиве, проявляются два разных скрытых поведения. Два связанных крайних случая следуют тому же принципу: NaN и Infinity сериализуются как null, а вызов JSON.stringify непосредственно с параметром undefined или функцией возвращает undefined, а не строку.

Даты возвращаются в виде строк

Кажется, что даты выживают после обработки, но только наполовину:

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"

Сама информация о дате остается нетронутой; она присутствует в выводе в виде строки по стандарту ISO 8601. Теряется лишь тип данных. Функция JSON.parse не может определить, что та или иная строка ранее представляла собой объект типа Date, а не просто текст, похожий на него, поэтому она возвращает строку, и эта строка остается таковой, пока кто-то не преобразует её обратно.

Это имеет значение сразу после того, как код вызывает методы, связанные с датой. Например, вызов record.createdAt.getFullYear() приводит к ошибке не потому, что данные неверны, а потому, что их тип тихо изменился в процессе обработки. Такая проблема часто возникает с ответами API, значениями, восстанавливаемыми из localStorage, и сообщениями, передаваемыми через очереди.

Циклические структуры также вызывают ошибки

Не все сбои происходят бесшумно. Некоторые объекты вообще невозможно сериализовать, и движок явно сообщает об этом:

const parent = { name: "parent" };
const child = { name: "child", parent };
parent.child = child;

JSON.stringify(parent); // TypeError: Converting circular structure to JSON

Для формирования вывода функция JSON.stringify просматривает граф объектов. Когда какой-либо объект в этом графе ведет обратно к одному из его предков, такой процесс никогда не закончится. Вместо бесконечной рекурсии движок обнаруживает цикл и сразу же выбрасывает ошибку TypeError. Это один из немногих случаев, когда сериализатор действительно терпит неудачу, и это имеет вескую причину: частичного или приблизительного результата дать невозможно, поскольку циклический граф действительно не может быть преобразован в структуру JSON. Значения типа BigInt также вызывают подобные проблемы; они приводят к ошибке TypeError, если их не преобразовать вручную.

Контроль вывода с помощью toJSON

Некоторые встроенные типы заранее определяют, как они должны выглядеть в формате JSON, поэтому объект Date преобразуется в строку в формате ISO, а не в пустой объект. Любой объект может использовать такое же поведение, определив метод toJSON. Если такой метод существует, функция JSON.stringify вызывает его и сериализует возвращаемое значение вместо собственных свойств объекта:

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"}}

Без метода toJSON экземпляр Money будет записан в своей внутренней форме {"cents":3499}, что приведет к утечке деталей реализации, на которые другие части системы не должны были полагаться. С помощью этого метода объект сам определяет свою публичную представление. Тип Date использует именно этот механизм: именно Date.prototype.toJSON генерирует строку в формате ISO.

Имейте в виду, что это однонаправленный процесс. Парсинг результата возвращает обычный объект с полями amount и currency, а не экземпляр класса Money; воссоздание объекта — задача функции-восстановителя, описанной далее.

Функции замены и восстановления: управление обоими направлениями

JSON.stringify принимает необязательный второй аргумент — функцию замены, которая вызывается для каждой пары ключ-значение перед их записью. Возврат значения undefined приводит к удалению этой записи, а любое другое значение заменяет исходное. Это удобный способ фильтрации конфиденциальных полей при сохранении данных:

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 предлагает аналогичный механизм — функцию восстановления, которая вызывается для каждой пары ключ-значение после парсинга. Это официальное решение проблемы с типом Date, показанной ранее:

const restored = JSON.parse(json, (key, value) => {
  if (key === "createdAt") return new Date(value);
  return value;
});
console.log(restored.createdAt instanceof Date); // true

Стоит знать два важных момента. Функции восстановления данных работают снизу вверх, поэтому вложенные значения уже восстанавливаются при обработке их родителя. Кроме того, проверка только имени ключа позволяет найти этот ключ на любой глубине, в результате чего поле createdAt, находящееся внутри какого-либо вложенного объекта, также будет преобразовано; если это не то, что вам нужно, проверьте также формат значения.

Ни один из этих моментов не является сложной особенностью API. Это предусмотренный способ точного контроля над сериализацией, и он гораздо надежнее, чем удаление свойств перед преобразованием в строку или ручное изменение объектов после парсинга.

Map и Set теряют всё

Разработчики, ожидаящие, что JSON сохранит любой тип объекта, часто сталкиваются с проблемами именно здесь:

const tags = new Set(["urgent", "billing"]);
console.log(JSON.stringify({ tags })); // {"tags":{}}

Для сериализатора Set не является ни массивом, ни обычным объектом с перечислимыми собственными свойствами, поэтому он превращается в пустой объект, и все хранившиеся в нем значения тихо удаляются. С Map происходит то же самое. Если какой-либо из этих типов должен сохраниться, его необходимо явно преобразовать перед преобразованием в строку, например, распаковав его в массив:

const json = JSON.stringify({ tags: [...tags] }); // convert Set to array before stringifying

Для Map методы [...map] или Object.fromEntries(map) генерируют сериализуемые формы, что позволяет функции восстановления воссоздать исходный набор данных при обратном преобразовании.

Глубокие копии: используйте structuredClone

В течение многих лет JSON.parse(JSON.stringify(obj)) считался распространенным способом глубокой копирования объекта. Он работает лишь случайно и наследует все вышеописанные ограничения: функции и значения undefined исчезают, даты преобразуются в строки, коллекции опустошаются, а циклические ссылки вызывают ошибки.

Современные браузеры и Node.js предлагают специально разработанную альтернативу:

const clone = structuredClone(original);

structuredClone выполняет настоящую глубокую копию с использованием алгоритма структурированной копирования. Он сохраняет объекты типа Date, Map и Set, а также справляется с циклическими ссылками, что метод JSON либо искажает, либо отвергает. Однако у него тоже есть ограничения: он выбрасывает исключение DataCloneError для функций и узлов DOM, а экземпляры классов возвращаются в виде обычных объектов без прототипа. Когда цель — просто скопировать данные, structuredClone почти всегда является лучшим выбором. JSON.stringify никогда не предназначался для копирования; он был достаточно удобен, поэтому люди использовали его в этой цели.

Основные выводы

  • JSON.stringify сериализует только ту часть значений JavaScript, которые может представить JSON, а не произвольные значения JavaScript.
  • Внутри объектов значения undefined, функции и символы удаляются; внутри массивов они превращаются в null.
  • Даты сохраняются в виде строк по стандарту ISO, но теряют свой тип; для восстановления используйте специальные функции.
  • Циклические ссылки и тип BigInt вызывают ошибку; объекты Map и Set беззвучно сериализуются в {}.
  • Используйте метод toJSON для определения публичной структуры объекта, функции замены для фильтрации результата и специальные функции для восстановления типов.
  • Обращайтесь к методу structuredClone, когда вам нужна копия, а метод JSON.stringify рассматривайте как инструмент форматирования, пробелы в котором необходимо учитывать явно, чтобы поля, коллекции и типы не исчезали незаметно между различными частями системы.
  • Связанные материалы

    • Что делает веб-приложение медленным: за пределами размера пакета — Почему сокращение количества килобайт редко помогает ускорить медленное приложение, и как отслеживать реальное время ожидания на серверах, в цепочках обработки, при использовании скриптов и изображений от сторонних поставщиков.
    • Что на самом деле гарантирует async/await и что оставляет за собой — Понимание того, что на самом деле приостанавливается с помощью await, способы избежания сериализованных запросов, а также причины, по которым для обработки ошибок, отмены операций, соблюдения порядка выполнения и повторных попыток требуются решения, выходящие за рамки async/await.
  • Что на самом деле делает npm install: реестр, package.json и Lockfiles — практическое руководство по npm: реестр и CLI, как npm install находит пакеты, что записывается в package.json и package-lock.json, а также как публиковать пакеты. —