Redux、MobX、Recoil、Zustand 、Jotai 对比
这五个库里有一个已经归档,一个的官方文档写着「别再直接用这个包」,还有一个刚发的大版本把装饰器删干净了。先把版本对齐,再谈选型。
横向对比状态管理库的文章有个通病:拿五年前的 API 比五年后的库。这五个里,Recoil 已经被归档,Redux 官方明写着不要再直接用 redux 包,MobX 刚在 7.0 里把老装饰器删干净。所以先对齐版本,再谈别的。
| 库 | 最新版本 | 发布时间 | 现在的入口 |
|---|---|---|---|
| Redux | redux 5.0.1 |
2023-12-23 | @reduxjs/toolkit 2.12.0 |
| MobX | mobx 7.0.0 |
2026-07-30 | makeAutoObservable 或 Stage 3 装饰器 |
| Recoil | recoil 0.7.7 |
2023-03-01 | 已归档,无 |
| Zustand | zustand 5.0.15 |
2026-08-13 | create |
| Jotai | jotai 2.20.2 |
2026-07-14 | atom + createStore |
Redux 的官方建议是别再直接用 redux
Redux 的模型没变:状态存在一个 store 里,改状态只能派发 action,reducer 是纯函数,拿旧状态和 action 算出新状态。这套东西二十行就能写出来,而且这二十行是真的能跑:
function createStore(reducer) { let state = reducer(undefined, {}); const subscribers = [];
function dispatch(action) { state = reducer(state, action); subscribers.forEach((subscriber) => subscriber()); }
function subscribe(callback) { subscribers.push(callback); }
function getState() { return state; }
return { dispatch, subscribe, getState };}dispatch 里那两行就是 Redux 的全部:算出新状态,然后通知所有人。剩下的中间件、DevTools、时间旅行,都是围着这两行长出来的。
变的是你该怎么用它。createStore 从 redux 4.2.0(2022-04-18) 起带上了 @deprecated 标记,官方文档的说法很直白:
We specifically recommend that our users should use Redux Toolkit (the
@reduxjs/toolkitpackage), and should not use the legacyreduxcore package for any new Redux code today!
两个容易被夸大的地方要说清。第一,这个弃用只是一个 JSDoc 标记,效果是编辑器里给你划一道删除线,运行时不打印任何警告;嫌碍眼可以改成 import { legacy_createStore as createStore } from 'redux',那个函数体就是原样转调 createStore,行为完全一致。第二,官方明说了 createStore 不会被移除。所以「Redux 废弃了 createStore,老代码跑不了了」是误传,老代码照跑。
新代码该长这样:
import { createSlice, configureStore } from '@reduxjs/toolkit';
const counterSlice = createSlice({ name: 'counter', initialState: { value: 0 }, reducers: { // 这里可以直接改 state —— 底下是 Immer 在录制草稿 increment: (state) => { state.value += 1 }, addBy: (state, action) => { state.value += action.payload }, },});
export const { increment, addBy } = counterSlice.actions;
const store = configureStore({ reducer: { counter: counterSlice.reducer } });createSlice 一次性生成 reducer 和对应的 action creator,action type 由 name 加方法名拼出来(counter/increment),省掉了手写常量那一层。configureStore 则默认装好了 thunk 中间件和 DevTools,还在开发构建里加了两道检查:state 里混进不可序列化的东西会警告,reducer 里意外改了原对象也会警告。
代价是 Immer 进了依赖,而且「看起来在改 state」这件事对新人是个认知负担——那个 state 是草稿代理,不是真状态。
MobX 的装饰器故事分三段
MobX 走的是另一条路:不派发 action,直接读写对象属性,靠依赖追踪知道谁该更新。最小实现的关键是读的时候登记,写的时候通知:
let activeReaction = null;
function observable(obj) { const observers = new Map(); // key -> Set<reaction> return new Proxy(obj, { get(target, key) { if (activeReaction) { if (!observers.has(key)) observers.set(key, new Set()); observers.get(key).add(activeReaction); } return target[key]; }, set(target, key, value) { target[key] = value; observers.get(key)?.forEach((fn) => fn()); return true; }, });}
function autorun(fn) { const reaction = () => { activeReaction = reaction; try { fn(); } finally { activeReaction = null; } }; reaction();}贴进控制台跑一遍:const s = observable({ count: 0 }); autorun(() => console.log(s.count)); 先打印 0,然后 s.count = 1 会自动再打印 1。activeReaction 这个全局变量是整套机制的枢纽——只有在某个 reaction 正在跑的时候,get 才会登记依赖。少了它,observers 永远是空的,set 也就通知不到任何人。
真实的 MobX 在这个骨架上加了批处理、计算值缓存和环检测,但登记与通知这两步是一样的。
装饰器这条线分三段,很多文章卡在第一段:
- MobX 4 / 5:
@observable、@action这套传统装饰器是主力写法。 - MobX 6(2020-09-30):装饰器改成 opt-in。主推
makeObservable(this, { … })和makeAutoObservable(this),在构造函数里调一次,把字段标注出来。想继续用装饰器也行,但要在工具链里开传统装饰器,并且仍然要在构造函数里调makeObservable(this)去读装饰器留下的元数据。 - MobX 7(2026-07-30):传统装饰器不再支持,改用 TC39 Stage 3 的新装饰器;用新装饰器时不需要再调
makeObservable。同时 observable 一律走 Proxy,configure({ useProxies })没了。
所以现在写 MobX,不碰装饰器的话是这样:
import { makeAutoObservable } from 'mobx';
class Counter { constructor() { makeAutoObservable(this) } value = 0; increment() { this.value += 1 } // 自动被推断成 action get doubled() { return this.value * 2 } // 自动被推断成 computed}makeAutoObservable 会遍历实例上的字段自己推断:普通字段变 observable,方法变 action,getter 变 computed。省事,代价是推断不总是你想要的,要覆盖就得传第二个参数。
Recoil 停在 2023 年,2025 年元旦被归档
这一条影响选型,值得单独说。
facebookexperimental/Recoil 仓库在 2025-01-01 被归档,页面上挂着「This repository was archived by the owner on Jan 1, 2025. It is now read-only.」。npm 上最后一个版本是 0.7.7,2023-03-01 发布,main 分支最后一次真实提交是 2023-09-07。
有几件事要按事实说:Meta 没有发过任何正式的停止维护声明,仓库是静悄悄归档的,官网到现在也没挂弃用提示,npm 包上同样没有 deprecated 标记。能引的最接近的表态来自贡献者 SamChou19815 在 2024-02 的一条 issue 回复,大意是这个项目没人维护了、内外仓库的同步也停了。他不是原作者,也不是以 Meta 名义发言,所以只能当作旁证。至于「Meta 裁掉了 Recoil 团队」那个说法,我没有找到可靠出处,不写。
结论对选型足够清楚了:新项目不要从 Recoil 起步。 它的 atom / selector 模型很好,好在这个模型被 Jotai 继承下来了,而 Jotai 还在更新。
Zustand 的 v5 没动 create,动的是默认导出
Zustand 的 store 就是一个闭包,装着当前状态和一组监听器:
function createStore(createState) { const listeners = new Set(); let state; const setState = (partial) => { const next = typeof partial === 'function' ? partial(state) : partial; state = Object.assign({}, state, next); // 默认浅合并,不是整体替换 listeners.forEach((l) => l()); }; const getState = () => state; const subscribe = (l) => { listeners.add(l); return () => listeners.delete(l) };
state = createState(setState, getState); return { setState, getState, subscribe };}注意 state = createState(setState, getState) 这一句:你写的那个初始化函数拿到 set 和 get,返回的对象里既有数据也有方法,两者放在同一层。这是 Zustand 和其它几个的最大区别——它不区分「状态」和「操作状态的东西」。
setState 默认浅合并而不是替换,想整体替换要传第二个参数 true。真实的 React 绑定在这个 vanilla store 外面包一层,用 useSyncExternalStore 订阅,selector 返回值变了才重渲染。
关于 v4 到 v5,有一个说法要纠正:create 的签名没变。 那个「柯里化」写法 create<T>()(fn) 从 4.0.0 就在了,v4 和 v5 的类型声明逐字相同;它存在的理由是 TypeScript 的一个泛型推断限制,运行时什么都不做。真正在 v5 里被拿掉的是这些:
- 默认导出没了。
import create from 'zustand'要改成import { create } from 'zustand'。具名导出 4.3.0 就加了,默认导出同期标记弃用,4.3.3 起会在运行时警告,5.0.0 删除。 - 最低 React 18,
use-sync-external-store从依赖降级成可选的 peer(只有zustand/traditional还需要它)。 - hook 上那个第三参数
equalityFn没了,要浅比较改用useShallow。 zustand/context整个入口、StoreApi.destroy()、以及 persist 的getStorage/serialize/deserialize都删了。
import { createStore } from 'zustand' 仍然可用——被删的是 zustand/vanilla 子模块的默认导出,不是 createStore 本身。
Recoil 和 Jotai 的最小实现几乎是同一段代码
两个库都基于原子化:状态拆成独立的最小单元,派生值由函数算出来。最小实现也就长一个样:
function atom(initialValue) { let value = initialValue; const subscribers = new Set();
return { get: () => value, set(newValue) { value = newValue; subscribers.forEach((s) => s()); }, subscribe(callback) { subscribers.add(callback); return () => subscribers.delete(callback); }, };}差别只在命名:Recoil 叫 useRecoilState,Jotai 叫 useAtom;Recoil 的派生单元叫 selector,Jotai 直接用 atom(get => …) 表示。
但这段示意有一处和真实的 Jotai 相反,值得点出来:真实的 atom 里不存值,也不存订阅者。 atom() 返回的只是一份 { read, write } 配置,值存在 store 的一个 WeakMap 里,订阅也登记在 store 上。这个区别不是细节——正因为值不在 atom 上,同一个 atom 才能在两个 store 里有两份互不相干的值,测试之间的隔离也才做得到。展开讲在 Jotai v2:React 状态管理的新篇章。
Recoil 的 atom 则必须带一个全局唯一的 key 字符串,重复了会在运行时报错——这也是它和 Jotai 在使用手感上最直接的差异。
五个库在四个维度上的差别
| 状态存在哪 | 订阅粒度 | 要不要 Provider | 异步写在哪 | |
|---|---|---|---|---|
| Redux | 一棵集中的 state 树 | selector 的返回值 | 要 <Provider> |
中间件(thunk / RTK Query) |
| MobX | 可观察对象自身 | 实际被读到的属性 | 不要 | 随便,action 里直接 await |
| Recoil | RecoilRoot 持有的 store |
单个 atom | 要 <RecoilRoot> |
selector 支持 async |
| Zustand | 模块作用域里的闭包 | selector 的返回值 | 不要 | 随便,写在 store 的方法里 |
| Jotai | store 里的 WeakMap | 单个 atom | 不要(有默认 store) | read 可以是 async,配 Suspense |
「不要 Provider」这一列有代价:状态挂在模块作用域上,服务端渲染时多个请求会共用同一份。Zustand 和 Jotai 都得靠额外手段隔离——Jotai 是每个请求一个 store,Zustand 是把 store 建在请求作用域里再用 context 传下去。
怎么选
按项目形态选,不按「大型 / 中型 / 小型」这种没有操作性的标签选。
已经在跑 Redux 的项目:迁到 Redux Toolkit,不要重写。createSlice 可以和老 reducer 共存,一个模块一个模块换。
新项目,团队里有人熟悉 Redux 生态:直接上 RTK。它值钱的地方不是少写样板,是那条完整的 action 日志——出了问题能回放,这一点其它四个都给不了。
状态改动频繁、读的地方分散:MobX 的心智负担最低,写起来像在改普通对象。代价是变更点不显式,谁改了这个字段只能靠 devtools 追;而且现在要留意 6 和 7 的装饰器差异,团队里版本不统一会很难受。
想要最少的概念:Zustand。一个 create,一个 hook,没有 Provider 也没有原子图。它的边界在于全局单例——SSR 和测试隔离要额外处理。
重渲染是主要矛盾:Jotai。订阅粒度压到单个 atom,绕开 Context 那种整棵子树一起重渲染的问题。代价是派生关系散在各个 atom 的 read 里,值算错了要顺着依赖一层层往回找。
Recoil:不选。模型很好,但仓库已经归档,Jotai 是它的现实替代。
最后一句还是原来那句,它没过时:一个项目里混用两套状态管理是允许的。表单和弹窗这类局部状态用 useState 就够,跨页面共享的那部分才需要库。像 dva 那样把 Redux 按领域收进 model 的框架(见 Dva 框架源码解析),本质上也是在同一套 Redux 上换了个组织方式。边界按模块划,不按库划。
这篇归在 React 与生态下。同一主题里最近的另外两篇是 Jotai v2:React 状态管理的新篇章和 React 18 完结:最佳实践与注意事项。