"深拷贝怎么写"是 JS 面试的常客,但它不只是面试题。真实项目里,只要你改过一份"看起来是新对象、结果原对象也跟着变"的数据,就踩过浅拷贝的坑。
这篇从问题出发,把各种方案的边界讲清楚,最后手写一个够用的深拷贝。
浅拷贝的问题
先明确:JS 里对象是按引用存储的。直接赋值只是复制了引用,两个变量指向同一块内存。
const a = { user: { name: 'Tom' } }
const b = a
b.user.name = 'Jerry'
console.log(a.user.name) // 'Jerry' —— a 也被改了浅拷贝(shallow copy)比直接赋值进了一步:它复制了第一层,但嵌套的对象仍然是共享引用。
const a = { count: 1, user: { name: 'Tom' } }
const b = { ...a }
b.count = 2
console.log(a.count) // 1 —— 第一层是独立的
b.user.name = 'Jerry'
console.log(a.user.name) // 'Jerry' —— 第二层还是共享的常见的浅拷贝方式:
const b1 = { ...a } // 扩展运算符
const b2 = Object.assign({}, a) // Object.assign
const arr2 = [...arr] // 数组扩展
const arr3 = arr.slice() // slice只要数据是一层的扁平结构,浅拷贝完全够用,而且最快。问题出在嵌套数据上。
JSON 大法:能用,但坑很多
最常被随手用的深拷贝:
const clone = JSON.parse(JSON.stringify(obj))它确实能拷贝任意层级,但代价是会悄悄丢失或改变一堆东西:
const obj = {
date: new Date(),
reg: /abc/g,
fn: () => 1,
undef: undefined,
inf: Infinity,
nan: NaN,
sym: Symbol('s'),
big: 10n,
}
console.log(JSON.parse(JSON.stringify(obj)))
// {
// date: '2026-06-20T...' → 变成了字符串
// reg: {} → 正则变成空对象
// inf: null, nan: null → Infinity / NaN 变 null
// fn / undef / sym 这三个 key 直接消失了
// }
// big: 10n 会直接抛错 TypeError: Do not know how to serialize a BigInt总结它的"丢失清单":
Date→ 变字符串RegExp/Map/Set→ 变空对象{}function/undefined/Symbol→ 整个键被丢掉Infinity/NaN→ 变nullBigInt→ 直接抛错- 循环引用 → 直接抛错
Converting circular structure to JSON
所以这个写法只适合:纯粹由 string / number / boolean / null / 普通对象 / 数组组成的数据(比如大多数接口返回的 JSON)。一旦数据里有 Date、Map 之类,就别用它。
structuredClone:现代标准方案
浏览器和 Node 17+ 现在内置了 structuredClone,这是目前最省事的深拷贝:
const clone = structuredClone(obj)它基于结构化克隆算法,能正确处理大量类型:
Date、RegExp、Map、Set、ArrayBuffer、TypedArray、Blob、File等- 循环引用也能正确处理
但它也有自己的边界:
- 函数会抛错
DataCloneError(无法克隆) Symbol会抛错- DOM 节点会抛错
- 原型链会丢失:克隆类实例后,结果是普通对象,不再是原来的类实例
- 不会拷贝 getter/setter、不可枚举属性
structuredClone({ fn: () => 1 }) // ❌ DataCloneError
class Point { constructor(x) { this.x = x } hi() {} }
const p = structuredClone(new Point(1))
console.log(p instanceof Point) // false —— 变成普通对象了结论:如果数据里没有函数、且不依赖类实例的方法,优先用 structuredClone,简单又可靠。需要拷函数、保留原型,才考虑手写。
手写一个深拷贝
面试要手写,实战里偶尔也要(比如要拷贝函数引用、或想完全掌控行为)。我们一步步加强。
第一版:基础递归
function deepClone(value) {
if (value === null || typeof value !== 'object') {
return value // 基本类型 + 函数,直接返回
}
const result = Array.isArray(value) ? [] : {}
for (const key in value) {
if (Object.prototype.hasOwnProperty.call(value, key)) {
result[key] = deepClone(value[key])
}
}
return result
}这版能处理普通对象和数组的嵌套,但有两个明显问题:遇到循环引用会爆栈,而且 Date、Map、Set、RegExp 都拷不对。
第二版:处理循环引用
循环引用是指对象间接或直接引用了自己:
const a = {}
a.self = a // 循环引用解决办法:用一个 WeakMap 记录"已经拷贝过的对象 → 它的副本"。再次遇到同一个引用时,直接返回已有副本。用 WeakMap 是因为它的键是弱引用,不会阻止垃圾回收。
function deepClone(value, cache = new WeakMap()) {
if (value === null || typeof value !== 'object') {
return value
}
// 命中缓存,说明是循环引用,直接返回之前的副本
if (cache.has(value)) {
return cache.get(value)
}
const result = Array.isArray(value) ? [] : {}
cache.set(value, result) // 先登记,再递归,关键顺序
for (const key in value) {
if (Object.prototype.hasOwnProperty.call(value, key)) {
result[key] = deepClone(value[key], cache)
}
}
return result
}注意 cache.set(value, result) 必须在递归之前,否则循环引用还是会无限递归。
第三版:处理特殊类型
把 Date、RegExp、Map、Set、Symbol 键都补上。这一版基本可以应付绝大多数实战场景:
function deepClone(value, cache = new WeakMap()) {
// 基本类型 + 函数:直接返回(函数一般共享引用即可)
if (value === null || typeof value !== 'object') {
return value
}
// 循环引用
if (cache.has(value)) return cache.get(value)
// Date
if (value instanceof Date) return new Date(value.getTime())
// RegExp(保留源和标志,lastIndex)
if (value instanceof RegExp) {
const re = new RegExp(value.source, value.flags)
re.lastIndex = value.lastIndex
return re
}
// Map
if (value instanceof Map) {
const result = new Map()
cache.set(value, result)
value.forEach((v, k) => result.set(deepClone(k, cache), deepClone(v, cache)))
return result
}
// Set
if (value instanceof Set) {
const result = new Set()
cache.set(value, result)
value.forEach((v) => result.add(deepClone(v, cache)))
return result
}
// 数组 / 普通对象;用 getPrototypeOf 让结果尽量保留原型
const result = Array.isArray(value)
? []
: Object.create(Object.getPrototypeOf(value))
cache.set(value, result)
// Reflect.ownKeys 能同时拿到 string 键和 Symbol 键
for (const key of Reflect.ownKeys(value)) {
result[key] = deepClone(value[key], cache)
}
return result
}相比前两版的改进:
- 用
instanceof分别处理了Date/RegExp/Map/Set - 用
Reflect.ownKeys替代for...in,能拷到 Symbol 键 - 用
Object.create(Object.getPrototypeOf(value))尽量保留原型(类实例的方法挂在原型上,这样不会丢)
这已经覆盖了日常 99% 的需求。真要做到工业级(getter/setter、不可枚举属性、各种 TypedArray),代码会膨胀很多,那时直接上 lodash 的 cloneDeep 更实际。
几种方案怎么选
| 方案 | 能拷嵌套 | 函数 | Date/Map/Set | 循环引用 | 速度 | 适用 |
|---|---|---|---|---|---|---|
| 扩展运算符 / Object.assign | 仅一层 | — | — | — | 最快 | 扁平数据 |
| JSON.parse(JSON.stringify) | 是 | 丢失 | 丢失/出错 | 报错 | 中 | 纯 JSON 数据 |
| structuredClone | 是 | 报错 | 支持 | 支持 | 较快 | 无函数、不依赖类方法 |
| 手写 deepClone | 是 | 可保留引用 | 可支持 | 可支持 | 看实现 | 需完全掌控 |
| lodash cloneDeep | 是 | 处理 | 支持 | 支持 | 中 | 工业级、省心 |
实战决策:
- 数据是扁平的 → 浅拷贝(
{ ...obj }),最快。 - 纯 JSON、要快糙猛 →
JSON.parse(JSON.stringify()),但确认没有特殊类型。 - 现代环境、无函数 →
structuredClone,首选。 - 要拷函数 / 保留类实例 / 完全可控 → 手写或 lodash。
总结
深拷贝没有银弹,关键是先看清你的数据长什么样:
- 一层就好 → 别上深拷贝,浅拷贝足够。
- 全是 JSON 基本类型 → JSON 大法或
structuredClone。 - 有 Date/Map/Set 等 →
structuredClone或手写。 - 有函数、要保留原型 → 手写或 lodash。
手写版的三个关键点也值得记牢:用 WeakMap 解决循环引用、set 要在递归前、用 Reflect.ownKeys 拷 Symbol 键。把这几点想明白,这道"面试题"就再也难不住你了。