ARTICLE DETAIL

资讯详情

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

深拷贝性能对比:lodash与radashi的cloneDeep差异解析

深拷贝性能对比:lodash与radashi的cloneDeep差异解析 之前排查一个线上问题折腾了半天最后发现罪魁祸首是cloneDeep的行为和我以为的不太一样一份配置对象被多处代码修改嵌套层级的引用没有真正断开改着改着就把全局配置污染了。从那次之后我对深拷贝就特别较真。正好那段时间 radashi 在社区里挺火很多人说它能替代 lodash我就把两个库里的cloneDeep单独拎出来做了一组对比测试。测试做完我最大的感受是这两个函数名字一样但设计取舍、性能表现、边界处理都很不一样如果没搞清楚就盲切会踩不少坑。这篇文章就记录一下我对比的过程和结论适合正在做 lodash 体积优化、或者准备迁移到现代工具库的前端开发者。1. 为什么单独对比 cloneDeep两个库的整体定位差异1.1 深拷贝到底在解决什么问题很多人觉得深拷贝就是个“拷贝对象”的工具但它在业务里的分量被严重低估了。以最常见的浅拷贝为例const config { theme: { color: #333 } }; const copied { ...config }; copied.theme.color #f00; console.log(config.theme.color); // #f00浅拷贝翻车了展开运算符只复制了第一层引用theme还是同一个对象。线上大量“某个组件的配置被另一个组件改了”的问题十有七八都是这里出的。深拷贝要解决的就是彻底切断引用链无论嵌套多深复制出来的都是独立的“数据快照”。实际场景里深拷贝最常见的用途是这几类全局配置隔离不同模块读同一份配置但各自可以临时修改副本不影响全局。组件默认值UI 组件拿到的 props 或 options不能直接改传入对象需要先拷贝一份再加工。状态管理某些老项目还没有 immutable 体系提交新状态之前先把旧状态深拷贝一份做快照。编辑器/撤销功能每次操作前保存完整数据之后才能可靠地回退。这些需求有一个共同点出错不是“功能不可用”而是“数据被悄悄污染”。所以要对比两个库的cloneDeep先得把“深拷贝必须可靠”这件事放在第一位其次才是性能和体积。1.2 lodash 和 radashi 的路线差异lodash 是一个历史包袱很重但极其成熟的全家桶。_.cloneDeep在 4.17.x 里经过了多年迭代处理了 Date、RegExp、Map、Set、ArrayBuffer、TypedArray、循环引用等一系列边界场景。代价是它的核心代码里保留了大量兼容分支而且lodash包整体导出方式偏老虽然官方也提供 ESM 版本但在实际打包里想要把 tree-shaking 发挥到极致还是需要额外配置。radashi 是后来出现的一个现代化工具库定位非常明确用 TypeScript 编写、ESM-first、按需导出、更小的包体。它不想做 lodash 那样的“全能选手”而是像“单点外卖”一样只提供开发里最高频的那批方法。cloneDeep就是它重点覆盖的函数之一。从设计哲学上看radashi 更像是在“现代 JavaScript 引擎 构建工具链”这个前提下重新思考哪些兼容逻辑可以砍掉哪些写法可以让 JIT 更快。为什么单独拿cloneDeep出来对比是因为它恰好处于两个库差异最大的交叉点。一方面它是高频率使用的数据安全函数另一方面它内部是递归逻辑最容易因为“砍兼容分支”而在边界场景上出问题。拿它当试金石最能看清两个库的真实差距。2. cloneDeep 的核心差异逐个拆给你看2.1 API 形态与类型推断先看调用方式。lodash 传统写法要么全量引用要么按模块引用import _ from lodash; const copy _.cloneDeep(source); // 或者按需引入 import cloneDeep from lodash/cloneDeep; const copy cloneDeep(source);radashi 的写法更符合现代项目的习惯直接从包名导出命名函数import { cloneDeep } from radashi; const copy cloneDeep(source);这个差别看着小但对构建产物影响很大。import _ from lodash这种写法即使只用了一个函数很多打包器仍然会把整个 lodash 主模块纳入依赖分析范围import cloneDeep from lodash/cloneDeep虽然能做到按需但依赖路径比较丑陋而且类型支持不如 radashi 原生 TypeScript 来得干净。类型推断方面radashi 的优势更明显。cloneDeep的输入输出类型能做到更精确的映射import { cloneDeep } from radashi; interface UserConfig { theme: { color: string }; retries?: number; } const source: UserConfig { theme: { color: #333 }, retries: 3 }; const copy cloneDeep(source); // copy 的类型能保留 UserConfig 的结构嵌套层级也完整lodash 也有类型定义但因为它要兼容太多历史 API泛型重载写得比较保守。实际开发里遇到复杂嵌套类型时lodash 的返回类型有时候会退化成较为宽泛的类型而 radashi 因为是新写的 TypeScript 实现类型映射更贴合输入结构。对于强类型项目来说这一点会直接影响日常开发体验。2.2 普通对象和数组的处理策略深拷贝最核心的场景就是纯净的普通对象、数组和基本类型混合体。两个库对这类数据的处理逻辑本质上都是“递归遍历属性对引用类型创建新容器对基本类型直接赋值”。区别主要体现在递归过程中的额外开销。lodash 的cloneDeep内部有一套成熟的类型分发机制。它要先判断当前值的类型是数组、是对象、是 Date 还是 RegExp再走不同的分支。这套机制保证了它在任何环境下的稳定性但同时也意味着每次递归都要执行很多类型检查和兼容性判断。在现代浏览器和 Node 引擎上这些判断大多不会命中但是成本已经付出去了。radashi 在设计上更信任现代 JavaScript 引擎的能力。它对“普通对象 数组 基本类型”这种常见数据做了更直接的处理先判断是否该走特殊类型分支如果不是就直接用Object.keys或for...in搭配递归复制。相当于把 lodash 里层层判断的路径缩短了。我自己测试下来在比较干净的纯数据对象上radashi 确实有优势尤其当嵌套深度变大时差距会更明显。这里要说明一个原则lodash 多出来的那些判断不是“多余的代码”而是它能在老环境、跨环境下保持行为一致的保障。如果你只需要支持现代浏览器radashi 的做法更划算如果你要兼容很老的环境lodash 的稳定性依然是优势。2.3 特殊类型与原型链的处理差异深拷贝真正的分水岭在于 Date、RegExp、Map、Set 这类特殊对象以及带原型链的 class 实例。lodash 的cloneDeep对特殊类型的支持非常全面。Date 会创建新的Date实例RegExp 会保留lastIndex等属性Map 和 Set 会逐项复制键值ArrayBuffer、TypedArray 也有对应处理。这意味着你在 lodash 里深拷贝一个混合对象得到的副本在类型上基本是“原汁原味”的。radashi 从我使用的版本来讲对常见特殊类型的覆盖也做得不错Date、RegExp、Map、Set 都能得到正确类型的副本。但对于一些比较冷门的类型比如File、Blob、Promise实例两者都不会真正“复制内部状态”而是选择保留引用。这其实是合理的降级策略这类对象的内部状态无法通过简单递归复制强行深拷贝反而会出错。所以如果你项目里有 File 对象、DOM 节点或者 Promise 被塞进需要深拷贝的数据里一定要自己对这部分做额外处理。原型链方面有一个最容易踩的坑class 实例经过深拷贝之后大概率不会保留完整的原型链。也就是说一个自定义类的实例被复制后可能变成一个“看起来结构一样”的普通对象实例方法不一定还在。这个现象在 lodash 和 radashi 里都存在只是表现细节可能不同。如果你要深拷贝的数据里有带方法的类实例建议先写个测试确认一下副本的行为别想当然。2.4 循环引用、Symbol 键和函数属性循环引用是深拷贝最容易出现栈溢出的场景。比如一个链表结构或者某个对象里引用了自己const obj { name: root }; obj.self obj;如果深拷贝不做特殊处理递归会无穷无尽地展开最终RangeError: Maximum call stack size exceeded。lodash 和 radashi 都采用了同样的核心方案用一个 WeakMap或者类似结构记录“已经访问过的原对象”当再次遇到同一个引用时直接返回之前创建的副本而不是继续递归。这个方案保证了循环引用能被安全复制。函数属性的处理上两个库都遵循了一个原则函数不复制内部实现直接保留原引用。原因是函数本质上是可执行代码没有“深拷贝”的价值复制反而可能破坏绑定关系。所以你会发现const source { handler() { console.log(hello); }, nested: { value: 1 } }; const copy cloneDeep(source); copy.handler source.handler; // true函数保持同一引用 copy.nested source.nested; // false嵌套对象是新副本Symbol 键的情况比较微妙。lodash 对 Symbol 键属性的处理相对完整但具体行为在不同版本里不太一样。radashi 作为新库也不会完全忽略 Symbol 键但这类边界场景最稳妥的做法是在你自己的项目里写一个针对性测试。我自己的经验是日常业务数据里用 Symbol 当键的情况本来就不多如果真的用了而且这部分数据需要深拷贝请一定在测试用例里单独覆盖不要依赖任何库的“默认行为”。3. 实操验证我把两个 cloneDeep 拉到同一赛道上跑了跑3.1 测试环境与用例设计为了不让这次对比停留在“看文档猜差异”的层面我建了一个最简单的 Node 脚本在同一台机器上分别跑两个库的cloneDeep。测试环境如下项目配置运行时Node.js 20操作系统macOSlodash 版本4.17.21radashi 版本当前 npm 最新稳定版测试方式多轮预热 取多轮平均值数据方面我构造了三种有代表性的输入浅层对象单个对象100 个基本类型字段。深层对象10 层嵌套的对象树每层带一个数组。大数据数组一万个对象组成的数组每个对象有 5 个字段。另外再加了一组混合对象里面包含Date、Map、Set和RegExp用于验证特殊类型场景的表现。生成测试数据的核心逻辑大概是下面这样function makeDeepObject(depth) { if (depth 0) { return { id: 1, name: node, tags: [a, b] }; } return { child: makeDeepObject(depth - 1), list: Array.from({ length: 10 }, (_, i) ({ index: i })) }; }必须强调的是性能测试很容易被 JIT 优化干扰。直接跑一遍得出的数据没有任何参考价值我先用一批数据做预热等引擎把热点代码优化好之后再正式计时然后连续跑多轮取平均值。3.2 性能结果与分析在我本机环境下的测试结果大致如下数据仅供这次对比参考不同机器会有差异测试场景lodash cloneDeepradashi cloneDeep备注浅层对象100 字段约 0.02ms/次约 0.01ms/次两者都很块差距不大深层对象10 层嵌套约 0.18ms/次约 0.11ms/次radashi 优势比较明显大数据数组一万个对象约 120~140ms/次约 75~95ms/次radashi 整体快 30% 左右混合特殊类型对象约 1.2ms/次约 1.1ms/次差距很小特殊类型处理拉平了优势这个结果和我的预期基本一致。radashi 的优势主要集中在“普通对象 数组”这种纯数据场景因为它省掉了很多兼容性判断。一旦数据里混入大量 Date、Map、Set 这类需要特殊处理的对象两个库的路径都会变长性能差距就没那么显著了。需要再次提醒这个测试并不能说明 radashi“永远比 lodash 快”。深拷贝的性能严重依赖数据特征嵌套深度、字段数量、特殊类型占比都会影响结论。如果有人告诉你不带数据场景谈“哪个更快”直接可以判断他还没真正做过对比。3.3 正确性验证快不等于对性能只是一个维度我更关心的是拷贝结果“是否真的断了引用”。最简单的验证方式是修改副本的深层字段然后检查原对象是否不受影响const source { name: config, nested: { children: [{ id: 1 }, { id: 2 }] } }; const copy cloneDeep(source); copy.nested.children[0].id 999; console.log(source.nested.children[0].id); // 必须还是 1 console.log(copy.nested.children[0].id); // 必须变成 999这样只验证了一个点。更稳妥的做法是递归遍历整个对象树对比新旧对象的每个引用是否都已经断开。这一步可以用一个简单的检查函数完成核心逻辑就是遍历所有键对每个值判断基本类型直接比较值。对象/数组比较引用是否不同然后递归往下检查。特殊对象Date、Map、Set确认类型一致并且内部内容一致。在检查的时候很多人喜欢用JSON.stringify(source) JSON.stringify(copy)做快照对比。这个思路可以辅助使用但它有两个硬伤第一Date会被转换成字符串测不出真正的类型差异第二undefined、Symbol、函数属性会被直接丢弃等于测了个寂寞。所以快照对比只能作为“第一步”不能作为“唯一标准”。3.4 从 lodash 迁移到 radashi 的实操步骤如果你看完上面的对比决定在新代码里尝试 radashi我给一个相对稳妥的迁移建议不要一上来就全局替换。第一步选一个不核心的模块做试点。把原来的import _ from lodash改成按需导入// 改造前 import _ from lodash; const copy _.cloneDeep(source); // 改造后 import { cloneDeep } from radashi; const copy cloneDeep(source);第二步跑一遍这个模块相关的单测和类型检查。重点看两件事返回类型是否符合预期深拷贝结果是否通过了递归引用检查。radashi 的类型推断一般比 lodash 好所以大概率类型层面会升级而不是退化。第三步打开构建产物分析工具确认 tree-shaking 真的生效了。只看代码改没改没用要看最终 bundle 里 lodash 的体积是不是真的降下去了。如果项目里还有其他地方继续用import _ from lodash那整体体积可能没有变化。第四步灰度观察。新代码上线后留意有没有“对象被意外修改”的线上告警这类问题往往在单元测试覆盖不到的场景里爆出来。4. 常见问题与排查技巧4.1 问题快查表现象可能原因处理办法深拷贝后修改子对象原对象还是变了用的其实是浅拷贝比如展开运算符或Object.assign改用cloneDeep或原生structuredClone复制后的自定义 class 实例变成普通对象深拷贝默认不保留原型链实例方法可能丢失如果依赖实例方法给这个类自己实现clone()或在拷贝后重新绑定原型拷贝循环引用对象时报RangeError用的深拷贝实现没有处理循环引用检查库版本自己写 deepClone 时要引入 WeakMap 记录已访问对象Date变成了字符串用了JSON.parse(JSON.stringify())换用库的深拷贝或对特殊类型单独处理undefined和函数属性丢失同样是 JSON 序列化的副作用不要用 JSON 做深拷贝直接使用cloneDeep类函数bundle 体积没有降下来还有代码使用import _ from lodash这样的全量引用全局搜索import _ from lodash改成按需引用或逐个替换4.2 我踩过的几个坑第一个坑用JSON.parse(JSON.stringify(obj))做深拷贝。早期为了省事我经常这么干。直到有一次拷贝的数据里带Date结果整个对象里的时间字段全都变成了字符串线上时间格式化直接错乱。那次之后我给自己立了一个规矩只要数据里可能包含Date、Map、Set、undefined、函数就绝对不用 JSON 做深拷贝。第二个坑只检查了第一层引用就以为深拷贝生效了。早期做验证时我经常只改第一层字段发现原对象没变就放心了。后来才意识到真正容易出问题的是第二层、第三层的嵌套引用。现在我的测试基准是至少改到第三层再把原对象整体做一次递归对比才能确认“真的深拷贝了”。第三个坑在浏览器和 Node 环境测出两种结果。Buffer是 Node 特有类型lodash 的cloneDeep在 Node 环境下对Buffer有特殊处理浏览器环境里没有这个概念。如果你写的是同构代码一定要在每个目标环境都跑一遍深拷贝测试不能只在本地 Node 里测完就算了。第四个坑自己写 deepClone 函数时没有防护原型污染。如果数据里出现了__proto__这样的键名自己实现的拷贝函数在赋值时可能改到原型链造成安全隐患。现代工具库对这类问题已经有防护但如果自己造轮子千万记住处理和__proto__、constructor有关的键。4.3 什么时候别用 cloneDeep再强的cloneDeep也不是万能药有些场景下你应该主动避开它。性能敏感的高频路径上比如动画循环里每帧都要深拷贝一个大对象这开销会直接把帧率拖垮。这种场景应该优先考虑浅拷贝配合不可变数据结构或者用 immer 这样的库做结构共享而不是每次创建完整快照。数据量极大时比如要拷贝几十 MB 的嵌套数据cloneDeep的时间和内存开销都很可观。如果是跨线程传递数据用structuredClone可能更合适它由浏览器和 Node 原生实现性能通常更好而且能处理更多类型。不过要用它之前先确认一下兼容性。还有前面提到的类实例场景如果你明确知道拷贝对象里有带方法的复杂实例与其寄希望于某个库帮你“神奇地保住原型”不如给这个类写专门的clone方法。一个清晰的手动克隆逻辑比依赖通用深拷贝的黑魔法更可控。最后分享一个我自己的小经验跑完这轮对比我的态度是已经大量使用 lodash 的项目不要为了换而换cloneDeep本身够成熟继续用完全没问题从零开始的新项目如果你在意包体体积和现代开发体验radashi 是一个值得认真评估的选项。但别把我在文中的性能数据当成定论数据结构一换结论可能就反转了。我建议你把上面的测试脚本复制一份塞进自己项目的真实数据里跑一跑看哪个库在你的场景下更合适。下回再有人问 lodash 和 radashi 的cloneDeep有什么区别你可以把这篇文章转发给他至少能帮对方少走一段弯路。
返回列表