深拷贝与浅拷贝:JavaScript内存模型、实现方式与实战避坑指南
这个话题几乎是前端面试的“钉子户”也是日常开发里最容易埋雷的地方。我见过不少同学写代码时碰到的诡异 bug——明明只改了 A 对象里的一个字段结果 B 对象跟着变了或者一个配置对象被多个模块共用某天某处往里面塞了个属性整个页面行为全乱了。排查到最后根源往往就一句话当初拷贝的时候用的是浅拷贝你拿到的根本不是一份独立的数据。这篇文章我想把自己对浅拷贝与深拷贝的理解完整梳理一遍包括它们到底在内存层面做了什么、各有什么边界限制、手写一个稳妥的深拷贝需要小心哪些细节以及实战中怎么快速判断该用哪一种。不管你是刚入门的新人还是被这类 bug 折磨过的老手这篇文章应该都能帮上忙。1. 先弄清楚你操作的是数据本体还是引用想搞懂深浅拷贝前提是先搞清楚 JavaScript 里数据是怎么存的。很多解释文章一上来就列方法、贴代码但根本没有讲透底层模型导致读者只会背结论换个场景就判断不了了。1.1 值类型与引用类型一切差异的源头JavaScript 的数据类型可以分为两大阵营。第一类是基本类型primitive type包括string、number、boolean、null、undefined、symbol、bigint。这类数据的特点是变量直接保存值本身赋值的时候是把值复制一份交给新变量。let a 10; let b a; b 20; console.log(a); // 10 console.log(b); // 20这里b拿到的是a的副本两者从此各过各的谁改都不影响对方。第二类是引用类型reference type包括普通对象、数组、函数、日期、正则等等。这类数据的特点是变量里存的不是数据本身而是数据在内存中的“地址”。赋值的时候复制的是地址而不是地址指向的那块数据。const objA { name: 张三 }; const objB objA; objB.name 李四; console.log(objA.name); // 李四 console.log(objB.name); // 李四看到没objA和objB指向的是同一个对象改任何一个另一个也会跟着变。这就是为什么扯到拷贝之前必须先建立这个认知你在操作引用对象时绝大多数情况下操作的是同一个东西的两把“钥匙”。1.2 内存里的模型栈与堆的一次比喻为了把上面说的“地址”变得更好理解可以把内存粗略分成两个区域栈stack和堆heap。栈的存取速度很快适合保存体积较小、大小固定的数据比如基本类型。当你写let a 10时栈里直接存了10这个值。引用类型的值通常比较大或者大小不固定所以实际数据放在堆里。栈里存的只是一个指向堆的内存地址。当你写const objA { name: 张三 }时堆里有一块区域存着{ name: 张三 }栈里存的则是这块区域的地址编号。于是赋值操作就分成了两种基本类型赋值栈里把值复制一份两个变量互不相干。引用类型赋值栈里把地址复制一份两个变量指向同一个堆内存数据。你可能会问那拷贝一个对象不就是把堆里的数据也复制一份吗这正是深浅拷贝要解决的。浅拷贝只复制了栈里的地址或者只复制了对象第一层的基本数据深拷贝则是把堆里层层嵌套的数据都复制一遍让新旧对象在内存层面完全独立。1.3 判断“真拷贝”的唯一标准很多教程里写“展开运算符实现的是浅拷贝”读者可能不理解明明{ ...obj }看起来像复制了一份新对象啊判断标准非常简单你得到的新对象和旧对象在内存里是不是完全独立。具体验证方式也很粗暴——修改新对象里某个值然后看旧对象有没有被影响到。const original { name: 张三, address: { city: 杭州 } }; const copy { ...original }; copy.name 李四; console.log(original.name); // 张三第一层字符串没受影响 copy.address.city 上海; console.log(original.address.city); // 上海嵌套对象被改了第一层name是基本类型展开操作符直接把值复制了一份所以改copy.name不影响原对象。但address是引用类型展开操作符复制的是address的地址所以copy.address和original.address依然指向同一个嵌套对象。这个例子基本揭示了浅拷贝的本质它能保证最外层是新的但不能保证嵌套层独立。2. 浅拷贝到底拷贝了什么理解深浅之分后先别急着追求深拷贝。浅拷贝在不少场景下是明智且高效的选择关键在于你要知道它在边界上止步于哪里。2.1 常见的浅拷贝写法日常开发里浅拷贝主要靠几种手段完成第一种是展开运算符。这是 ES6 之后最常用的写法简洁直观对象和数组都适用。const obj { a: 1, b: { c: 2 } }; const clone { ...obj }; const arr [1, 2, { x: 3 }]; const arrClone [...arr];第二种是Object.assign。它的行为是把源对象的所有可枚举属性复制到目标对象上然后返回目标对象。看起来也是“复制一份”但它的复制逻辑同样是第一层。const target {}; const source { a: 1, b: { c: 2 } }; const result Object.assign(target, source);第三种是数组相关的原生方法。Array.prototype.slice和Array.prototype.concat都不修改原数组而是返回一个新数组。很多老教程把它们当作“数组拷贝神器”但它们内部依然是浅拷贝逻辑。const arr [1, 2, { a: 3 }]; const sliced arr.slice(); sliced[2].a 999; console.log(arr[2].a); // 999嵌套对象还是共享的第四种是手写一个循环遍历。本质上就是把对象的第一层属性一个个赋值到新对象里。虽然代码是自己写的但只要没处理嵌套对象它依然是浅拷贝。function shallowClone(obj) { const result {}; for (const key in obj) { if (Object.prototype.hasOwnProperty.call(obj, key)) { result[key] obj[key]; } } return result; }2.2 第一层独立深层仍然共享理解浅拷贝的核心是记住这样一句话浅拷贝只复制第一层的值如果这一层的值是基本类型那么新旧对象不复用如果这一层的值是引用类型那么新旧对象共享同一个嵌套数据。用一个具体业务场景来演示。假设你有一个用户信息对象结构是这样的const user { id: 1001, name: 小明, profile: { age: 18, tags: [前端, 篮球], }, };这时你为了在页面上做一个“编辑用户资料”的功能先浅拷贝一份user作为草稿const draft { ...user }; draft.name 小红; draft.profile.age 20;你发现user.profile.age也变成了 20。因为draft.profile和user.profile是同一个对象。你本想改草稿结果把真实数据也改了。如果这个时候发请求保存草稿后端就会收到被污染的数据。深层共享的问题在数组里尤其隐蔽。比如二维数组或对象数组slice()之后你以为安全了结果修改arr[0][0]会牵动原数组。这类问题在复杂表单、异构数据整理、多级配置合并中非常容易踩中。2.3 浅拷贝并非鸡肋这些场景它很合适浅拷贝不是“不行”而是“浅”。有些场景下浅拷贝不仅是合理的甚至是性能最优解。第一个典型场景是 React 或 Vue 里的不可变更新。React 强调状态不可变更新状态时要返回一个新对象。但状态往往很深如果深拷贝整个状态树不仅慢而且会丢失“引用变化”的优化意义。正确的做法是只浅拷贝需要修改的那几层对象保持其他层引用不变。比如修改state.user.profile.age应该这样做setState({ ...state, user: { ...state.user, profile: { ...state.profile, age: 20, }, }, });这样 React 可以用引用比较快速判断哪一层变了从而精准触发渲染。第二个场景是配置合并。项目里经常会有默认配置和用户配置合并的需求比如组件参数、请求设置。通常只需要合并最外层的几项配置内层由具体逻辑自行决定是否覆盖。这种场景用{ ...defaults, ...custom }完全可以因为不需要把每个嵌套对象都解耦。第三个场景是对性能敏感的大数据。深拷贝的开销随数据规模和嵌套深度线性增长如果数据非常大且只修改最外层使用浅拷贝可以省下大量时间和内存。有些时候“共享引用”反而是有意的设计。所以说浅拷贝不是“错误”而是一种有边界的工具。边界内它是利器边界外它就是 bug 的温床。这也是为什么你必须清楚地知道它的边界到底在哪里。3. 深拷贝的几种靠谱实现当你确定需要一份完全独立的数据时就要上深拷贝了。深拷贝的目标很明确把原对象在堆内存里的数据完整复制一份包括所有嵌套的对象和数组新旧对象之间再没有任何引用关系。3.1 JSON 方法快但坑也多最简单粗暴的深拷贝方案是JSON.parse(JSON.stringify(obj))。它先把对象序列化成 JSON 字符串再把这个字符串解析成全新的对象。两步行云流水代码只有一行。const deepCopy JSON.parse(JSON.stringify(original));这个方案对付普通的数据结构比如纯对象、数组、字符串、数字嵌套确实方便快捷。但它有一长串限制我在实际开发中踩过的坑包括undefined、函数、symbol类型的属性会被整个丢弃不会保留在拷贝结果中。Date对象会被转换成字符串而不是原来的Date对象。RegExp、Map、Set、WeakMap、WeakSet等特殊对象会被序列化成空对象。NaN和Infinity会被转换成null。存在循环引用时会直接抛出错误TypeError: Converting circular structure to JSON。看一个实际例子const original { name: 小明, sayHi: () console.log(hi), date: new Date(), map: new Map([[key, value]]), nan: NaN, }; const copy JSON.parse(JSON.stringify(original)); console.log(copy); // { // name: 小明, // date: 2024-01-01T00:00:00.000Z, // map: {}, // nan: null, // // sayHi 直接消失了 // }如果你的数据里只是普通业务字段JSON 方案能用。但如果数据里含有特殊类型、循环引用或者函数它就会静默地改变甚至丢失数据。它是“能用但不完全可靠”的典型代表。3.2 手写递归深拷贝逐步完善想拥有一个符合自己业务需求的深拷贝手写递归是绕不开的练习。不光是面试要考理解这个递归过程后你对 JavaScript 的对象模型会有一层更深的认知。先写一个基础版本只处理普通对象和数组function deepClone(source) { if (source null || typeof source ! object) { return source; } if (Array.isArray(source)) { return source.map((item) deepClone(item)); } const result {}; for (const key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { result[key] deepClone(source[key]); } } return result; }这个版本已经能处理大多数“对象套数组、数组套对象”的结构了。但很快你就会发现新问题——循环引用。看这个结构const obj {}; obj.self obj;如果你用上面的函数去拷贝obj会陷入无限递归直到栈溢出。解决办法并不复杂用一个额外的容器记录“已经拷贝过的对象”当递归再次遇到同一个对象时直接返回之前拷贝的结果。这个容器用WeakMap最合适因为它的键是弱引用不会造成内存泄漏。function deepClone(source, map new WeakMap()) { if (source null || typeof source ! object) { return source; } if (map.has(source)) { return map.get(source); } if (Array.isArray(source)) { const result []; map.set(source, result); source.forEach((item) result.push(deepClone(item, map))); return result; } const result {}; map.set(source, result); for (const key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { result[key] deepClone(source[key], map); } } return result; }到这里一个能处理循环引用、嵌套数组/对象的基础深拷贝就成型了。但还远远没到“完善”的程度。如果要支持Date、RegExp、Map、Set以及保留原型链还需要写很多分支。比如function deepClone(source, map new WeakMap()) { if (source null || typeof source ! object) { return source; } if (map.has(source)) { return map.get(source); } if (source instanceof Date) { return new Date(source.getTime()); } if (source instanceof RegExp) { return new RegExp(source.source, source.flags); } if (source instanceof Map) { const result new Map(); map.set(source, result); source.forEach((value, key) result.set(deepClone(key, map), deepClone(value, map))); return result; } if (source instanceof Set) { const result new Set(); map.set(source, result); source.forEach((value) result.add(deepClone(value, map))); return result; } if (Array.isArray(source)) { const result []; map.set(source, result); source.forEach((item) result.push(deepClone(item, map))); return result; } const result {}; map.set(source, result); for (const key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { result[key] deepClone(source[key], map); } } return result; }写到这里你应该明白一件事手写一个“完全计划周全”的深拷贝工程量不小。平时如果时间紧我更推荐用成熟的开源方案后面会讲到。3.3 成熟方案内置 API 与 lodash 怎么选如果项目允许引入库lodash.cloneDeep是我用得最多的方案。它对各种内置类型、原型链、循环引用都有成熟处理源码久经考验。不需要自己维护那些边边角角的分支逻辑。import { cloneDeep } from lodash; const copy cloneDeep(original);如果你不想要 lodash 整个库的体积可以单独安装lodash.clonedeep这个包只引入一个函数构建体积影响极小。除了第三方库现代浏览器和 Node.js 17 环境下还有一个原生 API 值得关注structuredClone。它是真正意义上由运行环境提供支持的深拷贝能够处理Date、RegExp、Map、Set、ArrayBuffer、甚至File、Blob等复杂类型并且支持循环引用。const copy structuredClone(original);这行代码的干净程度跟JSON.parse(JSON.stringify(obj))不相上下但能力强了不是一个量级。我个人的建议是如果项目运行环境支持structuredClone可以直接用它作为默认深拷贝方案省心省力。不同方案的对比整理成一张表格更直观方案是否能处理嵌套对象是否能处理循环引用是否能保留Date/Map/Set/函数等性能适用场景JSON.parse(JSON.stringify())是否直接报错否会丢失或变形快纯数据业务对象手写递归是需借助 WeakMap需自行扩展分支取决于实现面试或定制需求lodash.cloneDeep是是大部分类型可以中等偏上生产环境通用structuredClone是是支持大多数内置类型快现代运行环境4. 实战中的那些坑和排查思路即使懂了概念实战中深浅拷贝的坑依然层出不穷。我自己就曾经因为一个浅拷贝问题在线上环境排查了整整一个下午。这一节把常见问题和排查思路整理出来希望能帮你少走弯路。4.1 “为什么我改了配置别人也变了”——共享引用类的 bug这类 bug 的典型特征多个变量看似独立实际共享底层数据。常见于以下场景从 store/state 里取出的对象未经拷贝就赋给局部变量然后修改了该局部变量。组件之间通过 props 传递对象子组件直接修改 props 里的嵌套属性。工具函数内部对传入的对象参数做了“写操作”影响了调用方的数据。多个模块共用同一个配置文件某个模块往里塞了字段其他模块也跟着看到。有一次我遇到的情况是A 模块初始化了一个全局配置对象为了让 B 模块也能用就const bConfig aConfig直接赋值过去了。后来 B 模块往配置里加了一个timeout字段A 模块读取配置时也读到了这个timeout。看起来是“全局生效”实则是共享引用的副作用。这种共享不一定是坏事但如果 B 只是想改自己的副本就会导致 A 被连带修改。排查这类问题最快的方法是在可疑的赋值处打一个“引用快照”。用console.log不足以判断两个对象是不是同一个引用更好的办法是给对象加个临时标记字段或者直接比较两个变量是否完全相等console.log(a b); // true 说明就是同一个引用如果a b为true那它们就是同一个对象改谁都是在改那一个。4.2 深拷贝导致性能慢大对象卡顿深拷贝不是“万能药”它同样有代价。当一个对象非常大、嵌套非常深时深拷贝的耗时和内存开销会成倍上涨。如果在一个高频调用的函数里做深拷贝页面卡顿是必然的。我之前处理过一个批量导入 Excel 的场景每次要拷贝一个包含数万条记录的大数组每条记录还有多层嵌套。一开始为了图省事所有地方都用深拷贝结果一次导入要卡两三秒。后来优化的思路是只在数据从“导入区”进入“正式区”时做一次深拷贝中间过程用引用传递。对于只读数据完全不做拷贝直接使用原引用用 Object.freeze 冻结对象防止意外修改。尽量用浅拷贝配合不可变更新模式只更新需要变化的层级。这里的大原则是能用浅拷贝解决的就不要用深拷贝深拷贝是一种负担而不是一种保险。尤其在列表渲染、热更新路径上越少的拷贝意味着越高的性能。4.3 常见问题速查表把我在开发中遇到的高频问题整理成了一张表格方便你快速对号入座。问题现象大概率原因解决方案修改拷贝后的对象原对象也跟着变了用了浅拷贝或直接赋值改用深拷贝或确认是否需要共享引用JSON 序列化后undefined、函数丢失使用了 JSON 方案拷贝换用 structuredClone 或 lodash.cloneDeep拷贝 Date 后变成字符串使用了 JSON 方案换用structuredClone或手动处理 Date 分支循环引用的对象无法拷贝使用了 JSON 方案使用structuredClone或手写带 WeakMap 的深拷贝深拷贝很慢页面卡顿拷贝了过大的对象或者高频调用考虑浅拷贝、不可变更新或数据扁平化对象被意外冻结/修改不动有人用了 Object.freeze检查代码中对 freeze 的调用拷贝时也无法解冻React state 更新后组件不重新渲染直接修改了原 state没有创建新引用使用展开运算符合并层级保持不可变更新4.4 我常用的三类排查技巧第一招打印内存中的引用关系。如果你怀疑两个变量指向同一对象可以给它们各打一个标签属性objA.__debugTag A; console.log(objB.__debugTag); // A说明 objB 和 objA 是同一个对象这个方法虽然粗糙但在复杂数据流中找“别名”很有效。第二招用structuredClone或JSON制造一个“隔离副本”作为对照实验。如果修改隔离副本后原对象不变而修改某个变量后原对象变了说明问题出在赋值/拷贝环节而不是逻辑环节。第三招遇到“改了这里那里变”的问题直接在可疑的深层对象上打断点观察它的调用栈看哪个函数先修改了它。很多时候问题不是出在你正在调试的那一行而是更早的某个操作。5. 最后再分享两个实操细节整篇讲下来核心就一句话拷贝之前先搞清楚你是要“独立的数据”还是“共享的引用”。下面是两个我每次写代码都会反复确认的细节。第一个细节是对象拷贝要看清数据来源。如果对象来自前端状态管理库Redux、Vuex、Pinia 等共享引用往往是刻意设计的。你贸然深拷贝一份反而可能破坏框架的响应式追踪或更新机制。正确的做法是遵循框架的更新惯例用浅拷贝加层级更新的方式去修改状态而不是一股脑深拷贝。第二个细节是如果你站在面试场景里不要只背结论要能画出一条记忆链基本类型存值、引用类型存地址、浅拷贝只复制第一层、深拷贝递归到底、循环引用用 WeakMap 兜底、JSON 方案有哪些坑、structuredClone 是什么。这条链理清楚了面试官怎么追问你都能接住。写代码这件事很多坑都是 “概念差之毫厘线上失之千里”。深浅拷贝这个点花点时间彻底吃透是真的能在未来某个深夜帮你省下几个小时 debug 时间的。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →