Mutative

性能

在 97 项工作负载中与 Immer、手写 reducer 的性能对比,以及基准测试的运行方式。

在 97 项基准工作负载中,相同配置下 Mutative 的性能约为 Immer 的 3.6 倍,各自默认配置下约为 6.6 倍。在多数场景中,只复制变化部分的手写 reducer 仍然更快。

基准测试套件

基准测试套件测量 101 项工作负载的耗时:Immer 自身的性能测试、数组方法、稀疏数组、读取、Map 和 Set 的值、对象记录、类实例、深层路径、补丁应用、返回值和搜索。最近一次完整测试测量了其中 97 项,另一次专项测试测量了两个返回全新数据的场景;两个稀疏数组场景是在此后加入的。套件先用手写 reducer 验证每个结果,再比较 Mutative、Immer 11.1.18 和手写 reducer 的耗时。性能报告包含完整结果、测试方法及其局限。

Mutative 与 Immer 对比

Mutative 通过了 Immer 的全部测试用例。

在相同配置下(同时开启或关闭冻结,同时开启或关闭补丁),Mutative 在 614 个测量案例中的 595 个更快,耗时比的几何平均值为 3.6 倍。各自默认配置下(Mutative 关闭自动冻结,Immer 开启),Mutative 在 157 个案例中的 154 个更快,几何平均值为 6.6 倍。其余 3 个场景返回的是 Immer 此前已冻结的状态:Immer 搜索返回状态中的草稿时会跳过已冻结的值,而默认不冻结的 Mutative 会继续搜索,除非用 rawReturn() 包裹返回值;此时耗时为 0.26 µs。

整体性能对比

97 项工作负载 · Immer / Mutative 耗时比的几何平均值

相同配置

自动冻结与补丁设置一致

3.6×几何平均加速比

595 / 614 个案例中 Mutative 更快

  • Mutative 更快: 595
  • 差距在 5% 以内: 13
  • Immer 更快: 6

各自默认配置

仅 Immer 自动冻结,均关闭补丁

6.6×几何平均加速比

154 / 157 个案例中 Mutative 更快

  • Mutative 更快: 154
  • 差距在 5% 以内: 0
  • Immer 更快: 3
两张卡片分别覆盖不同的测量案例。“更快”指耗时减少超过 5%;倍数为几何平均值,不保证每项工作负载都有同样的提升。

下表单位为每次更新的微秒数,取 Apple M1 Max、Node.js 24.16.0 环境下三次测试的中位数,各库在独立进程中运行;数值越小越好。Mutative、第一列 Immer 和手写 reducer 均关闭自动冻结,第二列 Immer 使用其默认配置。

工作负载行数MutativeImmerImmer,自动冻结手写实现
更新小对象的一个字段—0.510.690.950.02
按 ID 找到并更新数组项1001.121.8225.00.38
200 次 RTK Query 风格更新—1,0011,1334,795352
按索引读取每一行10,0004,7569,5849,68757.7
删除第一行10,0007.047,4088,1371.40
更新每一行10,0006,46212,57015,607211
更新 10,000 条记录中的一条10,0001,2002,0943,1312,088
向含 1,000 个属性的对象插入属性—50.3149229126
更新 Map 中的值10,000518526733519
向 Set 添加一个数字10,00029.590497561.2
更新类实例10,0002.923.446681.42
更新十层嵌套的值—3.504.417.840.76
对 10% 的行应用补丁10,0007341,1662,095—
shift() 后搜索 current()10,00025711,25511,84664.8
返回 draft.filter()10,0002,6584,2514,73677.7
返回新状态10,0002,6045,7180.790.02
通过 rawReturn() 返回10,0000.265,7030.790.02

每个库的每项工作负载均在独立进程中运行。把所有库放在同一进程时,所有库都会变慢,且幅度不同:Mutative 耗时的几何平均值为独立进程时的 1.1 倍,Immer 为 1.2 倍,手写 reducer 为 1.7 倍。搜索场景先用 shift() 删除第一行,再在数组快照中找到最后一行并更新。Immer 没有 rawReturn(),因此最后两行均返回相同的普通值;开启自动冻结后,Mutative 也像 Immer 一样跳过已冻结的返回状态,耗时为 0.53 µs。在 614 个相同配置的案例中,Immer 在 6 个案例中更快,全部开启了自动冻结:其中 4 个更新含 100 至 10,000 个字段的类实例,另 2 个更新 100 行数组,其多次运行的耗时区间与 Mutative 重叠。

大数组

当规模为 1,000 和 10,000 行时,差距进一步扩大。与未开启数组方法插件的 Immer 相比,Mutative 在这些规模的 232 个案例中的 222 个更快,几何平均值为 4.7 倍,且在所有移动元素的案例中都更快:

性能差距如何随数组规模变化

Immer 耗时 / Mutative 耗时 · 越高越有利于 Mutative

全部大数组场景对数刻度
大数组性能倍数:全部场景冻结关闭,补丁关闭:1,000 行时 8.7×,10,000 行时 10.8×。 冻结关闭,补丁开启:1,000 行时 4.1×,10,000 行时 4.5×。 冻结开启,补丁关闭:1,000 行时 3.9×,10,000 行时 4.2×。 冻结开启,补丁开启:1,000 行时 2.7×,10,000 行时 2.8×。1×2×5×10×20×1,000 行10,000 行冻结 关闭, · 补丁 关闭 · 1,000 行: 8.7×冻结 关闭, · 补丁 关闭 · 10,000 行: 10.8×冻结 关闭, · 补丁 开启 · 1,000 行: 4.1×冻结 关闭, · 补丁 开启 · 10,000 行: 4.5×冻结 开启, · 补丁 关闭 · 1,000 行: 3.9×冻结 开启, · 补丁 关闭 · 10,000 行: 4.2×冻结 开启, · 补丁 开启 · 1,000 行: 2.7×冻结 开启, · 补丁 开启 · 10,000 行: 2.8×
  • 冻结 关闭 · 补丁 关闭

    8.7× → 10.8× 规模从 1 千行 → 1 万行

  • 冻结 关闭 · 补丁 开启

    4.1× → 4.5× 规模从 1 千行 → 1 万行

  • 冻结 开启 · 补丁 关闭

    3.9× → 4.2× 规模从 1 千行 → 1 万行

  • 冻结 开启 · 补丁 开启

    2.7× → 2.8× 规模从 1 千行 → 1 万行

