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

前言

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 中的循环引用?欢迎在评论区交流你的思路~💬

相关推荐
崔庆才丨静觅17 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby606118 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了18 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅18 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅18 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅19 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment19 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅19 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊19 小时前
jwt介绍
前端
爱敲代码的小鱼19 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax