Detecting and fixing circular references in JavaScript

后端 未结 15 806
庸人自扰
庸人自扰 2020-11-28 23:29

Given I have a circular reference in a large JavaScript object

And I try JSON.stringify(problematicObject)

And the browser throws

相关标签:
15条回答
  • 2020-11-29 00:10

    @tmack's answer is definitely what I was looking for when I found this question!

    Unfortunately it returns many false positives - it returns true if an object is replicated in the JSON, which isn't the same as circularity. Circularity means that an object is its own child, e.g.

    obj.key1.key2.[...].keyX === obj
    

    I modified the original answer, and this is working for me:

    function isCyclic(obj) {
      var keys = [];
      var stack = [];
      var stackSet = new Set();
      var detected = false;
    
      function detect(obj, key) {
        if (obj && typeof obj != 'object') { return; }
    
        if (stackSet.has(obj)) { // it's cyclic! Print the object and its locations.
          var oldindex = stack.indexOf(obj);
          var l1 = keys.join('.') + '.' + key;
          var l2 = keys.slice(0, oldindex + 1).join('.');
          console.log('CIRCULAR: ' + l1 + ' = ' + l2 + ' = ' + obj);
          console.log(obj);
          detected = true;
          return;
        }
    
        keys.push(key);
        stack.push(obj);
        stackSet.add(obj);
        for (var k in obj) { //dive on the object's children
          if (Object.prototype.hasOwnProperty.call(obj, k)) { detect(obj[k], k); }
        }
    
        keys.pop();
        stack.pop();
        stackSet.delete(obj);
        return;
      }
    
      detect(obj, 'obj');
      return detected;
    }
    

    Here are a few very simple tests:

    var root = {}
    var leaf = {'isleaf':true};
    var cycle2 = {l:leaf};
    var cycle1 = {c2: cycle2, l:leaf};
    cycle2.c1 = cycle1
    root.leaf = leaf
    
    isCyclic(cycle1); // returns true, logs "CIRCULAR: obj.c2.c1 = obj"
    isCyclic(cycle2); // returns true, logs "CIRCULAR: obj.c1.c2 = obj"
    isCyclic(leaf); // returns false
    isCyclic(root); // returns false
    
    0 讨论(0)
  • 2020-11-29 00:10

    This is a fix for both @Trey Mack and @Freddie Nfbnm answers on the typeof obj != 'object' condition. Instead it should test if the obj value is not instance of object, so that it can also work when checking values with object familiarity (for example, functions and symbols (symbols aren't instance of object, but still addressed, btw.)).

    I'm posting this as an answer since I can't comment in this StackExchange account yet.

    PS.: feel free to request me to delete this answer.

    function isCyclic(obj) {
      var keys = [];
      var stack = [];
      var stackSet = new Set();
      var detected = false;
    
      function detect(obj, key) {
        if (!(obj instanceof Object)) { return; } // Now works with other
                                                  // kinds of object.
    
        if (stackSet.has(obj)) { // it's cyclic! Print the object and its locations.
          var oldindex = stack.indexOf(obj);
          var l1 = keys.join('.') + '.' + key;
          var l2 = keys.slice(0, oldindex + 1).join('.');
          console.log('CIRCULAR: ' + l1 + ' = ' + l2 + ' = ' + obj);
          console.log(obj);
          detected = true;
          return;
        }
    
        keys.push(key);
        stack.push(obj);
        stackSet.add(obj);
        for (var k in obj) { //dive on the object's children
          if (obj.hasOwnProperty(k)) { detect(obj[k], k); }
        }
    
        keys.pop();
        stack.pop();
        stackSet.delete(obj);
        return;
      }
    
      detect(obj, 'obj');
      return detected;
    }
    
    0 讨论(0)
  • 2020-11-29 00:11

    CircularReferenceDetector

    Here is my CircularReferenceDetector class which outputs all the property stack information where the circularly referenced value is actually located at and also shows where the culprit references are.

    This is especially useful for huge structures where it is not obvious by the key which value is the source of the harm.

    It outputs the circularly referenced value stringified but all references to itself replaced by "[Circular object --- fix me]".

    Usage:
    CircularReferenceDetector.detectCircularReferences(value);

    Note: Remove the Logger.* statements if you do not want to use any logging or do not have a logger available.

    Technical Explanation:
    The recursive function goes through all properties of the object and tests if JSON.stringify succeeds on them or not. If it does not succeed (circular reference), then it tests if it succeeds by replacing value itself with some constant string. This would mean that if it succeeds using this replacer, this value is the being circularly referenced value. If it is not, it recursively goes through all properties of that object.

    Meanwhile it also tracks the property stack to give you information where the culprit value is located at.

    Typescript

    import {Logger} from "../Logger";
    
    export class CircularReferenceDetector {
    
        static detectCircularReferences(toBeStringifiedValue: any, serializationKeyStack: string[] = []) {
            Object.keys(toBeStringifiedValue).forEach(key => {
                var value = toBeStringifiedValue[key];
    
                var serializationKeyStackWithNewKey = serializationKeyStack.slice();
                serializationKeyStackWithNewKey.push(key);
                try {
                    JSON.stringify(value);
                    Logger.debug(`path "${Util.joinStrings(serializationKeyStack)}" is ok`);
                } catch (error) {
                    Logger.debug(`path "${Util.joinStrings(serializationKeyStack)}" JSON.stringify results in error: ${error}`);
    
                    var isCircularValue:boolean;
                    var circularExcludingStringifyResult:string = "";
                    try {
                        circularExcludingStringifyResult = JSON.stringify(value, CircularReferenceDetector.replaceRootStringifyReplacer(value), 2);
                        isCircularValue = true;
                    } catch (error) {
                        Logger.debug(`path "${Util.joinStrings(serializationKeyStack)}" is not the circular source`);
                        CircularReferenceDetector.detectCircularReferences(value, serializationKeyStackWithNewKey);
                        isCircularValue = false;
                    }
                    if (isCircularValue) {
                        throw new Error(`Circular reference detected:\nCircularly referenced value is value under path "${Util.joinStrings(serializationKeyStackWithNewKey)}" of the given root object\n`+
                            `Calling stringify on this value but replacing itself with [Circular object --- fix me] ( <-- search for this string) results in:\n${circularExcludingStringifyResult}\n`);
                    }
                }
            });
        }
    
        private static replaceRootStringifyReplacer(toBeStringifiedValue: any): any {
            var serializedObjectCounter = 0;
    
            return function (key: any, value: any) {
                if (serializedObjectCounter !== 0 && typeof(toBeStringifiedValue) === 'object' && toBeStringifiedValue === value) {
                    Logger.error(`object serialization with key ${key} has circular reference to being stringified object`);
                    return '[Circular object --- fix me]';
                }
    
                serializedObjectCounter++;
    
                return value;
            }
        }
    }
    
    export class Util {
    
        static joinStrings(arr: string[], separator: string = ":") {
            if (arr.length === 0) return "";
            return arr.reduce((v1, v2) => `${v1}${separator}${v2}`);
        }
    
    }
    

    Compiled JavaScript from TypeScript

    "use strict";
    const Logger_1 = require("../Logger");
    class CircularReferenceDetector {
        static detectCircularReferences(toBeStringifiedValue, serializationKeyStack = []) {
            Object.keys(toBeStringifiedValue).forEach(key => {
                var value = toBeStringifiedValue[key];
                var serializationKeyStackWithNewKey = serializationKeyStack.slice();
                serializationKeyStackWithNewKey.push(key);
                try {
                    JSON.stringify(value);
                    Logger_1.Logger.debug(`path "${Util.joinStrings(serializationKeyStack)}" is ok`);
                }
                catch (error) {
                    Logger_1.Logger.debug(`path "${Util.joinStrings(serializationKeyStack)}" JSON.stringify results in error: ${error}`);
                    var isCircularValue;
                    var circularExcludingStringifyResult = "";
                    try {
                        circularExcludingStringifyResult = JSON.stringify(value, CircularReferenceDetector.replaceRootStringifyReplacer(value), 2);
                        isCircularValue = true;
                    }
                    catch (error) {
                        Logger_1.Logger.debug(`path "${Util.joinStrings(serializationKeyStack)}" is not the circular source`);
                        CircularReferenceDetector.detectCircularReferences(value, serializationKeyStackWithNewKey);
                        isCircularValue = false;
                    }
                    if (isCircularValue) {
                        throw new Error(`Circular reference detected:\nCircularly referenced value is value under path "${Util.joinStrings(serializationKeyStackWithNewKey)}" of the given root object\n` +
                            `Calling stringify on this value but replacing itself with [Circular object --- fix me] ( <-- search for this string) results in:\n${circularExcludingStringifyResult}\n`);
                    }
                }
            });
        }
        static replaceRootStringifyReplacer(toBeStringifiedValue) {
            var serializedObjectCounter = 0;
            return function (key, value) {
                if (serializedObjectCounter !== 0 && typeof (toBeStringifiedValue) === 'object' && toBeStringifiedValue === value) {
                    Logger_1.Logger.error(`object serialization with key ${key} has circular reference to being stringified object`);
                    return '[Circular object --- fix me]';
                }
                serializedObjectCounter++;
                return value;
            };
        }
    }
    exports.CircularReferenceDetector = CircularReferenceDetector;
    class Util {
        static joinStrings(arr, separator = ":") {
            if (arr.length === 0)
                return "";
            return arr.reduce((v1, v2) => `${v1}${separator}${v2}`);
        }
    }
    exports.Util = Util;
    
    0 讨论(0)
  • 2020-11-29 00:16

    Most of the other answers only show how to detect that an object-tree has a circular-reference -- they don't tell you how to fix those circular references (ie. replacing the circular-reference values with, eg. undefined).

    The below is the function I use to replace all circular-references with undefined:

    export const specialTypeHandlers_default = [
        // Set and Map are included by default, since JSON.stringify tries (and fails) to serialize them by default
        {type: Set, keys: a=>a.keys(), get: (a, key)=>key, delete: (a, key)=>a.delete(key)},
        {type: Map, keys: a=>a.keys(), get: (a, key)=>a.get(key), delete: (a, key)=>a.set(key, undefined)},
    ];
    export function RemoveCircularLinks(node, specialTypeHandlers = specialTypeHandlers_default, nodeStack_set = new Set()) {
        nodeStack_set.add(node);
    
        const specialHandler = specialTypeHandlers.find(a=>node instanceof a.type);
        for (const key of specialHandler ? specialHandler.keys(node) : Object.keys(node)) {
            const value = specialHandler ? specialHandler.get(node, key) : node[key];
            // if the value is already part of visited-stack, delete the value (and don't tunnel into it)
            if (nodeStack_set.has(value)) {
                if (specialHandler) specialHandler.delete(node, key);
                else node[key] = undefined;
            }
            // else, tunnel into it, looking for circular-links at deeper levels
            else if (typeof value == "object" && value != null) {
                RemoveCircularLinks(value, specialTypeHandlers, nodeStack_set);
            }
        }
    
        nodeStack_set.delete(node);
    }
    

    For use with JSON.stringify specifically, simply call the function above prior to the stringification (note that it does mutate the passed-in object):

    const objTree = {normalProp: true};
    objTree.selfReference = objTree;
    RemoveCircularLinks(objTree); // without this line, the JSON.stringify call errors
    console.log(JSON.stringify(objTree));
    
    0 讨论(0)
  • 2020-11-29 00:17

    Here is @Thomas's answer adapted for node:

    const {logger} = require("../logger")
    // Or: const logger = {debug: (...args) => console.log.call(console.log, args) }
    
    const joinStrings = (arr, separator) => {
      if (arr.length === 0) return "";
      return arr.reduce((v1, v2) => `${v1}${separator}${v2}`);
    }
    
    exports.CircularReferenceDetector = class CircularReferenceDetector {
    
      detectCircularReferences(toBeStringifiedValue, serializationKeyStack = []) {
        Object.keys(toBeStringifiedValue).forEach(key => {
          let value = toBeStringifiedValue[key];
    
          let serializationKeyStackWithNewKey = serializationKeyStack.slice();
          serializationKeyStackWithNewKey.push(key);
          try {
            JSON.stringify(value);
            logger.debug(`path "${joinStrings(serializationKeyStack)}" is ok`);
          } catch (error) {
            logger.debug(`path "${joinStrings(serializationKeyStack)}" JSON.stringify results in error: ${error}`);
    
            let isCircularValue;
            let circularExcludingStringifyResult = "";
            try {
              circularExcludingStringifyResult = JSON.stringify(value, this.replaceRootStringifyReplacer(value), 2);
              isCircularValue = true;
            } catch (error) {
              logger.debug(`path "${joinStrings(serializationKeyStack)}" is not the circular source`);
              this.detectCircularReferences(value, serializationKeyStackWithNewKey);
              isCircularValue = false;
            }
            if (isCircularValue) {
              throw new Error(`Circular reference detected:\nCircularly referenced value is value under path "${joinStrings(serializationKeyStackWithNewKey)}" of the given root object\n`+
                  `Calling stringify on this value but replacing itself with [Circular object --- fix me] ( <-- search for this string) results in:\n${circularExcludingStringifyResult}\n`);
            }
          }
        });
      }
    
      replaceRootStringifyReplacer(toBeStringifiedValue) {
        let serializedObjectCounter = 0;
    
        return function (key, value) {
          if (serializedObjectCounter !== 0 && typeof(toBeStringifiedValue) === 'object' && toBeStringifiedValue === value) {
            logger.error(`object serialization with key ${key} has circular reference to being stringified object`);
            return '[Circular object --- fix me]';
          }
    
          serializedObjectCounter++;
    
          return value;
        }
      }
    }
    
    0 讨论(0)
  • 2020-11-29 00:19

    Here is MDN's approach to detecting and fixing circular references when using JSON.stringify() on circular objects: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Errors/Cyclic_object_value :

    In a circular structure like the following

    var circularReference = {otherData: 123};
    circularReference.myself = circularReference;
    

    JSON.stringify() will fail:

    JSON.stringify(circularReference);
    // TypeError: cyclic object value
    

    To serialize circular references you can use a library that supports them (e.g. cycle.js) or implement a solution by yourself, which will require finding and replacing (or removing) the cyclic references by serializable values.

    The snippet below illustrates how to find and filter (thus causing data loss) a cyclic reference by using the replacer parameter of JSON.stringify():

    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;
          };
        };
    
    JSON.stringify(circularReference, getCircularReplacer());
    // {"otherData":123}
    
    0 讨论(0)
提交回复
热议问题