资讯动态

JavaScript深拷贝与浅拷贝:从原理到实战,彻底搞懂对象复制

发布时间:2026/9/8 4:20:27 来源:尧图企业网站定制
浅拷贝和深拷贝这俩概念几乎每次前端面试都会碰到但说真的能把这两个概念讲透的人不多。很多人背了答案知道Object.assign()是浅拷贝、JSON.parse(JSON.stringify())是深拷贝可真到了项目里遇到嵌套对象、循环引用、Date、RegExp这些特殊情况照样踩坑。我最早对深浅拷贝的认知也特别肤浅以为浅拷贝就是只复制第一层深拷贝就是把所有层都复制一份。这个理解方向没错但不精确。直到有一次在项目中因为浅拷贝修改了子对象结果把原数据也改了排查了半天才发现问题从那以后我才认真把这块彻底研究了一遍。这篇文章我不打算给你念文档而是结合这几年实际写业务代码的经验把浅拷贝、深拷贝的原理、实现方式、常见陷阱从头到尾捋一遍。看完之后你不仅能应对面试更重要的是在真实项目中知道什么时候该用哪种方式遇到性能问题怎么处理碰到特殊对象该怎么兼容。1. 先从JavaScript的数据类型聊起为什么会有深浅拷贝的问题搞清楚深浅拷贝之前必须先弄明白JavaScript里的数据类型是怎么存储的。这不是废话铺垫因为深浅拷贝的本质问题就出在数据存储机制上。1.1 基本类型与引用类型的存储差异JavaScript的数据类型分两大类基本类型string、number、boolean、null、undefined、symbol、bigint和引用类型object、array、function、date、regexp等。基本类型存储在栈内存中变量直接保存值本身。当你把一个基本类型变量赋值给另一个变量时相当于复制了一份值两个变量互不影响let a 10; let b a; b 20; console.log(a); // 10a没有被影响引用类型就不一样了。对象存储在堆内存中栈内存保存的是对象的地址引用。当你把一个对象变量赋值给另一个变量时复制的是引用地址而不是对象本身let obj1 { name: 张三, age: 30 }; let obj2 obj1; obj2.name 李四; console.log(obj1.name); // 李四obj1也被改了这就是深浅拷贝问题的根源所在。浅拷贝解决的是只复制第一层属性的问题深拷贝解决的是递归复制所有层属性的问题。如果你连这个存储差异都没理解透后面所有的实现方案对你来说都只是死记硬背。1.2 深浅拷贝的官方定义与本质区别先给出准确定义避免概念混淆浅拷贝Shallow Copy创建一个新对象新对象的第一层属性会复制原对象的值。如果属性值是基本类型复制的是值如果属性值是引用类型复制的是引用地址。也就是说新对象和原对象会共享内部的引用类型属性。深拷贝Deep Copy递归地复制所有层级的属性每一层的引用类型都会创建新的内存空间。新对象和原对象完全独立修改任何一个都不会影响另一个。一句话总结浅拷贝只解决第一层的独立性深拷贝解决所有层的独立性。用代码直观感受一下// 原对象 const original { name: 张三, address: { city: 北京, district: 朝阳区 } }; // 浅拷贝 const shallowCopy { ...original }; shallowCopy.name 李四; // 不影响原对象基本类型属性独立了 shallowCopy.address.city 上海; // 影响原对象引用类型属性还是共享的 console.log(original.address.city); // 上海被改了 // 深拷贝 const deepCopy JSON.parse(JSON.stringify(original)); deepCopy.address.city 广州; console.log(original.address.city); // 上海原对象完全不受影响这种现象在面试中经常被拿来考察但很多候选人只是背结论不理解背后的内存机制。面试官只要追问一句为什么浅拷贝修改嵌套对象会影响原对象就露馅了。2. 手写浅拷贝从实现细节理解每一种方案的边界浅拷贝的实现方式有很多种但每一种方案能覆盖的边界都不一样。我在项目里见过有人用Object.assign去拷贝数组结果拷贝出来的还是数组吗不一定。下面把常用的几种方案逐一拆解。2.1 展开运算符Spread与 Object.assign 的异同展开运算符是ES6之后最常见的浅拷贝方式const clone { ...original };Object.assign是ES6之前的老牌方案const clone Object.assign({}, original);这两种方式本质上没有区别都是浅拷贝。但它们有一个共同的限制只能拷贝可枚举的自身属性。原型链上的属性、不可枚举的属性都不会被拷贝。这里有一个面试中非常爱考的点Object.assign的处理逻辑和展开运算符其实不完全一样它在赋值时会触发源对象的setter而展开运算符不会。不过这个点比较冷门实际开发中很少遇到了解即可。另外注意一个陷阱很多人用Object.assign拷贝数组const arr [1, 2, 3]; const clone Object.assign([], arr); clone.push(4); console.log(arr); // [1, 2, 3]看起来没问题数组元素是基本类型这么做确实没问题。但如果是对象数组呢const arr [{ id: 1 }, { id: 2 }]; const clone Object.assign([], arr); clone[0].id 99; console.log(arr[0].id); // 99原数组被改了还是浅拷贝那一套嵌套的对象依然是共享的。2.2 手写浅拷贝的完整实现理解了原理之后手写一个通用的浅拷贝函数其实不难function shallowCopy(obj) { // 基本类型直接返回 if (obj null || typeof obj ! object) return obj; // 数组和对象分别处理 const clone Array.isArray(obj) ? [] : {}; // 遍历自身可枚举属性包括symbol for (const key in obj) { if (Object.prototype.hasOwnProperty.call(obj, key)) { clone[key] obj[key]; } } // 拷贝symbol属性 const symbols Object.getOwnPropertySymbols(obj); for (const sym of symbols) { clone[sym] obj[sym]; } return clone; }写这个实现的时候有几个细节要注意第一for...in会遍历原型链上的可枚举属性所以必须用hasOwnProperty过滤。不熟悉这个陷阱的人往往在这里踩坑。第二Object.getOwnPropertySymbols很容易被忽略。虽然日常业务代码里用symbol做属性名的场景不多但如果你写的是一个通用工具函数就必须考虑周全。第三这个实现只处理了数组和普通对象。Date、RegExp、Map、Set这些特殊对象浅拷贝之后得到的还是对象字面量而不是对应的类型。这其实也是深浅拷贝实现中最大的坑之一下面深拷贝部分还会细说。3. 深拷贝的经典实现与常见陷阱深入每一种方案的本质深拷贝的实现方案五花八门但主流方案也就那几种。很多人在项目里永远是JSON.parse(JSON.stringify())一把梭方便是方便但它有非常严重的限制。搞懂每一种方案的优缺点和适用场景才算真正掌握了深拷贝。3.1 JSON方案最常用但限制最多这是使用频率最高、写法最简单的深拷贝方案const deepCopy JSON.parse(JSON.stringify(original));一行代码搞定深拷贝性能也不错看起来完美。但实际用起来它的坑多到让人怀疑人生第一个坑函数和symbol属性会被直接丢弃const obj { name: 张三, sayHello: function() { console.log(hello); }, [Symbol(id)]: 123 }; const clone JSON.parse(JSON.stringify(obj)); console.log(clone); // { name: 张三 }函数和symbol全没了第二个坑undefined会被丢弃const obj { a: undefined, b: null }; const clone JSON.parse(JSON.stringify(obj)); console.log(clone); // { b: null }a直接被干掉了第三个坑特殊对象类型会出问题const obj { date: new Date(), regexp: /hello/g, map: new Map([[key, value]]), set: new Set([1, 2, 3]) }; const clone JSON.parse(JSON.stringify(obj)); console.log(clone.date); // 变成了字符串不是Date对象 console.log(clone.regexp); // 变成了空对象 {} console.log(clone.map); // 变成了空对象 {} console.log(clone.set); // 变成了空对象 {}Date会被转成ISO字符串RegExp、Map、Set直接变成空对象循环引用直接报错。这些问题在业务代码中一旦遇到排查起来非常痛苦因为报错信息并不直观。所以我的建议是JSON方案适合那些纯数据对象比如接口返回的JSON数据、需要存储到localStorage的配置对象。凡是涉及函数、Date、RegExp、Map、Set或者循环引用的场景一律不要用这个方案。3.2 递归实现深拷贝面试必考的手写题面试问答里面的手写深拷贝基本上就是你写出一个能够正确递归处理的函数。明确一下递归的退出条件、当前层属性类型判断以及各种数据类型的处理方式代码逻辑就清晰了。先给出我常用的一版实现再做逐段拆解function deepCopy(obj, weakMap new WeakMap()) { // 基本类型或函数直接返回 if (obj null || typeof obj ! object) return obj; // 处理循环引用 if (weakMap.has(obj)) return weakMap.get(obj); // 处理特殊对象类型 if (obj instanceof Date) return new Date(obj); if (obj instanceof RegExp) return new RegExp(obj.source, obj.flags); if (obj instanceof Map) { const cloneMap new Map(); weakMap.set(obj, cloneMap); obj.forEach((value, key) { cloneMap.set(deepCopy(key, weakMap), deepCopy(value, weakMap)); }); return cloneMap; } if (obj instanceof Set) { const cloneSet new Set(); weakMap.set(obj, cloneSet); obj.forEach(value { cloneSet.add(deepCopy(value, weakMap)); }); return cloneSet; } // 处理数组和普通对象 const clone Array.isArray(obj) ? [] : {}; weakMap.set(obj, clone); // 遍历所有可枚举属性包括symbol Reflect.ownKeys(obj).forEach(key { clone[key] deepCopy(obj[key], weakMap); }); return clone; }这个实现考虑了日常开发中能遇到的绝大多数数据类型包括循环引用、Date、RegExp、Map、Set、symbol属性等。逐个说说设计思路第一退出条件。obj null || typeof obj ! object这个判断同时处理了null、基本类型和函数。为什么函数也直接返回因为函数本身是有作用域和闭包的强行拷贝没有意义直接复用引用反而更合理。第二循环引用的处理。这是最容易遗漏的点。如果一个对象存在循环引用比如obj.self obj递归会无限循环导致栈溢出。用WeakMap记录已经拷贝过的对象遇到重复引用直接返回之前拷贝的结果既解决了循环引用问题又保证了两个引用指向同一个对象——这个细节非常关键如果不用WeakMap而用普通对象记录原对象中多个属性引用同一个子对象时拷贝后会变成多个独立的子对象破坏了引用关系。第三特殊对象类型的处理。Date要重新new一个RegExp要重新构造Map和Set需要逐个拷贝元素。Map的键和值都可能是引用类型所以都要递归深拷贝。第四属性遍历用Reflect.ownKeys。这个方法一次能拿到所有自身属性包括字符串属性和symbol属性不需要再额外处理。用它替代Object.keys加Object.getOwnPropertySymbols的组合代码更简洁。3.3 lodash的cloneDeep原理分析生产环境为什么推荐直接用如果你在项目里用的是lodash_.cloneDeep()几乎是零思考成本的选择。我的建议很简单业务代码里直接用lodash的cloneDeep除非你明确知道自己的数据结构非常简单不需要引入额外依赖。那么lodash的cloneDeep到底做了什么让它能成为事实标准第一它处理了几乎所有JavaScript内建类型包括Date、RegExp、Map、Set、ArrayBuffer、TypedArray、DataView、Promise、Symbol等针对每一种类型都有对应的克隆策略。第二它正确处理了原型链。对于自定义类的实例lodash会保留其原型链而不是退化成普通对象。这一点是JSON方案和很多手写实现做不到的。第三它在性能和安全性之间做了权衡内部使用栈结构处理循环引用比递归更健壮。我实际测试过一个包含循环引用的复杂对象手写递归版和lodash版本都能正确处理但lodash在边界情况上明显更靠谱。有一次我在项目中处理Uint8Array类型的数据手写的深拷贝直接把它搞成了普通对象换成cloneDeep后一切正常。所以我的经验是如果你已经引用了lodash直接用cloneDeep没必要重复造轮子。如果你不想引入lodash手写实现要覆盖的边界情况非常之多建议在理解原理的基础上根据自己项目的实际数据结构做一个精简版。4. 深拷贝的进阶场景数据结构类型与引用关系的全面剖析前面讲的是通用实现但实际开发中深拷贝遇到的情况远比普通对象嵌套普通对象复杂得多。这一节说几个我在真实项目里踩过的坑和总结出的处理思路。4.1 保留原型链 vs 纯数据处理的选择先看一个典型场景后端返回的数据经过类实例化处理比如把时间字符串包装成Dayjs或Moment对象。对这种对象做深拷贝你希望得到同样类型的实例而不是被降级成普通对象。手写深拷贝如果要保留原型链需要借助Object.getPrototypeOf和Object.createfunction deepCopyWithPrototype(obj, weakMap new WeakMap()) { if (obj null || typeof obj ! object) return obj; if (weakMap.has(obj)) return weakMap.get(obj); // 保留原型链 const clone Object.create(Object.getPrototypeOf(obj)); weakMap.set(obj, clone); Reflect.ownKeys(obj).forEach(key { clone[key] deepCopyWithPrototype(obj[key], weakMap); }); return clone; }但这里有个矛盾点如果对象是自定义类的实例类的内部可能维护着私有状态或闭合变量单纯复制属性并不能完整恢复对象的行为和状态。这就是为什么很多情况下深拷贝处理纯数据plain object就够了遇到类实例更应该考虑有没有必要做深拷贝还是直接重新构造一个实例更合理。我自己在项目中的处理原则是这样的纯数据对象接口返回的JSON、状态管理库中的state用深拷贝各层独立。类实例日期库对象、自定义类实例一般不深拷贝要么重新构造要么只拷贝需要的数据字段。4.2 数组、Map、Set等复杂结构的拷贝细节数组的深拷贝比普通对象多一个细节数组的空位hole处理。const arr [1, , 3]; // 中间有一个空位 const clone1 JSON.parse(JSON.stringify(arr)); const clone2 deepCopy(arr); console.log(clone1); // [1, null, 3]空位被变成了null console.log(1 in clone1); // true第二项变成了真实存在的null属性 console.log(1 in arr); // false原数组第二项根本不存在这是一个非常隐蔽的坑。JSON方案会把数组空位转成null语义完全改变了。而map、forEach等方法在遇到数组空位时会跳过不会执行回调如果你在深拷贝时用forEach遍历数组空位会被保留成空位不对forEach会跳过空位那你最终得到的数组就少了元素。所以处理数组空位时最稳妥的方式是显式判断下标function cloneArray(arr, weakMap) { const clone new Array(arr.length); for (let i 0; i arr.length; i) { if (i in arr) { // 只在索引存在时拷贝 clone[i] deepCopy(arr[i], weakMap); } } return clone; }Map和Set的深拷贝核心点是键和值都要递归拷贝。Map的键有可能是引用类型比如用对象作为键如果不深拷贝键克隆出来的Map查找行为就会和原对象不一致。这个细节在业务代码里非常容易忽略。4.3 函数与特殊对象的处理原则函数要不要深拷贝严格来说函数没有复制的概念。JavaScript中的函数是对象但它的行为由代码和闭包决定无法通过遍历属性来复制。通用的深拷贝实现直接返回原函数引用这是业界共识。特殊对象如Promise、WeakMap、WeakSet、ArrayBuffer等各有各的特性。WeakMap和WeakSet的键是弱引用无法遍历在深拷贝时怎么处理lodash的做法是直接返回一个新的空对象因为复制弱引用的内容没有意义。Promise直接返回引用因为一个Promise的状态和回调链没法被复制。这些边界情况不需要你每种都记得清清楚楚但思路要清晰深拷贝的本质是数据的复制凡是无法复制的对象函数、Promise、WeakMap等直接返回引用是合理的选择。5. 业务实战中的深拷贝应用从性能优化到框架实践理论讲完了来说说我在实际项目中怎么用深拷贝解决具体问题的。这一节的内容都是我踩过的坑和总结出的经验比单纯的语法讲解更有参考价值。5.1 深浅拷贝在React与Vue中的应用场景前两年我在React项目里处理一个复杂表单父组件维护一份初始数据子组件在编辑过程中直接修改了props传入的对象——因为子组件里做了一层浅拷贝只复制了顶层结果修改嵌套字段时把父组件的state也给改了页面直接乱掉。最后定位到问题就是浅拷贝嵌套层级的引用共享。这个教训告诉我在React里做不可变更新时浅拷贝只适用于更新顶层字段的场景。比如// 这是对的只更新顶层字段 setState(prev ({ ...prev, name: 新的名字 })); // 这也是对的更新嵌套字段时要逐层展开 setState(prev ({ ...prev, address: { ...prev.address, city: 新的城市 } })); // 这是错的直接把嵌套对象浅拷贝后修改 const newState { ...prev }; newState.address.city 新的城市; // prev.address.city也被改了 setState(newState);Vue里的情况类似。Vue的reactive是用Proxy做响应式代理如果你把一个响应式对象深拷贝得到一个新对象新对象和原响应式对象就完全脱钩了——这有时是好事打破响应式有时是坏事修改新对象后界面不更新。我在Vue项目里处理的一个典型场景是编辑弹窗数据回填// 打开编辑弹窗时深拷贝原始数据 editForm JSON.parse(JSON.stringify(currentRow)); // 用户修改editForm不影响currentRow // 点取消时直接关闭即可原始数据完好无损如果这里用浅拷贝用户在弹窗里改了某个字段表格里的数据也跟着变体验极差。另一个Vue开发中经常遇到的场景是watch深度监听与深拷贝的组合使用// 用deep: true监听对象变化然后在回调里用深拷贝做数据快照 watch( () props.formData, (newVal) { // 如果不做深拷贝snapshot和newVal共享引用后续修改会影响快照 this.formSnapshot JSON.parse(JSON.stringify(newVal)); }, { deep: true } );5.2 深拷贝性能测试与优化策略我经常遇到有人问深拷贝性能是不是很差用了会不会卡顿。这个问题得分场景看。在一次涉及上万条数据的表格中对一个包含大量字段的对象做深拷贝JSON.parse(JSON.stringify())大约耗时几毫秒到十几毫秒性能是可以接受的。但如果你的数据量级是几十万、上百万或者你需要高频每次渲染都做深拷贝那就不能无脑用了。我自己优化深拷贝性能的几个方向第一数据分级处理。不是所有数据都需要深拷贝。只需要拷贝涉及修改的那一层数据其余层级沿用原引用。这在React的状态更新中尤其常见不可变数据结构的核心思想就是结构共享。第二缓存深拷贝结果。如果一个对象不经常变化可以结合WeakMap做缓存只有对象内容变化时才重新深拷贝。我封装过一个带缓存版本的深拷贝工具通过记录对象的修改时间戳来判定是否需要重新拷贝。第三精简拷贝内容。有些大型对象的字段里包含不需要拷贝的临时状态比如theme配置、i18n词典深拷贝时可以通过白名单/黑名单过滤掉这些字段显著减少拷贝工作量。lodash的cloneDeep不支持自定义过滤但手写实现可以。第四使用结构化克隆。浏览器端的structuredClone是原生API性能比JSON方案更好而且能正确处理Date、RegExp、Map、Set、ArrayBuffer等类型。不过要注意浏览器兼容性Chrome 98和Firefox 94才支持。// 现代浏览器的结构化克隆方案 const clone structuredClone(original);structuredClone的出现某种程度上是官方对深拷贝场景的回应它比JSON方案全面得多。如果你不需要兼容老浏览器可以优先考虑它。5.3 深浅拷贝在数据持久化与状态管理中的实践在localStorage、sessionStorage或IndexedDB中存储对象时存储层会自动序列化读取时需要反序列化。这个过程中的深拷贝概念容易被忽略但其实非常关键。比如你从localStorage读取一个配置对象然后直接修改它并写回写回时序列化的是修改后的对象这是没问题的。但如果你从localStorage读取后希望在内存中保留一份原始数据做对比不做深拷贝的话修改读取出来的对象会同时影响原始数据引用因为它们指向同一个对象。状态管理库Redux、Vuex、Pinia等的核心思想就是不可变数据每次状态更新都生成一个新的对象。如果你在状态更新的过程中不做深拷贝直接修改嵌套属性就会出现改了状态但视图不更新的诡异问题。以Redux为例// 错误写法直接修改state case UPDATE_USER: state.user.name action.payload.name; return state; // 正确写法每层都做浅拷贝本质是逐层展开 case UPDATE_USER: return { ...state, user: { ...state.user, name: action.payload.name } };Redux官方推荐的写法不是深拷贝整个state会浪费性能而是逐层浅拷贝。这和深拷贝所有数据是不同的思路——它更像是按需浅拷贝哪一层要修改哪一层就新建对象不修改的层级直接复用原引用。这是性能与不可变性之间的平衡点。6. 高频深拷贝场景与残缺方案补全让代码更健壮写深拷贝代码最大的问题是你不确定自己的方案覆盖了多少边界情况。这种情况在工程实践中最难受——测试数据碰巧没遇到特殊类型线上就崩了。下面我梳理一下平时最容易踩的雷区。6.1 处理循环引用的必要性与实现要点循环引用在业务代码中出现的频率比想象中高得多。尤其是处理树形结构组织架构、菜单、评论回复、图结构依赖关系、双向关联数据父子互相引用时几乎必然出现。直接用JSON.parse(JSON.stringify())处理循环引用的结果就是直接抛异常const obj {}; obj.self obj; JSON.parse(JSON.stringify(obj)); // TypeError: Converting circular structure to JSON这个报错在浏览器控制台里很常见错误信息其实已经说得很明白了但很多人看到circular structure还是不意识反是循环引用的问题。手写深拷贝时用WeakMap解决循环引用关键点是先创建空容器再递归处理属性// 正确顺序先Set再递归 function deepCopy(obj, cache new WeakMap()) { if (obj null || typeof obj ! object) return obj; if (cache.has(obj)) return cache.get(obj); const clone Array.isArray(obj) ? [] : {}; cache.set(obj, clone); // 关键先存入缓存 for (const key of Reflect.ownKeys(obj)) { clone[key] deepCopy(obj[key], cache); } return clone; }注意看先cache.set(obj, clone)再递归处理属性。如果不这样做当递归遇到循环引用时cache里还没有当前对象的记录会再次进入递归导致栈溢出。另一个容易被忽略的点是循环引用必须保持引用关系的一致性。假设对象上有两个属性指向同一个子对象正确的深拷贝应该让克隆对象上的这两个属性也指向同一个克隆子对象而不是克隆出两个完全独立的子对象。WeakMap缓存机制天然保证了这一点。6.2 structuredClone现代浏览器原生的深拷贝解决方案2022年之后前端多了一个深拷贝的官方解决方案——structuredClone。很多人在项目里已经开始用它替代JSON方案了但它的兼容性和能力边界需要说清楚。structuredClone能正确处理的数据类型包括Date、RegExp、Map、Set、ArrayBuffer、Blob、File、ImageData、TypedArray等。它不能处理的是函数、Symbol、DOM元素会抛出DataCloneError。const obj { name: 张三, date: new Date(), regexp: /hello/g, map: new Map([[key, value]]), set: new Set([1, 2, 3]), arr: new Uint8Array([1, 2, 3]) }; const clone structuredClone(obj); console.log(clone.date instanceof Date); // true console.log(clone.map instanceof Map); // true console.log(clone.arr instanceof Uint8Array); // true从功能上看structuredClone已经覆盖了绝大多数业务场景。循环引用它也能正确处理const obj { name: 张三 }; obj.self obj; const clone structuredClone(obj); console.log(clone.self clone); // true所以在现代浏览器项目中structuredClone是我首选的深拷贝方案只有在需要兼容老浏览器、或者需要保留对象原型链、或者需要拷丙函数时才会考虑其他方案。6.3 原型链、symbol与不可枚举属性面试中的加分细节面试中聊深拷贝能主动提到这些边界情况的候选人通常会留下好印象因为这反映了你对语言机制的深入理解。原型链的保留问题JSON方案和大多数手写实现都会丢失原型链。简单说如果你拷贝的是一个class的实例拷贝结果会变成一个普通对象。如果你需要保留原型链需要在创建克隆对象时用Object.create(Object.getPrototypeOf(obj))。symbol属性的处理JSON方案不拷贝symbol属性for...in也不遍历symbol属性Object.keys同样忽略。只有Reflect.ownKeys和Object.getOwnPropertySymbols能拿到symbol属性。通用深拷贝要考虑这一点。不可枚举属性的处理Object.defineProperty定义的属性默认是不可枚举的for...in和Object.keys都拿不到。Reflect.ownKeys能拿到。但如果你只想拷贝可枚举属性比如业务场景中的表单数据就要用Object.keys而不是Reflect.ownKeys。属性描述符的保留有些深拷贝实现会把属性值拷贝过去但丢失了属性的writable、enumerable、configurable描述符信息。严格意义上的深拷贝应该通过Object.getOwnPropertyDescriptor和Object.defineProperty来复制属性保留完整描述符。不过日常业务开发中这个需求很少见一般只在写工具库时才需要考虑。7. 树形结构与超大数据集深拷贝的实战进阶前面讲的是通用场景最后再聊两个我实际工作中处理过的复杂场景一个是树形结构一个是超大数据集的性能问题。7.1 树形数据的深拷贝与路径追踪处理树形结构数据比如组织架构树、目录树、评论树时深拷贝的难点在于树节点的引用关系和循环引用常常并存子节点引用父节点、兄弟节点互相引用而且在拷贝过程中你可能还需要记录每个节点在树中的路径。我之前在做权限管理模块时需要把一棵组织架构树深拷贝一份做试编辑然后比对修改差异。一开始直接用JSON方案结果树里存在循环引用子节点持有parent引用直接报错。后来换了递归 WeakMap方案才解决了问题。处理树形数据的深拷贝一个常见的需求是在拷贝的过程中同时维护从根节点到当前节点的路径信息。这需要在递归函数里额外传递一个路径参数。function deepCopyTree(node, parentPath , cache new WeakMap()) { if (node null || typeof node ! object) return node; if (cache.has(node)) return cache.get(node); const clone Array.isArray(node) ? [] : {}; cache.set(node, clone); for (const key of Reflect.ownKeys(node)) { if (key parent) { // parent指向父节点复制引用关系但避免递归爆炸 clone[key] node[key] ? cache.get(node[key]) || deepCopyTree(node[key], , cache) : null; } else { clone[key] deepCopyTree(node[key], ${parentPath}.${String(key)}, cache); } } return clone; }需要注意的一个细节是处理parent引用的逻辑不能简单递归。如果node.parent已经被拷贝过cache里存在直接返回缓存结果。如果还没有被拷贝比如从根节点开始往下走这时候递归处理parent会导致先向上走一遍再回来虽然能用WeakMap防止死循环但效率很低。正确处理方式是记录每个节点的parent引用递归完成后统一回填function deepCopyTree(root) { const cache new WeakMap(); const parentMap new WeakMap(); // 记录原节点 - 克隆节点的父级关系 function copy(node) { if (node null || typeof node ! object) return node; if (cache.has(node)) return cache.get(node); const clone Array.isArray(node) ? [] : {}; cache.set(node, clone); for (const key of Reflect.ownKeys(node)) { if (key parent node[key] ! null) { // 记录父级关系后面统一处理 if (!parentMap.has(node)) parentMap.set(node, new Map()); parentMap.get(node).set(key, node[key]); clone[key] null; // 先置空 } else { clone[key] copy(node[key]); } } return clone; } const cloneRoot copy(root); // 统一回填parent引用 for (const [originalNode, keyMap] of parentMap) { const cloneNode cache.get(originalNode); for (const [key, originalParent] of keyMap) { cloneNode[key] cache.get(originalParent); } } return cloneRoot; }两遍遍历的代价是性能稍有下降但换来的是逻辑清晰、避免递归回溯时的重复计算。在处理深层次树结构时这种优化很有价值。7.2 大数据量对象的深拷贝性能优化策略最后聊一个我在真实项目中遇到的性能问题一个包含几百个字段、多层嵌套的配置对象每次页面切换都要深拷贝一次结果在低端手机上明显卡顿。我通过三个手段把耗时降了下来。手段一按需拷贝惰性深拷贝。有些层级的对象在后续操作中根本不会被修改就不需要深拷贝。只在真正需要修改某个子树时才对该子树做深拷贝。类似React的按需更新这是最简单有效的优化。手段二使用自定义序列化。JSON方案的瓶颈在于JSON.stringify和JSON.parse的全量序列化和反序列化。如果数据结构比较固定可以编写针对性的序列化和反序列化方法只处理必要的字段避免反射遍历所有属性。我的一个项目中把一个固定结构的对象深拷贝时间从平均8ms降到了1ms以内效果显著。手段三利用requestIdleCallback做延迟拷贝。如果深拷贝不是当前任务必须的比如预先缓存一份数据做备份可以安排在浏览器空闲时段执行let backupReady false; let backupData null; requestIdleCallback(() { backupData structuredClone(largeObject); backupReady true; }, { timeout: 2000 });不过这个方案有一个风险如果页面在拷贝完成前就对数据做了修改备份数据就不是原始状态了。使用前需要确认业务场景中的时间和逻辑约束。// 深拷贝耗时对比粗略测试具体数值以实际环境为准 // JSON方案约3ms10万字段纯数据 // structuredClone约2ms10万字段纯数据 // 手写递归约5ms10万字段纯数据含WeakMap开销 // 按需深拷贝小于1ms只拷贝需要修改的子树数据量小的时候各方案差异不明显。数据量大了优化策略的价值才会凸显。7.3 手写一个适用于业务场景的精简版深拷贝工具如果要给业务封装一个通用的深拷贝工具我建议不要直接用网上那种大而全的版本而是根据项目实际情况裁剪。下面是我在项目中用的基础版本兼顾了覆盖面和代码量function cloneDeep(source, cache new WeakMap()) { // 处理基本类型和函数 if (source null || typeof source ! object) return source; // 处理循环引用 if (cache.has(source)) return cache.get(source); // 处理Date if (source instanceof Date) return new Date(source.getTime()); // 处理RegExp if (source instanceof RegExp) return new RegExp(source.source, source.flags); // 处理Map if (source instanceof Map) { const cloneMap new Map(); cache.set(source, cloneMap); source.forEach((value, key) { cloneMap.set(cloneDeep(key, cache), cloneDeep(value, cache)); }); return cloneMap; } // 处理Set if (source instanceof Set) { const cloneSet new Set(); cache.set(source, cloneSet); source.forEach(value { cloneSet.add(cloneDeep(value, cache)); }); return cloneSet; } // 处理数组和对象 const clone Array.isArray(source) ? [] : {}; cache.set(source, clone); Reflect.ownKeys(source).forEach(key { const descriptor Object.getOwnPropertyDescriptor(source, key); if (descriptor value in descriptor) { // 数据属性递归拷贝值 clone[key] cloneDeep(source[key], cache); } else { // 访问器属性拷贝getter/setter Object.defineProperty(clone, key, { ...descriptor, configurable: true }); } }); return clone; }这个版本保持了代码可读性和覆盖面之间的平衡。实际使用中你还需要根据项目里可能出现的数据类型做增加或裁剪。比如你的数据里永远不会出现Map和Set那这两段就可以删掉减少代码量。8. 如何回答面试中的深拷贝问题从背答案到讲清楚面试官问深拷贝真正想考察的其实不只是会不会写而是你对JavaScript数据模型、内存机制、边界情况的理解程度。我面试别人时光是从候选人怎么讲深拷贝就能大概判断出他的实际水平。8.1 面试标准回答的框架与细节把控建议的回答逻辑分四步第一步讲清楚深浅拷贝的本质区别。浅拷贝复制第一层引用类型属性共享内存深拷贝递归复制所有层完全独立。用基本类型和引用类型在内存中的存储方式作为切入点解释原因。第二步列出浅拷贝的常见实现。展开运算符、Object.assign顺手提一下它们只处理可枚举自身属性的限制。第三步列出深拷贝的常见实现。JSON方案提一下限制、递归方案提代码思路、lodash的cloneDeep说明处理了哪些边界情况、structuredClone提浏览器兼容性。第四步手写一个相对完整的递归深拷贝实现。必须考虑循环引用WeakMap、Date、RegExp、Map、Set、symbol属性、数组等。如果你能把每个分支的设计原因说清楚面试官会明显对你另眼相看。8.2 面试中会追问的高频问题汇总我总结了深拷贝话题下面试官最爱追问的几个点逐个说一下追问一为什么用WeakMap不用MapWeakMap的键是弱引用不阻止垃圾回收。深拷贝过程中用WeakMap保存原对象和克隆对象的映射关系当原对象不再被引用时WeakMap里的记录也会被回收不会造成内存泄漏。面试官问这个问题通常想考察你对内存管理的理解。追问二JSON方案的深拷贝有什么缺陷至少要说四点不拷贝函数、不拷贝symbol属性、undefined会被丢弃、Date会被转成字符串、RegExp/Map/Set会被转成空对象、循环引用会报错。能说出这些说明你真的用过而不是背题。追问三Object.assign和展开运算符有什么区别最核心的区别是Object.assign拷贝时使用源对象的Get和目标对象的Set会触发setter而展开运算符只是做属性赋值不会触发源对象setter。面试中这个问题回答到这个深度就比较有亮点了。追问四深拷贝一个函数的期望结果是什么直接返回原函数引用。因为函数的行为由代码和作用域链决定无法通过复制属性来复制函数的逻辑。如果一个深拷贝连函数都不认识那这个实现肯定有问题。8.3 面试之外真正理解才能写出可靠的代码面试能过关不代表代码不会出问题。真正的理解在于实践在于你是否能写出应对复杂业务的深拷贝工具或者更重要的——知道什么时候不需要深拷贝。举个我实际踩过的例子有个项目我封装了一个图表组件接收配置对象作为props/options默认值为一个常量对象调用方传进来一个临时对象我在组件内部为了安全起见做了一次深拷贝。结果因为数据量巨大图表配置包含数千个数据点每次组件初始化都要卡顿一下。后来我把深拷贝换成了按需只读访问组件内部不做任何修改操作彻底解决了卡顿问题。这提醒我一个很重要的认知深拷贝不是银弹。拷贝一份以防修改这种想法本身通常说明设计上有问题——如果数据不应该被修改应该通过约定或类型系统来保证而不是靠深拷贝来兜底。9. 我的建议深拷贝方案该如何做工程化选型最后给一套我在实际项目中会执行的选择逻辑可以当做一个决策参考9.1 不同场景下的方案选择建议我整理了一个决策表按场景区分推荐方案场景推荐方案原因接口返回的纯JSON数据JSON方案或structuredClone数据结构简单没有特殊对象包含Date/Map/Set/RegExp的数据structuredClone现代浏览器或lodash cloneDeepJSON方案会导致类型丢失存在循环引用的数据手写递归 WeakMap或lodash cloneDeepJSON方案直接报错需要保留类实例原型链手写专用拷贝或lodash cloneDeep通用方案默认不保留原型超大对象且关注性能按需深拷贝/惰性拷贝避免全量拷贝全量拷贝耗时不可控需要兼容老浏览器手写递归 / lodash cloneDeepstructuredClone兼容性不够明确不需要深拷贝只做只读访问不做拷贝用只读封装或类型约束减少不必要的性能开销9.2 判断何时不需要深拷贝工程化选型最重要的一环其实是判断是否需要深拷贝。我的经验是以下几种情况可以不做深拷贝或者换个思路第一数据不会发生修改。如果对象在传递过程中只被读取、不被写入就不需要深拷贝。很多为了保险起见写下的深拷贝其实是早期设计留下的历史包袱。第二可以用不可变数据结构代替。使用Immer这类库通过对草稿状态的修改自动生成新的不可变数据不需要手动深层拷贝。实际效果比深拷贝好得多尤其在React/Redux体系中。第三可以使用浅拷贝加按需更新来代替深拷贝。大部分UI框架的状态更新只需要顶层不可变嵌套层级的更新逐层展开即可不需要对整个状态树做深拷贝。9.3 最终建议结合我的个人经验给一个明确的建议在现代浏览器项目中默认用structuredClone遇到不支持的浏览器用lodash的cloneDeep做降级。如果项目的数据结构比较单纯多半是接口返回的JSONJSON方案也完全够用但要在代码注释里写明这个函数的数据前提。如果你不想引入lodash手写一个精简版本的深拷贝也完全可以但要明确它能处理哪些类型不能处理哪些类型避免线上踩坑。无论用哪种方案都要对循环引用保持敏感。只要数据来自用户输入或第三方库假设它可能包含循环引用是最安全的。深拷贝这个话题从表面上看起来只是API形态的问题实际上涉及JavaScript的数据模型、内存机制、原型继承、遍历方式、浏览器API兼容性等多个层面。把这些问题想明白应对面试绰绰有余日常做开发选型也心里有底。如果这篇文章能让你对深浅拷贝的理解从会用变成懂原理那我写这些字就值了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价