ARTICLE DETAIL

资讯详情

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

JavaScript深拷贝全攻略:循环引用与特殊类型处理实战

JavaScript深拷贝全攻略:循环引用与特殊类型处理实战 写深拷贝的代码是每个前端开发者迟早要面对的事情。但我敢打赌大多数人写的第一版深拷贝函数用的都是JSON.parse(JSON.stringify(obj))——然后就在某个夜深人静的晚上被线上报错的Converting circular structure to JSON给炸醒。我今天打算把深拷贝里最麻烦的两块骨头单独拎出来讲循环引用和特殊类型。这两块搞不定你的深拷贝函数就只能算是个玩具遇到真实业务数据分分钟翻车。为什么要专门写这个因为我在实际项目里见过太多看起来能跑的深拷贝实现一测就崩。比如对象里有个Date拷贝完变字符串有个Map拷贝完变空对象更别说碰到循环引用直接栈溢出。这篇文章不会只给你一段能跑的代码而是把每种类型背后的坑、为什么要这么处理、以及现代浏览器提供的原生方案全部掰开揉碎讲清楚。如果你是刚接触 JavaScript 基础语法不久的新手可以先收藏再慢慢看如果你已经写过一段时间 ES6 深拷贝相关的代码那直接从循环引用和特殊类型这两章开始读一定会有收获。1. 先理顺思路浅拷贝、深拷贝和 JSON 方案的界限1.1 浅拷贝到底拷贝了个啥很多人对浅拷贝和深拷贝的概念其实是模糊的只知道大概是个复制的意思。我举个例子你就明白了const original { name: Jack, address: { city: Beijing } }; const shallowCopy Object.assign({}, original); shallowCopy.address.city Shanghai; console.log(original.address.city); // Shanghai原对象也被改了Object.assign展开的是第一层属性name是基本类型直接复制值这块没问题。但address是对象它只复制了引用。所以修改拷贝对象的address.city原对象跟着变。ES6 里的展开运算符{ ...obj }也是一样的道理它只是语法层面的浅拷贝糖。这就有个很实际的问题前端项目里经常遇到合并两个对象比如配置项覆盖如果你用的是浅拷贝嵌套层级里的小改动会悄咪咪地污染源数据。很多线上 bug 排查半天最后定位到是浅拷贝埋的雷。所以当对象的嵌套深度超过一层、且你需要完全隔离两个对象时就必须上深拷贝。1.2 深拷贝的本质递归 缓存深拷贝的核心就一句话遇到对象类型的属性就递归地再拷贝一层直到属性全是基本类型为止。听起来简单但一旦你开始实现就会发现要回答一堆问题拷贝到一半发现对象引用了自身怎么办遇到Date、RegExp、Map、Set这种内置构造函数生成的对象用普通的{}去接合适吗函数的引用要不要复制闭包环境怎么处理Symbol 作为属性键时要不要拷贝原型链要不要保留这些问题每个都是坑而且坑里还带刺。后面我会逐个展开。1.3 JSON 方案的三宗罪先聊聊最流行的JSON.parse(JSON.stringify())。别误会它作为快速方案在简单场景下确实好用性能也不差但它的三个硬伤属于知道的人不吭声、不知道的人踩坑里那种级别第一宗罪循环引用直接抛异常。只要对象里存在互相引用的关系JSON.stringify会直接抛出Converting circular structure to JSON整个程序当场卡住。我在前几年接手的某个老项目里用户反馈操作表单后浏览器直接白屏查到最后就是深拷贝一个包含图表配置的对象里面有个字段回指了根节点。第二宗罪特殊类型全部被打回原形。Date会被序列化成 ISO 字符串RegExp会变成{}Map、Set会变成{}undefined、函数、Symbol 会直接被丢弃NaN和Infinity会变成nullBigInt会直接抛TypeError。这相当于你拷贝一份文件结果复印件里所有图片都变成了文字描述格式全丢了。第三宗罪getter 会被执行。如果对象上有访问器属性JSON 序列化时会把 getter 的值读出来再写入结果这可能导致意外的副作用。你只是拷贝一下数据结果把状态给摸变了。所以 JSON 方案只适合一种场景纯 JSON 数据对象的拷贝即数据只包含普通对象、数组、字符串、数字、布尔值和 null。只要超出这个范围就得换方案。2. 循环引用让递归直接崩掉的元凶2.1 循环引用长什么样循环引用就是对象直接或间接地引用了自身。最常见的是双向关联结构比如const a { name: root }; const b { parent: a }; a.child b;更直接的还有obj.self obj这种。很多数据结构里都容易出现这种情况比如树节点的 parent 字段指向父节点、兄弟节点之间互相引用、链表结构、图结构等。你要是拿着这种数据去用朴素递归写深拷贝必然栈溢出因为递归的终止条件根本不存在。2.2 朴素递归为什么必死给你看一段最教科书的深拷贝写法function naiveDeepClone(obj) { if (obj null || typeof obj ! object) return obj; const copy Array.isArray(obj) ? [] : {}; for (const key in obj) { copy[key] naiveDeepClone(obj[key]); } return copy; }这段代码对纯树形结构没问题因为叶子节点最终会走到typeof obj ! object的分支返回。但一旦遇到循环引用naiveDeepClone(a)处理a.child时发现child.parent就是a于是又开始拷贝a然后又遇到a.child无限套娃调用栈以每秒上万层的速度增长最后浏览器弹出一个 Maximum call stack size exceeded。就算不循环深度特别大的对象比如深度一万层的嵌套也会栈溢出因为递归栈的深度有限。这两类问题都指向同一个结论深拷贝必须记录已经拷贝过哪些对象遇到重复的引用直接返回之前的结果。2.3 用 WeakMap 做引用缓存解决循环引用的标准做法是引入一个缓存表每次拷贝一个对象前先查表如果已经存在说明之前拷贝过直接返回缓存里的副本如果不存在先把源对象 - 目标副本的映射登记进去再去递归拷贝属性。用代码说话function deepCloneWithCache(source, cache new WeakMap()) { if (source null || typeof source ! object) return source; if (cache.has(source)) return cache.get(source); const copy Array.isArray(source) ? [] : {}; cache.set(source, copy); for (const key of Object.keys(source)) { copy[key] deepCloneWithCache(source[key], cache); } return copy; }关键在于cache.set(source, copy)这行必须放在递归遍历属性之前。因为循环引用往往发生在子节点指向祖先节点的那一刻如果等属性遍历完再存缓存递归到子节点回归时就查不到祖先的记录照样死循环。2.4 为什么偏偏用 WeakMap 而不是 Map这个问题我面试过不少人能答上来的没几个。WeakMap的键是弱引用意味着当原始对象不再被任何地方引用时它在WeakMap里的那一条记录可以被垃圾回收掉。而如果改用Map键是强引用即使原对象已经被业务代码置为null只要缓存表里还留着它垃圾回收就没法释放这块内存造成内存泄漏。你想想深拷贝的缓存表是一次性使用的用完就丢。如果里头强引用着一大堆历史对象长年累月在长生命周期页面里跑内存会越堆越高。WeakMap的弱引用机制正好解决了这个问题。另外WeakMap的键只能是对象深拷贝的缓存对象正好全部是对象类型语义完全匹配。3. 特殊类型每个都要单独打招呼3.1 Date 和 RegExptypeof 都是 object不能直接递归Date和RegExp的typeof都是object如果你用普通递归去处理会得到一个空对象所有方法和内部状态全丢。正确的做法是识别它们走各自的构造逻辑// Date const dateCopy new Date(source.getTime()); // RegExp const regexpCopy new RegExp(source.source, source.flags);Date拷贝直接用时间戳重建简单可靠。RegExp要注意三点source是正则文本、flags是修饰符、lastIndex是上次匹配的位置。如果你要拷贝一个带g或y修饰符的正则并且它之前执行过匹配lastIndex不是 0最好把lastIndex也复制过去否则拷贝后的正则会从lastIndex0重新开始行为跟原对象不一致。3.2 Map 和 Set要连键值一起递归Map和Set在 ES6 之后用得越来越多。Map的键可以是任意类型包括对象所以拷贝时键和值都要走递归。Set的值同理if (source instanceof Map) { const copy new Map(); cache.set(source, copy); for (const [key, value] of source.entries()) { copy.set(deepClone(key, cache), deepClone(value, cache)); } return copy; } if (source instanceof Set) { const copy new Set(); cache.set(source, copy); for (const value of source.values()) { copy.add(deepClone(value, cache)); } return copy; }这里有个容易忽略的点cache.set同样要先执行。因为 Map 的键或值有可能回指 Map 自身不提前登记就会死循环。我见过有人在new Map()之后直接遍历 entries忘了登记缓存测试数据一复杂就炸。3.3 函数一般直接返回引用函数能不能深拷贝严格来说函数是对象但它携带的是词法作用域和闭包环境。闭包是 JavaScript 引擎在函数创建时就锁定的一套作用域链你根本没有 API 能把闭包环境读出来再重建一份。所以行业惯例是遇到函数直接返回同一个引用。什么时候会用到这个规则很多配置类对象里会写回调函数比如{ onSuccess: () {} }拷贝这个对象时你会希望新对象里的onSuccess还是原来那个函数而不是一个空壳。直接在分支开头if (typeof source function) return source;就行。3.4 Symbol 和 BigIntES6 时代的新类型不能忽视Symbol作为属性值时本身就是原始类型直接返回没问题。但作为对象的属性键时Object.keys拿不到它必须用Object.getOwnPropertySymbols单独取。所以深拷贝时除了遍历字符串属性还要遍历 symbol 属性键否则一个带Symbol.iterator或Symbol.for键的对象会被拷得残缺。BigInt也是原始类型直接返回即可。要警惕的是很多人用JSON.parse(JSON.stringify())处理 BigInt直接抛TypeError: Do not know how to serialize a BigInt。这个问题在手写递归里不存在因为原始类型的分支直接返回了。3.5 undefined、NaN、Infinity不算类型但是 JSON 的隐形杀手undefined作为对象属性值时JSON 序列化会静默丢弃NaN和Infinity会变成null。手写递归完全不存在这个问题因为它们都是原始值直接赋值过去就行。很多人在做表单数据回显时遇到过上一次保存的数字为什么变成 null 了这种问题多半就是 JSON 方案吞掉了NaN。3.6 原型链和不可枚举属性要不要保留得看业务大部分简单的深拷贝实现包括很多博客里的教程直接用{}作为容器这会丢失原型链。如果你拷贝的是一个自定义类的实例比如class User的实例拷贝完会变成一个没有User.prototype方法的普通对象。严谨的做法是用Object.create(Object.getPrototypeOf(source))创建副本保留原型链。不可枚举属性同理for...in和Object.keys都拿不到它们。要想完整拷贝得用Object.getOwnPropertyDescriptors读取所有属性描述符再配合Object.defineProperties写回去。但这个操作会比较重如果你的业务场景不关心不可枚举属性可以跳过。我的习惯是普通数据对象用轻量方案框架类实例用完整方案按需取舍。3.7 其他常见对象ArrayBuffer、Error、DOM 节点ArrayBuffer和 TypedArray比如Uint8Array、Float64Array在如今的前端项目里很常见尤其是做音视频、文件上传、WebAssembly 的场景。ArrayBuffer可以直接slice(0)复制一份TypedArray 则要复制底层 buffer 再按偏移量和长度重建。Error对象比较特殊message、stack、name这些属性要手动拷贝。DOM 节点一般不建议深拷贝cloneNode(true)才适合 DOM但如果你在一个普通对象里意外混入了 DOM 节点引用深拷贝时最好单独做判断否则会触发意想不到的递归遍历。4. 手写一个能打的深拷贝函数4.1 完整代码先行把前面所有类型的处理合并起来一个可用的深拷贝函数大概是这个样子function deepClone(source, cache new WeakMap()) { // 原始类型null、string、number、boolean、undefined、symbol、bigint if (source null || typeof source ! object) return source; // 函数直接返回引用 if (typeof source function) 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) { const copy new RegExp(source.source, source.flags); copy.lastIndex source.lastIndex; return copy; } // Map if (source instanceof Map) { const copy new Map(); cache.set(source, copy); for (const [key, value] of source.entries()) { copy.set(deepClone(key, cache), deepClone(value, cache)); } return copy; } // Set if (source instanceof Set) { const copy new Set(); cache.set(source, copy); for (const value of source.values()) { copy.add(deepClone(value, cache)); } return copy; } // ArrayBuffer 和 TypedArray if (source instanceof ArrayBuffer) { return source.slice(0); } if (ArrayBuffer.isView(source)) { const buffer deepClone(source.buffer, cache); return new source.constructor(buffer, source.byteOffset, source.length); } // 普通对象和数组 const copy Array.isArray(source) ? [] : Object.create(Object.getPrototypeOf(source)); cache.set(source, copy); // 字符串键 Symbol 键 const props [ ...Object.keys(source), ...Object.getOwnPropertySymbols(source), ]; for (const key of props) { copy[key] deepClone(source[key], cache); } return copy; }4.2 这段代码为什么这么设计我先讲两个最重要的分支顺序。第一个是原始类型判断放在最前面typeof source ! object会覆盖所有非对象类型连null也一并返回了。第二个是cache.has判断放在 Date、Map 等分支之前保证循环引用在任意类型上都能被拦截。ArrayBuffer.isView这个分支处理的是 TypedArray 和 DataView。source.buffer是底层的 ArrayBuffer深拷贝它之后再用新的 buffer 重建视图这样两个拷贝的 TypedArray 不会共享底层内存。需要留意的是DataView的构造函数不比 TypedArray传给它的第三参数含义不完全一样所以这个分支对 TypedArray 更友好DataView 建议在真实场景里单独加分支处理。Object.getPrototypeOf保留了原型链Object.getOwnPropertySymbols保留了 Symbol 属性键这些都是完整拷贝的必要条件。但我没有用Object.getOwnPropertyDescriptors原因前面讲过——大多数业务场景不需要拷贝不可枚举属性如果加上会让代码复杂很多性价比不高。你可以在自己项目里按需增强。4.3 增强版用属性描述符做无损拷贝如果你需要 100% 无损拷贝包括不可枚举属性、getter/setter 原样搬到拷贝对象上可以改成const descriptors Object.getOwnPropertyDescriptors(source); Object.defineProperties(copy, descriptors);这么做的代价是如果原对象上有 getter拷贝到新对象后依然是个 getter每次访问都会触发一次求值。这个行为有时候是好事保持语义有时候是坏事意外执行副作用需要你根据业务场景判断。4.4 实测一下循环引用和特殊类型的表现我来跑一个综合测试const original { date: new Date(2024-01-01), regex: /test/gi, map: new Map([[key, { value: 1 }]]), set: new Set([1, 2, 3]), func: () console.log(hello), nested: { level: 1 }, }; original.self original; const copy deepClone(original); console.log(copy original); // false console.log(copy.self copy); // true循环引用被正确复制 console.log(copy.date instanceof Date); // true console.log(copy.regex.flags); // gi console.log(copy.map.get(key)); // { value: 1 }与原对象不共享引用 console.log(copy.func original.func); // true函数保持引用这个结果说明循环引用被正确映射、特殊类型都被保留、嵌套对象彻底隔离。这才是一个能扛住真实业务数据的深拷贝函数。5. 现代浏览器的原生方案结构化克隆算法5.1 structuredClone最省心的官方 API如果你不需要兼容 IE也不需要在老版本浏览器上运行那么有一个更省心的方案structuredClone。这是 HTML 标准提供的原生 API底层就是浏览器的结构化克隆算法Node.js 17 以上也内置了。const copy structuredClone(original);这一行代码能搞定很多事情循环引用直接识别、Date、RegExp、Map、Set、ArrayBuffer、TypedArray、Blob、File、ImageData等类型全部原生支持性能也非常优秀因为底层是浏览器 C 实现的。我建议你优先用它。手写深拷贝的主要价值在于学习原理、处理 JSON 方案无能为力的场景以及兼容旧环境。只要运行环境支持structuredClone它能节省你大量时间和 bug 排查成本。5.2 structuredClone 的边界限制structuredClone不是万能药它有三个明显的边界第一函数和 Symbol 不能拷贝。遇到函数会直接抛DataCloneErrorSymbol 作为属性值会被忽略Symbol 作为属性键时被拷贝但重复的 Symbol 键会退化为两个不同的 Symbol这点怪异行为我不建议依赖。第二DOM 节点不能拷贝。structuredClone(document.body)会抛错。如果你要拷贝一个混合了 DOM 引用的对象得先手动剔除或替换这些节点。第三getter 会被触发。结构化克隆遍历对象属性时会读取可枚举属性的值所以 getter 依然会被执行。如果原对象上的 getter 有副作用照样会踩雷。除此之外structuredClone会保留原型吗在实际行为中它把对象序列化成纯数据再反序列化自定义类实例拷贝后会变成普通对象原型方法会丢失。这一点和手写方案不同如果你的业务依赖对象原型上的方法需要额外注意。5.3 偏门方案MessageChannel 和 History API在structuredClone出现之前浏览器里其实有一些曲线救国的克隆方案。比如借助MessageChannelfunction cloneByMessageChannel(obj) { return new Promise(resolve { const { port1, port2 } new MessageChannel(); port1.onmessage e resolve(e.data); port2.postMessage(obj); }); }postMessage在传递消息时也会走结构化克隆所以循环引用和特殊类型都能处理。代价是它是异步的调用方必须用await或.then()拿结果。另外原型同样会丢失。还有一个更偏门的方案利用history.replaceState因为浏览器内部也用结构化克隆来处理状态对象function cloneByHistory(obj) { const oldState history.state; history.replaceState(obj, document.title); const copy history.state; history.replaceState(oldState, document.title); return copy; }这个方案是同步的但是会动浏览器的历史记录状态有可能触发路由或历史栈的脏数据属于能用但别乱用的奇技淫巧我在生产环境从来不敢直接上。5.4 方案对比究竟选哪个我把四种方案放一起对比方便你按场景选方案循环引用Date/RegExpMap/Set函数/Symbol原型保留性能环境要求JSON.parse(JSON.stringify())抛错丢失丢失丢失否快所有手写递归支持支持支持函数引用/Symbol键支持可配置一般所有structuredClone支持支持支持函数抛错/Symbol受限丢失很快现代浏览器/Node 17lodash.cloneDeep支持支持支持函数引用/Symbol键支持保留快所有结论很简单能用structuredClone就用它然后封装一个兜底方案处理它不支持的类型确实需要兼容老环境再用手写递归只有数据结构非常简单的场景才用 JSON 方案。6. 实战中的坑问题排查速查与选型心得6.1 问题速查表我踩过太多深拷贝的坑有些问题排查了大半天才找到根因直接整理成一张表给你现象真正原因解决方案报错Converting circular structure to JSON对象存在循环引用JSON.stringify 无法序列化换用 WeakMap 缓存递归方案或 structuredClone拷贝后的 Date 变成字符串JSON.stringify 把 Date 序列化为 ISO 字符串手写递归时检测 Date 分支或直接用 structuredClone拷贝后的 RegExp 变成 {}JSON.stringify 不支持正则序列化RegExp 走 source/flags 重建分支拷贝后的 Map/Set 变成 {}JSON.stringify 无法序列化 Map/Set 的迭代结构单独写 Map/Set 分支键值都递归拷贝结果没有函数回调JSON.stringify 静默丢弃函数属性手写递归时函数直接返回原引用深拷贝超大对象导致页面卡死递归遍历深度太大或者对象里混入了 DOM 节点检查数据里有没有 DOM 引用考虑用 structuredClone白屏/栈溢出循环引用没有被缓存拦截确认 cache.set 在递归遍历之前执行拷贝后自定义类的实例方法丢失容器用了普通{}而不是 Object.create(proto)用 Object.create(Object.getPrototypeOf(source)) 保留原型拷贝后出现奇怪的__ob__属性Vue 响应式对象的内部标记被当成普通属性拷贝了根据框架规则过滤内部字段或深拷贝前先转为纯 JSON 数据6.2 一个真实的线上事故getter 引起的性能灾难有一次我处理一个表单设计器项目用户反馈拖几个组件后页面变得特别卡。我打断点排查发现某处深拷贝用了Object.getOwnPropertyDescriptors方案而源对象恰好是 Vue 的响应式对象。Vue 在对象上定义的 getter 非常多每次访问属性的 getter 都会触发依赖收集深拷贝循环里大量触发 getter 后整个页面的响应式系统反复自检性能直接被拖垮。从那以后我对深拷贝有两个原则第一响应式框架的对象不要直接深度拷贝先序列化成纯 JSON 数据再操作第二如果确认要拷贝的对象里有 getter明确认知它的副作用风险别让深拷贝在循环里反复触发。6.3 跨 iframe 的 instanceof 陷阱前端的instanceof有个经典坑当一个对象来自另一个 iframe 窗口时在父窗口里obj instanceof Date可能是false因为 Date 构造函数来自不同的全局环境。低代码平台、微前端架构里尤其容易踩这个坑因为页面里可能嵌了多个 iframe 窗口数据在窗口之间传递后构造函数已经不是同一个。所以严谨的深拷贝工具函数不应该用instanceof而是用Object.prototype.toStringfunction getType(source) { return Object.prototype.toString.call(source); } // 输出形如 [object Date]、[object Map]、[object RegExp]用这个方式判断类型跨 iframe 环境下也能正常工作。我在封装通用工具库时严格遵守这条规则省去了很多跨窗口场景的隐性 bug。6.4 深拷贝二进制数据时的性能陷阱有人用深拷贝去复制一个巨型ArrayBuffer或Blob结果界面直接卡死。ArrayBuffer.slice(0)是深拷贝底层二进制数据如果数据有几百 MB这段操作本身就是重量级的。我遇到过一个导出 Excel 的功能每次导出前都要深拷贝一份模板 Buffer结果大数据量时直接内存翻倍。后来优化方案是改用 transferable 对象转移所有权或者干脆不拷贝、只用只读引用。深拷贝不是越彻底越好如果数据量大到影响性能你要先考虑是否真的需要复制一份。6.5 我的选型习惯把项目里可能会碰到的场景理顺后我的选型习惯基本固定下来了JavaScript 里处理深拷贝最优先的选项是structuredClone因为它原生支持循环引用、Map、Set、Date、RegExp、二进制数据等多种类型性能强代码量最少。项目要在旧环境运行或者需要保留函数引用、原型链等特殊语义时再降级到手写深拷贝但必须实现 WeakMap 缓存、Date/RegExp/Map/Set 分支、Symbol 属性键遍历这三个基本点。而JSON.parse(JSON.stringify())只用在纯配置对象或接口数据回显这类绝对安全的结构上。最后再分享一个小技巧如果你的深拷贝函数要面向整个团队复用最好用Object.prototype.toString做类型判断配合 WeakMap 缓存循环引用再给 Date、RegExp、Map、Set、ArrayBuffer 各写一个分支Symbol 和函数做单独处理。这一套工具函数写好后基本能覆盖 99% 的业务场景剩下的 1% 等你真正遇到了再来找我补充。
返回列表