先记住这个答案
浅拷贝只复制对象的第一层。如果某个属性的值是对象,复制过去的只是一个引用,副本和原对象指向同一个嵌套对象,改一边另一边也会变。深拷贝会一层层递归下去,把每个嵌套对象都新建一份,副本和原对象彻底分开。手写深拷贝的难点是循环引用,比如 obj.self = obj,直接递归会无限调用下去直到栈溢出。解决办法是用 WeakMap 记录「原对象到副本」的对应关系,递归前先查一下,拷过的就直接返回副本。
- 浅拷贝只复制第一层,嵌套对象共用
- 深拷贝递归复制,副本和原对象互不影响
- 用
WeakMap记录已拷贝对象,破解循环引用 structuredClone不能克隆函数
手写一个能处理循环引用的深拷贝
先看完整实现,再和浅拷贝的结果做对比。
WeakMapJavaScriptfunction deepClone(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) ? [] : Object.create(Object.getPrototypeOf(value))
seen.set(value, copy) // 先登记再递归
for (const key of Reflect.ownKeys(value)) {
copy[key] = deepClone(value[key], seen)
}
return copy
}
const obj = { tags: ['x'], nested: { n: 1 } }
obj.self = obj // 循环引用
const shallow = { ...obj }
const deep = deepClone(obj)
obj.nested.n = 2
console.log(shallow.nested.n, deep.nested.n)
console.log(deep.self === deep, deep.self === obj)查看输出与解释
2 1
true false改了原对象的 nested.n 之后,浅拷贝跟着变成了 2,深拷贝还是 1。deep.self 指向的是副本自己,而不是原对象,说明循环结构也被正确复制了。
关键在 seen.set(value, copy) 这一行。拷贝 obj 时先把它登记上,递归到 obj.self 时发现 obj 已经在表里,就直接返回正在构建的 copy,递归到此为止。这张表只在一次拷贝里用,换成 Map 结果也一样。习惯写 WeakMap,是因为它只接受对象当键、不会阻止键被回收,万一有人把这张表缓存起来复用,也不会造成内存泄漏。
Reflect.ownKeys 能拿到 Symbol 键和不可枚举的键,Object.create(Object.getPrototypeOf(value)) 保留了原型,副本仍能找到原型上的普通方法。但这不等于类实例一定能正常工作:方法若读取 # 私有字段,副本没有那个字段,调用时会抛错。这两点让这个版本比「for...in 加 {}」更完整,却仍不是通用克隆器。
不想手写时怎么选
项目里其实很少需要手写。先看数据长什么样,再选方案。
纯数据、来自接口的 JSON,用 JSON.parse(JSON.stringify(data)) 最省事。但它会丢掉值为 undefined 和函数的属性,Date 会变成字符串,NaN 变成 null,遇到循环引用直接抛 TypeError,遇到 BigInt 也会抛错。
包含 Date、Map、Set、循环引用的数据,用 structuredClone(data)。它是浏览器和 Node.js 都内置的深拷贝函数,用的是和 postMessage 相同的结构化克隆算法。它的限制是不能拷贝函数和 DOM 节点,遇到会抛 DataCloneError;类实例拷贝后原型会丢,变成普通对象;Symbol 键的属性也不会被复制。
需要保留原型、要特殊处理某些类型时,才自己写,或者用 lodash 的 cloneDeep。
容易答错的地方
- 以为
Object.assign或展开运算符是深拷贝 Object.assign({}, obj)和{ ...obj }都是浅拷贝,只复制第一层。数组的slice()、concat()、Array.from()也一样。- 用
typeof value === 'object'判断时忘了null typeof null也是'object',不先排除null,后面的Reflect.ownKeys(null)会直接报错。- 以为手写版能处理所有类型
- 上面的实现遇到
Date、Map、RegExp会拷出一个原型正确、但内部数据是空的对象,一调用getTime()之类的方法就报错。因为Date的时间值保存在引擎内部,不是普通属性,遍历键拿不到。要支持这些类型,得单独判断,比如new Date(value.getTime())。
面试官还会怎么问?
为什么用 WeakMap 而不是普通对象做缓存?
普通对象的键只能是字符串或 Symbol,对象当键会被转成 '[object Object]',所有对象都撞在一起。Map 可以用对象当键,一次性的拷贝里用它完全没问题,只是它会强引用这些对象。WeakMap 既能用对象当键,又不影响垃圾回收,缓存一旦被长期保留也更安全。
深拷贝会不会栈溢出?
循环引用解决了,但嵌套层级特别深的数据,比如一条几万层的链表,递归还是会爆栈。这种情况要把递归改成用数组模拟栈的循环写法。
函数需要深拷贝吗?
一般不需要,直接复用同一个函数引用即可。函数的闭包环境没法复制,硬要复制只能用 toString 再重新求值,会丢掉闭包,不推荐。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。