Проблема метода объекта 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 или неперечислимые свойства.

Telegram-канал по теме
NOP::HumorИТ-юмор и мемы о боли разработчиков
Подписаться

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

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


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

Мы в Telegram — подписывайтесь
NOP::Nuances of ProgrammingСтатьи и переводы для разработчиков — каждый день
Подписаться
NOP::HumorИТ-юмор и мемы о боли разработчиков
Подписаться
NOP::Recruiter RemoteУдалённые ИТ-вакансии с вилками
Подписаться
Предыдущая статьяКто есть кто: обратные вызовы, промисы и асинхронные функции
Следующая статьяPreact вместо ручной оптимизации React-приложения