61

I need a function building a JSON valid string from any argument but :

  • avoiding recursivity problem by not adding objects twice
  • avoiding call stack size problem by truncating past a given depth

Generally it should be able to process big objects, at the cost of truncating them.

As reference, this code fails :

var json = JSON.stringify(window);

Avoiding recursivity problem is simple enough :

var seen = [];
return JSON.stringify(o, function(_, value) {
    if (typeof value === 'object' && value !== null) {
        if (seen.indexOf(value) !== -1) return;
        else seen.push(value);
    }
    return value;
});

But for now, apart copying and changing Douglas Crockford's code to keep track of the depth, I didn't find any way to avoid stack overflow on very deep objects like window or any event. Is there a simple solution ?

Denys Séguret
  • 372,613
  • 87
  • 782
  • 758
  • What do you mean by "very deep object"? Are there really objects that go (without "recursive properties") beyond stack size? – Bergi Dec 13 '12 at 14:38
  • yes : `window` for example. It's possible there is a bug in my code and that the real problem is recursivity, though, as `window` is both recursive and deep (that's why I've given my code). – Denys Séguret Dec 13 '12 at 14:41
  • Hm, I'm getting a `out of (heap) memory` when trying your script on `window` :-/ – Bergi Dec 13 '12 at 14:43
  • That's what I'd like to avoid. A try/catch prevents it in my tests but then I have nothing. I'd like to have at least the first N levels. If necessary I'll cut big arrays/strings too but that's trivial. – Denys Séguret Dec 13 '12 at 14:44
  • 2
    Your implementation depends on the native `JSON.stringify`. And I fear that does a DFS in the "properties tree", not a BFS. Might get better if you could expand level-for-level (not sure though - the result just *is* a too huge string) – Bergi Dec 13 '12 at 14:47
  • At first sight I too think I can't use JSON.stringify. Maybe there's a trick to avoid going deeper using it but I didn't find it. I *could* rewrite it (using Crockford's code, still as DFS but passing the depth) but I asked to see if somebody with more knowledge or imagination than me would have a shorter and cleaner solution. – Denys Séguret Dec 13 '12 at 14:49

9 Answers9

103

