前端面试 P004:手写深拷贝、循环引用与实现边界
目前顯示中文(中國)原文
来源:方格前端讲面试 · P4:深浅拷贝的本质(含常见实现) 的手写深拷贝部分。概念与内建工具对比见 前端面试 P004:浅拷贝、深拷贝与 structuredClone。
简化版本的核心步骤
- 原始值直接返回。
- 已经见过的对象,返回之前建立的副本。
- 为数组或普通对象建立新容器。
- 先登记“原对象 → 副本”,再递归处理子属性。
下面保留课件简化实现的主要结构,用来学习递归和循环引用处理;它不是通用对象克隆器。
function deepCloneBasic(value, seen = new WeakMap()) {
if (value === null || typeof value !== 'object') {
return value;
}
if (seen.has(value)) {
return seen.get(value);
}
const copy = Array.isArray(value) ? [] : {};
seen.set(value, copy);
for (const key in value) {
if (Object.prototype.hasOwnProperty.call(value, key)) {
copy[key] = deepCloneBasic(value[key], seen);
}
}
return copy;
}
const original = { nested: { count: 1 } };
original.self = original;
const copy = deepCloneBasic(original);
console.log(copy !== original); // true
console.log(copy.nested !== original.nested); // true
console.log(copy.self === copy); // true
为什么必须先登记,再递归
处理 original.self 时又会遇到 original。如果此时缓存里已有它的副本,就返回该副本,建立 copy.self === copy。
如果直到所有子属性处理完才登记,第一次遇到 self 时缓存仍为空,会继续递归同一个对象,最终导致调用栈溢出。
这张映射还会保留对象图内部的共享关系:如果原对象的两个属性指向同一个子对象,副本的对应属性也应指向同一个克隆后的子对象,而不是分别复制两次。
为什么使用 WeakMap
键是原对象,值是对应副本,可以按对象身份查找。WeakMap 不会仅因为保存了键,就阻止该键对象被垃圾回收。
在一次同步克隆操作里,普通 Map 也能实现循环引用检测;WeakMap 的弱引用特点不意味着算法可以不处理递归深度或内存开销。
需要明确标注的实现限制
| 输入或特征 | 这个简化实现的表现 |
|---|---|
| 普通对象、稠密数组中的可枚举自有字符串属性 | 能展示基本递归复制 |
| 循环引用、重复引用同一子对象 | 通过 seen 映射处理 |
| Date、Map、Set 等内建对象 | 没有对应类型的专门复制逻辑,结果不正确 |
| Symbol 键、不可枚举属性 | for...in 不会复制它们 |
| 自定义原型、属性描述符 | 不会原样保留 |
| 函数 | 直接返回原函数,仍共享该函数对象 |
| getter | 读取属性时可能执行,并不会保留访问器描述符 |
| 深层嵌套 | 递归过深仍可能造成调用栈溢出 |
数组还有一个容易漏掉的情况:
const sparse = new Array(3);
const copy = [];
for (const key in sparse) {
if (Object.prototype.hasOwnProperty.call(sparse, key)) {
copy[key] = sparse[key];
}
}
console.log(sparse.length); // 3
console.log(copy.length); // 0
稀疏数组的空位不是自有元素属性,length 又不可枚举,所以仅建立 [] 并遍历属性会丢失这种数组的长度。若要扩展实现,至少需要考虑按原长度建立数组容器;这仍不能自动解决表中的其他限制。
还应谨慎处理 __proto__ 等特殊键,避免直接赋值触发目标对象继承的 setter。读取并定义属性描述符可以处理一部分问题,但这需要明确复制策略,不应只靠增加一个递归分支就声称支持任意对象。
面试时如何解释边界
先说明支持范围,再讲原始值分支、容器创建、缓存映射与递归顺序。接着说明 Date、Map、Set、Symbol 键、原型、访问器等需要额外策略。业务代码优先选择匹配数据类型的内建能力或经过验证的库,不直接把教学版本当作通用实现。