先记住这个答案
深拷贝就是把对象每一层都复制一份,改副本的任何一层都不会影响原对象。写法是递归:值是 null 或者不是对象,就直接返回;是数组就新建 [],是普通对象就新建 {};然后遍历它自己的属性,对每个属性值再调用一次深拷贝。判断时要先排除 null,因为 typeof null 也是 object。这个基础版能处理 JSON 那种纯数据,碰到循环引用、Date、Map 这些就要继续扩展。
typeof null是object,要先单独排除- 用
Array.isArray决定新建[]还是{} for...in要配hasOwnProperty过滤继承属性- 基础版不管
Date、Map、循环引用
递归版的写法和验证
先写出实现,再改副本最里层的值,看原对象有没有被影响。
function deepClone(source) {
if (source === null || typeof source !== 'object') return source // 原始值、null、函数
const target = Array.isArray(source) ? [] : {}
for (const key in source) {
if (Object.prototype.hasOwnProperty.call(source, key)) {
target[key] = deepClone(source[key]) // 每个值再深拷贝一次
}
}
return target
}
const form = { user: { name: 'Tom', tags: ['a', 'b'] }, count: 1 }
const draft = deepClone(form)
draft.user.name = 'Jerry'
draft.user.tags.push('c')
console.log(JSON.stringify(form))
console.log(JSON.stringify(draft))
console.log(Array.isArray(draft.user.tags), draft.user !== form.user)查看输出与解释
{"user":{"name":"Tom","tags":["a","b"]},"count":1}
{"user":{"name":"Jerry","tags":["a","b","c"]},"count":1}
true true副本改了第二层的 name,又往第三层的数组里加了元素,原对象都没变。tags 拷贝后仍然是数组,说明 Array.isArray 那一步生效了。如果这里用 Object.assign({}, form) 做浅拷贝,draft.user 和 form.user 还是同一个对象,改名字会连原对象一起改掉。
每个判断条件为什么这么写
source === null || typeof source !== 'object' 这一行同时处理了三类值。字符串、数字、布尔、undefined、Symbol、BigInt 这些原始值本身不可变,直接返回就是复制。函数的 typeof 是 function,也会直接返回,副本和原对象共用同一个函数,一般业务里这就是想要的效果。null 要写在前面,否则它会被当成对象去新建 {}。
- 数组为什么要单独判断
- 如果统一新建
{},数组拷贝出来就变成了{ "0": "a", "1": "b" },没有length,也不能map。Array.isArray是最可靠的判断方式。 for...in为什么要配hasOwnPropertyfor...in除了自己的属性,还会遍历原型链上可枚举的属性。不过滤的话,别人往原型上加的属性会被复制成副本自己的属性。直接用Object.keys(source)也行,它只返回自有的可枚举属性,效果相同。
基础版处理不了的数据
基础版把所有对象都当成普通对象,遇到内置对象不会报错,而是悄悄得到一个空壳,这是它最危险的地方。
Date、Set 和 Symbol 键JavaScriptfunction deepClone(source) {
if (source === null || typeof source !== 'object') return source
const target = Array.isArray(source) ? [] : {}
for (const key of Object.keys(source)) target[key] = deepClone(source[key])
return target
}
const id = Symbol('id')
const src = { created: new Date(0), tags: new Set([1, 2]), [id]: 42 }
const copy = deepClone(src)
console.log(copy.created instanceof Date) // 变成了普通对象
console.log(copy.tags.size) // Set 的内容全丢了
console.log(copy[id]) // Symbol 键没被复制查看输出与解释
false
undefined
undefinedDate 和 Set 的数据存在内部槽里,不是可枚举属性,遍历属性什么也拿不到,所以得到空对象。Object.keys 和 for...in 都不包含 Symbol 键。要支持它们,就要按类型加分支:Date 用 new Date(source),Map 和 Set 新建后逐项深拷贝,Symbol 键用 Reflect.ownKeys 取。
容易答错的地方
- 只写
typeof source === 'object'判断 - 忘了排除
null,null就会被拷成{}。原数据里明确表示「没有」的字段,拷贝后变成了空对象,后面的判空逻辑全部失效。 - 以为
JSON.parse(JSON.stringify(obj))等价于深拷贝 - 它只适合纯 JSON 数据。
undefined、函数、Symbol属性会被丢掉,Date变成字符串,NaN和Infinity变成null,Map和Set变成{},遇到循环引用直接抛TypeError。
面试官还会怎么问?
现在有原生的深拷贝吗?
有,structuredClone(value),现代浏览器和 Node.js 都支持。它能正确处理 Date、Map、Set、RegExp 和循环引用,但不能拷贝函数和 DOM 节点,遇到会抛 DataCloneError;类的实例拷贝后也会丢掉原型,变成普通对象。
对象嵌套很深会怎样?
每深一层递归就多一个调用栈帧,层数足够多时会抛 RangeError。业务数据很少深到这个程度,如果数据来自外部、层数不可控,可以改成用栈或队列的迭代写法。
为什么不复制函数?
函数里的代码本身不可变,共享一个引用不会造成数据互相影响,复制它也没有实际意义。真正的问题在于函数里引用的外部变量,这是深拷贝管不到的,所以通常直接返回原函数。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。