共享图慢 1.9 倍还丢类型、扁平对象只快不到一成:深拷贝选型的实测复盘

发布时间:2026/8/18 11:19:02
共享图慢 1.9 倍还丢类型、扁平对象只快不到一成:深拷贝选型的实测复盘 上周排查一个表单卡顿火焰图里赫然挂着JSON.parse(JSON.stringify(state))——团队一直把它当“最快的深拷贝”写在每个热路径上。我赌它快结果一上performance.now()打脸它在宽扁平对象上只快不到一成实测约 6%但在共享引用图上反过来慢约 1.9 倍遇到TypedArray、Date、Map还直接把数据搞丢。原来“JSON 快”只是半句真相另一半得看数据形状。背景为什么“深拷贝”值得专门写一篇深拷贝是开发者每天写、却很少测量的一行代码。社区里流传两句话一句是“用JSON.parse(JSON.stringify(x))最快”另一句是“用原生structuredClone最省心”。两句话都只对了一半而且“对的一半”在不同数据形状下会翻面。我们手上的真实负载大致分两类一类是表单、配置这种扁平 JSON 数据另一类是带Map/Set/Date、存在共享引用的归一化状态比如 Redux、Zustand store。选型错误要么悄悄丢数据要么在热路径上悄悄拖慢。所以我决定不喊口号直接在 Node 22 上给三种克隆方式跑六组真实形状把数字摊开。解剖三种克隆方式到底在做什么JSON 往返JSON.parse(JSON.stringify(x))先把整棵对象序列化成字符串再解析回来。优点是 V8 给JSON.stringify做了专门的字节码快路径缺点是每个数组下标都要整数转字符串、每个值都要按类型重新格式化且只认 JSON 类型。structuredCloneV8 的“结构化克隆”算法边遍历边写一张记忆表memo遇到已经见过的对象直接写 back-referenceTypedArray 按原始 buffer 整块拷贝天然支持Date/Map/Set/循环引用。手写递归紧致的专用循环没有字符串化和通用记忆表开销对“已知形状”最快——但漏掉一个分支比如 TypedArray就会静默返回原引用根本没拷贝。核心差异一句话JSON 为“可序列化”优化SC 为“保真”优化手写为“已知形状”优化。实证六种数据形状的真实跑分环境Node 22.22.2 / Windows计时用performance.now()time-based 采样每组先 warmup 再跑满约 400ms取 3 次中位数。指标是 ops/sec越高越快。# 复现命令bench.mjs 见同包 node bench.mjs图1六种数据形状下三种克隆方式的吞吐对比。手写递归在多数形状上一骑绝尘但这是“已知形状”的特例JSON 与 SC 的差距远没有传闻里“几倍”那么夸张。数据形状JSON (ops/s)structuredClone (ops/s)手写 (ops/s)SC ÷ JSON小扁平对象50 键153.7k171.1k542.7k1.09×基本持平宽扁平对象5000 键831.3k773.2k1.5k0.94×JSON 更快约 6%数字数组30 万23.1k29.7k546.6k1.28×SC 略快TypedArray30 万损坏变{}2.0k4.5k—深树深度 10003.2k3.5k31.0k1.09×共享引用归一化图1.4k2.7k6.4k1.87×SC 明显快三个反直觉点宽扁平对象上 JSON 只快不到一成实测约 6%——JSON.stringify的快路径在“纯字符串键 字符串值”上极其能打结构化克隆的记忆表反而成了负担但优势远没有传闻里“快几倍”那么夸张。共享引用图上 SC 快约 1.9 倍——归一化状态里一个user被 1000 个 slice 引用JSON 会把这棵树重复序列化 1000 次SC 走一次 back-reference 就完事。手写递归在多数形状最快数字数组上 546.6 vs 23.1约 24×代价是脆弱——见下文“局限”。共享引用这一点尤其值得看图图2左路 JSON 给每个 slice 各拷一份 user1000 个 slice 就是 1000 份独立 user右路 structuredClone 用一张 memo 表所有 slice 仍指向同一份 user。正确性JSON 在四类数据上静默丢东西性能只是上半场下半场是“它到底拷对了没有”。下面这组富类型对象JSON 往返和 SC 的结果天差地别图3同一份富类型对象左列经JSON.parse(JSON.stringify())右列经structuredClone。绿色表示保真红色表示被破坏或丢弃。类型原始JSON 往返后structuredClone 后DateDate对象变成字符串仍是DateMap2 键2 个键值变成{}数据丢失仍是Map2 个键Set2 元素2 个元素变成{}数据丢失仍是Set2 个元素undefined字段字段存在但值未定义字段被直接删除字段保留更隐蔽的是TypedArrayJSON.stringify(new Float64Array(30万))得到{}还原回来是个空对象原始 30 万个浮点数原地蒸发。如果你的状态里藏着Uint8Array图片数据或Float64Array特征向量用 JSON 做“深拷贝”等于做了一场无损声明下的有损压缩。局限手写克隆的 23 倍与它的雷区手写递归跑分好看但它是个 correctness 雷区不能因为它快就无脑上漏掉一个分支就静默共享本文初版手写克隆没判断TypedArray结果直接return v返回原引用30 万浮点数组“拷”完还是同一块 buffer——改一个俩都变。这类 bug 不会报错只会在某天数据串味时才暴露。不保留共享引用没有 memo 表归一化图被展开成 1000 份拷贝内存与后续 diff 成本双双爆炸。循环引用直接死循环遇到a.self a会栈溢出或挂死。不保留原型类实例、函数属性全部丢失。所以手写的“23 倍”只适用于形状完全已知、纯 JSON、无共享、无循环的窄场景比如热路径里反复拷贝同一个固定结构的小对象并且要配单元测试守住分支。通用场景别碰。另外本 benchmark 没有覆盖跨MessagePort/Worker 的 transfer 零拷贝那是另一篇、超大对象下的 GC 压力、以及不同 V8/SpiderMonkey 版本的绝对数值漂移。结论永远只对“你这次测的形状”成立换负载请自己用performance.now()复跑。结论与下一步深拷贝没有“唯一正确解”只有“按数据形状选”默认用structuredClone——零依赖、保真、抗共享引用与循环性能也并不差共享引用图上还更快。只有当你本就想做有损归一化比如给 API 准备纯 JSON body、清洗混合来源数据时才用JSON.parse(JSON.stringify())并且要清楚它在丢Date/Map/Set/undefined。手写递归留给形状已知、追求极致的窄热路径且必须配测试守住 TypedArray / 共享 / 循环三个雷区。别信“谁快几倍”的 folklore——把你的真实数据形状喂进performance.now()跑一遍数字比任何博客标题都诚实。开源地址矩阵门户https://github.com/wangzifan396-wzf/WB单文件工具聚合器https://github.com/wangzifan396-wzf/nano-workbenchGitHub 组织主页https://github.com/wangzifan396-wzf