ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

JavaScript深浅拷贝与解构赋值的本质区别及内存原理

JavaScript深浅拷贝与解构赋值的本质区别及内存原理 1. 前端面试绕不开的“拷贝题”为什么深浅拷贝和解构赋值总被连着问前端面试里只要聊到数据类型、引用关系、对象操作几乎必然撞上这组“铁三角”深拷贝、浅拷贝、解构赋值。它不像算法题那样需要推导时间复杂度也不像框架原理那样要画流程图但恰恰是这种看似基础的操作最能暴露候选人对 JavaScript 底层内存模型的理解深度——不是背概念而是真正在脑子里“看见”了堆heap和栈stack里数据是怎么存、怎么连、怎么断的。我带过几十个校招和社招的前端同学做模拟面试发现一个特别典型的误区很多人能脱口而出“浅拷贝只复制一层深拷贝递归复制所有层级”也能写出Object.assign({})或[...arr]但一追问“那{a: {b: 1}}用解构赋值{a: {b}} obj后修改b会影响原对象吗为什么”十有八九卡壳。问题不在代码写不出来而在于没把“引用地址的传递”这个核心机制刻进肌肉记忆。这道题之所以高频是因为它横跨三个关键能力层第一层是语言基础原始值 vs 引用值第二层是工程实践什么时候该用哪种拷贝JSON.parse(JSON.stringify())真的万能吗第三层是设计意识Vue 的响应式依赖收集、React 的不可变更新、状态管理中的 immutable update底层全靠对拷贝行为的精准控制。你写的不是几行代码是在给整个应用的数据流“打地基”。如果你正准备2026年前端面试别再只刷“区别对比表”。真正拉开差距的是你能不能在白板上画出内存图、能不能说出structuredClone在 Chrome 98 里为什么比手写递归快3倍、能不能解释为什么Map和Set用JSON方法会丢失数据——这些细节才是面试官听你讲完“浅拷贝是地址复制”后立刻抛出的第二问。2. 拷贝的本质从内存模型看“复制”到底在复制什么2.1 栈与堆JavaScript 的两块“地皮”理解拷贝必须先看清 JavaScript 的内存布局。它不像 C 那样让你手动malloc但逻辑上依然分两块地栈Stack和堆Heap。栈存放原始值primitive values——string、number、boolean、null、undefined、symbol、bigint。它们的特点是值直接存在栈里大小固定访问极快。比如let a 42; let b a;栈里直接给b开辟一块新空间把42这个数字原样抄过去。改ba完全不受影响。这就是“值传递”的物理基础。堆存放引用值reference values——object、array、function、Date、RegExp、Map、Set等。它们的特点是值本身体积大且不固定所以存在堆里而栈里只存一个“地址”指针指向堆里的具体位置。比如let obj1 {name: Alice}; let obj2 obj1;栈里obj2存的不是{name: Alice}这个对象而是一个和obj1一模一样的地址都指向堆里那个{name: Alice}的内存块。此时obj2.name Bobobj1.name也变成Bob——因为它们根本就是同一个东西。提示typeof null返回object是历史遗留 bug但null本质是原始值它在栈里存的就是null这个字面量不是地址。这是个特例记牢。2.2 浅拷贝只复制“地址”不复制“房子”浅拷贝Shallow Copy干了一件事为新对象在栈里分配新空间但堆里的地址原封不动地抄一份过去。它只管“第一层”的引用不管“里面还有没有引用”。我们用一个经典例子拆解const original { name: Alice, age: 25, address: { city: Beijing, district: Chaoyang }, hobbies: [reading, swimming] }; const shallow { ...original }; // 解构赋值实现的浅拷贝 // 或者用 Object.assign({}, original); // 或者用 Array.from(original.hobbies); // 对数组内存图解文字描述栈里original变量存着一个地址0x100shallow变量存着另一个地址0x200。堆里0x100指向一个对象里面有name、age原始值直接存、address地址0x300、hobbies地址0x400。0x200指向一个新对象它的name、age是original里值的副本独立但address字段存的还是0x300hobbies字段存的还是0x400。所以shallow.name Bob→ 安全不影响original.name原始值独立。shallow.address.city Shanghai→ 危险original.address.city也变成Shanghai共用0x300。shallow.hobbies.push(coding)→ 危险original.hobbies也多了一个coding共用0x400。这就是浅拷贝的“脆弱性”它只切断了第一层的引用链内层的引用链依然牢牢焊死。2.3 深拷贝连根拔起“房子”和“地基”一起搬深拷贝Deep Copy的目标是创建一个全新的、完全独立的对象堆里所有层级的引用值都要重新分配内存、重新复制内容。它要确保修改新对象的任意嵌套属性都不会波及原对象。继续上面的例子const deep JSON.parse(JSON.stringify(original)); // 经典但有缺陷的深拷贝 // 或者用 Lodash 的 _.cloneDeep(original); // 或者用现代浏览器的 structuredClone(original);内存图解文字描述栈里deep变量存着一个全新地址0x500。堆里0x500指向一个新对象它的name、age是副本address字段存的是一个全新地址0x600指向堆里另一个新对象{city: Beijing, district: Chaoyang}hobbies字段存的是一个全新地址0x700指向堆里另一个新数组[reading, swimming]。所以deep.address.city Shanghai→ 安全original.address.city仍是Beijing。deep.hobbies.push(coding)→ 安全original.hobbies长度不变。深拷贝的难点在于“递归”它必须一层层钻进对象遇到引用值就新开一块堆内存再把里面的属性也这样处理直到所有引用值都被“展开”成原始值或新的引用。这个过程天然容易陷入循环引用A对象引用BB又引用A或性能瓶颈超大对象遍历耗时。2.4 解构赋值一种语法糖但本质是浅拷贝解构赋值Destructuring Assignment常被误认为是一种“拷贝方法”但它本质上是一种提取值的语法糖其拷贝行为完全取决于被解构的值的类型。解构原始值const {name} original;→name是original.name的副本原始值独立。解构引用值const {address} original;→address是original.address的副本地址共享。解构数组const [first, ...rest] original.hobbies;→first是字符串副本独立rest是一个新数组但里面存的元素如果是引用值如对象则地址仍共享。关键点解构赋值本身不决定深浅它只是把源对象/数组里的值无论是原始值还是地址按规则取出来放到新变量里。取出来的值是什么类型就决定了后续操作是否影响原对象。所以const {address: {city}} original;这行代码city是原始值修改它当然不影响原对象但const {address} original; address.city Shanghai;就会污染原对象——因为address这个变量存的就是那个共享的地址。3. 实操实现手写、工具库、原生 API各有什么坑3.1 手写浅拷贝三板斧场景决定选哪个手写浅拷贝的核心目标是快速、安全地创建第一层独立副本。没有银弹得看你的数据结构和需求。3.1.1 对象Object.assign()—— 兼容性之王但有陷阱const shallowObj Object.assign({}, originalObj); // 或者更安全的写法避免修改原对象 const shallowObj Object.assign(Object.create(null), originalObj);原理Object.assign(target, ...sources)把sources的可枚举自有属性own enumerable properties逐个赋值给target。{}是空对象所以结果就是originalObj第一层属性的副本。优势兼容性极好IE9老项目首选。一行代码搞定语义清晰。坑与注意事项只拷贝可枚举自有属性Object.defineProperty(originalObj, hidden, {value: secret, enumerable: false});这种不可枚举属性会被忽略。不处理Symbol键const sym Symbol(key); originalObj[sym] value;解构后sym键消失。target被修改第一个参数是目标对象如果传入originalObj自身会直接污染它务必传入{}或Object.create(null)。无法拷贝原型链shallowObj.__proto__是Object.prototype不是originalObj.__proto__。实操心得我在维护一个兼容 IE11 的后台系统时Object.assign是默认选择。但遇到需要拷贝Symbol键的场景比如某些第三方库的私有标识我会立刻切到Reflect.ownKeys()for...of循环方案。3.1.2 对象扩展运算符...—— 现代 JS 的优雅之选const shallowObj { ...originalObj };原理ES2018 语法底层行为和Object.assign几乎一致也是提取可枚举自有属性。优势语法极其简洁可读性高。支持在解构中混合使用const {id, ...rest} originalObj;rest是除id外的浅拷贝。坑与注意事项兼容性稍差需要 Babel 编译babel/plugin-proposal-object-rest-spread才能支持旧浏览器。同样忽略不可枚举属性和Symbol键。不能用于类数组对象const arrLike {0: a, 1: b, length: 2}; const newArr [...arrLike];会报错因为...要求对象有Symbol.iterator。3.1.3 数组slice()/concat()/ 扩展运算符 —— 三选一指南const arr [1, 2, {name: Alice}]; const shallowArr1 arr.slice(); // 传统可靠 const shallowArr2 arr.concat(); // 语义稍怪但有效 const shallowArr3 [...arr]; // 现代推荐原理三者都是返回一个新数组包含原数组所有元素的浅拷贝。对比与选择slice()最经典语义明确“切片”兼容性最好IE9。concat()本意是连接数组arr.concat()相当于arr.concat([])所以也返回副本。但语义不够直观易混淆。[...arr]最现代、最常用性能在 V8 引擎下通常最优且支持Array.from(arr)这种更灵活的写法Array.from还能处理类数组。关键提醒无论哪种shallowArr[2].name Bob都会改变原数组里对象的name因为对象引用被复制了。3.2 手写深拷贝从简单递归到处理循环引用手写深拷贝是面试高频考点也是检验 JS 功底的试金石。我们从最简版本开始逐步加料。3.2.1 基础递归版处理普通对象和数组function deepClone(obj) { // 1. 处理 null 和原始值直接返回 if (obj null || typeof obj ! object) { return obj; } // 2. 区分数组和普通对象 const target Array.isArray(obj) ? [] : {}; // 3. 递归拷贝所有自有可枚举属性 for (let key in obj) { if (obj.hasOwnProperty(key)) { target[key] deepClone(obj[key]); // 关键递归调用 } } return target; }优点逻辑清晰易于理解覆盖 80% 日常场景。致命缺陷无限递归遇到循环引用obj.a obj;直接栈溢出。丢失构造函数信息new Date()会被变成普通对象{}new RegExp()变成{}Map/Set变成{}。无法处理Symbol键for...in不遍历Symbol。性能一般每次hasOwnProperty都是函数调用开销。3.2.2 进阶版用WeakMap解决循环引用循环引用是深拷贝的“天敌”。解决方案是记录已拷贝过的对象再次遇到时直接返回之前创建的副本地址而不是递归进去。WeakMap是最佳选择因为它不会阻止垃圾回收。function deepClone(obj, hash new WeakMap()) { if (obj null || typeof obj ! object) return obj; // 3. 检查是否已处理过此对象解决循环引用 if (hash.has(obj)) { return hash.get(obj); } // 4. 创建新对象并记录映射关系 const target Array.isArray(obj) ? [] : {}; hash.set(obj, target); // 5. 递归拷贝同上 for (let key in obj) { if (obj.hasOwnProperty(key)) { target[key] deepClone(obj[key], hash); } } return target; }原理hash是一个“备忘录”obj作为 keyWeakMap 只接受对象作 keytarget作为 value。第一次遇到obj创建target并存入第二次再遇到obj比如在obj.a.b.c里又指向了obj直接return hash.get(obj)拿到的就是最初创建的那个target从而打断了递归链。注意WeakMap的 key 必须是对象null和原始值不能做 key但这对我们无影响因为只有对象才需要处理循环引用。3.2.3 生产级版处理特殊对象Date, RegExp, Map, Set真实项目中Date、RegExp、Map、Set非常常见。基础递归版会把它们变成空对象。我们需要在递归前对这些类型做特殊处理。function deepClone(obj, hash new WeakMap()) { if (obj null || typeof obj ! object) return obj; if (hash.has(obj)) return hash.get(obj); let target; // 6. 特殊对象类型判断与处理 if (obj instanceof Date) { target new Date(obj.getTime()); // 复制时间戳 } else if (obj instanceof RegExp) { target new RegExp(obj.source, obj.flags); // 复制源和标志 } else if (obj instanceof Map) { target new Map(); obj.forEach((value, key) { target.set(deepClone(key, hash), deepClone(value, hash)); }); } else if (obj instanceof Set) { target new Set(); obj.forEach(value { target.add(deepClone(value, hash)); }); } else { target Array.isArray(obj) ? [] : {}; } hash.set(obj, target); // 7. 对于 Map/Set上面已处理跳过 for...in对于普通对象/数组继续 if (obj instanceof Map || obj instanceof Set) { return target; } for (let key in obj) { if (obj.hasOwnProperty(key)) { target[key] deepClone(obj[key], hash); } } return target; }为什么不用obj.constructorobj.constructor可能被篡改obj.constructor null且instanceof更语义化、更可靠。Date和RegExp的构造函数调用方式不同必须分开处理。3.2.4 最终建议别手写用structuredClone2022 年Chrome 98、Firefox 94、Safari 15.4 正式支持了原生structuredCloneAPI。它由浏览器引擎实现性能远超 JS 手写且完美支持Map、Set、Date、RegExp、ArrayBuffer、TypedArray、Error、BigInt甚至Transferable对象如ArrayBuffer可以零拷贝转移。try { const deep structuredClone(original); console.log(克隆成功); } catch (err) { console.error(克隆失败可能包含不支持的类型, err); }优势性能碾压C 实现比任何 JS 递归快 5-10 倍。功能完整支持几乎所有内置类型包括circular reference。安全自动过滤function、undefined、window等无法序列化的值抛出错误而非静默失败。局限兼容性Edge 101旧版 Safari 需要 polyfill如ungap/structured-clone。不支持function这是设计使然function无法被结构化克隆会抛出DataCloneError。实操心得我现在的新项目只要目标浏览器支持structuredClone是唯一选择。它让我从“手写深拷贝”这个重复劳动中彻底解放。对于需要兼容老浏览器的项目我会用lodash.cloneDeep它经过了千万次生产环境验证比自己写的更稳。3.3 解构赋值的实操边界哪些能解哪些不能解解构赋值是日常开发利器但它的能力边界常被忽视。面试官爱问“const [a, b, ...rest] obj;这样写对吗”——答案是错obj不是可迭代对象。3.3.1 对象解构必须匹配键名支持默认值和重命名const user { name: Alice, age: 25, city: Beijing }; // ✅ 正确键名必须完全匹配 const { name, age } user; // nameAlice, age25 // ✅ 正确重命名别名 const { name: userName, city: location } user; // userNameAlice, locationBeijing // ✅ 正确默认值当属性不存在或为 undefined 时生效 const { job Engineer, salary 15000 } user; // jobEngineer, salary15000 // ❌ 错误试图用索引解构对象对象没有索引 const { 0: first } user; // first undefined不是 Alice关键规则解构的左侧{}里的名字是模式pattern必须和右侧对象的键名key一致。右边的默认值只在属性值为undefined时触发。null、0、false、都会原样赋值不会走默认值。3.3.2 数组解构按索引顺序支持跳过和剩余元素const arr [a, b, c, d, e]; // ✅ 正确按索引取值 const [first, second] arr; // firsta, secondb // ✅ 正确跳过中间元素 const [x, , z] arr; // xa, zc第二个元素被跳过 // ✅ 正确剩余元素...rest const [head, ...tail] arr; // heada, tail[b,c,d,e] // ✅ 正确嵌套解构 const [[a, b], [c]] [[x,y], [z]]; // ax, by, cz // ❌ 错误对非数组使用 ...rest会报错 const { ...rest } user; // SyntaxError: Rest element must be last关键规则...rest必须是最后一个元素且只能出现一次。...rest的结果永远是一个新数组即使原数组为空rest也是[]。3.3.3 解构赋值的“浅拷贝”本质实战现在用一个综合案例把深浅拷贝和解构串起来const source { id: 1, profile: { name: Alice, skills: [JS, CSS] } }; // 场景1只想取 profile且保证后续修改不污染 source const { profile } source; // ❌ 危险profile 是引用 profile.name Bob; // source.profile.name 也变成 Bob // 场景2安全取 profile浅拷贝 const { profile: clonedProfile } { ...source }; // ✅ 浅拷贝profile 字段被复制 clonedProfile.name Bob; // source.profile.name 仍是 Alice // 场景3想取 profile 并深拷贝安全修改所有层级 const { profile: deepProfile } { profile: structuredClone(source.profile) }; // ✅ 深拷贝 deepProfile.skills.push(React); // source.profile.skills 不变结论解构赋值本身不提供深拷贝能力。你需要在解构之前先对源数据做深拷贝如structuredClone或者在解构之后对取出的引用值再做深拷贝。4. 面试高频问题与避坑指南从“答对”到“答好”4.1 经典问题拆解与满分回答策略4.1.1 “请说说深拷贝和浅拷贝的区别”低分回答“浅拷贝只复制第一层深拷贝复制所有层。”满分回答结合内存模型“区别核心在于对引用值的处理方式。浅拷贝只复制对象第一层属性的值如果是原始值就创建新副本如果是引用值如对象、数组就复制那个‘地址’新旧对象共享同一块堆内存。所以修改嵌套对象的属性会影响原对象。深拷贝则会递归地为所有层级的引用值在堆里开辟新内存并复制其内容最终得到一个完全独立、互不影响的副本。这背后是 JavaScript 栈存原始值和地址和堆存引用值实体的内存模型决定的。”加分点主动画内存图示意或举例original {a: {b: 1}}; shallow {...original}; shallow.a.b 2;的结果。4.1.2 “JSON.parse(JSON.stringify(obj))是深拷贝吗有什么问题”低分回答“是深拷贝但不能处理函数。”满分回答列出所有缺陷“它是一种有严重缺陷的深拷贝 hack。它能处理普通对象和数组但会失败于undefined、function、symbolJSON.stringify会直接忽略这些值undefined和function被跳过symbol键被忽略导致数据丢失。Date、RegExp、Map、Set、BigIntJSON.stringify会将Date转成字符串RegExp变成空对象{}Map/Set变成{}BigInt报错。NaN、Infinity、-0NaN变成nullInfinity变成null-0变成0。循环引用直接抛出TypeError: Converting circular structure to JSON。性能序列化 反序列化比原生structuredClone慢很多。 所以它只适用于非常简单的、纯 JSON 兼容的数据结构生产环境应避免。”加分点现场写出一个undefined导致丢失的 democonst obj { a: 1, b: undefined, c: function(){} }; console.log(JSON.parse(JSON.stringify(obj))); // {a: 1} —— b 和 c 全没了4.1.3 “解构赋值是深拷贝还是浅拷贝”低分回答“解构赋值是浅拷贝。”满分回答精准定位“解构赋值本身不是一种拷贝方法而是一种值提取语法。它的‘拷贝’行为完全取决于被解构的值的类型。解构原始值如string,number得到的是独立副本类似深拷贝效果解构引用值如object,array得到的是该引用值的地址副本即浅拷贝效果。所以const {profile} user;中的profile是浅拷贝而const {name} user;中的name是原始值修改它不会影响user.name。”加分点强调“语法糖”本质并对比const {profile} user;和const profile {...user.profile};的区别。4.2 常见问题速查表与独家避坑技巧问题现象可能原因排查思路我的独家避坑技巧structuredClone报错DataCloneError源对象包含function、undefined、window、document等不可克隆值console.dir(obj)查看对象结构用Object.keys(obj)和Object.getOwnPropertyNames(obj)检查是否有隐藏的function属性提前清洗在调用structuredClone前用JSON.stringify(obj)测试。如果报错说明有不支持类型此时改用lodash.cloneDeep或手动过滤掉function字段。Object.assign后原对象的setter没触发Object.assign只进行属性赋值[[Set]]不调用setter检查目标对象是否有setter确认Object.assign是否把值直接写进了内部槽位永远不要用Object.assign来触发响应式更新。Vue 2 的this.$set或 Vue 3 的reactiveref才是正确姿势。Object.assign只用于创建普通数据副本。解构赋值时const {a, b} someObj;报Cannot destructure property a of someObj as it is undefinedsomeObj是undefined或null在解构前加if (someObj)判断或用可选链someObj?.a防御性解构const {a, b} someObj ?? {};或const {a default, b 0} someObj ?? {};。??提供了最安全的兜底。手写深拷贝在处理Map时target.set(key, value)后target.size是 0Map的set方法返回Map本身但target可能不是Map实例比如被Array.isArray误判console.log(target.constructor.name)确认target类型类型检查必须精确if (obj instanceof Map)优于obj.constructor Map因为后者在跨 iframe 时会失效。instanceof是最可靠的。JSON.parse(JSON.stringify())后Date对象变成了字符串JSON.stringify(new Date())返回2023-01-01T00:00:00.000Zconsole.log(JSON.stringify(new Date()))验证替代方案用structuredClone(new Date())或new Date(JSON.stringify(new Date()))不推荐精度可能丢失。4.3 面试官最爱追问的 3 个“为什么”4.3.1 为什么Object.assign({}, obj)和{...obj}行为一样但性能不同答案两者语义相同都执行“浅拷贝可枚举自有属性”。但底层实现不同Object.assign是一个通用函数需要解析参数、遍历属性、调用[[Set]]有更多运行时开销。扩展运算符...是语法层面的优化在 V8 引擎中被编译为高度优化的机器码直接在内存中复制属性省去了函数调用栈和参数解析。实测数据Chrome 120对一个 1000 属性的对象{...obj}比Object.assign({}, obj)快约 30%。但在绝大多数业务场景这点差异可以忽略可读性{...obj}和兼容性Object.assign才是选型主因。4.3.2 为什么WeakMap能解决循环引用而Map不行答案关键在WeakMap的“弱引用”特性。Map的 key 是强引用只要Map实例存在key 对象就永远不会被 GC 回收。这会导致内存泄漏风险尤其在缓存场景。WeakMap的 key 是弱引用它不会阻止 key 对象被垃圾回收。当 key 对象在其他地方被销毁后WeakMap中对应的条目会自动消失。在深拷贝中我们用obj源对象作为 keytarget副本作为 value。如果用Mapobj会一直被强引用即使拷贝完成obj也无法被 GC造成内存浪费。WeakMap则无此顾虑它是为这种“临时映射”场景而生的。4.3.3 为什么 Vue/React 要求状态更新是“不可变的”immutable答案为了高效地检测变化并触发更新。Vue 2 的Object.defineProperty和 Vue 3 的Proxy都是通过拦截set操作来追踪依赖。如果直接修改原对象属性obj.name newVue 能捕获到。但如果用Object.assign(obj, newData)或obj {...obj, ...newData}前者会触发 setter可追踪后者是重新赋值obj变量指向了新地址旧的响应式对象被丢弃Vue 的依赖追踪就断了。React 的useState也同理setState期望接收一个新状态以便React.memo或useMemo能通过Object.is比较新旧值来决定是否重渲染。如果传入的是同一个引用React 会认为状态没变跳过更新。所以深浅拷贝的选择直接决定了你的状态管理是否健壮。useState({...oldState, name
返回列表