第 14 章

原子化状态:Jotai、Valtio 与 Recoil 横向对比

第14章:原子化状态:Jotai、Valtio 与 Recoil 横向对比

原子化状态管理的核心洞见是:性能问题不来自'状态太多',而来自'订阅粒度太粗'。

本章核心问题:原子化状态与 selector 模式有什么本质区别?如何根据场景选择? 读完本章你将理解


Level 1 · 你需要知道的(1-3年经验)

原子化状态的核心概念

原子化状态管理(Atomic State Management)的核心洞见是:大多数应用的性能问题不来自"状态太多",而来自"订阅粒度太粗"。当你有一个包含 100 个字段的全局 store,每个组件订阅整个 store,任何字段变化都导致所有组件重渲染——即使 selector 可以缓解这个问题,根本的数据模型仍然是"一个大对象"。

原子化状态把这个模型倒置:状态被拆分成最小的独立单元(atom),每个组件只订阅它实际依赖的 atoms。当 atom A 变化时,只有订阅了 A 的组件重渲染,订阅 B 和 C 的组件完全不受影响。这是真正的细粒度订阅(fine-grained reactivity),在概念上比 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 如果:

选择 Jotai 如果:

选择 Valtio 如果:

选择 Redux Toolkit 如果:

不推荐 Recoil 用于新项目:维护停滞,React 19 兼容性问题,替代品(Jotai)更优。

原子化状态管理不是银弹。它在细粒度更新方面有真实优势,但也带来了"atom 爆炸"的潜在问题——当你有数百个 atoms 时,它们之间的依赖关系可能和 Redux 的 action 图一样难以追踪。正确的工具取决于你的团队习惯、应用复杂度和性能需求。理解每个库背后的权衡,才能做出真正适合你场景的选择。


Level 4 · 边界与陷阱(所有人)

生产环境常见问题

在实际项目中,本章涵盖的概念最常见的问题包括:

  1. 忽视边界条件:在正常路径下工作正常的代码,在异常路径(网络失败、用户快速操作、组件卸载)下可能产生 bug。始终考虑清理和取消逻辑。

  2. 过早优化:在没有测量的情况下添加优化代码,增加了复杂度但不一定提升性能。先用 Profiler 确认问题存在,再实施优化。

  3. 文档与实际行为的差异:React 的行为在不同版本间可能有微妙差异,尤其是并发模式相关的特性。始终以实际测试结果为准。

本章评分
4.6  / 5  (20 评分)

💬 留言讨论