Проблема метода объекта 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 раза:
- для ключа
a
, в строке 4 значение (1
) уменьшается до0
; - для ключа
b
, в строке 8 значение (2
) увеличивается до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, а также имеете представление о нескольких способах решения проблемы циклических зависимостей.
Читайте также:
- JSON-сериализация необязательных полей в Go
- Структурированное логирование JSON в приложениях на Golang
- Как комментировать файлы JSON
Читайте нас в Telegram, VK и Яндекс.Дзен
Перевод статьи Jennifer Fu: Exploring JSON, JSON5, and Circular References