1. 为什么你的“深拷贝”总在线上翻车先问一个扎心的问题你写的const copy JSON.parse(JSON.stringify(obj))真的把数据拷干净了吗我见过太多项目备份配置、回滚表单、缓存快照全靠这一行平时风平浪静一遇到Date、RegExp、循环引用或者undefined字段立刻原地爆炸。更离谱的是有些翻车根本不报错数据悄悄变了等你发现时已经是生产事故。深拷贝在浏览器和 Node 环境里都不是“一行代码”能糊弄过去的事。JSON.parse(JSON.stringify())只能处理纯 JSON 数据函数、undefined、Symbol、Date、RegExp、Map、Set、循环引用统统会出问题解构赋值{...obj}和Object.assign只解决第一层嵌套对象照样共享引用手写递归不加WeakMap遇到循环引用直接栈溢出把页面卡死。这篇就按“能跟做、能自测、能选型”的思路来先讲清楚典型翻车场景再给出structuredClone的兼容性判断方法然后手写一个带WeakMap去环的递归深拷贝函数骨架最后用JSON.parse(JSON.stringify())的边界用例做对照验证。你照着敲一遍基本就能判断自己项目里该用哪种方案。适合前端、Node 后端以及任何需要处理复杂对象拷贝的同学。2. 前置准备TaoToken 接入与运行环境确认在动手写深拷贝代码之前先把运行环境和模型调用链路准备好。我平时做这类验证习惯用 TaoToken 统一管理模型调用这样在浏览器和 Node 里跑同一套逻辑时不用来回切换配置。官网地址是 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content API 入口是 https://taotoken.net/api 注意 API 地址不带 UTM 参数。你需要先拿到 API Key入口在这里https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi_keysutm_campaignrewrite 。拿到 Key 之后建议先确认一下模型对话是否正常入口是 https://taotoken.net/models?utm_sourcetaotoken_aicg_blog_endutm_contentmodelsutm_campaignrewrite 这样后面写验证脚本时如果遇到报错可以快速区分是深拷贝逻辑问题还是调用链路问题。如果你打算长期做编码和 Agent 相关开发可以了解一下 Coding Planhttps://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding_planutm_campaignrewrite 。接入文档在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 控制台在 https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_contentconsoleutm_campaignrewrite 。Claude Code 相关配置可以参考 https://taotoken.net/claudecode?utm_sourcetaotoken_aicg_blog_endutm_contentclaudecodeutm_campaignrewrite 。环境方面浏览器端建议 Chrome 98、Firefox 94、Safari 15.4Node.js 建议 17因为structuredClone在这些版本才原生可用。低于这些版本要么用 polyfill要么走手写递归方案。下面所有代码都可以直接在浏览器控制台或 Node 里跑不需要额外依赖。3. 可复制配置structuredClone 兼容性判断与手写递归骨架3.1 structuredClone 兼容性判断先写一个兼容性检测函数别上来就无脑用structuredClone老项目里翻车概率不低。function canUseStructuredClone() { if (typeof structuredClone ! function) { return false; } try { const test { date: new Date(), regex: /abc/gi, map: new Map([[k, v]]), set: new Set([1, 2, 3]), nested: { a: 1 } }; const cloned structuredClone(test); return ( cloned.date instanceof Date cloned.regex instanceof RegExp cloned.map instanceof Map cloned.set instanceof Set cloned.nested ! test.nested ); } catch (e) { return false; } } console.log(structuredClone 可用:, canUseStructuredClone());这个检测覆盖了Date、RegExp、Map、Set和嵌套引用比单纯判断typeof structuredClone function靠谱得多。有些 polyfill 只实现了部分能力跑一遍真实数据才能确认。3.2 手写递归 WeakMap 去环函数骨架下面这个函数是我实测下来比较稳的骨架支持Date、RegExp、Map、Set、数组、普通对象、Symbol键以及循环引用。你可以直接复制到项目里用。function deepClone(obj, hash new WeakMap()) { // 基本类型和 null 直接返回 if (obj null || typeof obj ! object) { return obj; } // Date if (obj instanceof Date) { return new Date(obj.getTime()); } // RegExp if (obj instanceof RegExp) { return new RegExp(obj.source, obj.flags); } // 循环引用检测 if (hash.has(obj)) { return hash.get(obj); } // Map if (obj instanceof Map) { const cloneMap new Map(); hash.set(obj, cloneMap); obj.forEach((value, key) { cloneMap.set(deepClone(key, hash), deepClone(value, hash)); }); return cloneMap; } // Set if (obj instanceof Set) { const cloneSet new Set(); hash.set(obj, cloneSet); obj.forEach((value) { cloneSet.add(deepClone(value, hash)); }); return cloneSet; } // 数组 if (Array.isArray(obj)) { const cloneArr []; hash.set(obj, cloneArr); for (let i 0; i obj.length; i) { cloneArr[i] deepClone(obj[i], hash); } return cloneArr; } // 普通对象 const cloneObj {}; hash.set(obj, cloneObj); // 拷贝 Symbol 键 const symKeys Object.getOwnPropertySymbols(obj); for (const symKey of symKeys) { cloneObj[symKey] deepClone(obj[symKey], hash); } // 拷贝字符串键 for (const key in obj) { if (Object.prototype.hasOwnProperty.call(obj, key)) { cloneObj[key] deepClone(obj[key], hash); } } return cloneObj; }关键点有三个第一hash.set(obj, cloneObj)必须在递归子属性之前执行否则循环引用还是会无限递归第二用WeakMap而不是Map避免原对象被回收后哈希表还占着内存第三Symbol键要单独用Object.getOwnPropertySymbols处理for...in拿不到。3.3 JSON.parse(JSON.stringify()) 边界验证用例现在写一组对照用例把JSON方案和手写方案的差异跑出来。你可以直接复制到控制台执行。const testCases { date: { createTime: new Date(2024-01-01T00:00:00Z) }, regex: { pattern: /abc/gi }, undefinedField: { a: undefined, b: 1 }, symbolField: { [Symbol(secret)]: value, normal: 1 }, mapData: { map: new Map([[1, one]]) }, setData: { set: new Set([1, 2, 3]) }, circular: (() { const a { name: a }; const b { name: b, ref: a }; a.ref b; return a; })() }; function testJsonClone(obj) { try { return JSON.parse(JSON.stringify(obj)); } catch (e) { return ERROR: ${e.message}; } } for (const [name, data] of Object.entries(testCases)) { console.log(\n ${name} ); const jsonResult testJsonClone(data); console.log(JSON 方案:, jsonResult); try { const deepResult deepClone(data); console.log(手写方案:, deepResult); } catch (e) { console.log(手写方案 ERROR:, e.message); } }跑完之后你会看到Date在 JSON 方案里变成字符串手写方案保留DateRegExp在 JSON 方案里变成空对象{}手写方案保留正则undefined字段在 JSON 方案里直接消失Symbol键在 JSON 方案里丢失Map、Set在 JSON 方案里变成空对象循环引用在 JSON 方案里直接抛TypeError手写方案正常返回。4. 验证请求与成功结果跑通完整自测流程4.1 浏览器端验证打开 Chrome 控制台把第 3 节的deepClone函数和测试用例粘贴进去执行后观察输出。重点看三个指标循环引用是否报错、Date是否还是Date实例、Map数据是否还在。const original { name: test, date: new Date(), map: new Map([[key, value]]), nested: { count: 1 } }; const cloned deepClone(original); cloned.nested.count 999; console.log(原对象 nested.count:, original.nested.count); // 应该是 1 console.log(克隆对象 nested.count:, cloned.nested.count); // 应该是 999 console.log(date 是否 Date 实例:, cloned.date instanceof Date); // true console.log(map 数据:, cloned.map.get(key)); // value如果original.nested.count还是 1说明嵌套引用被正确断开如果cloned.date instanceof Date为 true说明Date类型保留成功。4.2 Node 环境验证Node 17 可以直接用structuredClone低于这个版本用上面的手写函数。在 Node 里跑一遍// node test-clone.js const original { createTime: new Date(), pattern: /test/gi, data: new Map([[1, one]]), list: [1, 2, { deep: true }] }; const cloned structuredClone(original); cloned.list[2].deep false; console.log(原对象 deep:, original.list[2].deep); // true console.log(克隆对象 deep:, cloned.list[2].deep); // false console.log(Date 保留:, cloned.createTime instanceof Date); // true console.log(RegExp 保留:, cloned.pattern instanceof RegExp); // true console.log(Map 保留:, cloned.data instanceof Map); // true4.3 性能对比验证数据量大的时候选型不能只看功能还得看速度。下面这段代码可以粗略对比三种方案function createTestData(depth 3, breadth 50) { if (depth 0) return { value: Math.random() }; const obj {}; for (let i 0; i breadth; i) { obj[key${i}] createTestData(depth - 1, breadth / 5); } return obj; } const testData createTestData(3, 50); console.time(JSON); for (let i 0; i 100; i) JSON.parse(JSON.stringify(testData)); console.timeEnd(JSON); console.time(structuredClone); for (let i 0; i 100; i) structuredClone(testData); console.timeEnd(structuredClone); console.time(deepClone); for (let i 0; i 100; i) deepClone(testData); console.timeEnd(deepClone);实测下来structuredClone通常最快因为底层是原生实现JSON方案中等但会丢数据手写递归在小数据量下略慢但大数据量和循环引用场景下更稳。5. 本篇常见错排查5.1 structuredClone 报 DataCloneError报错信息通常是DataCloneError: function(){} could not be cloned或者Symbol could not be cloned。原因是structuredClone不支持函数、Symbol、Error对象、DOM 节点等。解决办法是先判断数据里有没有这些类型有的话降级到手写递归方案或者提前把函数字段剥离。function safeClone(obj) { try { return structuredClone(obj); } catch (e) { console.warn(structuredClone 失败降级手写方案:, e.message); return deepClone(obj); } }5.2 手写递归栈溢出报错RangeError: Maximum call stack size exceeded说明递归太深或者循环引用没处理好。检查两个点hash.set(obj, cloneObj)是否在递归子属性之前执行hash.has(obj)判断是否在函数入口处。如果数据本身嵌套超过几千层递归方案还是会爆栈这时候要改成循环 栈的写法。5.3 Date 变成字符串后调用 getTime 报错这是JSON.parse(JSON.stringify())的典型后遗症。拷贝后的Date字段变成 ISO 字符串再调getTime()、getFullYear()直接报is not a function。解决办法是拷贝后手动恢复function cloneWithDateFix(obj) { const copy JSON.parse(JSON.stringify(obj)); if (copy.createTime) copy.createTime new Date(copy.createTime); if (copy.updateTime) copy.updateTime new Date(copy.updateTime); return copy; }但更推荐直接用structuredClone或手写递归从源头避免这个问题。5.4 Map/Set 拷贝后数据丢失JSON方案会把Map、Set变成空对象{}数据全丢。手写方案里如果忘了处理Map、Set分支也会出问题。检查deepClone函数里是否有obj instanceof Map和obj instanceof Set的判断并且hash.set要在遍历之前执行。5.5 undefined 字段消失导致表单校验异常JSON.stringify会直接忽略值为undefined的字段拷贝后对象里这个键就没了。如果后续代码用if (rule.validator)判断逻辑会变。解决办法是用手写递归保留undefined或者在拷贝后手动补回字段。6. 选型建议与后续接入选型其实就三条线现代浏览器 Node 17数据里没有函数直接用structuredClone性能和兼容性都最好需要支持老浏览器或者数据里有函数、Symbol用手写递归 WeakMap方案只是处理纯 JSON 接口数据且能接受Date变字符串JSON.parse(JSON.stringify())也能凑合但心里要有数。如果你在项目里需要频繁做模型调用和编码验证可以把 API Key 和接入文档配好后续调试深拷贝逻辑时直接用模型对话快速生成测试用例。API Key 入口https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi_keysutm_campaignrewrite 接入文档https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 。长期做编码和 Agent 开发的话Coding Plan 入口在 https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding_planutm_campaignrewrite 控制台在 https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_contentconsoleutm_campaignrewrite 。最后提醒一句深拷贝不是万金油用之前先问自己是不是真的需要隔离数据。只读展示直接传引用状态更新优先用不可变策略实在需要全量拷贝再上structuredClone或手写递归。把第 3 节的函数复制到你的工具库里下次遇到对象莫名被改先跑一遍测试用例基本就能定位是浅拷贝还是类型丢失的问题。
