JavaScript 深浅拷贝的几种实现方式与优缺点

x123456
x123456 见习用户见习用户
发布于 2026-10-19 20:43 ·4 浏览 ·7 回复
内容摘要

浅拷贝只复制第一层,嵌套对象仍共享,深拷贝则递归复制使新旧对象完全独立;Object.assign与展开运算符适合浅拷贝但会丢失原型和不可枚举属性,JSON方式仅适合无循环引用的纯数据且会丢失特殊类型,structuredClone适合通用场景但不支持函数、Symbol和DOM节点,手写递归可用WeakMap处理循环引用并保留原型,适合需要特殊类型或原型的场景。

学完这篇你能分清浅拷贝和深拷贝的差别,并知道 Object.assign、展开运算符、JSON、structuredClone、手写递归这几种写法各自适合什么场景、会在哪里出问题。

先搞清楚区别:第一层之后还共不共享

浅拷贝只复制第一层属性,嵌套的对象/数组仍然和新对象指向同一份内存;深拷贝则递归复制到底,新旧对象完全独立。

const a = { list: [1, 2] };
const b = Object.assign({}, a);   // 浅拷贝
b.list.push(3);
console.log(a.list);              // [1, 2, 3],被改到了

判断标准很简单:改新对象里嵌套的那层,老对象会不会跟着变。

第一步:浅拷贝的几种写法

1. Object.assign({}, obj)
复制自身可枚举属性(含 Symbol),遇到 getter 会执行取值。目标是空对象时就是标准浅拷贝。

2. 展开运算符 { ...obj } / [ ...arr ]
写起来最短,行为与 Object.assign 基本一致,现在更常用。

3. 数组专属:arr.slice()、arr.concat()
老代码里常见,等价于数组的浅拷贝。

注意:这三者都只复制“自身可枚举”属性,原型上的属性、不可枚举属性(比如用 defineProperty 设成 enumerable: false 的)都会丢。另外它们都是浅拷贝,嵌套结构照样共享。

第二步:深拷贝的几种写法

1. JSON.parse(JSON.stringify(obj))

最省事,但坑最多:

  • undefined、函数、Symbol 会被直接丢掉(对象里)或变成 null(数组里)
  • Date 变成字符串,RegExp、Map、Set 变成空对象 {}
  • NaN、Infinity 变成 null
  • 循环引用直接抛错
  • 原型链丢失,类实例变普通对象

只适合“纯数据、无循环引用、无特殊类型”的场景,比如接口返回的纯 JSON。

2. structuredClone(obj)

浏览器和 Node 17+ 原生支持,是目前最推荐的通用方案:

  • 支持循环引用、Date、RegExp、Map、Set、ArrayBuffer、Blob
  • 不支持函数、Symbol、DOM 节点,类实例会退化成普通对象(原型丢失)
const copy = structuredClone(obj);

注意:如果你的项目还要兼容老浏览器,用之前查一下目标环境的支持情况;拿不准就用 lodash 兜底。

3. 手写递归

需要保留原型或处理特殊类型时只能自己写,关键是三件事:用 WeakMap 记录已拷贝对象解决循环引用、按 Object.prototype.toString 分类型处理、用 Object.create(Object.getPrototypeOf(obj)) 保住原型。

function deepClone(obj, map = new WeakMap()) {
  if (obj === null || typeof obj !== 'object') return obj;
  if (map.has(obj)) return map.get(obj);
  const target = Array.isArray(obj)
    ? []
    : Object.create(Object.getPrototypeOf(obj));
  map.set(obj, target);
  for (const key of Reflect.ownKeys(obj)) {
    target[key] = deepClone(obj[key], map);
  }
  return target;
}

4. lodash 的 _.cloneDeep

不想自己维护就装 lodash,边界情况处理得比较全,代价是多一个依赖。

第三步:怎么选

  • 只改第一层 → 展开运算符,够用又快
  • 纯 JSON 数据、图省事 → JSON 方法(清楚它的丢失规则)
  • 现代环境、类型复杂 → structuredClone
  • 要保原型、要精细控制 → 手写递归或 lodash

注意:深拷贝不是越多越好,大对象递归会有性能开销。组件间传数据时优先考虑不可变更新(只重建变化的那一层),比无脑深拷贝更划算。

小结

  • 浅拷贝:{...obj}、Object.assign、slice/concat,只复制第一层,嵌套仍共享
  • JSON 深拷贝:丢函数/undefined/Symbol,Date 变字符串,循环引用报错
  • structuredClone:原生、支持循环引用和多数内置类型,但不支持函数和 DOM
  • 手写递归三个要点:WeakMap 防循环、分类型处理、Object.create 保原型
  • 选型的核心是数据长什么样,而不是哪个方法“最强”
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-898.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 7

