Zustand与Redux Toolkit与Context:基于Renders的React状态管理器及包体积对比
一个使用八种React状态模式构建的购物车应用,经过渲染次数与压缩后包大小的测试:这些数据揭示了Zustand与Redux Toolkit的优劣。
来源: I Built the Same React App With All 8 State Management Patterns. The Numbers Picked Three Winners.
你应该选择哪种 React 状态管理器?这个问题每隔几个月就会被提起,而答案往往取决于个人偏好。要想得到更准确的答案,可以用每种常见的模式构建同一个小型功能,通过一套统一的测试用例来验证行为表现,然后统计真正存在差异的两点:组件重新渲染的频率以及每种方案会增加多少千字节的数据量。下文介绍了使用从 Zustand、Redux Toolkit 到普通 Context 共八种模式的实验结果,说明了数值出现这些差异的原因,同时还提供了一套你可以用于自己项目状态管理的测试方法。
实验的设置方式
这个测试应用被刻意设计得非常简单:一个购物车,上面有苹果按钮、香蕉按钮、实时总计以及一个值永远不变的主题徽章。所有的实现都会通过相同的行为测试来验证,即点击三次苹果按钮、两次香蕉按钮,然后确认结果一致。在测试运行期间,计数器会记录每个组件的每次渲染情况。此外,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
从这些代码片段中可以看出三个不同的现象。
提升的状态与上下文会重新渲染所有内容
由于使用了提升的 useState 以及单一的上下文,每个组件在每次点击时都会重新渲染。尽管主题徽章的值从未改变,但它仍被渲染了六次;而香蕉按钮则在每次点击苹果按钮时都会出现。这正是“上下文并非状态管理器”这一常见警告背后的具体机制。useContext 会让组件订阅整个上下文值,因此一旦该值发生改变,所有使用该值的组件都会重新渲染。将所有状态放在一个上下文中,实际上就相当于在提升状态的基础上又增加了一层结构。
通常的解决办法是将状态分散到多个上下文中,或者对使用这些状态的组件进行缓存处理,但这里并未进行相关测试。该示例展示了最常见的上下文实现方式:一个提供者持有一个值。
四种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 KB:Zustand和手动编写的存储方案。Redux Toolkit的渲染性能同样优秀,但体积为10.7 KB,MobX则为13.2 KB。这并不一定意味着它们就较差,这只是相应的成本,只有了解这些成本能带来什么价值后才有意义。
三种实用选择及各自的成本
对于典型应用中的共享客户端状态,各项测试结果表明有三种工具值得考虑。
默认选择:Zustand
Zustand能够在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,但其强大的开发者工具、时间回溯调试功能、中间件以及适用于大型组织的规范,都为其价值加分。在拥有50名开发人员的代码库中,这些规范才是真正的核心价值。
- MobX的观察模型在包含大量类的领域代码中表现最佳,而这款应用并不属于此类场景。
并非主观喜好的设计规则
这里的每个实现都在组件内部创建了状态存储。在模块级别定义的状态存储不会因组件卸载而消失,这就是为什么购物车能在用户登出后依然存在,并在同一设备上有新用户登录时继续工作。同时,这种方式也会导致测试之间以及服务器渲染过程中请求之间状态相互干扰。如果要从这次对比中提炼出一条代码审查规则,那就是:除非有意让状态具有全局生命周期,否则应将状态存储限制在组件树范围内,通常是通过在提供者中创建它们来实现。
在自己的应用中运行相同的测试
你只需一个下午就能为自己的状态实现同样的测试:
- 选出应用中争议最多的共享状态,然后围绕它构建一个由四个组件组成的测试框架:两个用于写入数据的组件,一个用于读取派生值的组件,以及一个闲置的旁观组件。
- 为每个组件添加一个渲染计数器。一个模块级对象加上每个组件主体中的一次
track()调用,大约需要十行代码。旁观者计数器即为你的Context开销,是通过实际测量而非猜测得出的。 - 使用
esbuild --bundle --minify对每个候选方案进行测试,同时将React标记为外部依赖。这个过程只需几秒钟,还会在结果中增加一个表示文件大小的列。 - 如果你正在使用Context且旁观者组件会重新渲染,要么拆分Context,要么改用基于选择器的存储方案,之后重新运行计数器,并在拉取请求中列出处理前后的数值。
- 一旦React Compiler成为构建流程的一部分,就需要重新执行整个测试,因为自动记忆化功能可能会大幅改变状态传递的相关数据。
未解决的问题
还有两个线程未被处理。较小的那个是导致 Jotai 额外渲染的原因,较大的那个则是 React Compiler。lifted-state 行显示为 6/6/6/6,正是因为该行中的内容都没有被缓存,而缓存正是编译器会自动处理的功能。接下来的明显实验就是看看在真正的生产代码中,这种处理方式能否让该行与基于选择器的状态存储保持一致,而不仅仅是在演示环境中。
渲染次数与代码包大小的综合影响
- 在较小规模下,状态管理库之间的样板代码差异可以忽略不计;真正的差异体现在渲染行为和代码包大小上。
- 一个包含变化状态的 Context 会重新渲染所有使用它的组件,包括那些从未读取过该变化值的组件。
- 无论是 Redux Toolkit、Zustand、MobX,还是自行实现的
useSyncExternalStore存储,基于选择器的存储方案最终都会采用相同的高效渲染模式。 - Zustand以及手动编写的存储方案在不到千字节的体积下就能实现这种性能;而那些体积较大的库,则是其工具和规范带来的优势,而非渲染效率。
- Valtio 的批量处理功能更适用于突发性的更新流,而非普通的点击操作。
- 应在组件树内部创建存储,这样状态就不会超出生成它的会话的生命周期而存在。