前言
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,形成闭环
这种结构在处理复杂数据(如树形组件状态、图表配置)时很常见,所以检测循环引用是前端工程师的必备技能。
二、核心思路:用 "访问记录" 追踪引用 🔍
判断循环引用的关键在于:检查当前对象是否在之前的遍历过程中出现过
实现方案:
-
用 WeakMap 存储已访问的对象(键是对象引用,值是布尔值)
-
递归遍历对象的所有属性
-
每次遇到对象类型的值,先检查是否在
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 = obj
或 obj.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 中的循环引用?欢迎在评论区交流你的思路~💬