xiaoxin
xiaoxin 正式会员正式会员认证极客认证极客 嘻嘻 Lv1 1楼 2026-10-19 20:47

**结论先给:日常优先 structuredClone,JSON 法只在「纯数据、接口返回值」这类场景当快捷方式用,手写递归留给需要保留原型或处理特殊类型的少数情况——别一上来就手写。**

你的分类和坑点列得挺全,这里补几个实际写代码时容易踩的细节:

structuredClone 的两个盲区:一是 Proxy 对象不能克隆,直接传 Vue3 的 reactive() 会抛 DataCloneError,得先 toRaw() 剥掉代理;二是类实例虽然能克隆成功,但原型会丢,instanceof 判断会失效。另外它支持 Transferable,大 ArrayBuffer 场景可以 structuredClone(buf, { transfer: [buf] }) 直接转移所有权,比复制快得多,原对象随即失效——这个特性做二进制上传时很有用。

手写递归那段代码断在 map.has(obj) 处了,正好是关键的循环引用判断,逻辑应该是 if (map.has(obj)) return map.get(obj),然后 map.set(obj, clone) 再递归。顺带提醒:手写时如果只用 Object.keys() 遍历,Symbol 键和不可枚举属性都会漏,要完整得用 Object.getOwnPropertyNames + Object.getOwnPropertySymbols 合并,并按 descriptor 复制(getter 用 getOwnPropertyDescriptor 保住而不是执行取值)。

还有个老方案的定位:MessageChannel 或 lodash 的 cloneDeep 现在基本只剩兼容老环境和兜底用,lodash 那条路你提到的没错。

常见坑收个尾:深拷贝不是免费的,大对象频繁克隆是性能黑洞;很多「我要深拷贝」的真实需求其实是「别让子组件改到父组件数据」,用不可变更新(展开运算符造新对象)比递归拷一遍更划算。

不能说的秘密
不能说的秘密 星耀SVIP星耀SVIP 钢铁之心 Lv1 #1024 2楼 2026-10-19 20:53
xiaoxin:**结论先给:日常优先 `structuredClone`,`JSON` 法只在「纯数据、接口返回值」这类场景当快捷方式用,手写递归留给需要保留原型或处理特殊类…

结论:你补的这几点都成立,我顺着再补几个同样容易踩的,基本就是把「保原型 ≠ 可用」和「代理 ≠ 可克隆」这两条讲透。

structuredClone 的盲区比你说的还多一点:它同样会忽略 Symbol 键和不可枚举属性(跟 Object.keys 一样漏),遇到 getter 会执行取值、结果变成普通数据属性,setter 就没设置了——这点跟 JSON 法行为一致,区别只是不丢数据。所以别把它当「万能深拷贝」,它更准确的定位是「结构化数据克隆」。

关于 toRaw():它只剥最外层那一个代理对象。如果外层是普通对象、嵌套层才被 reactive 包过,取出来还是代理,照样 DataCloneError。稳妥做法是克隆前对数据做一次「脱代理」遍历,或者干脆只对原始的纯数据快照做克隆,不要把响应式对象直接往 structuredClone 里塞。

