😈 藏在对象里的 “无限套娃”?教你一眼识破循环引用诡计!

前言

Hello~大家好。我是秋天的一阵风

在之前的文章里,我们聊过深拷贝的两种实现方案:

  • 用得最多但坑不少的 JSON.parse(JSON.stringify())
  • 浏览器原生支持的 structuredClone()

其中 structuredClone 最让人惊喜的能力,就是能优雅解决循环引用导致的拷贝失败问题。但在实际开发中,我们往往需要先判断一个对象是否存在循环引用,再决定用哪种方案处理。

Tips:

如果你对这两者之间的对比感兴趣可以点击我的这篇文章:

告别老套!structuredClone:新一代深拷贝神器,让 JSON.parse 和 JSON.stringify 靠边站!

今天就来手把手教大家:如何自己实现一个循环引用检测器。

一、先搞懂:什么是循环引用? 🌀

先看个直观的例子

js 复制代码
const obj = { name: "循环引用" };

obj.self = obj; // 对象的属性引用了自身

这时如果你尝试用 JSON.stringify(obj) 序列化,会直接报错:Converting circular structure to JSON。

循环引用的本质是:对象的某个属性(直接或间接)指向了对象本身,形成一个闭环。 就像贪吃蛇咬到了自己的尾巴🐍

再看个间接引用的例子

js 复制代码
const a = {};
const b = { a };

a.b = b; // a 引用 b,b 引用 a,形成闭环

这种结构在处理复杂数据(如树形组件状态、图表配置)时很常见,所以检测循环引用是前端工程师的必备技能。

二、核心思路:用 "访问记录" 追踪引用 🔍​

判断循环引用的关键在于:检查当前对象是否在之前的遍历过程中出现过

实现方案:

  1. 用 WeakMap 存储已访问的对象(键是对象引用,值是布尔值)

  2. 递归遍历对象的所有属性

  3. 每次遇到对象类型的值,先检查是否在 WeakMap 中存在:

  • 存在 → 说明有循环引用

  • 不存在 → 加入 WeakMap 继续遍

为什么用 WeakMap 而不是 Map?

因为 WeakMap 的键是弱引用,不会阻止垃圾回收机制清理这些对象,避免内存泄漏👍​

三、动手实现:循环引用检测函数 🛠️

js 复制代码
      function hasCircularReference(target) {
        // 存储已访问的对象
        const visited = new WeakMap();

        // 递归检查函数
        function check(node) {
          // 非对象类型直接返回(只有对象才有引用关系)
          if (typeof node !== "object" || node === null) {
            return false;
          }

          // 如果已经访问过,说明存在循环引用
          if (visited.has(node)) {
            return true;
          }

          // 标记为已访问
          visited.set(node, true);
          // 遍历自身属性(不包括原型链)
          for (const key of Object.keys(node)) {
            // 递归检查子属性
            if (check(node[key])) {
              return true;
            }
          }

          // 遍历完所有属性都没发现循环引用
          return false;
        }
        return check(target);
      }  return check(target);
}

测试用例:

js 复制代码
// 测试1:存在循环引用

const obj1 = {};

obj1.self = obj1;

console.log(hasCircularReference(obj1)); // true

// 测试2:间接循环引用

const a = {};

const b = { a };

a.b = b;

console.log(hasCircularReference(a)); // true

// 测试3:正常对象

const obj2 = { name: "正常对象", child: { age: 18 } };

console.log(hasCircularReference(obj2)); // false

四、代码优化:从能用 to 好用 ✨

上面的基础版能工作,但在处理大型对象时还有优化空间:

优化 1:用迭代代替递归(防栈溢出)

递归深度过大会导致 Maximum call stack size exceeded 错误,改成迭代版更安全:​

js 复制代码
  function hasCircularReferenceV2(target) {
        const visited = new WeakMap();

        const stack = [target]; // 用栈模拟递归调用

        while (stack.length > 0) {
          const node = stack.pop(); // 取最后一个元素(深度优先)
          if (typeof node !== "object" || node === null) {
            continue;
          }

          if (visited.has(node)) {
            return true;
          }

          visited.set(node, true);
          // 将子属性推入栈
          Object.keys(node).forEach((key) => {
            stack.push(node[key]);
          });
        }
        return false;
      }

优化 2:增加参数校验和类型支持

实际开发中需要处理更多边缘情况:

js 复制代码
  function hasCircularReferenceV3(target) {
        // 严格校验输入类型

        if (typeof target !== "object" || target === null) {
          throw new TypeError("检测目标必须是对象或数组");
        }

        const visited = new WeakMap();

        const stack = [target];

        while (stack.length > 0) {
          const node = stack.pop();

          // 支持数组类型(数组也是对象)
          if (Array.isArray(node)) {
            node.forEach((item) => stack.push(item));

            continue;
          }

          // 处理普通对象
          if (typeof node === "object" && node !== null) {
            if (visited.has(node)) {
              return true;
            }

            visited.set(node, true);

            Object.keys(node).forEach((key) => stack.push(node[key]));
          }
        }

        return false;
      }

优化 3:终极版:处理特殊情况

有一种特殊情况需要说明:当在对象中添加一个属性 c,且 c 的值等于该对象的另一个属性 a 时(即 obj.c = obj.a),这种情况其实并不属于真正的循环引用。

但此时我们的 hasCircularReferenceV3 函数仍会返回 true,示例代码如下:

js 复制代码
   const obj = {
        a: {
          b: 1,
        },
      };
      obj.c = obj.a;
      console.log(hasCircularReferenceV3(obj));

这是因为在遍历过程中,a 属性指向的对象已被存入 visited 集合,当后续遍历到 c 属性时,由于它与 a 指向同一个对象,自然会判定为已访问过。

而真正的循环引用应该是形成闭环的情况,例如 obj.a = objobj.a.b = obj 这样的结构。

因此我们需要进一步优化代码,思路是为每个属性值(若为对象)创建独立的 Set 来存储访问记录,通过这种方式就能准确区分是否为真正的循环引用。

js 复制代码
   function hasCircularReferenceSimple(target) {
        const path = new Set();

        function check(obj) {
          if (path.has(obj)) return true;
          path.add(obj);

          try {
            if (Array.isArray(obj)) {
              return obj.some(
                (item) => typeof item === "object" && item && check(item)
              );
            } else if (obj && typeof obj === "object") {
              return Object.values(obj).some(
                (value) => typeof value === "object" && value && check(value)
              );
            }
          } finally {
            path.delete(obj);
          }
          return false;
        }

        return check(target);
      }

      const obj = {
        a: {
          b: 1,
        },
      };
      obj.c = obj.a; // 共享引用,不是循环引用
      console.log("=== 共享引用测试 ===", hasCircularReferenceSimple(obj)); // false

      // 真正的循环引用测试
      const circularObj = {
        a: 1,
        b: 2,
      };
      circularObj.self = circularObj; // 真正的循环引用
      console.log(
        "=== 循环引用测试 ===",
        hasCircularReferenceSimple(circularObj)
      ); // true

      // 深层循环引用测试
      const deepCircular = {
        a: {
          b: {
            c: 1,
          },
        },
      };
      deepCircular.a.b.d = deepCircular; // 深层循环引用
      console.log(
        "=== 深层循环引用测试 ===",
        hasCircularReferenceSimple(deepCircular)
      ); // true

      // 数组循环引用测试
      const arr = [1, 2, 3];
      arr.push(arr); // 数组循环引用
      console.log("=== 数组循环引用测试 ===", hasCircularReferenceSimple(arr)); // true

      // 复杂共享引用测试
      const sharedObj = { value: 42 };
      const complexObj = {
        x: sharedObj,
        y: sharedObj,
        z: { nested: sharedObj },
      };
      console.log(
        "=== 复杂共享引用测试 ===",
        hasCircularReferenceSimple(complexObj)
      ); // false

结语:理解引用,才能驾驭对象 🚴‍♂️​

JavaScript 中的对象引用机制是很多高级特性的基础,循环引用只是其中一个典型场景。

掌握今天的检测方法,不仅能帮你解决深拷贝问题,还能在处理复杂状态管理、数据可视化等场景时避开很多坑。

最后留个小练习:如何修改代码,让它能检测 Set 和 Map 中的循环引用?欢迎在评论区交流你的思路~💬

相关推荐
Ice_Sugar_713 分钟前
CSS:BFC
前端·css
Java中文社群13 分钟前
说说内存泄漏的常见场景和排查方案?
java·后端·面试
林太白17 分钟前
Vue3 导入导出
前端
_Kayo_30 分钟前
JS深拷贝 浅拷贝、CSS垂直水平居中
开发语言·前端·javascript
key_Go41 分钟前
18.WEB 服务器
服务器·前端·firefox
C4程序员1 小时前
北京JAVA基础面试30天打卡08
java·开发语言·面试
碎像1 小时前
uni-app实战教程 从0到1开发 画图软件 (学会画图)
前端·javascript·css·程序人生·uni-app
Hilaku1 小时前
从“高级”到“资深”,我卡了两年和我的思考
前端·javascript·面试
WebInfra2 小时前
Rsdoctor 1.2 发布:打包产物体积一目了然
前端·javascript·github
用户52709648744902 小时前
SCSS模块系统详解:@import、@use、@forward 深度解析
前端