Json safe stringify

Json safe stringify

На этот раз о программировании. В JS есть удобная функция JSON.stringify. Однако, я наткнулся аж на три проблемы, которые сильно мешают ей жить:
  • циклические ссылки
  • слишком большая глубина
  • разряженные массивы
Циклические ссылки

Возьмем кусок кода
var obj = {q: '1', w: '2', e: '3'};
obj.e = obj;
var circularReference = {a: 'test', b: obj};
Как видно - беда тут в
obj.e = obj;
Если мы попытаемся сделать
JSON.stringify(circularReference);
то получим
"TypeError: Converting circular structure to JSON"

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

JSON.stringify(json, function(key, value) {
  if (value && typeof value === 'object') {
    // это объект

    if (parsedObjects.indexOf(value) === -1) {
      // этого объекта нет среди уже просмотренных
      parsedObjects.push(value);

      // продолжаем с этим объектом
      return value;
    }

    // объект уже был просмотрен, значит наткнулись на циклическую ссылку, возвращаем toString от объекта
    return value.toString();
  }

  // продолжаем со значением
  return value;
});


Глубина

JSON.Stringify хоть и предлагает передавать ему функцию replacer, однако в ней очень не хватает третьего аргумента - уровня в глубину.
Чтобы это исправить, придется для каждого узла проверять не является ли он потомком предыдущего (тут использован lodash, однако вполне можно обойтись и без него):

function isChild(haystack, needle) {
var isFound = false;

_.forEach(haystack, function (k) {
isFound = isFound || _.isEqual(k, needle);
});

return isFound;
}
Небольшая оптимизация: уровень имеет смысл проверять только для объектов, можно проверить typeof.
Теперь можно добавить счетчик уровня в replacer, в результате:
JSON.stringify(json, function (key, value) {
if (value && typeof value === 'object') {
// это объект

if (isChild(lastParsed, value)) {
// мы все еще идем вглубь, увеличиваем счетчик
currentDepth++;
} else {
// вышли наверх
currentDepth = 0;
}

lastParsed = value;

// проверяем не достигли ли максимальной глубины
if (maxDepth && currentDepth >= maxDepth) {
return 'max depth reached';
}

if (parsedObjects.indexOf(value) === -1) {
// этого объекта нет среди уже просмотренных
parsedObjects.push(value);

// продолжаем с этим объектом
return value;
}

// объект уже был просмотрен, значит наткнулись на циклическую ссылку, возвращаем toString от объекта
return value.toString();
}

// продолжаем со значением
return value;
});
Разряженные массивы

Для начала о том, как его сделать:
var a = [];
a[666] = true;
Теперь в a массив длиной 667, а именно
[undefined × 666, true]

При чем тут stringify? Дело в том, что он пройдется по всем элементам этого массива и для каждого вызовет replacer. К сожалению, мне попался такой массив в котором было несколько сот тысяч элементов, таких массивов тоже было много - replacer выжирает память и вешает браузер.

Починка оказалась крайне простой - нужно пройтись по всему массиву циклом for...in и проверить на hasOwnProperty.

Итого метод:

function fixArray(arr) {
var compactArray = [];

for (var i in arr) {
// пропустит undefined
if (arr.hasOwnProperty(i)) {
compactArray.push(arr[i]);
}
}

return compactArray;
}
Все вместе

Собираем все вместе:

/**
* @ngdoc method
* @name safeStringify
*
* @description
* Безопасно форматирует json в строку. В случае циклической ссылки вернет [object Конструктор]
*
* @param {Object} json - json для превращения в строку
* @param {number} [maxDepth] - максимальная глубина, если не передана то равна бесконечности
* @return {string} - строковое представление
*
* @private
*/
var safeStringify = function (json, maxDepth) {
var parsedObjects = [],
currentDepth = 0,
lastParsed = {};

/**
* @description
* Чинит разряженные массивы
*
* @param {Array} arr - входной массив
* @return {Array}
*/
function fixArray(arr) {
var compactArray = [];

for (var i in arr) {
// пропустит undefined
if (arr.hasOwnProperty(i)) {
compactArray.push(arr[i]);
}
}

return compactArray;
}

/**
* Проверка того что needle содержится в haystack
* @param {Object} haystack - в чем ищем
* @param {Object} needle - что ищем
*
* @return {boolean}
*/
function isChild(haystack, needle) {
var isFound = false;

_.forEach(haystack, function (k) {
isFound = isFound || _.isEqual(k, needle);
});

return isFound;
}

return JSON.stringify(json, function (key, value) {
if (value && _.isArray(value)) {
// если это массив то заполняем его чтобы избавится от массивов вида [undefined, ..., undefined, value]
value = fixArray(value);
}

if (value && typeof value === 'object') {
// это объект

if (isChild(lastParsed, value)) {
// мы все еще идем вглубь, увеличиваем счетчик
currentDepth++;
} else {
// вышли наверх
currentDepth = 0;
}

lastParsed = value;

// проверяем не достигли ли максимальной глубины
if (maxDepth && currentDepth >= maxDepth) {
return 'max depth reached';
}

if (parsedObjects.indexOf(value) === -1) {
// этого объекта нет среди уже просмотренных
parsedObjects.push(value);

// продолжаем с этим объектом
return value;
}

// объект уже был просмотрен, значит наткнулись на циклическую ссылку, возвращаем toString от объекта
return value.toString();
}

// продолжаем со значением
return value;
});
};