两库配置相同。纵轴为对数刻度,1× 表示耗时相同。元素移动包括 shift、unshift、splice 插入和 reverse。未开启 Immer 数组方法插件。
自动冻结补丁全部场景,1,000 行全部场景,10,000 行元素移动,1,000 行元素移动,10,000 行
关闭关闭8.7x10.8x209x430x
关闭开启4.1x4.5x7.1x7.3x
开启关闭3.9x4.2x32x39x
开启开启2.7x2.8x6.6x6.8x

每个值都是 Immer 耗时除以 Mutative 耗时的几何平均值;元素移动包括 shift、unshift、splice 插入和 reverse。Mutative 在副本上原生移动元素,Immer 则逐个通过草稿代理移动:删除 10,000 行中的第一行分别需要 7.04 µs 和 7,408 µs,相差 1,052 倍。开启补丁后,两者都为每个移动的索引生成补丁,因此优势缩小为 5–9 倍。性能报告按场景列出了详细结果。

开启补丁

开启补丁、关闭自动冻结时,Mutative 在 150 个案例中的 148 个快于 Immer,其余 2 个差距在 5% 以内,几何平均值为 3.3 倍;相比 Mutative 1.3.0,在 131 个案例中更快,其余差距在 5% 以内,几何平均值为 4.6 倍。在所有已测量的数组场景中,它都快于两者:几何平均值分别为 Immer 的 4.4 倍和 Mutative 1.3.0 的 13 倍。如果更新只涉及少量路径,补丁开销很小:在 10,000 行场景中追加一行并插入一个属性,开启补丁耗时 63.2 µs,关闭时为 63.8 µs。移动元素时,各库都会为每个移动索引生成补丁,因此删除 10,000 行中的第一行会生成 10,000 个正向补丁和 10,000 个逆向补丁。

以下耗时单位为每次更新的微秒数,开启补丁、关闭自动冻结:

生成补丁时的更新成本

每次更新耗时(微秒)· 越短越好

追加一行并插入一个属性

性能为 Immer 的 5.7×

Mutative63.2 µs
Immer360 µs

用 shift 删除第一行

性能为 Immer 的 8.41×

Mutative1,063 µs
Immer8,943 µs

更新每一行

性能为 Immer 的 2.54×

Mutative8,555 µs
Immer21,767 µs

更新 10,000 条记录中的一条

性能为 Immer 的 1.78×

Mutative1,184 µs
Immer2,104 µs
开启补丁,关闭自动冻结,10,000 行。每个场景独立使用线性刻度。数据为 Apple M1 Max、Node.js 24.16.0 上三次运行的中位数;Immer 11.1.18 未开启数组方法插件。
工作负载行数MutativeImmerMutative 1.3.0
追加一行并插入一个属性10,00063.2360210
按 ID 找到并更新数组项1001.503.223.35
200 次 RTK Query 风格更新—1,1401,4702,116
用 splice 删除第一行10011.583.62,982
在中间插入一行1008.8348.01,191
对行排序1001051292,466
用 shift 删除第一行10,0001,0638,943265,439
反转行顺序10,0001,0719,424280,542
更新每一行10,0008,55521,76722,549
更新 10,000 条记录中的一条10,0001,1842,1041,181

Immer 可选的 enableArrayMethods() 插件也在草稿副本上执行数组方法。开启补丁时,移动 1,000 或 10,000 行,Immer 的耗时为 Mutative 的 1.13–1.70 倍(即慢 13%–70%)。本对比未开启该插件,因为在 Immer 11.1.18 中它破坏了通常成立的保证:shift、pop 和 splice 返回原始基础对象,修改移出的对象会改变之前的状态;重排序也可能暴露原始对象,并在结果中留下已撤销的草稿;正向补丁还可能无法正确重放。审计中的 4,913 组三步操作序列,有 123 组在开启插件时出错,关闭时则全部通过。性能报告单独列出了开启该插件的 Immer 结果。

Mutative 与手写 reducer 对比

只复制变化部分的手写 reducer 比使用草稿机制的库更快,在套件的 150 个案例中有 129 个快于 Mutative。Mutative 的优势在于:复制宽对象(如含 1,000 个键的记录或含 100 个字段的类实例,快 1.5–2.8 倍)、在 10,000 行数组头部插入(16 µs 对 28 µs),以及向含 1,000 或 10,000 个数字的 Set 添加数字。Mutative 复制 Set 比 new Set() 更快,在 10,000 个元素时为 30 µs 对 61 µs。

如果代码反复复制同一结构,展开语法也会变慢。无论更新函数修改多少次,Mutative 每次更新最多复制每个对象一次。

如何避免不必要的草稿、搜索、复制、冻结和补丁,见性能建议。

运行基准测试

克隆仓库后运行:

pnpm install --frozen-lockfile
pnpm benchmark:immer:check
pnpm benchmark:immer

基准测试指南介绍了工作负载、选项、内存测量和 CI 回归预算。

在 GitHub 上编辑

最后更新于 作者:unadlib

本页内容