先讲一个我踩过的坑。有段时间我维护一个后台订单系统下单时间字段在展示前会先做一次深拷贝再传给统计组件。当时图省事直接写了JSON.parse(JSON.stringify(obj))本地一切正常结果上了生产之后时间图表里全是2024-01-01T08:00:00.000Z这种原始字符串排查了半天才发现罪魁祸首就是这句人见人爱的深拷贝——它把 Date 对象悄悄换成了字符串。JSON.parse(JSON.stringify(obj))大概是前端圈流传最广的深拷贝写法面试必背、项目必抄。但它有非常明确的能力边界只对纯 JSON 数据能完整深拷贝遇到函数、Date、Map、Set、循环引用、BigInt 这些要么静默丢数据要么直接抛异常。这篇文章想把这些需要注意的问题从头到尾拉一遍包括底层原理、逐项验证、真实翻车场景以及 structuredClone、Lodash cloneDeep 这些替代方案到底该怎么选。看完之后你会发现深拷贝其实不是一个一行代码问题而是一个先搞清楚数据类型再动手的问题。1. 先弄清楚底层原理这一行代码执行时究竟发生了什么1.1 JSON.stringify 不是简单的压成字符串JSON.stringify做的事情是把一个 JavaScript 值序列化成 JSON 文本。但 JSON 规范里只有六种合法值字符串、数字、布尔值、null、数组、对象。这意味着一个对象里只要有超出这六种类型的东西序列化阶段就会丢失或被改写。这不是 bug是设计使然——JSON 格式本身就没有函数、Date、Map 这些东西的表示方式。具体到 stringify 的序列化规则我实际测试过的主要行为如下每一条都值得在小本本上记一下对象属性值是 function、undefined、Symbol 时这个 key 会直接被丢弃数组里的 function、undefined、Symbol 会被替换成 null而不是丢弃因为数组不能少一个元素NaN、Infinity、-Infinity 会被转成 nullDate 对象会调用自身的toJSON()输出类似2024-01-01T00:00:00.000Z的 ISO 字符串RegExp 没有可枚举的自有属性序列化出来就是{}Map、Set 同样没有可枚举的自有属性序列化出来也是{}里面的键值对全部丢失BigInt 无法序列化直接抛TypeError: Do not know how to serialize a BigInt遇到循环引用会抛TypeError: Converting circular structure to JSON只遍历可枚举的自有属性不可枚举属性、Symbol 作为 key 的属性都会被忽略getter 属性会被当作普通属性读取一次也就是会真的执行 getter 逻辑。建议你把下面这段代码复制到浏览器控制台或 Node 里跑一遍亲眼看一下输出const target { fn: () console.log(run), u: undefined, sym: Symbol(s), nan: NaN, inf: Infinity, negZero: -0, date: new Date(2024-01-01T08:00:00.000Z), reg: /abc/g, map: new Map([[key, value]]), set: new Set([1, 2, 3]), big: 9007199254740993n, }; try { const json JSON.stringify(target); console.log(json); } catch (error) { console.error(stringify 抛错, error.message); }第一次跑的时候建议先把big那行注释掉然后再对比输出。不注释 BigInt 的话整个 stringify 直接抛错后面什么都拿不到。注释掉之后输出大概是这样的结构{nan:null,inf:null,negZero:0,date:2024-01-01T08:00:00.000Z,reg:{},map:{},set:{}}注意fn、u、sym三个 key 已经消失了nan、inf变成 nullnegZero从 -0 变成了 0date变成字符串。跑完你会意识到单是 stringify 这一步保留什么、丢掉什么就已经定了后面的 parse 根本没有机会把它们找回来。1.2 JSON.parse 只能还原 JSON 文本还原不了类型JSON.parse是把字符串解析回 JavaScript 值。它按照 JSON 语法生成普通对象、普通数组、字符串、数字、布尔值和 null仅此而已。所以stringify parse整条链路的本质是原对象 →stringify→ 过滤掉所有非 JSON 类型变成文本 →parse→ 还原成只有 JSON 类型的纯数据对象结果有两点特别要命。第一Date 恢复了不是 DateRegExp 恢复了不是 RegExp函数更是直接找不回来第二原型链会丢。如果原始对象是某个 class 的实例拷贝结果是一个普通 Object实例上所有通过原型提供的方法都调不了。看这段class User { constructor(name) { this.name name; } greet() { return Hello, ${this.name}; } } const user new User(张三); const clone JSON.parse(JSON.stringify(user)); console.log(clone instanceof User); // false console.log(clone.greet); // undefined这在业务代码里是典型的静默 bug不报错但跑起来行为和预期完全不同。尤其当类实例还被当作普通对象传来传去时很难第一时间想到是深拷贝的问题。1.3 共享引用会被拆散一个隐蔽的数据一致性 bug还有一个很少被提到的点如果原对象里两个属性指向同一个对象JSON 深拷贝之后它们会变成两个独立副本。const shared { count: 1 }; const source { first: shared, second: shared, }; const cloned JSON.parse(JSON.stringify(source)); console.log(cloned.first cloned.second); // false在source里first和second是同一个引用修改first.count会同步影响second.count。拷贝之后就完全不会了因为 stringify 并不知道这是同一个对象它把shared序列化成了两份文本parse 自然造出两个独立对象。这种引用关系断裂在很多场景下是灾难——比如状态管理里共享的 store、关系图里多个节点指向同一份数据、父子组件共用的配置对象。相比之下后面要讲的structuredClone在内部维护一个已经克隆过的对象映射表遇到同一个引用会直接复用从而保留共享关系。1.4 key 顺序和 getter两个容易被忽略的副作用先说 getter。stringify 遍历属性时凡是 getter都会被当作普通访问执行一次。这意味着深拷贝一个对象会顺带触发它内部所有 getter 的逻辑。如果某个 getter 里有副作用——比如发请求、改全局变量、自增计数器——这些副作用都会被这个拷贝动作意外触发。更麻烦的是getter 如果抛异常整个深拷贝直接失败。我见过因为某个对象里写了个不稳定的 getter导致表单提交时偶尔报错最后定位到是深拷贝把 getter 跑了一遍。再说 key 顺序。JS 对象对整数型 key 有特殊排序规则stringify 也会遵循这个枚举顺序整数 key 按升序排在最前然后才是字符串 key 按插入顺序排列。如果你的业务对 key 顺序敏感——比如接口签名、断言、diff 算法——拷贝前后 key 顺序可能不一致会引发一些很难看出规律的隐性 bug。2. 六类拷了等于没拷/拷了会炸的数据逐个验证先给一张速查表这是我在团队文档里沉淀下来的版本基本覆盖了日常开发会遇到的绝大多数情况数据 / 特性JSON 深拷贝后的表现风险等级function对象属性被丢弃数组内变 null高静默丢失undefined对象属性被丢弃数组内变 null中一般无感Symbol 值属性被丢弃中NaN / Infinity / -Infinity变成 null高数值语义被破坏-0变成 0低Date变成 ISO 字符串高类型判断全失效RegExp变成 {}高Map / Set变成 {}数据全丢高BigInt直接抛 TypeError高显式报错循环引用直接抛 TypeError高显式报错类实例 / 原型方法变成普通对象方法丢失高静默丢失不可枚举属性丢弃中getter会被执行结果备份为普通属性中副作用风险超过 2^53 的大整数精度丢失高共享引用拆成两个独立对象中逻辑 bug逐个看几个重点。函数和 undefined 的丢失属于闷声搞破坏不报错但对象形状变了。比如你判断if (obj.callback)发现是 undefined代码走了一个完全没想到的分支这种 bug 极度难查。NaN 和 Infinity 变成 null 也很坑图表组件拿到null直接不渲染你以为是数据源问题结果是深拷贝干的。Date 变字符串是出现频率最高的一类。instanceof Date从 true 变 false时间格式化函数不认了组件里的getTime()直接 not a function。RegExp 变成空对象{}正则匹配功能完全失效。Map 和 Set 更惨里面的所有键值对都没了只剩一个空壳。BigInt 和循环引用比较幸运因为它们会直接抛错你第一时间就能发现问题而不是等用户反馈 bug。但 BigInt 这个坑容易在不知不觉中踩到很多团队代码里没有 BigInt但某些第三方库返回的对象里可能带着 bigint 字段你无脑深拷贝一下整个函数直接崩。大整数精度问题也值得注意。JSON.parse(9007199254740993)的结果实际是9007199254740992因为 JavaScript 的 Number 无法精确表示超过 2^53 的整数。如果你的数据里有雪花 ID、大数字字符串转数字后的结果深拷贝一次就可能把精度吃了。最后补一个前面表格没写全的稀疏数组。JSON.stringify([, 1])的输出是[null,1]也就是说数组里的空位会被填成 null。这个细节在数据处理管道里会造成多了一个 null 元素的错觉统计长度时尤其容易中招。验证代码很简单拷贝前后做类型对拍const source { fn: () 1, u: undefined, nan: NaN, date: new Date(), map: new Map([[1, 2]]), }; const clone JSON.parse(JSON.stringify(source)); console.log(clone.fn, clone.u); // undefined undefined console.log(clone.nan null); // true console.log(clone.date instanceof Date); // false console.log(clone.map instanceof Map); // false跑完你就知道这行深拷贝代码真正能稳妥处理的其实只有普通对象、普通数组、字符串、数字、布尔值和 null。3. 真实项目里的三次翻车与完整排查思路3.1 场景一订单时间字段拷贝后秒变字符串这是我的真实经历也是本文开头的那个坑。现象是生产环境的时间图表出现了大量2024-01-01T08:00:00.000Z样式的刻度标签而本地环境完全正常。排查过程是这样的先在图表组件入口打日志打印传入的数据发现date字段已经是字符串了。继续往上游翻在深拷贝代码的前后各打一条日志对比typeof和instanceof结果一目了然拷贝前是 Date 对象拷贝后是字符串。定位到根因后处理方式也简单——把图表组件的数据源改成只传时间戳数字不进深拷贝链路或者干脆换用structuredClone。这里有个经验本地环境正常、生产环境异常别急着怀疑环境差异先检查数据里有没有 Date、Map 这类本地造、生产也有的对象。很多时候就是深拷贝把类型改了本地数据恰好是字符串所以没触发。3.2 场景二带回调函数的配置对象方法集体失踪另一个高频场景是配置对象。很多组件配置会带上回调函数比如表单配置里的validator、提交按钮的onClick、列表的format函数。这类对象如果走了 JSON 深拷贝拷贝出来的对象里所有函数都没了。有个同事遇到过从某个公共方法里拿了一份组件配置就地 deep copy 了一份做二次修改结果页面渲染时调用config.onClick(...)直接报undefined is not a function。他当时很困惑因为配置对象从打印结果来看很正常只是少了几个看着无关紧要的 key。这种问题我在代码评审里有个快速自检方法在拷贝前后各打一次Object.keys(obj)对比 key 数量。只要数量对不上说明有属性被吞了先怀疑函数、undefined、Symbol 这三个无声杀手。3.3 场景三树结构带 parent 指针一拷贝就抛循环引用错误第三个场景来自组织架构树。树的子节点通常会存一个parent引用指向父节点方便回溯上级。整个结构就是一个循环引用根节点 → 子节点 → 根节点。JSON 深拷贝遇到这种结构直接抛TypeError: Converting circular structure to JSON。这个错误好在是显式报错不会静默埋雷。但新手容易困惑的是我明明没在代码里写循环引用为什么对象会成环实际上树、图、链表这类数据结构天然就有环。排查时可以写一个简单的遍历函数用 WeakSet 记录已经访问过的对象一旦重复访问就打印当前路径很快就能定位成环的位置。用JSON.stringify逐步缩小对象范围也是一种办法就是效率低一些。3.4 通用排查链路拷贝前后做类型对拍综合三类翻车场景我总结了一套通用排查步骤找对拍点。在深拷贝代码的前一行和后一行分别打印关键字段的typeof、instanceof确认类型是否变化。二分缩小范围。对象很大时用console.dir看个别分支或者手写一个只复制部分字段的临时函数逐步确认是哪个子树出了问题。归类定位。时间变字符串 → 怀疑 Date方法消失 → 怀疑 function变成空对象 → 怀疑 RegExp / Map / Set直接抛错 → 怀疑 BigInt 或循环引用。修复。要么换深拷贝方案要么从源头保证数据里不混入这些类型——很多团队在接口层统一清洗数据把 Date、Map 转成纯 JSON 类型也是一种可行的防御手段。调试代码长这样const beforeKeys Reflect.ownKeys(source); const clone JSON.parse(JSON.stringify(source)); const afterKeys Reflect.ownKeys(clone); console.log(key 数量对比, beforeKeys.length, afterKeys.length); // 不相等说明有东西被吞了 console.log(Date 抽查, clone.date instanceof Date); // false 说明被改写了这套流程看起来朴素但胜在快五分钟内就能判断当前数据集适不适合 JSON 深拷贝。4. 适用边界JSON 深拷贝在什么场景反而是最优解前面讲了那么多问题但如果你想当然地认为以后绝不用 JSON 深拷贝那也走偏了。它依然是很多场景下最合适的方案关键是要用对地方。4.1 纯数据快照场景如果对象本身就是纯 JSON 数据——只有普通对象、普通数组、字符串、数字、布尔值、null——那么JSON.parse(JSON.stringify(obj))依然是最简单、最稳、零依赖的深拷贝方案。比如从后端拿到一份配置快照拷贝一份做本地编辑提交时再全量替换完全没问题。Api 响应、缓存数据、埋点数据大多属于这一类。4.2 本来就要走 JSON 序列化的场景有些数据在生命周期里本来就要过一遍 JSON比如存 localStorage、通过 postMessage 传递、作为 HTTP body 发给后端。这种场景下数据在到达你手上时就已经是纯 JSON 类型了用 JSON 深拷贝完全没毛病甚至比structuredClone更合适——因为你本来就要过一次 JSON深拷贝顺便就把序列化和反序列化一起做了少了一次类型转换的开销。4.3 性能上的一个冷知识在 V8 引擎里JSON.stringify JSON.parse对纯数据做了深度优化很多时候比手写递归深拷贝快一个数量级也比lodash.cloneDeep快。如果你的数据量很大且类型简单JSON 方案可能是性能最优解。反过来说如果数据里混了复杂类型性能优势就不存在了——因为类型转换、错误处理本身就浪费了时间。4.4 用 replacer 和 reviver 做定向修补如果确实需要在 JSON 深拷贝里保住某些类型可以用第二参数做自定义处理。比如用 reviver 把符合日期格式的字符串还原成 Dateconst cloned JSON.parse(JSON.stringify(source), (key, value) { if (typeof value string /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(value)) { return new Date(value); } return value; });反过来stringify 的 replacer 也可以做白名单过滤只保留指定字段。这在某些场景反而是 JSON 方案独有的优势const safeData JSON.parse(JSON.stringify(obj, [id, name, status]));但这属于打了补丁的 JSON 深拷贝前提是你非常清楚自己数据里有哪些类型维护成本比较高。如果团队代码里大量出现这种补丁我建议直接换方案而不是继续打补丁。5. 更稳的替代方案structuredClone、Lodash cloneDeep、手写深拷贝怎么选5.1 structuredClone当前最接近万能的答案structuredClone是浏览器和 Node 原生提供的深拷贝 API底层走的是结构化克隆算法Node 17、Chrome 98、Firefox 94、Safari 15.4 都支持。它能正确处理 Date、RegExp、Map、Set、ArrayBuffer、TypedArray、Blob、File还支持循环引用并且会保留共享引用。使用方式很简单const clone structuredClone(source);但也有边界函数和 Symbol 依然不支持遇到会抛DataCloneError自定义类实例在不同环境下的原型保留行为有差异用之前最好先写个几行的小例子验证一下。另外 IE 完全不支持老项目要用得做兼容处理。之前提到的共享引用问题structuredClone是能处理的因为它内部会记录已经克隆过的对象同一个引用会复用不会像 JSON 方案那样拆成两个副本。5.2 Lodash cloneDeep老项目和复杂边界类型的稳妥选择如果你的项目还在用 ES5 时代的技术栈或者对象里包含大量自定义边界类型_.cloneDeep是相当稳的选择。它能处理循环引用、Date、RegExp、Map、Set函数虽然不能复制函数体但会按引用共享至少不会让方法消失。Symbol key 会保留原型链也能保持。代价是引入体积。不过 npm 上有lodash.clonedeep单独发布的包可以只引入这一块不用把整个 lodash 拉进来。性能上cloneDeep一般比 JSON 方案慢一些但在绝大多数业务场景里这点性能差异可以忽略。5.3 手写深拷贝什么时候值得自己造轮子面试题除外实际项目里手写深拷贝的唯一理由是对特殊类型有定制需求。如果真要写必须处理四个核心点循环引用用 WeakMap 缓存、Date/RegExp/Map/Set 单独走类型分支、用Reflect.ownKeys Object.getOwnPropertyDescriptors覆盖 Symbol key 和不可枚举属性、用Object.create(Object.getPrototypeOf(obj))保持原型。下面是一份教学版实现能覆盖大部分业务数据function deepClone(value, cache new WeakMap()) { if (value null || typeof value ! object) return value; if (cache.has(value)) return cache.get(value); if (value instanceof Date) return new Date(value); if (value instanceof RegExp) return new RegExp(value.source, value.flags); if (value instanceof Map) { const clone new Map(); cache.set(value, clone); for (const [k, v] of value) clone.set(k, deepClone(v, cache)); return clone; } if (value instanceof Set) { const clone new Set(); cache.set(value, clone); for (const item of value) clone.add(deepClone(item, cache)); return clone; } if (Array.isArray(value)) { const clone []; cache.set(value, clone); for (const item of value) clone.push(deepClone(item, cache)); return clone; } const clone Object.create(Object.getPrototypeOf(value)); cache.set(value, clone); for (const key of Reflect.ownKeys(value)) { const descriptor Object.getOwnPropertyDescriptor(value, key); if (descriptor value in descriptor) { descriptor.value deepClone(descriptor.value, cache); } Object.defineProperty(clone, key, descriptor); } return clone; }注意这份代码没处理 ArrayBuffer、访问器属性的精细克隆等边界场景生产环境我依然建议优先用成熟方案。自己造轮子最大的价值是一次性把数据类型的规则理清楚以后排查问题时心里有条线。5.4 一张表收尾方案支持循环引用保留 Date/RegExp/Map/Set支持函数/Symbol保留原型依赖适用场景JSON 方案否否否否无纯数据快照structuredClone是是否内置类型是无现代项目通用cloneDeep是是函数共享/Symbol 保留是需要引入老项目/边界类型多手写取决于实现取决于实现取决于实现取决于实现无定制场景6. 我现在的选型习惯一条很简单的判断线实践多了之后我给自己定了一套判断逻辑写出来供参考。第一数据确定是纯 JSON 数据从接口来、要存 localStorage、本来就要做 JSON 序列化直接用 JSON 方案。简单、快、零依赖没有理由换。第二对象来源不可控可能混入 Date、Map、Set、RegExp甚至可能有循环引用尤其是要封装成团队通用工具函数时默认structuredClone。兼容性不允许就上lodash.cloneDeep。第三永远不要拿 JSON 方案去拷贝状态管理大对象、类实例、带方法回调的配置对象。这三个场景我在项目里都见过翻车能避就避。我通常会在项目里封装一个统一的 copy 方法环境支持structuredClone就用它否则回落到lodash.cloneDeep。整个团队只有一个入口后面再遇到深拷贝丢了方法之类的 bug直接看入口实现就明白了不用一个个站点排查。再分享一个小技巧。调试深拷贝问题时拷贝前后用Reflect.ownKeys对比 key 列表再挑几个关键字段做instanceof抽查五分钟就能判断当前数据集适不适合 JSON 深拷贝。比如beforeKeys.length ! afterKeys.length说明有属性被吞了clone.date instanceof Date false说明 Date 被改写了。先定位类型变化点再决定是用structuredClone、打补丁还是改数据源。最后说句个人体会深拷贝的坑不是深拷贝本身的问题而是 JS 对象远比 JSON 能表达的东西多。只要写代码前先问一句这份数据里到底有哪些类型用哪个方案都不会出大问题。反而是无脑抄一行代码最容易埋雷。