浅拷贝和深拷贝这俩概念,几乎每次前端面试都会碰到,但说真的,能把这两个概念讲透的人不多。很多人背了答案,知道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); // 10,a没有被影响引用类型就不一样了。对象存储在堆内存中,栈内存保存的是对象的地址引用。当你把一个对象变量赋值给另一个变量时,复制的是引用地址,而不是对象本身:
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.create:
function 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方案:约3ms(10万字段纯数据) // structuredClone:约2ms(10万字段纯数据) // 手写递归:约5ms(10万字段纯数据,含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不用Map?
WeakMap的键是弱引用,不阻止垃圾回收。深拷贝过程中用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 cloneDeep | JSON方案会导致类型丢失 |
| 存在循环引用的数据 | 手写递归 + WeakMap,或lodash cloneDeep | JSON方案直接报错 |
| 需要保留类实例原型链 | 手写专用拷贝,或lodash cloneDeep | 通用方案默认不保留原型 |
| 超大对象且关注性能 | 按需深拷贝/惰性拷贝,避免全量拷贝 | 全量拷贝耗时不可控 |
| 需要兼容老浏览器 | 手写递归 / lodash cloneDeep | structuredClone兼容性不够 |
| 明确不需要深拷贝,只做只读访问 | 不做拷贝,用只读封装或类型约束 | 减少不必要的性能开销 |
9.2 判断何时不需要深拷贝
工程化选型最重要的一环其实是:判断是否需要深拷贝。我的经验是,以下几种情况可以不做深拷贝,或者换个思路:
第一,数据不会发生修改。如果对象在传递过程中只被读取、不被写入,就不需要深拷贝。很多"为了保险起见"写下的深拷贝,其实是早期设计留下的历史包袱。
第二,可以用不可变数据结构代替。使用Immer这类库,通过对草稿状态的修改自动生成新的不可变数据,不需要手动深层拷贝。实际效果比深拷贝好得多,尤其在React/Redux体系中。
第三,可以使用"浅拷贝加按需更新"来代替深拷贝。大部分UI框架的状态更新只需要顶层不可变,嵌套层级的更新逐层展开即可,不需要对整个状态树做深拷贝。
9.3 最终建议
结合我的个人经验,给一个明确的建议:
- 在现代浏览器项目中,默认用
structuredClone,遇到不支持的浏览器用lodash的cloneDeep做降级。 - 如果项目的数据结构比较单纯,多半是接口返回的JSON,JSON方案也完全够用,但要在代码注释里写明这个函数的数据前提。
- 如果你不想引入lodash,手写一个精简版本的深拷贝也完全可以,但要明确它能处理哪些类型,不能处理哪些类型,避免线上踩坑。
- 无论用哪种方案,都要对循环引用保持敏感。只要数据来自用户输入或第三方库,假设它可能包含循环引用是最安全的。
深拷贝这个话题,从表面上看起来只是API形态的问题,实际上涉及JavaScript的数据模型、内存机制、原型继承、遍历方式、浏览器API兼容性等多个层面。把这些问题想明白,应对面试绰绰有余,日常做开发选型也心里有底。如果这篇文章能让你对深浅拷贝的理解从"会用"变成"懂原理",那我写这些字就值了。