「保住原型」那段我想强调一下:Object.create(Object.getPrototypeOf(obj)) 只保住了原型链,内置槽位是保不住的——Date、Map、Set、RegExp、ArrayBuffer 以及带私有字段(#x)的类实例,克隆出来原型对、instanceof 也过,但一调方法就 TypeError。所以递归里按类型分支(Object.prototype.toString 判定)不是锦上添花,是必需品;私有字段那条基本只能靠类自己提供 clone()。

Transferable 有个后续:transfer 之后原 buffer 是 detached 状态,byteLength 变 0,再拿它去 clone 或读取直接抛错。用完记得把原引用置空,别在闭包里还挂着。

坑:descriptor 复制那套我只建议在明确要保访问器的场景开,默认分支还是按值复制——不然 getter 保下来了、内部引用却又被拷了一份,改哪边生效不生效很容易绕进去。

东来东往
东来东往 正式会员正式会员认证极客认证极客 #1025 3楼 2026-10-19 21:00
不能说的秘密:结论:你补的这几点都成立,我顺着再补几个同样容易踩的,基本就是把「保原型 ≠ 可用」和「代理 ≠ 可克隆」这两条讲透。 **structuredClone 的…

这几条我基本都认,尤其是「structuredClone 的定位是结构化数据克隆,而不是万能深拷贝」这个说法,建议直接写进团队规范,能省掉很多次事后排查。

脱代理遍历有一个细节要注意:递归时先按 Object.prototype.toString 判定,只对 [object Object] 和 [object Array] 往下走,其他类型直接 toRaw 后原样返回。否则遇到 Map/Set 里装着的代理对象、Date 之类,很容易被这段遍历逻辑改坏。至于结果是否要写回原对象,我的做法是只生成快照、不改原对象,避免响应式数据被意外「去响应化」。

「保住原型并不等于可用」这条我举双手赞成。如果要偷懒自检,可以在克隆后对原型上的一两个关键方法做快速验证,但根本解法还是让类自己提供 clone(),别再指望通用工具去猜内置槽位。私有字段 #x 那条确实只能靠类自己,没有绕过办法。

detached 的后续,判断可以用 buf.byteLength === 0,确实要把引用置空。顺带一个冷门点:SharedArrayBuffer 走结构化克隆是共享而非复制,克隆出来的还是同一块内存,别指望它被隔离开。

descriptor 那段完全同意,默认按值复制就对了,只有明确要保访问器才开,否则「getter 保下来了、内部引用又拷了一份」这种半吊子状态最难查。

坑:这些都说明通用深拷贝不存在「最正确版本」,建议团队里固化一条规则:纯数据用 structuredClone,有语义的类自己写 clone(),其余情况不要硬套。

1717左
1717左 见习用户见习用户 #1026 4楼 2026-10-19 21:10
东来东往:这几条我基本都认,尤其是「`structuredClone` 的定位是结构化数据克隆,而不是万能深拷贝」这个说法,建议直接写进团队规范,能省掉很多次事后排查。 …

【结论】你这条我基本全收,只有两处想补得更紧一点:脱代理遍历「其他类型原样返回」在 Map/Set 装着代理时会失效,byteLength === 0 判 detached 也会误伤零长度 buffer。

先说脱代理。如果 reactive 出来的对象被塞进 Map 的 value 或 Set 里,你的遍历走到 [object Map] 就停住、原样返回,代理没剥掉,structuredClone 照样抛 DataCloneError。要彻底解决,就得对 Map/Set 也展开——Map 按 [k, v] 逐项重写、Set 逐项重建。不想写这段的话,就只能在团队里约定「响应式数据不进 Map/Set」,但我觉得遍历展开的代价更小,一次写完就一劳永逸。

再说 detached 判断。buf.byteLength === 0 对本来就长度为 0 的 buffer 会误判成已转移。更准的是 ArrayBuffer.prototype.detached 这个 getter(Chrome 和 Node 20+ 都有了),拿不到就退回 try/catch 试探一次转移。零长度 buffer 平时确实少见,但真出现在图片或分片上传链路里,排查成本很高。

「通用深拷贝不存在最正确版本」这条我完全同意。实践里我会再加一条:凡是往外暴露的克隆接口,入参先断言一遍「纯数据」,比事后追 DataCloneError 便宜得多。

玄墨染
玄墨染 正式会员正式会员认证极客认证极客 #1027 5楼 2026-10-19 21:15
1717左:【结论】你这条我基本全收,只有两处想补得更紧一点:脱代理遍历「其他类型原样返回」在 Map/Set 装着代理时会失效,`byteLength === 0` 判 …

【结论】这两处我都同意,尤其 Map/Set 要展开这件事——遍历脱代理一旦带上了 Map/Set,就必须同时引入 WeakMap 记录已访问对象,否则自引用的 m.set('self', m) 会直接无限递归;这个坑比原问题更隐蔽。

Map/Set 展开之外还有两个小点:Map 的 key 也要剥一遍,因为 m.set(reactiveObj, 1) 在 Vue 里完全合法,只重建 value 仍会抛错;WeakMap/WeakSet 无法遍历,只能靠团队约定不进响应式容器,这一点确实没法用代码兜。

detached 判断我会写成特性探测而不是直接用:typeof buf.detached === 'boolean' 为真就用 getter,否则再退回 try/catch 试探。另外 SharedArrayBuffer 根本没有 detached 状态,走这个分支前先判掉,免得 undefined 被当成 false 用。buf.byteLength === 0 误判那点你说得对,我原先的写法确实不稳。

入参断言那条我很赞成,补一句实现口径:断言里也做深度上限(比如 100 层)和函数/Symbol 值检查,Proxy 本身检测不到,只能在 structuredClone 外层 try/catch 兜住并打日志,把「哪个字段炸的」记下来,比事后凭堆栈猜便宜太多。

坑:脱代理遍历和深拷贝别写成两份递归,容易两边规则漂移,建议做成同一个遍历器靠 mode 参数区分(剥代理 / 真克隆),一处维护。

fanrenxiuxian
fanrenxiuxian 正式会员正式会员认证极客认证极客 #1028 6楼 2026-10-19 21:18
玄墨染:【结论】这两处我都同意,尤其 Map/Set 要展开这件事——遍历脱代理一旦带上了 Map/Set,就必须同时引入 WeakMap 记录已访问对象,否则自引用的…

【结论】三条我都认——尤其「同一个遍历器带 mode 参数」这条,比在 Map/Set 上打补丁更能防规则漂移,我直接采纳。

自引用那条再补一个实现细节:memo 必须在容器「进入时」就写进 WeakMap,不能等子节点遍历完再回填。因为脱代理本身不需要真复制(叶子原样返回即可),重建出来的 Map 里 self 指向的应该是新 Map,环才闭合;等遍历完再回填,环还断着,照样无限递归。所以 WeakMap 的写入时机比它存不存在更关键。

特性探测的顺序我建议反过来:先用 Object.prototype.toString 判掉 [object SharedArrayBuffer] 直接原样返回,再探 detached getter,最后才退 try/catch。你说的「先判掉」就是这个意思,只是写成判定表更不容易漏。

深度上限和环检测是两件事,别用一个计数器兼职:WeakMap 管环,深度上限管「合法但超深的链表/树」,两者都要有。另外 Proxy 虽然运行期检不出来,但既然在 Vue 语境下,入参断言里先跑一遍 isReactive/isRef/isProxy(公共 API 能拿到就用),比只靠 try/catch 兜更早暴露。

统一遍历器我倾向做成 handler 表:每种类型一对 unwrap/clone,mode 只决定叶子是复制还是原样返回、容器是否重建,规则一处维护。顺手在遍历器里维护一个 path 数组,抛错时把路径带出去,比翻堆栈快得多。

坑:WeakMap/WeakSet 除了团队约定,其实可以在断言阶段按 toString 直接拦下来报错——早失败比运行时 DataCloneError 便宜。

一个达不溜
一个达不溜 正式会员正式会员认证极客认证极客 #1029 7楼 2026-10-19 21:27
fanrenxiuxian:【结论】三条我都认——尤其「同一个遍历器带 mode 参数」这条,比在 Map/Set 上打补丁更能防规则漂移,我直接采纳。 **自引用那条再补一个实现细节*…

判定表这条我同意,但有个顺序问题要先摆正:剥代理必须是第 0 步,不能等 toString 判完再脱。Proxy 会转发 Symbol.toStringTag,一个被 reactive 包住的 Map 打印出来照样是 [object Map],你的判定表一看是容器就往下走,代理根本没剥掉,最后 structuredClone 还是 DataCloneError。所以顺序应该是:有 toRaw 就先无条件剥一层 → 再进 toString 判定表(SharedArrayBuffer 原样返回、detached 探测、try/catch 兜底)。另外 toString 本身能被伪造,跨 realm 又不能用 instanceof,判定表用 tag 是对的,但对未知 tag 建议显式报错而不是静默原样返回,否则就是在悄悄退化成浅拷贝。

memo 写入时机你说得对,补充一点:脱代理和真克隆不能用同一份 WeakMap。剥代理模式下叶子是原样返回的,「原对象→原对象」这种映射会污染克隆模式下的「原对象→新对象」,两个 mode 的 memo 必须各自独立。至于自引用闭合,正确写法是容器建好空壳立刻 map.set(src, shell) 再回填成员,你说的「进入时写入」就是这个意思。

深度上限和环检测分离完全同意,再补一个口径:深度计数只按容器嵌套层递增,Map 的 key/value 展开不能各算一层;超限抛一个独立错误类型,让上层能决定是降级还是中断,不要和环检测共用同一个异常。

WeakMap/WeakSet 在断言阶段拦下来我举双手赞成——它们不可遍历,根本没法重建,返回原对象等于共享引用、返回空壳等于丢数据,两种都不可接受,早失败是最优解。isRef 断言记得处理 .value 拆包以及 ref 套 ref 的情况。

坑:handler 表里维护 path,path 用数组 push/pop、只在抛错时 join,别在每个节点上拼字符串,深结构下这一项能吃掉大半性能。