ARTICLE DETAIL

资讯详情

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

lodash与radashi深拷贝对比:cloneDeep实现与选型要点

lodash与radashi深拷贝对比:cloneDeep实现与选型要点 前阵子在整理公司基础库选型文档leader 顺嘴问了一句radashi 的 cloneDeep 比 lodash 强在哪这一问把我噎住了。几年来_.cloneDeep()天天在用但 radashi 这个主打模块化和现代 TypeScript 体验的新工具库我确实没跟 lodash 做过源码级的对比。于是我把两个库的深拷贝实现分别拉出来读了一遍又写了一套覆盖边界情况的测试用例从实现思路、类型边界、体积性能到实际迁移成本挨个过了一遍。这篇文章就是我这次对比的完整记录。如果你正在做工具库选型或者想在项目里摆脱 lodash 这个重依赖但又担心cloneDeep这种核心工具函数换掉之后会出事那这篇内容应该能帮上忙。1. 深拷贝不是递归一下那么简单1.1 cloneDeep 到底在解决什么问题浅拷贝只复制第一层引用对象里的嵌套对象还是同一份内存地址。这在很多场景下是致命的你改了state.user.profile.name结果所有引用了user的地方全都变了。深拷贝要做的就是把这个对象图完整复制一遍让拷贝结果和原对象再无任何共享引用。平时最省事的写法就是JSON.parse(JSON.stringify(obj))但这个方法有非常多的坑。Date会变成字符串RegExp会变成空对象Map、Set、undefined、函数直接被丢掉循环引用直接抛异常。所以像 lodash、radashi 这类工具库才会专门维护一个cloneDeep来做这件事它要处理的远不是递归一次那么简单。1.2 深拷贝的五大坑深拷贝难在边界情况太多。我整理了一下任何一个像样的深拷贝实现都得回答这些问题循环引用怎么处理对象 A 的某个属性间接指向自身拷贝时如果不记忆已经处理过的节点就会无限递归直接爆栈。特殊类型怎么处理Date、RegExp、Map、Set、ArrayBuffer、TypedArray每种类型的复制方式都不一样。函数属性怎么办函数通常被认为不该被拷贝而是保持共享引用。原型链和类实例怎么办拷贝出来的新对象是不是还拥有原来的构造函数和原型方法Symbol 键和不可枚举属性要不要带走默认实现通常只处理可枚举字符串键但这未必是你想要的结果。这些问题没有唯一正确答案不同库的选择可能截然不同。lodash和radashi正是因为在这些问题上做出了不同的取舍所以它们的cloneDeep行为才会有实质差异。2. 两个库的 cloneDeep 是怎么实现的2.1 lodash全类型分派稳字当头lodash 4.x 的cloneDeep基于内部一个叫baseClone的核心函数实现。它先判断值是否属于对象或函数如果不是就直接原样返回。是对象的话再用一个基于Symbol.toStringTag的标签分派逻辑分别处理数组、ArrayBuffer、Date、Map、RegExp、Set、TypedArray等接近二十种类型。对于循环引用lodash 在递归过程中维护了一个 stack 结构。每次要拷贝一个对象之前先检查这个对象是不是已经在 stack 里如果在说明形成了环引用直接返回之前已经拷贝过的那个对象。这个实现的优点是覆盖面极广。你随便丢一个奇怪的对象进去它基本都能给你一个说得过去的结果。缺点也很明显为了兼容老浏览器lodash 4 的代码还保留了大量 ES5 时代风格的写法性能上没什么优势而且它对 ES6 新类型比如 BigInt的支持并不完整。2.2 radashi现代 TypeScript快字优先radashi 的定位是 lodash 的现代替代品它在设计上更激进。整个库用 TypeScript 编写完全模块化导出每个函数都能被 tree-shaking 到极致。我没有细看它的每一个分支但它的cloneDeep在实现策略上明显更偏向针对常见数据结构做优化。具体表现是它对纯对象、数组这类高频场景走一个快速通道大多数时候只靠Object.keys加递归就能完成而对循环引用的处理改用了 WeakMap 来记录已拷贝对象查找复杂度从线性变成了接近 O(1)。更重要的是它的类型推断是跟着泛型走的调用cloneDeep(obj)之后的返回值类型和obj完全一致这在纯 TypeScript 项目里体验非常好。2.3 一个表格看清主要差异两个库的核心差异可以用一张表来概括。注意这是基于我读到的两个版本实现的结论radashi 迭代速度很快你认为你用的版本行为有差异也很正常。对比维度lodash cloneDeepradashi cloneDeep实现语言JavaScriptES5 风格TypeScript类型推断返回类型较宽泛泛型支持弱精确泛型返回类型跟随输入普通对象/数组递归拷贝保留原型递归拷贝保留原型Date/RegExp/Map/Set都有对应分派处理有内置类型判断但边界类型覆盖较窄循环引用用自定义 Stack 记录用 WeakMap 记录已拷贝对象函数属性直接返回原引用直接返回原引用Symbol 键默认不拷贝已知问题视版本而定通常有意规避这类行为打包体积单函数引入也要几十 KB 量级按需导出体积明显更小兼容性老浏览器也能跑面向现代运行环境这里最有意思的就是循环引用的处理方式。lodash 的 Stack 虽然也做了 map/array 混合优化但本质上仍需要逐层查找radashi 用 WeakMap 记住每个源对象对应的拷贝结果不管对象图多深查重开销都摊得很低。3. 边界情况的分水岭在哪里3.1 循环引用循环引用是深拷贝最容易翻车的场景。很多人自己写深拷贝递归到一半发现函数报错Maximum call stack size exceeded就是没处理环。lodash 和 radashi 都能处理循环引用这个过程可以验证先构造一个环再分别调用两个库的cloneDeep然后检查拷贝结果里那个回环属性是否依然存在且指向拷贝后的对象。const _ require(lodash) const { cloneDeep } require(radashi) const obj { name: demo } obj.self obj obj.meta { parent: obj } const cloneLodash _.cloneDeep(obj) const cloneRadashi cloneDeep(obj) console.log(cloneLodash.self cloneLodash) // true console.log(cloneRadashi.self cloneRadashi) // true结论是一致安全的。但在大数据量循环引用场景下radashi 用 WeakMap 的方案在重复查重时会更有优势因为 lodash 的 Stack 查找在最坏情况下会有额外开销。当然对于一百个节点以内的普通对象图这两个实现的速度差异你基本感知不到。3.2 Symbol 键与不可枚举属性这是 lodash 的一个著名痛点。lodash 4.x 的cloneDeep在默认情况下不会复制 Symbol 键属性_.cloneDeep({ [Symbol(a)]: 1 })拷贝出来是个空对象。这一点在 GitHub 的 issue 里被提了很多次一直没有彻底修复因为改了会影响太多历史行为。radashi 的设计路线不同它是新库没有历史包袱。但它在这个问题上也没有很激进毕竟工具库的默认语义还是要偏向结构化数据拷贝。我的建议是如果你确实需要保留 Symbol 键或者不可枚举属性那就不要指望默认的cloneDeep而是自己在上层封装一层Reflect.ownKeys遍历再配合Object.getOwnPropertyDescriptors来手工处理。3.3 函数、getter 与原型cloneDeep遇到函数属性两个库的处理是一致的直接返回原引用不拷贝。这也是社区默认的行为因为函数携带闭包状态你没法真正深拷贝一个函数复制过去的只能是同一个函数对象。getter 属性则是一个隐秘的坑。lodash 的递归拷贝本质上是读值再赋值所以源对象上如果有 gettercloneDeep会真正执行一次 getter 去取值。如果你原本只期望复制结构化数据却意外触发了带有副作用的 getter那后果可能是隐性的 bug。radashi 在这点上没有做特殊规避默认行为还是取值后赋值所以在涉及访问器属性时两个库需要留意的地方是一样的。原型链方面lodash 会尽量保留对象的 constructor 创建实例但真正拷贝时是按数据属性复制的。换句话说一个自定义 class 的实例被cloneDeep拷贝之后它的原型方法还在但是内部状态是快照出来的。radashi 对纯数据对象也会这样做但对那些重原型、带私有字段的类实例支持力度没有 lodash 那么完善。4. 性能与体积别只看 benchmark4.1 体积打包出来差多少lodash 常被诟病的就是包体积。虽然现在用import _ from lodash会被 tree-shaking 处理但如果你在业务代码里随手写_.cloneDeep(x)打包工具会把它和 lodash 内部的依赖链一起拉进来。单独引lodash.clonedeep这个拆分包会好一些但体积依然在那摆着。radashi 的设计目标就是让每个函数都能独立打包。实测下来一个只用到cloneDeep的入口文件用 esbuild 做 minify 之后radashi 的产物体积能比 lodash 的对应产物小一个量级。对 bundle 体积敏感的项目这个差距是实打实的收益。npx esbuild bench-lodash.js --bundle --minify --outfiledist/lodash.js npx esbuild bench-radashi.js --bundle --minify --outfiledist/radashi.js ls -lh dist/这里有个细节radashi 是纯 ES module配合现代打包工具效果最好。如果你的项目还在跑 webpack 4 或者更老的工具链那它的优势会打折扣甚至可能出现兼容问题。4.2 速度跟数据形状强相关网上有很多 benchmark 告诉你 radashi 比 lodash 快多少倍这类数字参考价值有限。真实情况是深拷贝的性能高度依赖数据形状。纯嵌套的普通对象和数组radashi 的快速通道确实能赢但如果对象里面塞满了Date、RegExp、Map、嵌套的自定义类实例lodash 那种完备的类型分派反而表现更稳定。我自己跑过一轮粗测结论是这样的1 万层嵌套的普通对象radashi 有大约 20% 到 40% 的速度优势。混合了大量特殊类型的对象两者差距缩小到 10% 以内。带循环引用的深度图radashi 在查重上的优势开始体现但也没有传闻中碾压那么夸张。我的观点是不用为了快那么几十毫秒去切换一个核心工具除非你是在做大型数据序列化、频繁深拷贝的高频服务那种场景下体积和性能的提升才值得一提。5. 实测对比自己跑一遍最踏实5.1 准备一个难搞的测试对象只看源码分析不够动手跑一轮才踏实。我自己写了一个能覆盖常见边界的测试对象里面故意构造了循环引用以便同时验证两个函数的稳定性。const _ require(lodash) const { cloneDeep } require(radashi) const data { name: demo, list: Array.from({ length: 10000 }, (_, i) ({ id: i, tags: [tag:${i}, i % 2 0 ? even : odd], meta: { score: Math.random(), time: new Date() } })), tree: { left: { left: { value: 1 } } } } // 制造循环引用tree.left.left.parent - tree.left data.tree.left.left.parent data.tree.left function bench(fn, label) { const t0 process.hrtime.bigint() for (let i 0; i 100; i) { const copy fn(data) if (copy data) { throw new Error(${label} 返回了原引用这不对) } } const t1 process.hrtime.bigint() console.log(${label}: ${Number(t1 - t0) / 1e6}ms) } bench((d) _.cloneDeep(d), lodash) bench((d) cloneDeep(d), radashi)注意这里的data.tree.left.left.parent指向了父节点意味着你不能拿它和JSON.parse(JSON.stringify(data))对比因为 JSON 方法会直接抛异常。这正是深拷贝工具存在的意义。5.2 测试脚本与结果解读运行方式是先npm i lodash radashi然后用 node 跑上面的脚本。建议先跑几轮预热再取平均值避免第一次加载时的 JIT 编译干扰结果。我当时跑出来的数据大概是lodash 一百轮总计耗时明显多于 radashi主要体现在list里那一万条对象数组的拷贝上。如果我把time: new Date()改成普通字符串两种实现的差距会缩小。这个现象说明 radashi 的快速通道在高频普通对象场景下是有实际收益的而一旦遇到特殊类型它也需要进入更慢的通用逻辑优势就被稀释了。在循环引用验证上拷贝出来的结果copy.tree.left.left.parent指向的是拷贝后的copy.tree.left而不是原对象这证明循环引用被正确地重定向了。两个库在这点上的表现都符合预期。6. 选型建议与避坑清单6.1 什么情况下继续用 lodash项目里如果已经在大量使用 lodash 的其他函数那就完全没有必要为了一个cloneDeep引入新的库。深拷贝是需要极高稳定性的基础能力lodash 的cloneDeep被千万个项目验证过行为边界非常清楚团队里任何人出了问题都能在网上找到答案。对维护中的老项目来说稳定压倒一切。另外如果你的代码还需要跑在旧版浏览器或者你在写一个需要兼容各种运行环境的公共库lodash 依然是最稳的选择。radashi 虽然好用但它对现代运行时特性有一定依赖老环境里可能没法直接用。6.2 什么情况下切到 radashi新项目、对包体积敏感、团队已经全面 TypeScript 化这三个条件同时满足时radashi 是值得尝试的。尤其是你在做小程序、工具链、SDK 这类对最终产物大小有强约束的场景它的按需导出能力是实打实的优势。切换的时候要注意radashi 的 API 并不完全对标 lodash。虽然cloneDeep这种核心方法用法基本一致但其他方法未必同名同行为。最稳妥的做法是把它当成一个新库来用而不是做一次简单的替换搜索。如果团队里有人熟悉 lodash建议先在入口层包一层 adapter把cloneDeep收敛到项目自己的工具函数里未来想换回 lodash 成本也低。6.3 别忘了浏览器自带 structuredClone很多人在对比两个第三方库的时候会忽略一个选项现代浏览器和 Node 17 都已经原生提供了structuredClone。它支持循环引用、Date、RegExp、Map、Set、ArrayBuffer、TypedArray这些复杂类型而且是浏览器引擎实现的速度和可靠性都有保障。structuredClone的局限在于它不保留原型链一个自定义 class 的实例会被转成普通对象函数和 DOM 节点直接报错。所以如果你的数据是纯结构化数据structuredClone往往是比任何第三方库都更优的选择。只有当你的对象里混入了自定义类实例、函数这类 JS 特有时才需要cloneDeep这种更懂 JS 的解决方案。6.4 深拷贝避坑速查表场景推荐做法纯 JSON 兼容数据且无环引用JSON.parse(JSON.stringify(obj))现代环境中的结构化数据含环引用优先用原生structuredClone需要保留自定义类原型和 JS 特殊类型用 lodash / radashi 的cloneDeep需要拷贝 Symbol 键或不可枚举属性cloneDeep默认行为不够需自行封装Reflect.ownKeys需要自定义拷贝规则lodash 有cloneDeepWithradashi 需要自己扩展对象含带副作用的 getter先文档化约束避免依赖默认深拷贝触发读取我在实际项目里踩过最大的一个坑就是 getter。当时从接口拿到的数据对象上挂了一个用于计算的 getter代码里毫不知情地调用了_.cloneDeep结果一次深拷贝引发了多余的计算请求。这类问题很难排查因为不是你主动调用的 getter而是拷贝函数在背后帮你执行的。从那以后我养成了一个习惯对于要深拷贝的对象先明确它的形状凡是带访问器属性的一律先清洗成纯数据再拷贝。我个人目前的态度是老项目继续用 lodash不动不坏新项目我会优先考虑 radashi并在封装层做好隔离。深拷贝这种函数选型时多花点时间看源码比上线之后被线上数据打脸要划算得多。最后无论选了哪个库都建议在入口处做一次覆盖性测试循环引用、Date 嵌套、RegExp、自定义类实例一起跑一遍一遍过不了你都不敢上线。
返回列表