Проблема метода объекта toString

В JavaScript и TypeScript объект  —  это набор свойств. Например:

const value = {a: 1, b: 2};

Значение можно записать так:

console.log(value); // {a: 1, b: 2}

А как насчет многокомпонентного сообщения?

console.log(`My value is ${value}`); // My value is [object Object]

Что такое [object Object]? Это значение метода объекта toString().

Сообщение console.log можно исправить, представив объекты в качестве независимых параметров.

console.log('My value is', value); // My value is {a: 1, b: 2}

Однако есть и другие случаи, когда объекты сериализуются с помощью toString(). Например, элемент JSX:

<div>{value}</div>

Во многих ситуациях помогает JSON.stringify().

console.log(`My value is ${JSON.stringify({a: 1, b: 2})}`); // My value is {"a":1,"b":2}

<div>{JSON.stringify(value)}</div> {/* display {"a":1,"b":2} */}

Рассмотрим подробно JavaScript Object Notation (JSON).

Что такое JSON?

JSON (JavaScript Object Notation)  —  это упрощенный формат обмена данными. Его легко читать и писать людям, а машинам просто интерпретировать и генерировать. Он основан на подмножестве стандарта языка программирования JavaScript ECMA-262 (3-я редакция, декабрь 1999).

JSON был специфицирован Дугласом Кроуфордом в марте 2001 года. Синтаксис JSON разработан для сериализации объектов (objects), массивов (arrays), чисел (numbers), строк (strings), булевых значений (booleans) и неопределенных значений (null). В октябре 2013 года он стал международным стандартом ECMA.

JSON обладает двумя основными функциональными возможностями:

  • является форматом данных, передаваемых между клиентом и сервером;
  • используется для определения конфигураций.

Синтаксис JSON прост и обладает ограниченной поддержкой типов данных, к которым относятся object, array, number, string, boolean и null. Однако функции, NaN, Infinity, undefined и Symbol не являются допустимыми значениями JSON. JSON не имеет поддержки пространств имен, комментариев или атрибутов. Он не может поддерживать сложные конфигурации. Эти ограничения делают JSON простым и доступным, и поэтому он быстро усваивается и легко интерпретируется.

JSON предлагает два статических метода  —  JSON.parse() и JSON.stringify().

JSON.parse()

JSON.parse(text) парсит строку JSON для создания значения или объекта JavaScript. Для объектов имена свойств JSON должны быть строками с двойными кавычками, а запятые в конце строки запрещены. Для примитивных типов JSON.parse() возвращает примитивные значения. Для чисел запрещены начальные нули, а за десятичной точкой должна следовать хотя бы одна цифра. Любые нарушения синтаксиса JSON приводят к ошибке SyntaxError.

Ниже приведены примеры того, как JSON.parse() создает значения и объекты JavaScript:

