衡量 React 状态管理器:渲染次数与打包成本对比
一个使用八种React状态模式构建的购物车应用,对其不必要的渲染次数及压缩后的包大小进行了测试,同时分析了这些数据对Zustand、Valtio和Context的启示。
关于该选择哪种 React 状态管理器的疑问不断出现,而相关的讨论往往基于个人观点而非实际数据。一种更有效的方法是使用每种常见模式构建相同的小功能,通过统一的测试套件确保行为一致,然后统计实际存在的差异:组件重新渲染的频率以及每种方案会增加多少千字节的数据量。本文将通过八种模式展示这样的实验,解释数据为何会呈现如此结果,并提供一套可重复的方案,让你能够对自己的状态管理方式开展同样的对比测试。
实验的设置方式
这个测试应用被刻意设计得非常简单:一个购物车,上面有苹果按钮、香蕉按钮、实时总计以及一个值永远不变的主题徽章。所有的实现都会通过相同的行为测试来验证,即点击三次苹果按钮、两次香蕉按钮,并确保得到完全一致的结果。在测试运行期间,计数器会记录每个组件的每次渲染情况。此外,esbuild还会测量每种方案在生成压缩后的最小化包时所占的体积,不过React不在测试范围内,因为所有选项带来的额外开销都是相同的。
参与竞争的八种方案分别是:
- 通过属性传递的 lifted
useState - 结合
useReducer的 Context - Redux Toolkit
- Zustand
- Jotai
- Valtio
- MobX
- 基于
useSyncExternalStore编写且不依赖任何外部库的手写状态管理方案
这八个实现都通过了共同约定的测试标准,因此后续的差异仅体现在成本上,而非正确性方面:
✓ lifted useState › passes the shared cart contract
✓ Context + useReducer › passes the shared cart contract
✓ Redux Toolkit › passes the shared cart contract
✓ Zustand › passes the shared cart contract
✓ Jotai › passes the shared cart contract
✓ Valtio › passes the shared cart contract
✓ MobX › passes the shared cart contract
✓ useSyncExternalStore › passes the shared cart contract
Tests 8 passed (8)
此处列出了用于测试的库版本及运行时环境,以及存放这八个实现、共同测试用例和两个测试脚本的代码仓库地址:
Tested with react@19.2.8, zustand@5.0.15, @reduxjs/toolkit@2.12.0, react-redux@9.3.0,
jotai@2.20.2, valtio@2.3.2, mobx@7.0.3, mobx-react-lite@5.0.3, node 22.
Repo: github.com/noorjsdivs/state-patterns — 8 implementations, 1 shared test, 2 measurements.
确保比较公平的两种选择
这些做法均源于实际应用中出现的缺陷,而非基准测试的规范要求。
首先,每个实现都在组件内部创建自己的状态存储,而非在模块级别。这样一来,每个启动的应用都会真正处于初始状态,测试运行之间也不会出现状态泄漏。
其次,主题徽章仅作为无关的元素存在。它订阅的是一个永远不会被点击操作修改的值,因此在测试过程中对其进行渲染纯属浪费资源,这只能归咎于状态管理模式的问题。
刻意排除在范围之外的内容
本次竞赛仅涉及共享的客户端状态。TanStack Query未被纳入,因为它负责管理服务器数据缓存,这属于另一个有不同规则的问题。URL状态也未被考虑,因为地址栏由路由器负责管理。您的应用可能同时需要这两者,但在这次竞赛中它们并不相互竞争。
意外发现:代码大小几乎相同
在那些有趣的数字之前,有一个值得关注的枯燥数据。这八种实现方式的代码行数介于39行到58行之间。其中代码行数最多的Redux Toolkit为58行,仅比最简单的实现方式——仅有39行的lifted state多出19行。在这种规模下,那些在众多状态管理讨论中占据主导地位的“样板代码”问题其实仅相当于19行代码而已。真正的差异在于其他方面,而且只有通过性能检测工具才能发现这些差异。
渲染次数统计
以下是包括初始加载在内的五次点击中每个组件的渲染次数:
5 clicks (3 apple, 2 banana) Apple Banana Total Theme(idle)
lifted useState 6 6 6 6
Context + useReducer 6 6 6 6
Redux Toolkit 4 3 6 1
Zustand 4 3 6 1
MobX 4 3 6 1
useSyncExternalStore 4 3 6 1
Jotai 5 4 7 2
Valtio 2 2 2 1
从这些数据中可以得出三个不同的结论。
Lifted state和Context会重新渲染所有内容
由于使用了提升的 useState 机制以及单一的 Context,每次点击时所有组件都会重新渲染。尽管主题徽章的值从未改变,却仍被渲染了六次;而每次点击苹果图标时香蕉按钮也会随之渲染。这正是“Context 不是状态管理器”这一常见警告背后的具体机制。useContext 会让组件订阅整个 Context 的值,因此一旦该值发生改变,所有使用它的组件都会重新渲染。将所有状态放在一个 Context 中,实际上相当于在提升状态的基础上又增加了一层结构。
通常的解决办法是将状态分散到多个 Context 中,或者对使用这些状态的组件进行记忆化处理,但这里并未对此进行测试。该示例展示的是最常见的 Context 使用方式:一个提供者持有单一值。
四种 API,相同的结果
Redux Toolkit、Zustand、MobX以及手动实现的存储机制都会产生完全相同的渲染效果:每个组件在初始化时渲染一次,只有当其读取的数据真正发生变化时才会再次渲染。尽管它们的API外观截然不同,但运行时的行为却一致,因为这四种方案都基于相同的底层原理。组件会监听特定的状态数据而非整个存储,只有当该数据发生变化时才会收到通知。若想深入了解其中一个库中如何进行状态选择与相等性检测,可参阅React Redux如何决定何时重新渲染。
Valtio异常低的数值
起初看来,Valtio的统计结果就像损坏的计数器:点击三次按钮却只有两次渲染。但实际上这些数值是正确的。Valtio会在微任务队列中对变更通知进行批量处理,因此多个快速、同步的更新会被合并为每个组件的一次重新渲染,最终数值依然准确。
不过有一个重要的注意事项。以正常速度点击时,每次点击都会产生一次渲染,因为前一次点击在开始下一次之前就已经完成。只有当更新以连续的方式到来时,这种优势才会显现,比如WebSocket消息、流式数据或拖动事件。如果你的应用属于这种情况,就应当密切关注这一统计指标。
Jotai始终存在的额外渲染
Jotai 对每个组件的渲染次数比基于选择器的组合多一次,其中空闲主题徽章更是被渲染了两次。其粒度是正确的,因为组件仍然只会对它们所使用的元素作出反应,且这种额外的渲染在所有列中都是均匀的。这一现象表明问题出在初始挂载时与 store Provider 相关,而非订阅泄漏,但具体原因尚未确定。应将其视为一个未解之谜,而非对 Jotai 的定论。
包大小分析
以下是每种方案在压缩后的包中增加的大小,既包括该方案自身的代码也包括其依赖的库,其中 React 被视为外部依赖:
lifted useState 0.4 KB
useSyncExternalStore 0.5 KB (zero dependencies)
Context + useReducer 0.5 KB
Zustand 0.7 KB
Valtio 2.7 KB
Jotai 4.4 KB
Redux Toolkit 10.7 KB
MobX 13.2 KB
最重的方案比最轻的方案大 33 倍。换言之,Redux Toolkit 和 MobX 加起来为 23.9 KB,而其余六种方案加起来的总大小仅为 9.2 KB。
有趣的是,这份列表与渲染性能排行榜之间的关联。仅有两种方案能在保持完美渲染性能的同时将内存占用控制在1千字节以内:Zustand和手写的状态管理方案。Redux Toolkit的渲染性能同样出色,但内存占用为10.7 KB,而MobX则为13.2 KB。这并不一定意味着它们就逊色于前者,这只是相应的成本而已——只有明白这些成本能换来什么,才有意义。
三种实用方案及其成本
对于典型应用中的共享客户端状态管理,测试结果显示有三种工具值得考虑。
Zustand作为默认选择
Zustad仅用54行代码就能实现0.7 KB的内存占用,并拥有极高的渲染性能;其API对于新成员来说几乎无需解释——它只是一个接收选择器的钩子函数。在本次测试中,它的运行性能与Redux相当,但代码体积仅为后者的约十五分之一。
该结果取决于一个原则:始终选择特定的数据片段。像 useCart(s => s) 这样的调用会让组件绑定到整个存储,从而重现上下文问题。高效的解决方案在于编写精确的选择器,而非依赖库的名称。如果每次调用时选择器都会返回新的对象或数组,那么就需要一个浅层相等性检查工具,否则每次更新都会被视为一次变化。
手写的 useSyncExternalStore 存储
这一选项是实验中最具吸引力的方案。包括存储功能在内的完整实现仅占58行代码,大小为0.5 KB,与Redux的渲染机制完全兼容,且无需审核或升级任何依赖项。useSyncExternalStore是React本身提供的用于安全地订阅外部存储的原始函数,即使在并发渲染场景下也能正常工作。对于库的开发者或那些希望减少依赖项的团队而言,这已经足够了。其代价在于代码由你自行掌控:如果需要,你必须自己实现调试工具、中间件以及持久化功能。
用于突发性更新的Valtio
Valtio的微任务批量处理效果可衡量,且在同类方案中独树一帜,2.7 KB的体积也属于合理范围。直接使用如state.apples++这样的写法就能让对应组件立即更新,这种便利性几乎令人难以置信,但渲染次数统计证实了这一行为的真实性。
为何其他方案虽不错却输掉竞争
测试的设计方式起着关键作用,它更偏向于小型、扁平化的状态结构。其他方案拥有这款框架所不具备的优势:
- Redux Toolkit的体积为10.7 KB,但其强大的开发者工具、时间回溯调试功能以及适用于大型组织的规范体系值得这一重量。在同一个代码库中有五十名开发人员时,这些规范才是真正的价值所在。
- MobX的观察模型在包含大量类的领域代码中表现最佳,而这款应用并不属于此类场景。
并非主观喜好的设计规则
这里的每个实现都在组件内部创建了自己的存储。在模块级别定义的存储不会因组件卸载而消失,这就是为什么购物车能在用户登出后依然存在,并在同一设备上有新用户登录时继续工作。同时,这也会导致状态在测试之间以及服务器渲染时的请求之间相互干扰。如果要从这次对比中提炼出一条代码审查规则,那就是:除非有意让存储具有全局生命周期,否则应将其作用范围限制在组件树内,通常是通过在提供者中创建存储来实现。
在自己的应用中运行相同的测试
你只需一个下午就能为自己的状态重现这一测试结果:
- 选出应用中争议最多的共享状态,然后围绕它构建一个由四个组件组成的测试框架:两个用于写入数据的组件、一个用于读取派生值的组件,以及一个闲置的旁观者组件。
track()函数,大约需要十行代码。旁观者计数器实际上就是你对Context所付出的成本,是通过实际测量而非猜测得出的。esbuild --bundle --minify来测试每个候选方案,同时将React标记为外部依赖。这个过程只需几秒钟,还会在结果中增加一个表示文件大小的列。未解决的问题
还有两个线程未处理。较小的那个是导致 Jotai 额外渲染的原因,较大的则是 React Compiler。lifted-state 行显示为 6/6/6/6,正是因为其中没有任何内容被缓存,而缓存正是编译器自动处理的功能。接下来的明显实验就是验证这种行在真实生产代码中的表现是否与基于选择器的状态管理方式一致,而不仅仅是在演示环境中。
关键要点
- 在小规模应用中,不同状态管理库之间的样板代码差异可以忽略不计;真正存在差异的是渲染行为和打包大小。
- 一个包含变化状态的 Context 会重新渲染所有使用它的组件,包括那些从未读取过该变化值的组件。
- 无论是 Redux Toolkit、Zustand、MobX,还是自写的
useSyncExternalStore状态管理方式,最终都会采用相同的高效渲染模式。