ARTICLE DETAIL

资讯详情

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

深拷贝与浅拷贝:从对象引用到前端数据安全实践

深拷贝与浅拷贝:从对象引用到前端数据安全实践 1. 一场诡异的数据“串改”被连带修改的原始对象1.1 事故现场还原先说个我实际遇到的bug。某个管理后台的编辑页面用户反馈在表格里点“编辑”把某一条记录的姓名改掉点保存后却发现列表里另一条完全无关的数据也被改了。最初看到这个问题我以为是接口返参有问题或者是状态管理库的数据流哪里出了幺蛾子。结果排查了半天最后定位到的问题极其朴素——编辑表单初始化时直接把列表里拿到的那个对象赋值给了表单数据对象。简化一下就是这样的代码const list [ { id: 1, name: 张三, info: { age: 30, city: 上海 } }, { id: 2, name: 李四, info: { age: 25, city: 北京 } } ]; // 点击编辑按钮把第 1 条数据塞进表单 const formData list[0]; formData.name 张三四; // 用户输入新名字就这么几行list[0].name也变成了“张三四”。页面上表格复用同一条数据源所以列表跟着变了。表面上看是“数据串了”实际是变量之间根本没有做拷贝只是把同一个对象的引用复制了一份。这事情的本质就是前端最基础也最容易被忽略的深拷贝与浅拷贝问题。1.2 排查链路从表象到本质我当时是怎么定位的其实过程很简单但每一步都有代表性。第一反应是打印。在保存前分别打印list[0]和formData发现两个对象的内容一模一样。这不意外因为本来就是把后者赋给了前者。然后我做了个关键测试对两个变量执行console.log(formData list[0])结果输出true。这说明它们在内存里指向了同一个对象——比较的是引用地址不是内容。从这一步开始问题就锁定在了“赋值没有产生新对象只复制了引用”上。// 关键判断如果为 true说明根本是同一个对象 console.log(formData list[0]); // true这个场景我建议每一个前端新手都亲手复现一遍。它就像学车时的起步熄火——看似低级却是后面所有驾驶技巧的地基。搞懂之后再看 Vue、React 里的数据更新、状态管理库的不可变数据约束、组件间传参的副作用都会通透很多。2. 值类型与引用类型变量里存的到底是什么2.1 栈与堆两种数据的存储方式要理解深浅拷贝先得搞清楚一个最基本的问题JavaScript 里变量到底存了什么。JS 的数据类型分两类。一类是原始类型primitive包括number、string、boolean、null、undefined、symbol、bigint。另一类是引用类型reference主要是object往下细分有普通对象、数组、函数、Date、RegExp、Map、Set等。这两类数据的存储方式完全不同。原始类型的数据体积小、结构固定直接存在栈内存里变量保存的就是实实在在的值。引用类型的数据体积可能很大也可能动态变化所以它的实际内容存在堆内存里而栈内存里保存的是一个指向堆内存的地址——你可以把它理解为门牌号。let a 10; // 栈内存里存的是 10 let b a; // 复制的是值 10b 现在是独立的 10 let obj { name: 张三 }; // 栈里存的是指向堆内存的地址 let obj2 obj; // 复制的是同一个地址我之前给新人讲这个的时候常用一个类比栈里的变量就像写在便利贴上的数字堆里的对象是一间房间引用变量等于房间的门牌号。你把门牌号抄给另一个人对方和你进的是同一间房你把便利贴上的数字誊一份那只是数字和原来的数字没有关系。2.2 赋值的本质拷贝的是值还是地址搞清楚存储方式之后“赋值”这个动作就好理解了。原始类型赋值比如let b a是把 a 的值复制一份给 b。之后改 ba 不受影响因为两个变量是独立的两张便利贴。引用类型赋值比如let obj2 obj复制的是门牌号。之后通过obj2.name修改属性本质上是进了 obj 指向的那个房间改东西obj 自然也能看到变化。// 原始类型 let x 1; let y x; y 2; console.log(x); // 1互不影响 // 引用类型 let origin { name: 张三 }; let later origin; later.name 李四; console.log(origin.name); // 李四同一个房间都被改了理解了这一层“拷贝”这个词的含义就清晰了如果只是赋值引用类型其实拷贝的是地址而非对象本身。浅拷贝shallow copy的意思是只复制第一层属性——第一层的属性值如果是原始类型就复制值如果是引用类型就复制引用地址。而深拷贝deep copy则是把所有层级的引用类型都递归地复制成新的对象最终得到的是一棵完全独立的内存结构。3. 浅拷贝全家桶Object.assign、展开运算符与数组方法3.1 常用浅拷贝手段与实现对比工程里最常见的浅拷贝写法有这么几种。第一种是Object.assign。它可以把一个或多个源对象的可枚举属性复制到目标对象然后返回目标对象。const source { name: 张三, info: { age: 30 } }; const target Object.assign({}, source); target.name 李四; console.log(source.name); // 张三第一层字符串是值拷贝 target.info.age 31; console.log(source.info.age); // 31第二层对象是引用拷贝第二种是展开运算符。ES6 之后这个写法成了很多人的首选因为它足够简洁const target { ...source }; // 数组同理 const arr [1, 2, 3]; const copyArr [...arr];第三种是针对数组的浅拷贝方法Array.prototype.slice()、Array.prototype.concat()、Array.from()这些方法都会产生一个新数组但数组元素如果是对象拷贝的依然是引用。我把这几种方式放在一起对比了一下拷贝方式适用对象第一层表现嵌套对象表现Object.assign对象产生新对象值拷贝引用拷贝展开运算符{...obj}对象产生新对象值拷贝引用拷贝Array.prototype.slice数组产生新数组引用拷贝Array.from数组/类数组产生新数组引用拷贝3.2 浅拷贝的边界为什么只解决了一层问题工程中很多 BUG 的根源就是把浅拷贝当成了深拷贝用。比如你知道要“复制一份再修改”于是写了const formData { ...row }以为万事大吉。结果 formData 里有嵌套对象比如info: { age: 30 }你在表单里改了formData.info.age回头一看row.info.age也变了当场破防。拿前面的例子演示一遍const row { id: 1, name: 张三, info: { age: 30, city: 上海 } }; const formData { ...row }; formData.name 张三四; // row.name 不受影响 formData.info.age 31; // row.info.age 变成 31 console.log(row); // { id: 1, name: 张三, info: { age: 31, city: 上海 } }原因不复杂展开运算符遍历row的各个属性时info这个属性的值本身是一个引用地址指向那个嵌套对象所以formData.info和row.info还是同一个门牌号。只有第一层被“浅”地剥开了里面的房间还是原来的房间。判断一段拷贝代码是深是浅有一个很简单的测试方法改一下拷贝结果里第二层的属性值再看原对象有没有变。变了就是浅拷贝没变就是深拷贝。这个测试方法我在面试中也经常用来反考候选人。4. 深拷贝的经典路线JSON序列化与递归实现4.1 JSON.parse(JSON.stringify())简单但坑多前端做深拷贝最早接触的方案大概率是JSON.parse(JSON.stringify(obj))。确实它能在一行代码里解决大部分普通对象的深拷贝问题const deepCopy JSON.parse(JSON.stringify(row)); deepCopy.info.age 31; console.log(row.info.age); // 30互不影响但“大部分”意味着不是一个。这个方法本质上是先把对象序列化成 JSON 字符串再把字符串解析成全新的对象结构。这个过程中会有几类数据被无情地丢弃或变形undefined、函数function、symbol这三个在序列化时会被直接忽略在数组中则会被转成null。Date对象序列化后会变成 ISO 格式的字符串拷贝出来的就不再是Date了。NaN、Infinity和-Infinity会被转成null。RegExp对象序列化后会变成空对象{}。循环引用直接抛错TypeError: Converting circular structure to JSON。const obj { name: 张三, sayHi: function() { console.log(hi); }, reg: /abc/g, birthday: new Date(2020-01-01), score: NaN, symbolKey: Symbol(test) }; const copy JSON.parse(JSON.stringify(obj)); console.log(copy); // { name: 张三, reg: {}, birthday: 2020-01-01T00:00:00.000Z, score: null } // sayHi 和 symbolKey 直接消失所以这个方案只能用在“数据全是纯 JSON 兼容内容”的场景。比如表单数据、接口返回的普通对象绝大多数时候够用。但一旦对象里混入了函数、正则、日期、循环引用就会在你不注意的地方埋雷。我在项目里见过有人用这个方案拷贝一个包含Map的配置对象结果拷贝出来的数据在后续逻辑里行为变得非常诡异排查了很久才意识到Map已经被悄悄转成了普通对象。4.2 手写递归深拷贝完整实现与逐步拆解既然JSON.parse(JSON.stringify())有这么多限制手写一个递归深拷贝就成了前端绕不开的基本功。一个初级版本的实现长这样function deepClone(target) { // 如果是原始类型直接返回 if (target null || typeof target ! object) { return target; } // 如果是数组创建数组并递归拷贝每一项 if (Array.isArray(target)) { const newArray []; target.forEach((item, index) { newArray[index] deepClone(item); }); return newArray; } // 如果是普通对象创建空对象并递归拷贝每个属性 const newObj {}; for (let key in target) { if (Object.prototype.hasOwnProperty.call(target, key)) { newObj[key] deepClone(target[key]); } } return newObj; }这个版本已经能处理大多数包含普通对象、数组、嵌套结构的数据了。逻辑拆开看就三步第一步判断类型。如果是null或者不是object说明是原始类型函数其实也属于这一类因为typeof function functiontypeof null object这个历史包袱让判断必须显式先处理null直接原样返回。第二步区分数组和对象。数组要新建一个数组然后逐个元素递归。对象要新建一个对象遍历每个可枚举自有属性把属性值递归拷贝。第三步递归核心。每遇到一个属性值还是对象的情况就再走一遍同样的逻辑直到所有嵌套层级的引用类型都被处理成新对象。这个版本的局限也很明显它不认识Date、RegExp、Map、Set也不处理循环引用更不会管原型链和不可枚举属性。下面这部分才是深拷贝真正的深水区。5. 深拷贝的进阶战场循环引用、特殊类型与原型链5.1 用WeakMap解决循环引用先看循环引用。什么叫循环引用就是对象里有个属性直接或间接指向了它自己const obj {}; obj.self obj;这种结构在业务里不常见但在处理复杂状态、图结构数据时是真实存在的。如果你用上面那个递归版本去拷贝obj会陷入无限递归直接栈溢出。原因不复杂拷贝obj时遇到self属性self指向obj于是又开始拷贝obj……永无止境。解决办法是给递归加一个“记忆”已经拷贝过的对象第二次遇到时直接返回之前拷贝出来的副本。这个映射关系用WeakMap来存最合适因为WeakMap的键是弱引用不会影响原对象被垃圾回收而且它本来就是设计成用来做这类“对象关联额外信息”的。function deepClone(target, map new WeakMap()) { if (target null || typeof target ! object) { return target; } // 如果已经拷贝过直接返回之前的副本 if (map.has(target)) { return map.get(target); } // 区分数组和对象初始化结果并存入 map const result Array.isArray(target) ? [] : {}; map.set(target, result); // 递归拷贝 for (let key in target) { if (Object.prototype.hasOwnProperty.call(target, key)) { result[key] deepClone(target[key], map); } } return result; }关键动作是在初始化结果对象之后、开始递归之前就把target - result的映射塞进 map。这样再碰到循环引用时map 里已经存在了对应关系递归不会继续往下走。5.2 Date、RegExp、Map、Set等特殊类型的处理循环引用解决了接下来是各类内置对象。每一种对象都有它独特的“内部结构”通用的for...in遍历根本拿不到内部数据必须针对类型做专门处理。处理方式不难构造一个新的同类型对象把原对象的内容填充进去。function deepClone(target, map new WeakMap()) { if (target null || typeof target ! object) { return target; } if (map.has(target)) { return map.get(target); } // 处理 Date基于原时间戳创建新 Date if (target instanceof Date) { return new Date(target.getTime()); } // 处理 RegExp保留 source 和 flags if (target instanceof RegExp) { return new RegExp(target.source, target.flags); } // 处理 Map逐键值对递归拷贝 if (target instanceof Map) { const result new Map(); map.set(target, result); target.forEach((value, key) { result.set(deepClone(key, map), deepClone(value, map)); }); return result; } // 处理 Set逐项递归拷贝 if (target instanceof Set) { const result new Set(); map.set(target, result); target.forEach((value) { result.add(deepClone(value, map)); }); return result; } const result Array.isArray(target) ? [] : {}; map.set(target, result); for (let key in target) { if (Object.prototype.hasOwnProperty.call(target, key)) { result[key] deepClone(target[key], map); } } return result; }注意一个细节Map和Set的forEach回调里value和key都有可能本身是引用类型所以都要递归deepClone否则Map里面的嵌套对象依然共享引用。5.3 原型链与不可枚举属性深拷贝要不要管再往上走一层就是原型链和不可枚举属性。在市面上的多数教程里通常不会覆盖得这么深但面试官如果问“如何实现一个完美的深拷贝”这些问题就会浮出水面。大部分业务场景下我们拷贝的是普通对象用的是for...in加hasOwnProperty的组合。注意for...in本身会遍历到原型链上的可枚举属性所以必须用hasOwnProperty过滤掉。如果你希望拷贝结果也保留原对象的原型可以考虑用Object.create(Object.getPrototypeOf(target))来创建新对象而不是用{}。不可枚举属性比如Object.defineProperty定义的那些是无法用for...in遍历到的。要用Object.getOwnPropertyNames()拿全部属性名搭配Object.getOwnPropertyDescriptor()读取属性描述符再通过Object.defineProperty复制到新对象上。这样做出来的拷贝连属性的writable、enumerable、configurable都能保持原样。我个人的态度是完美的深拷贝在工程里是一个“理论上存在但实践上没必要”的东西。第三方的lodash.cloneDeep已经足够完整它考虑了上述绝大部分问题并做了性能优化。自己手写的时候能覆盖普通对象、数组、循环引用、常见内置类型已经超过绝大多数人了。再往上追求属于面试炫技或者学术探讨的范畴性价比不高。6. structuredClone现代浏览器原生的深拷贝6.1 结构化克隆算法是什么前面聊了这么多手写方案很多人可能不知道现代浏览器其实已经内置了一个深拷贝 APIstructuredClone()。它是 HTML 规范里的“结构化克隆算法”structured clone algorithm的对外暴露接口。这个算法最早用于postMessage、IndexedDB等场景负责在多个执行环境之间复制复杂数据。用法极其简单const original { name: 张三, info: { age: 30 } }; const cloned structuredClone(original); cloned.info.age 99; console.log(original.info.age); // 30真正的深拷贝这个 API 支持的拷贝范围非常广包括Date、RegExp、Map、Set、Blob、ArrayBuffer、TypedArray连循环引用都能正确处理。对于“数据依赖浏览器环境”的前端代码来说structuredClone是当前最省力、最可靠的深拷贝方案。6.2 structuredClone的兼容性与限制不过它也有明确的限制主要有三点。第一它不能拷贝函数。函数本身不会被结构化克隆算法序列化遇到会直接抛DataCloneError。第二它不能拷贝 DOM 节点。拷贝一个Element或Node会抛错。想要克隆 DOM应该用Node.cloneNode()这是另一码事。第三类的实例拷贝出来会丢原型。通过new MyClass()创建的对象拷贝后不会保留MyClass的原型链只会变成一个包含同样属性的普通对象。浏览器兼容性方面Chrome 98、Firefox 94、Safari 15.4 都支持Node.js 17.0 开始全局支持。现在的主流工作环境基本都能用。如果非要兼容更老的浏览器只能回退到JSON方案或lodash。6.3 为什么面试官还是要你手写既然浏览器已经原生支持深拷贝为什么面试题里还让你手写这是我给很多学员解释过的一件事面试官要的不是这一行 API 的调用而是通过手写过程考察你“是否理解引用、递归、堆栈、边界情况”这些底层能力。structuredClone是拿来用的不是拿来讲原理的。你要是真在现场答一句“用 structuredClone”最多只能证明你了解新 API完全无法证明你懂深浅拷贝的本质。面试官继续追问一句“它是怎么处理循环引用的”答不上来就露馅了。所以我的建议很清楚面试前手写递归深拷贝是必练的基础题日常开发中能用structuredClone用structuredClone不能用了再用lodash.cloneDeep不到万不得已别自己撸一个完整版。7. 面试场上的深浅拷贝问题问法、答法与加分项7.1 常见问法拆解深浅拷贝在面试题的“前端八股”里属于必考项但问法五花八门我在面试候选人和模拟面试时总结了几类常见问法。第一类直接概念题。“说说深拷贝和浅拷贝的区别。”这种题看似简单但回答的层次很能区分水平。初级候选人会说“浅拷贝只拷贝一层深拷贝递归拷贝所有层”。高级候选人会补一句“浅拷贝对第一层的原始类型做值拷贝对引用类型拷贝引用地址深拷贝则递归到所有嵌套引用类型生成全新对象”。第二类代码分析题。面试官贴一段代码问你输出什么。const a { name: 张三, info: { age: 30 } }; const b { ...a }; b.name 李四; b.info.age 18; console.log(a.name, a.info.age);答案是张三, 18。name是值拷贝互不影响info是引用拷贝所以a.info.age跟着变了。第三类手写实现题。“手写一个深拷贝函数。”这里考察的不只是能不能写出来还看你是否考虑边界情况数组和对象要区分hasOwnProperty要加循环引用要不要处理特殊类型要不要处理。能写出来基础版拿个及格分能主动提循环引用和WeakMap就能明显加分。第四类场景辨析题。“项目中在什么场景必须用深拷贝”典型的回答是对列表里的某一行做编辑时如果不深拷贝就直接改原对象会污染列表数据源React 的setState使用不可变数据需要在更新前拷贝旧对象多个组件共享一份全局配置时防止某个组件内部修改影响其他组件。7.2 手写题的标准答案模板手写题如果让我给出一个既不会太长又能体现足够功底的模板我会写下面这个版本。它同时处理了循环引用、数组、对象三个要点且代码量控制在二十行以内面试现场能背能默。function deepClone(target, map new WeakMap()) { if (target null || typeof target ! object) { return target; } if (map.has(target)) { return map.get(target); } const result Array.isArray(target) ? [] : {}; map.set(target, result); for (let key in target) { if (Object.prototype.hasOwnProperty.call(target, key)) { result[key] deepClone(target[key], map); } } return result; }这个版本的精髓在于把 map 作为默认可选参数传入递归时每一步都共享同一个 map。如果你不熟悉WeakMap的默认参数写法也可以写成入口函数包内部函数的方式效果一样。答到这一步我建议主动补充一句“如果是面试场景我还会说明 JSON 方案的局限和 lodash 的 cloneDeep 在工程中的成熟应用。”这句话会让面试官觉得你既有原理认知又有工程视野不是只会背答案。8. 工程实践什么时候该深拷贝什么时候该浅拷贝8.1 忌乱用深拷贝性能与语义代价聊到最后我想从资深从业者的角度泼一盆“清醒水”深拷贝在工程里不是越深越好也不是用了就安全。第一个代价是性能。深拷贝需要递归遍历整个对象树对象越庞大、层级越深耗时和内存开销越大。我之前处理过一个复杂的画布配置对象里面有上千个节点、每个节点带坐标和样式字段整个对象序列化加反序列化一次要几十毫秒。如果在每次拖动或缩放时都无脑深拷贝一份交互帧率直接崩盘。第二个代价是语义。有些对象本来就应该共享引用深拷贝反而会破坏它们的联系。比如多个组件引用同一份常量配置你本来希望任何一个人改了之后大家都能感知结果深拷贝把数据拆成了各自独立的副本后续同步逻辑反而要额外写。再比如状态管理库里的数据某些中间层对象被刻意设计为共享引用深拷贝反而会破坏它们之间的引用关系。所以在工程里我的经验是遵循“能浅则浅必须深才深”的原则。表单编辑、副本修改、跨层传递需要隔离的场景才用深拷贝纯展示场景、共享配置、事件总线传递对象直接引用传送往往效率更高也更自然。8.2 实际项目中的推荐方案落到具体选型上我给大家一个建议清单。如果是现代浏览器环境、没有老版本兼容压力优先用structuredClone一行代码搞定支持的类型最多循环引用也内置处理。如果是老项目、兼容要求高用lodash.cloneDeep。lodash 对边界情况的覆盖和性能调优经过了大量生产环境的验证比你自己写的递归版本稳定得多。有的团队不喜欢引 lodash那用JSON.parse(JSON.stringify())也凑合但前提是你能确认数据里没有函数、Date、undefined、循环引用这些坑。如果是面试题或者学习目的手写递归版依然是必经之路。手写的重点不是追求“完美”而是理解递归的展开过程、WeakMap的用途、特殊类型的处理方式。你能说出“为什么 lodash 比我写的稳”比“我能默写一个很长的 cloneDeep”要有价值得多。最后再分享一个我常用的验证脚本写一个包含嵌套对象、数组、Date、Map、循环引用的测试对象分别用JSON.parse(JSON.stringify())、structuredClone、手写递归跑一遍打印结果对比。这套脚本很适合用来评估一个新方案能不能在项目里落地也适合面试前自查。深浅拷贝这个词看起来不高大上但它关系到你对 JavaScript 数据模型最基本的理解——这个地基牢了上面盖什么楼都不怕。
返回列表