JSON.parse('{}'); // {}
JSON.parse('{"a":1,"b":2}'); // {a: 1, b: 2}
JSON.parse('{"a": 1 , "b": 2}'); // {a: 1, b: 2}
JSON.parse('{" a ":1," b ":2}'); // {" a ": 1, " b ": 2}
JSON.parse('{"a":1,"b":2,}'); // Uncaught SyntaxError
JSON.parse(`{'a':1,'b':2}`); // Uncaught SyntaxError
JSON.parse("{'a':1,'b':2}"); // Uncaught SyntaxError
JSON.parse("{a:1,b:2}"); // Uncaught SyntaxError
JSON.parse('{"a":1,"b":2,"c":() => null}'); // Uncaught SyntaxError
JSON.parse('{"a":1,"b":2,"c":function() {return null;}}'); // Uncaught SyntaxError
JSON.parse('[1,2]'); // [1, 2]
JSON.parse('[1,2,]'); // Uncaught SyntaxError
JSON.parse('[1,2,null]'); // [1, 2, null]
JSON.parse('[1,2,undefined]'); // Uncaught SyntaxError
JSON.parse('[1,2,Symbol(\'a\')]'); // Uncaught SyntaxError
JSON.parse('[1,2,() => 1]'); // Uncaught SyntaxError
JSON.parse('1'); // 1
JSON.parse((2 + 1)); // 3
JSON.parse(-5); // -5
JSON.parse('-5'); // -5
JSON.parse(+5); // 5
JSON.parse('+5'); // Uncaught SyntaxError
JSON.parse(10n); // 10
JSON.parse(100000000000000000000n); // 100000000000000000000
JSON.parse(1000000000000000000000n); // 1e+21
JSON.parse('1.0'); // 1
JSON.parse('1.'); // Uncaught SyntaxError
JSON.parse('10n'); // Uncaught SyntaxError
JSON.parse(NaN); // Uncaught SyntaxError
JSON.parse(Infinity); // Uncaught SyntaxError
JSON.parse('""'); // ''
JSON.parse('"string"'); // 'string'
JSON.parse(`"string"`); // 'string'
JSON.parse("\"string\""); // 'string'
JSON.parse("string"); // Uncaught SyntaxError
JSON.parse("'string'"); // Uncaught SyntaxError
JSON.parse(true); // true
JSON.parse((true)); // true
JSON.parse("true"); // true
JSON.parse('true'); // true
JSON.parse(`true`); // true
JSON.parse(2 > 1); // true
JSON.parse((2 > 1)); // true
JSON.parse("2 > 1"); // Uncaught SyntaxError
JSON.parse(!false); // true
JSON.parse(false); // false
JSON.parse((false)); // false
JSON.parse("false"); // false
JSON.parse('false'); // false
JSON.parse(`false`); // false
JSON.parse(true && false); // false
JSON.parse(true || false); // true
JSON.parse(null); // null
JSON.parse('null'); // null
JSON.parse("null"); // null
JSON.parse(undefined); // Uncaught SyntaxError
JSON.parse('undefined'); // Uncaught SyntaxError
JSON.parse(Symbol('symbol')); // Uncaught SyntaxError
JSON.parse("{[Symbol('symbol')]: 1}"); // Uncaught SyntaxError
JSON.parse("{[Symbol.for('symbol')]: 1}"); // Uncaught SyntaxError
JSON.parse(`{"a": Symbol('symbol')}`); // Uncaught SyntaxError

У JSON.parse(text[, reviver]) есть необязательная функция reviver, которая может изменять возвращаемое значение.

const obj = JSON.parse('{"a":1, "b":2}', 
(key, value) => {
if (key == 'a') {
return value - 1;
}

if (key === 'b') {
return value + 1;
}

return `key is "${key}", and value is ${JSON.stringify(value)}`;
}
);

console.log(obj); // key is "", and value is {"a":0,"b":3}

reviver (строки 2–12) вызывается 3 раза:

  1. для ключа a, в строке 4 значение (1) уменьшается до 0;
  2. для ключа b, в строке 8 значение (2) увеличивается до 3;
  3. для ключа "", значением будет текущий объект ({"a":0,"b":3}). В строке 11 возвращаемое значение преобразуется в строку key is "", and value is {"a":0,"b":3}.

В строке 15 пишется key is "", and value is {"a":0,"b":3} (ключ — "", а значение  —  {"a":0,"b":3}).

JSON.stringify()

JSON.stringify(value) возвращает JSON-строку, соответствующую указанному value. boolean, number и string преобразуются в соответствующие примитивные значения. Функции, undefined и Symbol являются недопустимыми значениями JSON, которые опускаются в объекте или заменяются на null в массиве. NaN, Infinity и null заменяются на null. Если у значения есть метод toJSON(), то при сериализации данных вызывается этот метод. Date реализует функцию toJSON(), возвращая строку date.toISOString(). JSON не может сериализовать значения BigInt или неперечислимые свойства.

Вот примеры того, как JSON.stringify() формирует JSON-строки:

