在复杂 React 项目里,状态管理工具的选择不能只看 API 是否顺手。真正需要关注的是:当状态量越来越大、页面越来越复杂、多个模块同时读写状态时,这套工具还能不能稳定地控制更新范围。
MobX 曾经是一个很有吸引力的选择。它的心智模型接近“直接修改数据,界面自动更新”,对业务开发非常友好。问题在于,当一个项目里的状态对象持续膨胀,页面又存在大量派生数据和观察者时,MobX 的自动响应式机制会变得难以预测,性能问题也会被放大。
我们遇到的核心问题并不是“MobX 不好用”,而是状态量很大以后,页面会出现明显卡顿。一次看似普通的状态变更,可能触发大量 observable 依赖收集、computed 重算和组件更新。排查时也很难快速回答:到底是谁订阅了这块状态,为什么这次更新影响了这么多地方。
这也是为什么后来更倾向于使用 Zustand。Zustand 并不是功能更多,而是模型更克制:store 是普通对象,组件通过 selector 显式订阅自己需要的片段。它把很多“自动发生”的事情重新变成“开发者明确声明”的事情,这在大型项目里反而更可控。
MobX 的优势在于表达自然
MobX 最大的优势是响应式模型自然。业务代码可以围绕对象本身组织,修改状态的方式非常接近日常 JavaScript 写法。
例如一个项目状态可以这样组织:
class ProjectStore { projects = []; currentProjectId = null; constructor() { makeAutoObservable(this); } get currentProject() { return this.projects.find(item => item.id === this.currentProjectId); } selectProject(projectId) { this.currentProjectId = projectId; }}
这种写法有几个明显好处:
- 状态和行为可以封装在同一个类里。
- 派生状态可以通过
computed或 getter 表达。 - 修改 observable 后,依赖它的视图会自动更新。
- 对偏 OOP 的业务建模比较友好。
在中小型业务里,这种体验很好。很多页面不需要写太多模板化代码,也不需要手动考虑每个组件订阅什么状态。开发者只要把状态声明成 observable,剩下的更新链路由 MobX 接管。
MobX 的问题也来自自动响应式
MobX 的问题不是语法,而是自动响应式在大型项目里容易失控。
当状态对象比较小、依赖关系比较清晰时,自动追踪能减少心智负担。但状态量变大后,依赖图会变得越来越复杂。一个 store 里可能同时包含列表数据、详情数据、筛选条件、权限、弹窗状态、编辑草稿和各种中间态。组件只要在渲染过程中读取了某个 observable,就可能被纳入响应式更新链路。
这会带来几个问题。
第一,订阅关系不够显式。代码里很难一眼看出某个组件到底依赖了哪些状态。一次状态变更发生后,也不容易判断哪些组件会重新渲染。
第二,深层对象和大数组的观察成本较高。如果把大量接口数据直接放进 observable,MobX 需要维护对象属性级别的响应式能力。数据越大,初始化、追踪和变更传播的成本越明显。
第三,派生状态可能层层叠加。computed 本身是很好的能力,但当 computed 之间相互依赖,且底层 observable 变化频繁时,重算链路会变长。开发者看到的只是“改了一个字段”,实际可能带动多个派生计算。
第四,组件更新边界容易变宽。observer 组件在渲染时读取了哪些 observable,就会对这些 observable 建立依赖。如果组件过大,或者渲染时读取了一个复杂对象,后续更新就可能牵动较大的 UI 区域。
这就是大状态量下卡顿的常见来源:
状态对象很大 -> observable 初始化和追踪成本上升 -> 组件和 computed 建立大量隐式依赖 -> 一次状态变更触发多条响应式链路 -> 页面出现批量重算和批量渲染 -> 用户体感变成输入卡、切换慢、列表抖动
MobX 可以通过拆 store、减少深层 observable、控制 observer 范围、使用 action 批处理等方式优化。但这些优化本质上是在约束 MobX 的自动性。项目越大,越需要团队成员都理解响应式边界,否则很容易因为一次普通需求又把状态范围扩大。
Zustand 的优势在于更新边界清晰
Zustand 的模型更简单。它没有复杂的自动依赖追踪,核心就是一个 store 和一组更新函数。组件通过 hook 读取状态,并且可以用 selector 只订阅自己关心的字段。
import { create } from "zustand";export const useProjectStore = create(set => ({ currentProjectId: null, sidebarCollapsed: false, selectProject: projectId => set({ currentProjectId: projectId }), toggleSidebar: () => set(state => ({ sidebarCollapsed: !state.sidebarCollapsed }))}));
组件使用时,订阅关系是明确的:
function SidebarToggle() { const collapsed = useProjectStore(state => state.sidebarCollapsed); const toggle = useProjectStore(state => state.toggleSidebar); return ( <button onClick={toggle}> {collapsed ? "展开导航" : "收起导航"} </button> );}
这个组件只关心 sidebarCollapsed 和 toggleSidebar。项目 ID、筛选条件或其他页面状态变化时,它不应该跟着更新。相比 MobX 的“渲染时读到了什么就追踪什么”,Zustand 的订阅边界更直观,也更容易在 code review 中发现问题。
Zustand 还有一个很重要的优点:它更鼓励小 store 和扁平状态。因为没有类、装饰器和复杂响应式封装,开发者通常会把状态拆得更轻:
工作空间选择页面视图偏好弹窗开关跨页面临时选择局部编辑上下文
这些状态如果都放在一个巨大的 MobX store 中,很容易变成互相牵连的对象图;放在 Zustand 中,则更容易按业务边界拆成几个小的 store。
Zustand 也不是没有代价
Zustand 的克制也意味着它不会替开发者做太多事情。
它没有 MobX 那种天然的 computed 体系。派生状态需要自己写 selector,或者在组件层、hooks 层组织。如果派生逻辑很复杂,需要团队明确约定它应该放在哪里。
它也不会自动帮你管理服务端状态。如果把接口返回的数据大量塞进 Zustand,同样会遇到缓存、刷新、一致性和过期策略的问题。复杂项目里更合理的分工通常是:
服务端数据:React Query 负责获取、缓存、刷新客户端全局状态:Zustand 负责选择、偏好、跨页面交互局部交互状态:留在组件或局部 context 中
也就是说,Zustand 更适合做客户端状态,而不是替代所有数据管理能力。
为什么 Zustand 在这个场景下更优秀
如果只看单个小页面,MobX 和 Zustand 都能完成工作,甚至 MobX 写起来会更舒服。但在大状态量和复杂协作场景下,Zustand 的优势会更明显。
第一,Zustand 的订阅是显式的。组件通过 selector 选择字段,状态变更影响范围更容易判断。性能问题出现时,可以直接检查哪个 selector 订阅过宽,而不是在隐式响应式依赖图里寻找原因。
第二,Zustand 对大对象没有深层响应式成本。它不需要把接口返回的复杂对象全部变成 observable,也不需要维护深层属性的依赖追踪。状态更新更多依赖引用变化和 selector 比较,模型更接近 React 本身。
第三,Zustand 更容易控制 store 规模。它没有强烈的类模型约束,也没有鼓励把大量行为和状态放进同一个对象。项目可以按业务模块拆 store,避免一个全局 store 承载过多职责。
第四,Zustand 更适合和 React Query 组合。服务端状态交给 React Query,客户端交互状态交给 Zustand,这样数据来源更清楚。MobX 项目里常见的问题是把接口数据、派生数据和交互状态都放进 observable store,最后变成一套很难拆分的全局状态系统。
第五,Zustand 的调试路径更直接。状态从哪里 set、组件订阅了什么、为什么更新,通常能在代码里直接追到。MobX 的自动依赖追踪虽然强大,但在复杂页面里经常需要理解运行时依赖关系。
可以把两者差异概括成这样:
| 维度 | MobX | Zustand |
|---|---|---|
| 状态模型 | 响应式对象图 | 普通 store |
| 订阅关系 | 渲染时自动追踪 | selector 显式订阅 |
| 大状态量成本 | 深层 observable 和依赖追踪成本更明显 | 更依赖引用变化和订阅粒度 |
| 派生状态 | computed 表达力强 | 需要手动组织 selector 或 hooks |
| 性能排查 | 需要理解隐式依赖图 | 更容易从 selector 和 set 入口排查 |
| 适用场景 | 对象模型清晰、中小规模响应式业务 | 大型 React 项目、状态边界需要强控制 |
真正要迁移的是状态治理方式
从 MobX 切到 Zustand,不应该被理解成简单的库替换。真正要迁移的是状态治理方式。
过去可以依赖 MobX 的自动追踪快速完成业务,但当项目进入复杂阶段,自动性会变成维护成本。一个状态变更为什么让页面卡住,为什么触发这么多组件更新,为什么某个 computed 被频繁重算,这些问题如果不能快速回答,状态系统就已经在拖慢工程效率。
Zustand 更优秀的地方,不在于它拥有更多能力,而在于它把更新边界暴露出来。哪些状态应该全局共享,哪些状态只属于当前页面,哪些数据应该交给 React Query,哪些组件应该订阅哪些字段,这些问题都需要被明确设计。
对大型前端项目来说,可控往往比自动更重要。MobX 适合快速表达复杂对象关系;Zustand 更适合在状态量变大以后,保持更新链路清晰、性能边界稳定、问题排查直接。这才是迁移的真正原因。