JavaScript深拷贝与浅拷贝全解析:原理、手写实现与工程选型
1. 先搞清楚浅拷贝和深拷贝到底差在哪1.1 一个改Bug改到怀疑人生的下午先分享个我自己的真实经历。几年前在做一个数据可视化大屏项目接口返回一坨好几层嵌套的配置对象我需要基于这份配置做临时改动来适配不同的展示端。当时图省事直接const tempConfig originalConfig在tempConfig上改了某个图表的颜色和尺寸结果刷新页面之后发现原始配置也被改了另一块无关的图表渲染全乱了。排查了半天才意识到这根本不是业务逻辑的问题而是我根本没有理解引用类型在 JavaScript 里的赋值行为。tempConfig和originalConfig指向的是同一个堆内存地址我改tempConfig的某个嵌套属性本质上就是在改原来的对象。这就是典型的浅复制。很多人在面试里能背出浅拷贝是复制引用深拷贝是复制数据但真正写代码时还是会在嵌套对象上翻车。究其原因就是没有从内存模型的角度去理解这两种拷贝方式的本质差异。1.2 两者本质区别栈引用与堆数据的复制层级JavaScript 的数据类型分两大类基础类型string、number、boolean、null、undefined、symbol、bigint和引用类型object、array、function。基础类型存在栈内存里赋值就是复制值本身互不干扰引用类型的变量名存在栈里但它存的只是一个指向堆内存地址的指针。浅拷贝只复制最外层的一层引用。拷贝后的对象和原对象共享同一个嵌套对象的内存地址。改其中一个的嵌套属性另一个跟着变。深拷贝递归地把每一层的值都复制一份完全在堆内存中创建一个独立的对象。原对象和拷贝对象之间没有任何共享引用。我用买房来打个比方。浅拷贝相当于你拿到了一把别人房子的钥匙引用地址你进屋改动里面的家具房子的主人也会受影响。深拷贝则是把房子完整复制了一份你拿到的是全新的房子里面就算重新装修、拆墙跟原来的房子也没有任何关系。判断一个拷贝是不是深的核心标准就一条拷贝结果和原对象之间还有没有共享引用。只要还有一层共享引用它就不是深拷贝。2. 为什么深拷贝这么难写从 JSON 方案聊起2.1 最常用的 JSON 深拷贝三个致命伤一说到深拷贝绝大多数人第一个想到的就是const clone JSON.parse(JSON.stringify(original));这个写法确实能应对大量常规场景嵌套对象、数组、基础类型混合结构一条代码全搞定性能也说得过去。但它在生产环境里埋着三颗雷第一颗雷undefined、函数、symbol会被直接丢弃。比如对象里有个方法拷贝之后方法不见了有个属性值是undefined拷贝之后这个键直接消失。这在很多业务场景里是致命的因为你可能拿到的是一份残缺的数据而且很难第一时间发现。第二颗雷Date对象会被转成字符串。JSON.stringify会把Date序列化成 ISO 格式的字符串JSON.parse之后得到的是一个字符串不再是Date实例。之后你要是对这个字段调.getTime()或者.toISOString()直接报错。第三颗雷遇到循环引用直接抛异常。常见的场景是树形结构里子节点持有parent引用或者全局状态管理里某个对象回指了根对象。JSON.stringify遇到循环引用会抛出TypeError: Converting circular structure to JSON。不信的话你可以在浏览器控制台跑一下这段代码const obj { name: test }; obj.self obj; JSON.parse(JSON.stringify(obj)); // TypeError: Converting circular structure to JSON2.2 什么时候可以放心用 JSON 方案我不主张一棍子打死 JSON 方案关键要看数据长什么样。如果你的数据结构满足以下全部条件直接用JSON.parse(JSON.stringify())完全没问题不需要为了上深拷贝而上深拷贝数据结构中只包含 JSON 安全的值字符串、数字、布尔值、null、普通对象、数组没有Date、RegExp、Map、Set、WeakMap、WeakSet、ArrayBuffer、TypedArray等特殊类型没有函数、类实例、Symbol属性没有循环引用不需要保留undefined、NaN、Infinity这些特殊值NaN会被转成null满足这些条件的数据比如接口返回的纯 JSON 配置、后端下发的静态列表数据JSON 方案是最省力、最稳妥的选择。它还是个纯函数可读性好代码审查的时候一目了然。问题只出现在你以为数据结构简单其实并不简单的时候。3. 从零手写一个生产级深拷贝3.1 第一阶段先解决循环引用手写深拷贝是很多前端团队的面试必考题。我面过不少人能写出递归版本的不少但能处理循环引用的寥寥无几。我们先从最基础的递归版本讲起一步步升级。最朴素的版本长这样function deepClone(obj) { if (typeof obj ! object || obj null) { return obj; } const result Array.isArray(obj) ? [] : {}; for (const key in obj) { if (Object.prototype.hasOwnProperty.call(obj, key)) { result[key] deepClone(obj[key]); } } return result; }这个版本的问题在于只要数据里有循环引用递归就会无限调用最终爆栈。解决方案是引入一个Map或者WeakMap作为缓存把每一个已经拷贝过的对象记录下来。遇到同一个对象再次出现时直接从缓存里拿之前拷贝出来的结果而不是重新递归。function deepClone(obj, cache new WeakMap()) { if (typeof obj ! object || obj null) { return obj; } // 如果之前拷贝过这个对象直接返回拷贝结果 if (cache.has(obj)) { return cache.get(obj); } const result Array.isArray(obj) ? [] : {}; cache.set(obj, result); for (const key in obj) { if (Object.prototype.hasOwnProperty.call(obj, key)) { result[key] deepClone(obj[key], cache); } } return result; }这里用WeakMap比Map更合适因为WeakMap的键是弱引用不会阻止垃圾回收。如果被拷贝的原始对象被标记为可回收WeakMap里的对应条目也会被自动清理不容易造成内存泄漏。提示循环引用的本质是对象引用自己所以缓存必须使用引用类型做键。如果你用对象属性名做缓存去重遇到不同属性指向同一个对象的场景比如a.husband b; b.wife a这种互相引用还是会出问题。3.2 第二阶段处理 Date/RegExp/Map/Set 等特殊对象循环引用只是第一关。真正让深拷贝从面试题升级为工程能力的是各种内置特殊对象的处理。Date不能直接new Date(拷贝值)因为你拷贝到的是一个Date实例它的内部时间戳在Object遍历里拿不到Map和Set内部的键值对也不能用普通属性遍历的方式复制。我的做法是先判断类型分门别类地处理function deepClone(obj, cache new WeakMap()) { if (typeof obj ! object || obj null) { return obj; } if (cache.has(obj)) { return cache.get(obj); } // 处理 Date if (obj instanceof Date) { return new Date(obj.getTime()); } // 处理 RegExp if (obj instanceof RegExp) { return new RegExp(obj.source, obj.flags); } // 处理 Map if (obj instanceof Map) { const result new Map(); cache.set(obj, result); obj.forEach((value, key) { result.set(deepClone(key, cache), deepClone(value, cache)); }); return result; } // 处理 Set if (obj instanceof Set) { const result new Set(); cache.set(obj, result); obj.forEach((value) { result.add(deepClone(value, cache)); }); return result; } // 处理普通对象和数组 const result Array.isArray(obj) ? [] : {}; cache.set(obj, result); // 除了自身可枚举属性还要处理 Symbol 类型的键 Reflect.ownKeys(obj).forEach((key) { if (Object.prototype.propertyIsEnumerable.call(obj, key)) { result[key] deepClone(obj[key], cache); } }); return result; }这个版本已经能覆盖绝大多数前端场景了。要注意Reflect.ownKeys能返回包括Symbol键在内的所有键但需要配合propertyIsEnumerable过滤掉不可枚举属性避免拷贝出一些不该拷贝的内部状态。3.3 第三阶段函数和原型链怎么处理函数怎么办这是一个两难的选择。严格意义上讲函数不应该被克隆因为函数本质上就是一个可以执行的对象它捕获了定义时的作用域链。你要是真的把一个函数重新eval一份得到的也是两个完全不同的东西。工程上的主流做法是遇到函数直接复用引用。也就是说拷贝后的对象里函数字段指向的还是原函数。这个方案在绝大多数业务场景下是合理的因为我们在深拷贝对象时核心诉求是分离数据状态而不是重新创建函数。原型链的问题也容易被忽略。如果你拷贝一个类实例简单遍历属性只能得到实例自身的属性原型上的方法默认是访问不到的。严谨的深拷贝应该通过Object.getPrototypeOf(obj)拿到原型用Object.create(proto)创建继承相同原型的新对象const result Array.isArray(obj) ? [] : Object.create(Object.getPrototypeOf(obj));这样一来拷贝出来的对象不仅数据结构独立原型链也保持一致调用实例方法不会报错。注意单独判断typeof obj function时直接返回原引用这样函数既不会丢也不会被深拷贝。4. 现代方案structuredClone 与 ES6 展开运算符的正确打开方式4.1 structuredClone浏览器原生深拷贝的边界其实现在很多场景根本不用手写深拷贝。浏览器早在 2022 年左右就原生支持了structuredClone全局函数。这个 API 是浏览器引擎级别的深拷贝实现可以处理循环引用、Date、RegExp、Map、Set、ArrayBuffer、Blob、File、ImageData等大量内置类型性能也比手写的递归函数好得多。const original { name: test, tags: new Set([a, b]), date: new Date(), nested: { list: [1, 2, 3] }, self: null }; original.self original; const clone structuredClone(original); console.log(clone original); // false console.log(clone.self original.self); // false console.log(clone.date instanceof Date); // truestructuredClone的第二个参数是一个可选的传输列表用来把ArrayBuffer等可转移对象的所有权转移给新对象转移之后原对象的 buffer 就不可用了。前端日常开发中这个参数很少用到主要是 Web Worker 之间传递数据时配合使用。它的边界有两条第一不能处理函数。遇到函数字段structuredClone会直接抛DataCloneError。所以如果你的对象里有方法得先把函数字段剥离再克隆或者退回手写方案。第二不支持Symbol对象和DOM节点。Symbol作为属性的键没问题但是Object(Symbol())这种包装对象会报错。4.2 展开运算符、Object.assign、Array.from 的误区展开运算符{ ...obj }和Object.assign({}, obj)是不是深拷贝不是。它们连浅拷贝都不算是最完整的那种准确点说应该叫一层浅拷贝。嵌套对象仍然共享引用const original { a: 1, nested: { b: 2 } }; const clone { ...original }; clone.nested.b 999; console.log(original.nested.b); // 999跟着变了Object.assign本质上也是遍历可枚举属性复制到一个新对象处理引用类型的方式和展开运算符一模一样。Array.from对数组也是同样的情况const original [[1, 2], [3, 4]]; const clone original.map(item [...item]); // 只能深拷贝到第二层嵌套数组里如果还有数组展开运算符也只是复制引用。所以用map 展开运算符处理数组碰到三层、四层嵌套一样会出问题。千万别觉得用了 ES6 就安全了ES6 只提供了浅拷贝的便利深拷贝还得靠前面几章说的手段。5. 实际场景中的选型建议与性能取舍5.1 不同场景该选哪种深拷贝方案为了让你看得清楚我把各种方案的适用场景整理成了一个对照表方案适用场景不适用场景优点缺点JSON.parse(JSON.stringify())接口纯 JSON 数据、配置对象含Date、函数、循环引用的数据代码简洁、零依赖、性能好丢字段、类型降级、循环引用崩溃手写递归深拷贝自定义控制拷贝逻辑、面试场景数据结构极大的场景可控性强、按需定制代码量大、特殊类型处理繁琐structuredClone浏览器环境、复杂数据类型含函数、DOM 节点、Symbol 对象原生 API、支持类型丰富、内置循环引用处理兼容性需考虑、不支持函数lodash_.cloneDeepNode.js 或浏览器、团队已引入 lodash首屏体积敏感的项目成熟稳定、类型支持全面、社区验证充分依赖包体积约几十 KB以我自己的经验一般在浏览器端做用户态数据管理比如表格编辑草稿、表单回显时我用structuredClone最多在 Node.js 服务端处理复杂配置深拷贝时我倾向于直接用 lodash 的_.cloneDeep省心而且 lodash 在服务端没有体积压力。手写版本我只有在面试或者需要特殊定制时才用。5.2 深拷贝与性能优化的博弈深拷贝是有代价的。递归遍历整个对象树意味着每一次深拷贝的时间复杂度和对象节点数量成正比。如果数据有几千个嵌套节点一次深拷贝可能就要几毫秒在渲染高频更新的场景里积少成多页面就会出现明显卡顿。一个常被忽略的性能技巧是能不做深拷贝就不做深拷贝。很多业务场景要的不是深拷贝而是不可变更新。你可以用结构共享的方式只更新变化的那条路径其他路径复用原对象引用。Redux 的 reducer 写法就是这么干的每次只创建变化部分的新对象不变化的部分直接复用。这比每次全量深拷贝高效一个数量级。如果确实需要深拷贝也有几个降噪手段只拷贝白名单字段而不是全量拷贝整个对象拷贝完成之后立刻释放原始对象引用让垃圾回收尽早介入高频操作比如拖拽实时刷新中用节流或防抖降低拷贝次数数据不更新时缓存拷贝结果不要每次渲染都重新拷贝6. 我踩过的那些深拷贝的坑经验总结6.1 浅拷贝失误导致的线上事故复盘有一个印象很深的线上事故。当时负责一个可视化编辑器画布上的每个组件有一个props对象用户拖拽组件时我们会临时改props里的样式。因为这个props是从全局配置里直接引用过来的结果用户 A 改了一个组件的背景色整个项目的所有同类型组件颜色全变了。复盘的时候发现问题从源头就埋下了组件初始化时我用的是Object.assign({}, defaultProps, componentProps)看着像一个拷贝实际上只拷贝了第一层。defaultProps里的style是一个嵌套对象复制出来的组件之间共享了同一个style引用。用户改某个组件的style.backgroundColor等于改了共享对象上的值。这个教训让我养成了一个习惯所有跨模块传递的配置对象在初始化阶段就规定好拷贝策略。属于共享基准配置的数据全部深拷贝一份再进入业务逻辑。判断不清的时候宁可多花点拷贝成本也比线上出 Bug 强。6.2 实操心得与自检清单深拷贝写多了之后我总结了一份自检清单如果你也经常跟数据拷贝打交道建议直接收藏拿到一份数据第一反应先看有没有函数字段和Date字段有就不能用 JSON 方案数据里有没有互相引用或者自引用拿不准就写个循环检测试一下拷贝出来的对象需要一个独立原型吗还是普通的Object.create(null)就够了大对象拷贝之前先估算节点量级超过上千节点就要警惕性能问题有没有可能改成不可变更新的结构共享方案深拷贝只是手段不是目的团队代码库如果已经引入了 lodash直接用_.cloneDeep不要重复造轮子手写深拷贝的时候WeakMap缓存一定要有不然循环引用直接爆栈最后再说一个我自己处理过的小细节。有些对象的属性值是只读的或者绑定了 setter直接赋值拷贝会触发副作用。靠谱的做法是拷贝时先判断属性的descriptor如果是writable: false或者有set方法就不要直接赋值改用Object.defineProperty去拷贝。这个坑比较深但遇到一次你就知道厉害了。深拷贝这个话题表面上是个面试八股题真正深挖下去能牵扯出 JavaScript 的引用模型、内置类型体系、递归算法设计、性能工程等多个层面。把这份功夫下在平时线上就能少很多莫名其妙的 Bug。