JSON.stringify({}); // '{}'
JSON.stringify({a: 1, b: 2}); // '{"a":1,"b":2}'
JSON.stringify({a: 1, b: 2, c: undefined}); // '{"a":1,"b":2}'
JSON.stringify({a: 1, b: 2, c: () => 3}); // '{"a":1,"b":2}'
JSON.stringify({a: 1, b: 2, c: Symbol('a')}); // '{"a":1,"b":2}'
JSON.stringify({a: 1, b: 2, [Symbol('c')]: 3}); // '{"a":1,"b":2}'
JSON.stringify({a: 1, b: 2, toJSON: () => 'I can return anything'}); // '"I can return anything"'
JSON.stringify({a: 1, b: 2, toJSON() {return 'I can return anything';}}); // '"I can return anything"'
JSON.stringify({a: 1, b: 2, toJSON() { return this.a + this.b; }}); // '3'
JSON.stringify({a: 1, b: 2, toJSON: () => this.a}); // undefined
JSON.stringify({a: 1, b: 2, toJSON: () => this.b}); // undefined
JSON.stringify(null); // null
JSON.stringify(Infinity); // null
JSON.stringify(NaN); // null
JSON.stringify({a: 1, b: 2, toJSON: () => this.a + this.b}); // null (converted from NaN)
JSON.stringify(1); // '1'
JSON.stringify(5 + 5); // '10'
JSON.stringify(-5); // '-5'
JSON.stringify(+5); // '5'
JSON.stringify('string'); // '"string"'
JSON.stringify('true'); // '"true"'
JSON.stringify(true); // 'true'
JSON.stringify(false); // 'false'
JSON.stringify(2 < 3); // 'true'
JSON.stringify(); // undefined
JSON.stringify(undefined); // undefined
JSON.stringify(Object); // undefined
JSON.stringify(() => 1); // undefined
JSON.stringify(Symbol('a')); // undefined
JSON.stringify([undefined, () => 1, Symbol('a')]); // '[null,null,null]'
JSON.stringify(new Date()); // '"2022-03-27T20:02:33.476Z"'
JSON.stringify([new Number(1.1), new Number(NaN), new String('text'))]); // '[1.1,null,"text"]'
JSON.stringify([new Boolean(true), new Boolean(false)]); // '[true,false]'
JSON.stringify(new Set([1, 2, 3])); // {}
JSON.stringify(new Map([[1, 2]])); // {}
JSON.stringify(new WeakSet([{a: 1}, {b: 2}])); // {}
JSON.stringify(new WeakMap([[{}, 2]])); // {}
JSON.stringify(new Int8Array([1])); // '{"0":1}'
JSON.stringify(new Int16Array([1, 2])); // '{"0":1,"1":2}'
JSON.stringify(new Int32Array([1, 2, 3])); // '{"0":1,"1":2,"2":3}'
JSON.stringify(new Uint8Array([-1])); // '{"0":255}'
JSON.stringify(new Uint16Array([-1])); // '{"0":65535}'
JSON.stringify(new Uint32Array([-1])); // '{"0":4294967295}'
JSON.stringify(new Uint8ClampedArray([2.2])); // '{"0":2}'
JSON.stringify(new Float32Array([1.1])); // '{"0":1.100000023841858}'
JSON.stringify(new Float64Array([1.1])); // '{"0":1.1}'
JSON.stringify({x: 10n}); // Uncaught TypeError
JSON.stringify(Object.create(null, { a: {value: 1, enumerable: true}, b: {value: 2, enumerable: false}}) ); // '{"a":1}'

У JSON.stringify(value[, replacer]) есть опциональная функция replacer, которая может изменять возвращаемое значение.

Напишем replacer, который подобен reviver в JSON.parse(). Угадайте, что будет записано в логе?

const obj = JSON.stringify({a: 1, b: 2}, 
(key, value) => {
if (key == 'a') {
return value - 1;
}

if (key === 'b') {
return value + 1;
}

return `key is "${key}", and value is ${JSON.stringify(value)}`;
}
);

console.log(obj);

replacer определен в строках 2–12 и выводит "key is \"\", and value is {\"a\":1,\"b\":2}".

Функция replacer противоположна reviver. Первый вызов имеет ключ "" и значение {"a":0,"b":3}. В строке 11 он возвращает строковое значение "key is \"\", and value is {\"a\":1,\"b\":2}". Поскольку это строка, которая не имеет свойства для следующей итерации, происходит выход из функции replacer. В строке 15 записано "key is \"\", and value is {\"a\":1,\"b\":2}".

Если изменить строку 11 на return {a: 5};, то следующим будет вызвано свойство a. Поскольку другого свойства нет, происходит выход из функции replacer. В строке 15 записано {"a":4}.

А что если изменить строку 11 на return {c: 5};? Тогда следующим будет вызвано свойство c. Снова выполнится цикл по свойству c, и оно вернет {c: 5}. Получаем бесконечный цикл, который приводит к ошибке Uncaught RangeError: Maximum call stack size exceeded (превышен максимальный размер стека вызовов).

