原子化状态:Jotai、Valtio 与 Recoil 横向对比
第14章:原子化状态:Jotai、Valtio 与 Recoil 横向对比
原子化状态管理的核心洞见是:性能问题不来自'状态太多',而来自'订阅粒度太粗'。
本章核心问题:原子化状态与 selector 模式有什么本质区别?如何根据场景选择? 读完本章你将理解:
- 原子化状态把订阅粒度从'整个 store'细化到'每个独立状态单元'
- Jotai 的派生 atom 是其最强大特性,Valtio 用 Proxy 实现属性级追踪
- Recoil 维护停滞,新项目应选 Jotai 或 Zustand
Level 1 · 你需要知道的(1-3年经验)
原子化状态的核心概念
原子化状态管理(Atomic State Management)的核心洞见是:大多数应用的性能问题不来自"状态太多",而来自"订阅粒度太粗"。当你有一个包含 100 个字段的全局 store,每个组件订阅整个 store,任何字段变化都导致所有组件重渲染——即使 selector 可以缓解这个问题,根本的数据模型仍然是"一个大对象"。
原子化状态把这个模型倒置:状态被拆分成最小的独立单元(atom),每个组件只订阅它实际依赖的 atoms。当 atom A 变化时,只有订阅了 A 的组件重渲染,订阅 B 和 C 的组件完全不受影响。这是真正的细粒度订阅(fine-grained reactivity),在概念上比 selector 更彻底。
三个库都实现了这个理念,但方式迥异:
- Jotai:最小化的 atoms,派生 atom,API 极简
- Valtio:Proxy 驱动,直接 mutate,无需任何 API
- Recoil:Facebook 出品,atom + selector 双层模型,实验性
Level 2 · 它是怎么运行的(3-5年经验)
Valtio:代理驱动的可变状态
Valtio 的设计哲学与 Jotai 和 Redux 都截然不同:既然 JavaScript 已经有了完美的数据结构(对象和数组),为什么不直接修改它们?
Valtio 使用 ES6 Proxy 包裹你的状态对象,追踪所有读取(订阅关系)和写入(触发更新)。你的代码看起来就像在操作普通 JS 对象,但底层 Proxy 在默默地维护响应式订阅图。
import { proxy, useSnapshot } from 'valtio';
// 创建响应式 store:就是一个对象
const state = proxy({
count: 0,
user: null as User | null,
cart: {
items: [] as CartItem[],
discount: 0,
},
});
// 在任何地方直接修改——Proxy 追踪变更
state.count += 1;
state.cart.items.push({ id: '1', name: 'Widget', price: 9.99, quantity: 1 });
state.cart.discount = 10;
// 在 React 组件中使用 useSnapshot 获取快照(不可变)
function CartView() {
// snap 是 state 的不可变快照
// 只有 snap 中被访问的属性变化才会触发重渲染
const snap = useSnapshot(state);
return (
<div>
<p>{snap.cart.items.length} items</p>
{/* 访问 snap.cart.items 时,Valtio 记录了这个订阅 */}
{snap.cart.items.map(item => (
<div key={item.id}>{item.name}</div>
))}
</div>
);
}
function AddToCart({ item }: { item: CartItem }) {
// 操作直接在 state 上,不是 snap 上
const handleAdd = () => {
state.cart.items.push(item); // 直接 mutate!
};
return <button onClick={handleAdd}>Add</button>;
}
Valtio 的精细订阅机制
Valtio 的 useSnapshot 使用 Proxy 追踪组件渲染过程中访问了哪些属性。如果 CartView 渲染时只访问了 snap.cart.items.length,那么只有 items.length 变化时(即添加或删除 item)它才会重渲染——即使 cart.discount 变化也不会触发。这是真正的属性级别细粒度订阅。
// 计算状态:使用 derive
import { derive } from 'valtio/utils';
const derived = derive({
// 自动追踪依赖:total 只在 items 或 discount 变化时重新计算
total: (get) => {
const cart = get(state).cart;
const subtotal = cart.items.reduce(
(sum, item) => sum + item.price * item.quantity, 0
);
return subtotal * (1 - cart.discount / 100);
},
});
function CartTotal() {
const snap = useSnapshot(derived);
return <span>Total: ${snap.total.toFixed(2)}</span>;
}
Valtio 的局限:与 React 并发模式的张力
Valtio 的直接 mutate 模型在 React Concurrent Mode 下有理论上的 tearing 风险——Proxy 状态在 React 树外部存在,React 暂停并恢复渲染时可能读取到中间状态。实践中 Valtio 通过 useSnapshot 生成不可变快照来缓解这个问题,但它并不使用 useSyncExternalStore,这意味着在某些极端的并发场景下行为可能不完全符合预期。对于需要严格并发安全的应用,这是一个值得权衡的点。
Recoil:Facebook 的实验
Recoil 由 Facebook 团队在 2020 年发布,是所有原子化状态库中概念最丰富的。它引入了正式的 atom/selector 二元模型,并有一套完整的异步处理框架。
import { atom, selector, useRecoilState, useRecoilValue, RecoilRoot } from 'recoil';
// atom 必须有唯一的 key(字符串)
const countAtom = atom({
key: 'count', // 全局唯一,用于持久化和 DevTools
default: 0,
});
const textAtom = atom({
key: 'text',
default: '',
});
// selector:纯函数计算,自动追踪依赖
const derivedState = selector({
key: 'derivedState',
get: ({ get }) => {
const count = get(countAtom);
const text = get(textAtom);
return `${text}: ${count}`;
},
});
// 使用方式与 Jotai 相似,但需要 RecoilRoot 包裹
function Counter() {
const [count, setCount] = useRecoilState(countAtom);
const derived = useRecoilValue(derivedState);
return (
<div>
<p>{derived}</p>
<button onClick={() => setCount(c => c + 1)}>+</button>
</div>
);
}
function App() {
return (
<RecoilRoot> {/* 必须包裹整个应用 */}
<Counter />
</RecoilRoot>
);
}
Recoil 的现状:2024 年的诚实评估
Recoil 的开发在 2023 年事实上停滞。最新稳定版仍标记为 0.7.x,核心团队成员已离开 Facebook,GitHub 上积压了大量未处理的 issues。React 19 的兼容性目前需要通过 override 标志强制安装,这不是一个健康的信号。
对于新项目,选择 Recoil 需要认真考虑维护风险。Jotai 在 API 设计上深受 Recoil 启发,同时避免了 atom key 管理的复杂性,并且活跃维护——是更安全的选择。
Level 3 · 规范怎么定义的(资深)
Jotai:最小化的原子哲学
Jotai 的核心设计哲学借鉴了 Recoil 的 atom 概念,但去掉了 Recoil 的所有复杂性:没有 RecoilRoot 的复杂配置,没有 atom key 字符串,没有 selector 与 atom 的概念分离。
import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai';
// atom() 创建一个原子状态单元
const countAtom = atom(0); // 原始值
const textAtom = atom('hello'); // 字符串
const userAtom = atom<User | null>(null); // 复杂类型
// 使用 atom:三种 Hook 的精确语义
function Counter() {
// useAtom = useState 等价物,返回 [value, setter]
const [count, setCount] = useAtom(countAtom);
// useAtomValue:只读,只在 atom 变化时重渲染
const text = useAtomValue(textAtom);
// useSetAtom:只写,永远不触发组件重渲染
const setUser = useSetAtom(userAtom);
return (
<div>
<span>{count}</span>
<button onClick={() => setCount(c => c + 1)}>+</button>
</div>
);
}
useSetAtom 是 Jotai 的性能利器:当你的组件只需要修改状态但不需要读取它(比如一个"清空购物车"按钮),使用 useSetAtom 确保该组件永远不会因状态变化而重渲染。
派生 Atom:Jotai 的精华
派生 atom(derived atom)是 Jotai 最强大的特性。它让你从其他 atoms 计算出新的状态,同时保持细粒度的订阅关系:
// 基础 atoms
const priceAtom = atom(100);
const quantityAtom = atom(1);
const discountAtom = atom(0);
// 派生 atom(只读):自动追踪依赖
const totalAtom = atom(get => {
const price = get(priceAtom);
const quantity = get(quantityAtom);
const discount = get(discountAtom);
return price * quantity * (1 - discount / 100);
});
// totalAtom 只在 priceAtom、quantityAtom 或 discountAtom 变化时重新计算
// 可写派生 atom:同时定义 get 和 set
const selectedCurrencyAtom = atom('USD');
const exchangeRateAtom = atom({ USD: 1, CNY: 7.2, EUR: 0.92 });
const priceInCurrencyAtom = atom(
// 读取:派生计算
(get) => {
const priceUSD = get(priceAtom);
const currency = get(selectedCurrencyAtom);
const rates = get(exchangeRateAtom);
return (priceUSD * rates[currency as keyof typeof rates]).toFixed(2);
},
// 写入:当这个派生 atom 被 set 时,转换并写回基础 atom
(get, set, newPriceInCurrency: number) => {
const currency = get(selectedCurrencyAtom);
const rates = get(exchangeRateAtom);
const priceUSD = newPriceInCurrency / rates[currency as keyof typeof rates];
set(priceAtom, priceUSD);
}
);
异步 Atom
Jotai 原生支持异步 atom,与 React Suspense 完美集成:
// 异步 atom:返回 Promise
const userDataAtom = atom(async (get) => {
const userId = get(currentUserIdAtom);
if (!userId) return null;
const response = await fetch(`/api/users/${userId}`);
return response.json() as Promise<User>;
});
// 组件使用(配合 Suspense)
function UserInfo() {
const userData = useAtomValue(userDataAtom); // 可能抛出 Promise(触发 Suspense)
return <div>{userData?.name}</div>;
}
function App() {
return (
<Suspense fallback={<Spinner />}>
<UserInfo />
</Suspense>
);
}
Jotai 与 atomWithStorage、atomWithReducer 扩展
import { atomWithStorage, atomWithReducer } from 'jotai/utils';
// 自动持久化到 localStorage
const themeAtom = atomWithStorage<'light' | 'dark'>('theme', 'light');
// atom + reducer 模式(类 Redux 语义)
const countReducer = (prev: number, action: 'inc' | 'dec' | 'reset') => {
if (action === 'inc') return prev + 1;
if (action === 'dec') return prev - 1;
return 0;
};
const countAtomWithReducer = atomWithReducer(0, countReducer);
function Counter() {
const [count, dispatch] = useAtom(countAtomWithReducer);
return (
<>
<span>{count}</span>
<button onClick={() => dispatch('inc')}>+</button>
<button onClick={() => dispatch('dec')}>-</button>
<button onClick={() => dispatch('reset')}>Reset</button>
</>
);
}
性能对比:哪个库更新最少的组件
以下测试场景:全局状态包含 { count: number, name: string, items: Item[] },50 个组件分别订阅不同的部分,然后只更新 count:
| 方案 | 更新 count 时重渲染数量 | 原因 |
|---|---|---|
| React Context | 50 个(全部) | Context 广播所有消费者 |
| Zustand(无 selector) | 50 个(全部) | 未使用 selector,订阅整个 store |
| Zustand(有 selector) | 只有订阅 count 的组件 | Object.is 比较 selector 输出 |
| Jotai | 只有订阅 countAtom 的组件 | atom 级别隔离 |
| Valtio | 只有访问 count 属性的组件 | Proxy 属性级别追踪 |
| Redux + RTK(无 selector) | 50 个(全部) | useSelector 每次运行 |
| Redux + RTK(有 selector) | 只有订阅 count 的组件 | reselect memoization |
结论:原子化库(Jotai/Valtio)在零配置下实现了细粒度订阅;selector-based 库(Zustand/Redux)需要正确使用 selector 才能达到同等效果。 如果团队纪律足够,两者在实践中的性能差异不大;如果团队容易忽略 selector,原子化库更安全。
Bundle Size 对比
Zustand: ~8KB (minified + gzipped)
Jotai: ~13KB (包含 utils 时 ~19KB)
Valtio: ~16KB
Recoil: ~76KB ← 显著更大
Redux Toolkit: ~47KB (+ react-redux ~18KB = ~65KB)
Recoil 的体积在轻量应用中是明显劣势。Zustand 是所有选项中最小的,是移动端优先的 Web 应用的首选。
React 19 兼容性
React 19 在 2024 年底正式发布,主要变化是 Compiler(自动 memoization)、use() Hook 和 Server Components 进一步成熟。
| 库 | React 19 状态 |
|---|---|
| Zustand | 完全兼容,useSyncExternalStore 是 React 官方 API |
| Jotai | 完全兼容,v2+ 使用 useSyncExternalStore |
| Valtio | 兼容,但并发模式 tearing 理论风险仍存在 |
| Recoil | 需要 --legacy-peer-deps 强制安装,不推荐 |
| Redux Toolkit | 完全兼容,长期维护,企业级支持 |
决策树:根据团队和应用选择库
选择 Zustand 如果:
- 团队规模 2-8 人
- 应用以 SPA 为主,状态逻辑中等复杂
- 需要最小的 bundle size
- 希望渐进式引入,不想全局重构
- 喜欢熟悉的 Hook 模式
选择 Jotai 如果:
- 需要真正的细粒度订阅,且不想手写 selector
- 应用有大量独立的小状态单元(表单字段、UI 开关)
- 团队接受 atom-first 的思维方式
- 需要 Suspense 的原生异步状态集成
选择 Valtio 如果:
- 有大量已有的 JavaScript 对象模型(从后端直接映射)
- 团队习惯直接 mutate 的风格(迁移自 MobX 或 Vue)
- 希望最小的 API 学习曲线
- 不需要时间旅行调试
选择 Redux Toolkit 如果:
- 团队 8 人以上
- 需要时间旅行调试
- 有复杂的跨域状态交互
- 需要 RTK Query 的完整数据层方案
- 项目生命周期 3 年以上,需要长期可维护性
不推荐 Recoil 用于新项目:维护停滞,React 19 兼容性问题,替代品(Jotai)更优。
原子化状态管理不是银弹。它在细粒度更新方面有真实优势,但也带来了"atom 爆炸"的潜在问题——当你有数百个 atoms 时,它们之间的依赖关系可能和 Redux 的 action 图一样难以追踪。正确的工具取决于你的团队习惯、应用复杂度和性能需求。理解每个库背后的权衡,才能做出真正适合你场景的选择。
Level 4 · 边界与陷阱(所有人)
生产环境常见问题
在实际项目中,本章涵盖的概念最常见的问题包括:
-
忽视边界条件:在正常路径下工作正常的代码,在异常路径(网络失败、用户快速操作、组件卸载)下可能产生 bug。始终考虑清理和取消逻辑。
-
过早优化:在没有测量的情况下添加优化代码,增加了复杂度但不一定提升性能。先用 Profiler 确认问题存在,再实施优化。
-
文档与实际行为的差异:React 的行为在不同版本间可能有微妙差异,尤其是并发模式相关的特性。始终以实际测试结果为准。