I did what I initially feared I'll have to do : I took Crockford's code and modified it for my needs. Now it builds JSON but handles

  • cycles
  • too deep objects
  • too long arrays
  • exceptions (accessors that can't legally be accessed)

In case anybody needs it, I made a GitHub repository : JSON.prune on GitHub

Here is the code :

// JSON.pruned : a function to stringify any object without overflow
// example : var json = JSON.pruned({a:'e', c:[1,2,{d:{e:42, f:'deep'}}]})
// two additional optional parameters :
//   - the maximal depth (default : 6)
//   - the maximal length of arrays (default : 50)
// GitHub : https://github.com/Canop/JSON.prune
// This is based on Douglas Crockford's code ( https://github.com/douglascrockford/JSON-js/blob/master/json2.js )
(function () {
    'use strict';

    var DEFAULT_MAX_DEPTH = 6;
    var DEFAULT_ARRAY_MAX_LENGTH = 50;
    var seen; // Same variable used for all stringifications

    Date.prototype.toPrunedJSON = Date.prototype.toJSON;
    String.prototype.toPrunedJSON = String.prototype.toJSON;

    var cx = /[\u0000\u00ad\u0600-\u0604\u070f\u17b4\u17b5\u200c-\u200f\u2028-\u202f\u2060-\u206f\ufeff\ufff0-\uffff]/g,
        escapable = /[\\\"\x00-\x1f\x7f-\x9f\u00ad\u0600-\u0604\u070f\u17b4\u17b5\u200c-\u200f\u2028-\u202f\u2060-\u206f\ufeff\ufff0-\uffff]/g,
        meta = {    // table of character substitutions
            '\b': '\\b',
            '\t': '\\t',
            '\n': '\\n',
            '\f': '\\f',
            '\r': '\\r',
            '"' : '\\"',
            '\\': '\\\\'
        };

    function quote(string) {
        escapable.lastIndex = 0;
        return escapable.test(string) ? '"' + string.replace(escapable, function (a) {
            var c = meta[a];
            return typeof c === 'string'
                ? c
                : '\\u' + ('0000' + a.charCodeAt(0).toString(16)).slice(-4);
        }) + '"' : '"' + string + '"';
    }

    function str(key, holder, depthDecr, arrayMaxLength) {
        var i,          // The loop counter.
            k,          // The member key.
            v,          // The member value.
            length,
            partial,
            value = holder[key];
        if (value && typeof value === 'object' && typeof value.toPrunedJSON === 'function') {
            value = value.toPrunedJSON(key);
        }

        switch (typeof value) {
        case 'string':
            return quote(value);
        case 'number':
            return isFinite(value) ? String(value) : 'null';
        case 'boolean':
        case 'null':
            return String(value);
        case 'object':
            if (!value) {
                return 'null';
            }
            if (depthDecr<=0 || seen.indexOf(value)!==-1) {
                return '"-pruned-"';
            }
            seen.push(value);
            partial = [];
            if (Object.prototype.toString.apply(value) === '[object Array]') {
                length = Math.min(value.length, arrayMaxLength);
                for (i = 0; i < length; i += 1) {
                    partial[i] = str(i, value, depthDecr-1, arrayMaxLength) || 'null';
                }
                v = partial.length === 0
                    ? '[]'
                    : '[' + partial.join(',') + ']';
                return v;
            }
            for (k in value) {
                if (Object.prototype.hasOwnProperty.call(value, k)) {
                    try {
                        v = str(k, value, depthDecr-1, arrayMaxLength);
                        if (v) partial.push(quote(k) + ':' + v);
                    } catch (e) { 
                        // this try/catch due to some "Accessing selectionEnd on an input element that cannot have a selection." on Chrome
                    }
                }
            }
            v = partial.length === 0
                ? '{}'
                : '{' + partial.join(',') + '}';
            return v;
        }
    }

    JSON.pruned = function (value, depthDecr, arrayMaxLength) {
        seen = [];
        depthDecr = depthDecr || DEFAULT_MAX_DEPTH;
        arrayMaxLength = arrayMaxLength || DEFAULT_ARRAY_MAX_LENGTH;
        return str('', {'': value}, depthDecr, arrayMaxLength);
    };

}());

An example of what can be done :

var json = JSON.pruned(window);

Note: Contrary to the code in this answer, the GitHub repository is updated when needed (documentation, compatibility, use as module in commonjs or node, specific serializations, etc.). It's a good idea to start from the repository if you need this pruning feature.

Denys Séguret
  • 372,613
  • 87
  • 782
  • 758
  • @SarahManning JSON doesn't include functions, of course. If you want to serialize them, you can do it with JSON.prune though: https://github.com/Canop/JSON.prune#example-4-function-serialization – Denys Séguret Dec 21 '15 at 08:14
  • 1
    Anyone needing functions see this issue http://github.com/Canop/JSON.prune/issues/5 –  Dec 22 '15 at 01:07
  • 2
    You made history here mate – Omri Luzon Mar 17 '17 at 19:44
21

If you're using Node.js you can use util.inspect, which takes a depth argument.

Rhys van der Waerden
  • 3,526
  • 2
  • 27
  • 32
6

I've revised @dystroy's answer, adding:

  • Indentation for sub-properties.
  • An indication of where circular references point to.
/**
 * Returns the JSON representation of an object.
 *
 * @param {value} object the object
 * @param {number} objectMaxDepth for objects, the maximum number of times to recurse into descendants
 * @param {number} arrayMaxLength for arrays, the maximum number of elements to enumerate
 * @param {string} indent the string to use for indentation
 * @return {string} the JSON representation
 */
var toJSON = function(object, objectMaxDepth, arrayMaxLength, indent)
{
    "use strict";

    /**
     * Escapes control characters, quote characters, backslash characters and quotes the string.
     *
     * @param {string} string the string to quote
     * @returns {String} the quoted string
     */
    function quote(string)
    {
        escapable.lastIndex = 0;
        var escaped;
        if (escapable.test(string))
        {
            escaped = string.replace(escapable, function(a)
            {
                var replacement = replacements[a];
                if (typeof (replacement) === "string")
                    return replacement;
                // Pad the unicode representation with leading zeros, up to 4 characters.
                return "\\u" + ("0000" + a.charCodeAt(0).toString(16)).slice(-4);
            });
        }
        else
            escaped = string;
        return "\"" + escaped + "\"";
    }

    /**
     * Returns the String representation of an object.
     * 
     * Based on <a href="https://github.com/Canop/JSON.prune/blob/master/JSON.prune.js">https://github.com/Canop/JSON.prune/blob/master/JSON.prune.js</a>
     *
     * @param {string} path the fully-qualified path of value in the JSON object
     * @param {type} value the value of the property
     * @param {string} cumulativeIndent the indentation to apply at this level
     * @param {number} depth the current recursion depth
     * @return {String} the JSON representation of the object, or "null" for values that aren't valid
     * in JSON (e.g. infinite numbers).
     */
    function toString(path, value, cumulativeIndent, depth)
    {
        switch (typeof (value))
        {
            case "string":
                return quote(value);
            case "number":
                {
                    // JSON numbers must be finite
                    if (isFinite(value))
                        return String(value);
                    return "null";
                }
            case "boolean":
                return String(value);
            case "object":
                {
                    if (!value)
                        return "null";
                    var valueIndex = values.indexOf(value);
                    if (valueIndex !== -1)
                        return "Reference => " + paths[valueIndex];
                    values.push(value);
                    paths.push(path);
                    if (depth > objectMaxDepth)
                        return "...";

                    // Make an array to hold the partial results of stringifying this object value.
                    var partial = [];

                    // Is the value an array?
                    var i;
                    if (Object.prototype.toString.apply(value) === "[object Array]")
                    {
                        // The value is an array. Stringify every element
                        var length = Math.min(value.length, arrayMaxLength);

                        // Whether a property has one or multiple values, they should be treated as the same
                        // object depth. As such, we do not increment the object depth when recursing into an
                        // array.
                        for (i = 0; i < length; ++i)
                        {
                            partial[i] = toString(path + "." + i, value[i], cumulativeIndent + indent, depth,
                                arrayMaxLength);
                        }
                        if (i < value.length)
                        {
                            // arrayMaxLength reached
                            partial[i] = "...";
                        }
                        return "\n" + cumulativeIndent + "[" + partial.join(", ") + "\n" + cumulativeIndent +
                            "]";
                    }

                    // Otherwise, iterate through all of the keys in the object.
                    for (var subKey in value)
                    {
                        if (Object.prototype.hasOwnProperty.call(value, subKey))
                        {
                            var subValue;
                            try
                            {
                                subValue = toString(path + "." + subKey, value[subKey], cumulativeIndent + indent,
                                    depth + 1);
                                partial.push(quote(subKey) + ": " + subValue);
                            }
                            catch (e)
                            {
                                // this try/catch due to forbidden accessors on some objects
                                if (e.message)
                                    subKey = e.message;
                                else
                                    subKey = "access denied";
                            }
                        }
                    }
                    var result = "\n" + cumulativeIndent + "{\n";
                    for (i = 0; i < partial.length; ++i)
                        result += cumulativeIndent + indent + partial[i] + ",\n";
                    if (partial.length > 0)
                    {
                        // Remove trailing comma
                        result = result.slice(0, result.length - 2) + "\n";
                    }
                    result += cumulativeIndent + "}";
                    return result;
                }
            default:
                return "null";
        }
    }

    if (indent === undefined)
        indent = "  ";
    if (objectMaxDepth === undefined)
        objectMaxDepth = 0;
    if (arrayMaxLength === undefined)
        arrayMaxLength = 50;
    // Matches characters that must be escaped
    var escapable =
        /[\\\"\x00-\x1f\x7f-\x9f\u00ad\u0600-\u0604\u070f\u17b4\u17b5\u200c-\u200f\u2028-\u202f\u2060-\u206f\ufeff\ufff0-\uffff]/g;
    // The replacement characters
    var replacements =
        {
            "\b": "\\b",
            "\t": "\\t",
            "\n": "\\n",
            "\f": "\\f",
            "\r": "\\r",
            "\"": "\\\"",
            "\\": "\\\\"
        };
    // A list of all the objects that were seen (used to avoid recursion)
    var values = [];
    // The path of an object in the JSON object, with indexes corresponding to entries in the
    // "values" variable.
    var paths = [];
    return toString("root", object, "", 0);
};
Gili
  • 86,244
  • 97
  • 390
  • 689
5

Here is a function that respects the built-in JSON.stringify() rules while also limiting depth. This version handles cyclical references by making them either null, or using an optional callback to get an object ID (such as a GUID).

function stringify(val, depth, replacer, space, onGetObjID) {
    depth = isNaN(+depth) ? 1 : depth;
    var recursMap = new WeakMap();
    function _build(val, depth, o, a, r) { // (JSON.stringify() has it's own rules, which we respect here by using it for property iteration)
        return !val || typeof val != 'object' ? val
            : (r = recursMap.has(val), recursMap.set(val,true), a = Array.isArray(val),
               r ? (o=onGetObjID&&onGetObjID(val)||null) : JSON.stringify(val, function(k,v){ if (a || depth > 0) { if (replacer) v=replacer(k,v); if (!k) return (a=Array.isArray(v),val=v); !o && (o=a?[]:{}); o[k] = _build(v, a?depth:depth-1); } }),
               o===void 0 ? (a?[]:{}) : o);
    }
    return JSON.stringify(_build(val, depth), null, space);
}

var o = {id:'SOMEGUID',t:true};
var value={a:[12,2,{y:3,z:{o1:o}}],s:'!',b:{x:1,o2:o,o3:o}};

console.log(stringify(value, 0, (k,v)=>{console.log('key:'+k+';val:',v); return v}, 2));
console.log(stringify(value, 1, (k,v)=>{console.log('key:'+k+';val:',v); return v}, 2));
console.log(stringify(value, 2, (k,v)=>{console.log('key:'+k+';val:',v); return v}, 2));
console.log(stringify(value, 3, (k,v)=>{console.log('key:'+k+';val:',v); return v}, 2));
console.log(stringify(value, 4, (k,v)=>{console.log('key:'+k+';val:',v); return v}, 2, (v)=>{return v.id}));

{}

{
  "a": [
    12,
    2,
    {}
  ],
  "s": "!",
  "b": {}
}

{
  "a": [
    12,
    2,
    {
      "y": 3,
      "z": {}
    }
  ],
  "s": "!",
  "b": {
    "x": 1,
    "o2": {},
    "o3": null
  }
}

{
  "a": [
    12,
    2,
    {
      "y": 3,
      "z": {
        "o1": {}
      }
    }
  ],
  "s": "!",
  "b": {
    "x": 1,
    "o2": null,
    "o3": null
  }
}

{
  "a": [
    12,
    2,
    {
      "y": 3,
      "z": {
        "o1": {
          "id": "SOMEGUID",
          "t": true
        }
      }
    }
  ],
  "s": "!",
  "b": {
    "x": 1,
    "o2": "SOMEGUID",
    "o3": "SOMEGUID"
  }

(taken from my post here https://stackoverflow.com/a/57193068/1236397)

Here is a TypeScript version:

/** A more powerful version of the built-in JSON.stringify() function that uses the same function to respect the
 * built-in rules while also limiting depth and supporting cyclical references.
 */
export function stringify(val: any, depth: number, replacer: (this: any, key: string, value: any) => any, space?: string | number, onGetObjID?: (val: object) => string): string {
    depth = isNaN(+depth) ? 1 : depth;
    var recursMap = new WeakMap();
    function _build(val: any, depth: number, o?: any, a?: boolean, r?: boolean) {
        return !val || typeof val != 'object' ? val
            : (r = recursMap.has(val),
                recursMap.set(val, true),
                a = Array.isArray(val),
                r ? (o = onGetObjID && onGetObjID(val) || null) : JSON.stringify(val, function (k, v) { if (a || depth > 0) { if (replacer) v = replacer(k, v); if (!k) return (a = Array.isArray(v), val = v); !o && (o = a ? [] : {}); o[k] = _build(v, a ? depth : depth - 1); } }),
                o === void 0 ? (a?[]:{}) : o);
    }
    return JSON.stringify(_build(val, depth), null, space);
}

Note: Arrays are treated like strings - an array of primitive values; thus, any nested object items are treated as the next level instead of the array object itself (much like how a string can be an array of characters, but is one entity).

Update: Fixed a bug where empty arrays rendered as empty objects.

James Wilkins
  • 6,836
  • 3
  • 48
  • 73
  • Why minify the code, should instead publish it on npm or github for people to use. – Viktor M May 23 '23 at 22:40
  • I did not minify it, that's how I programmed it. Every part was typed by my own hands, not generated, so that IS the code, no need to post any "original". ;) Also, it was taken from code I made for a personal project, so I just pasted it here (the letters have meaning to me). When you get a lot of experience, you can easily write and read small bits of short-hand code. :) – James Wilkins Jun 09 '23 at 05:39
2

You can simply use a Censor function like in the example below:

function censor(key, value) {
  if (typeof(value) == "string") {
    return undefined;
  }
  return value;
}

var foo = {foundation: "Mozilla", model: "box", week: 45, transport: "car", month: 7};
var jsonString = JSON.stringify(foo, censor);

The output is {"week":45,"month":7}.

So as for your example, you have to return undefined if you have a value object, which is a window.

Gábor Lipták
  • 9,646
  • 2
  • 59
  • 113
0

I think that the format you're using is just unproper to do what you want. Getting all datas contained in the window object to a single JSON string suppose you keep this string in memory during you're building her causing issues you encountered.

You need a format givin you the ability to send datas as it is parsed from the window object in order to free memory on the fly. For that matter, you should use something like CSV, Text or VarStream ( https://github.com/nfroidure/VarStream ).

You could also iterate throught object and try to JSON.stringify them in a try ... catch. If the try is a success, you send the JSON file, if it fails, you iterate througt the object properties with the same try ... catch etc... But it's a ugly workaround i do not encourage you to use.

nfroidure
  • 1,531
  • 12
  • 20
0

Here's my stringifier to stripped JSON for safe logging of objects with cyclical references, DOM elements, angular scopes, or window.

Prevents TypeError: Converting circular structure to JSON by replacing circular references with ''.

Prevents RangeError: Maximum call stack size exceeded. However, it's recommended to use maxDepth or filterObjects anyway, because serializing very deep objects costs both time and space, which may lower its usability for general logging, and even make the test browser disconnect when used in tests.

Optionally:

  • limits object inspection depth (not implemented yet),
  • filters objects (like window, test framework, test runner),
  • filters DOM elements,
  • filters angular object $attributes.

Source+comments: https://gist.github.com/iki/9371373

iki
  • 101
  • 1
  • 7
0
(function (input, level) {
    if (!input)
        return input;

    level = level || 4;

    var objectsAlreadySerialized = [input],
        objDepth = [input];

    return JSON.stringify(input, function (key, value) {
        if (key) {
            if (typeof value === 'object') {
                if (objectsAlreadySerialized.indexOf(value) !== -1)
                    return undefined;

                objectsAlreadySerialized.push(value);
            }

            if (objDepth.indexOf(this) === -1)
                objDepth.push(this);
            else while(objDepth[objDepth.length-1] !== this)
                objDepth.pop();

            if (objDepth.length > level)
                return undefined;
        }

        return value;
    });
})(window, 6)
wscourge
  • 10,657
  • 14
  • 59
  • 80
Jarle
  • 21
  • 2
    While this code may answer the question, providing additional context regarding how and/or why it solves the problem would improve the answer's long-term value. – Donald Duck Mar 08 '17 at 11:23
-6

You could just maintain the depth you're at:

function stringify(obj, currentDepth, maxDepth) {
  if (currentDepth == maxDepth) return '[Warning: max level reached]'
  var str = '{';
  for (var key in obj) {
    str += key + ': ' + typeof obj == 'object' ?
        stringify(obj[key], currentDepth + 1, maxDepth) :
        obj[key];
  }
  return str + '}'
}

(just example- obviously this snippet doesn't detect recursion)

Nick
  • 220
  • 1
  • 2
  • 12