Нужно очень осторожно использовать replacer. Первый вызов для пустого ключа должен просто вернуть исходное значение. Ниже приведен типичный JSON.stringify с replacer:

const obj = JSON.stringify({a: 1, b: 2}, (key, value) => {
if (key == 'a') {
return value - 1;
}

if (key === 'b') {
return value + 1;
}

return value;
});

console.log(obj);

В строке 13 записано {"a":0,"b":3}.

У JSON.stringify(value[, replacer[, space]]) есть второй опциональный параметр space, который представляет собой string или number и вставляет пробелы в выходную JSON-строку для удобочитаемости.

JSON.stringify({ a: 1 }, null, ''); // '{"a":1}'
JSON.stringify({ a: 1 }, null, ' '); // '{\n "a": 1\n}'
JSON.stringify({ a: 1 }, null, '\n'); // '{\n\n"a": 1\n}'
JSON.stringify({ a: 1 }, null, '\t'); // '{\n\n"a": 1\n}'
JSON.stringify({ a: 1 }, null, 5); // '{\n "a": 1\n}'

\n  —  символ новой строки, и \n  —  символ табуляции. '{\n "a": 1\n}' означает следующую структуру:

{
"a": 1
}

Что такое JSON5?

JSON  —  это круто, а JSON5  —  еще круче!

Формат обмена данными JSON5  —  это расширенная JSON-версия, которая призвана смягчить некоторые ограничения JSON, расширив его синтаксис и включив в него некоторые функции из ECMAScript 5.1.

Объекты

  • Ключи объектов могут быть именами идентификаторов ECMAScript 5.1.
  • Объекты могут иметь одну запятую.

Массивы

  • Массивы могут иметь одну запятую.

Строки

  • Строки могут заключаться в одинарные кавычки.
  • Строки могут охватывать несколько строк, экранируя символы новой строки.
  • Строки могут включать в себя экранирование символов.

Числа

  • Числа могут быть шестнадцатеричными.
  • Числа могут иметь ведущую или последующую десятичную точку.
  • Числа могут быть Infinity, -Infinity2 и NaN.
  • Числа могут начинаться с явно определенного знака +.

Комментарии

  • Допускаются однострочные и многострочные комментарии.

Пробельные символы

  • Разрешены дополнительные пробельные символы.

Статические API, parse и stringify, у JSON5 такие же, как и у JSON.

Чтобы использовать JSON5, нужно установить пакет json5, загружаемый по 50 миллионов раз в неделю.

npm i json5

json5 становится частью зависимостей в package.json:

"dependencies": {
"json5": "^2.2.1"
}

Затем можно импортировать parse и stringify из json5. Вот некоторые из неудачных примеров работы с JSON5:

import { parse, stringify } from 'json5';

console.log(parse('{"a":1,"b":2,}')); // {a: 1, b: 2}
console.log(parse(`{'a':1,'b':2}`)); // {a: 1, b: 2}
console.log(parse("{'a':1,'b':2}")); // {a: 1, b: 2}
console.log(parse("{a:1,b:2}")); // {a: 1, b: 2}
console.log(parse('{"a":1,"b":2,"c":() => null}')); // {a: 1, b: 2}
console.log(parse('{"a":1,"b":2,"c":function() {return null;}}')); // {a: 1, b: 2}
console.log(parse('[1,2,]')); // [1, 2]
console.log(parse('+5')); // 5
console.log(parse('1.')); // 1
console.log(parse(NaN)); // NaN
console.log(parse(Infinity)); // Infinity
console.log(parse("'string'")); // 'string'
console.log(stringify({x: 10n})); // {}

Как преобразовать циклическую структуру

JSON/JSON5 является более мощным инструментом, чем простой toString(). JSON.stringify() решает проблему сериализации объекта [object Object]. Таким образом устраняется первоначальная проблема.

Однако при использовании JSON.stringify() возникает новая загвоздка.

Вам, скорее всего, приходилось неоднократно сталкиваться с ошибкой Uncaught TypeError: Converting circular structure to JSON (неперехваченная ошибка: преобразование циклической структуры в JSON). Вот пример:

const value = {a: 1, b: 2};
value.c = value;
console.log(JSON.stringify(value));
// выдается следующая ошибка:
// Uncaught TypeError: Converting circular structure to JSON
// --> начиная с объекта с конструктором 'Object',
// --- свойство 'c' замыкает круг
// на JSON.stringify (<anonymous>)
// на <anonymous>:1:6

Циклические зависимости  —  признак плохого кода. Но иногда приходится их использовать, если плохие JSON-структуры приходят из бэкенда или сторонних пакетов.

Как справиться с циклическими зависимостями? Есть несколько способов решить эту проблему:

  • replacer из JSON.stringify;
  • сторонние пакеты.

replacer из JSON.stringify

Для проблемы циклических зависимостей спасительным решением может стать replacer из JSON /JSON5 stringify. Вот пример кода c сайта MDN:

const value = {a: 1, b: 2};
value.c = value;
const getCircularReplacer = () => {
const seen = new WeakSet();
return (key, value) => {
if (typeof value === "object" && value !== null) {
if (seen.has(value)) {
return;
}
seen.add(value);
}
return value;
};
};
console.log(JSON.stringify(value, getCircularReplacer()));

Строки 3–14 определяют функцию getCircularReplacer. Она возвращает функцию, которая создает замыкание seen, представляющее собой WeakSet  —  набор, хранящий слабо удерживаемые объекты. Для каждого ключа она проверяет, находится ли значение уже в seen (строка 7). Если да, то это циклическая зависимость, пара ключ/значение игнорируется (строка 8). В противном случае значение добавляется в seen (строка 10) и возвращается (строка 12).

В строке 15 вызывается getCircularReplacer для возврата функции replacer, и JSON.stringify() выводит {"a":1,"b":2} с удалением циклического свойства.

Сторонние пакеты

Есть ряд сторонних пакетов, которые решают проблему циклических зависимостей. json-stringify-safe  —  популярный пакет, который еженедельно скачивают 17 миллионов пользователей. Он работает аналогично JSON.stringify, но не полагается на циклические зависимости. Его можно установить с помощью следующей команды:

npm i json-stringify-safe

json-stringify-safe становится частью зависимостей в package.json:

"dependencies": {
"json-stringify-safe": "^5.0.1"
}

Пакет содержит метод stringify. Метод обладает четырьмя параметрами  —  stringify(obj, serializer, indent, decycler). Первые три параметра такие же, как у JSON.stringify. По умолчанию stringify отображает циклическую зависимость в виде строки '[Circular]'. decycler может настроить ее отображение.

import stringify from 'json-stringify-safe';

const value ={a:1, b:2};
value.c = value;
console.log(stringify(value)); // '{"a":1,"b":2,"c":"[Circular ~]"}'
console.log(stringify(value, null, null, () => undefined)); // '{"a":1,"b":2}'

В строке 5 показан результат stringify по умолчанию для структуры JSON с циклической зависимостью.

В строке 6 результат настраивается так, чтобы удалить свойство с циклической зависимостью.

Вот код того, как json-stringify-safe определяет stringify:

function stringify(obj, replacer, spaces, cycleReplacer) {
return JSON.stringify(obj, serializer(replacer, cycleReplacer), spaces)
}

function serializer(replacer, cycleReplacer) {
var stack = [], keys = []

if (cycleReplacer == null) cycleReplacer = function(key, value) {
if (stack[0] === value) return "[Circular ~]"
return "[Circular ~." + keys.slice(0, stack.indexOf(value)).join(".") + "]"
}

return function(key, value) {
if (stack.length > 0) {
var thisPos = stack.indexOf(this)
~thisPos ? stack.splice(thisPos + 1) : stack.push(this)
~thisPos ? keys.splice(thisPos, Infinity, key) : keys.push(key)
if (~stack.indexOf(value)) value = cycleReplacer.call(this, key, value)
}
else stack.push(value)

return replacer == null ? value : replacer.call(this, key, value)
}
}

Заключение

JSON  —  это упрощенный формат обмена данными. Он обрабатывает сериализацию объектов более корректно, чем toString() объекта.

Теперь вы знаете, как работают JSON и JSON5, а также имеете представление о нескольких способах решения проблемы циклических зависимостей.

Читайте также:

Читайте нас в TelegramVK и Яндекс.Дзен


Перевод статьи Jennifer Fu: Exploring JSON, JSON5, and Circular References

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