Zustand vs Jotai:两个轻量级状态管理库,两种截然不同的设计哲学

2,133 阅读12分钟

前言简谈

Zustand 和 Jotai 算是当今 React 的主流状态管理库中较为热门的两个了(在同系列分类中名列前茅),二者都是由 Poimandres 团队开发。主力开发者均是 Daishi Kato,他算是名副其实的“Jotai 和 Zustand 之父”,可以说他以一己之力让前端开发圈里的开发者拥有了相对丰富的状态管理库选择,也同时带来了开发时需要皱眉深思选择的心智负担=)

Valtio 也是出自 Poimandres 团队和 Daishi 之手

zustand&jotai.png

实际上,在 2019 年左右出现的 Zustand 的目标就是为了简化 React 的全局状态管理,用最少的 API、最小的心智负担,干掉 Redux 那堆模版化 ceremony。事实上,除了对标 Redux 以外,Zustand 也解决了当时所遇到的一些痛点问题:

  1. React 的 Context API 用多了会重新渲染过多,性能堪忧,开发者的抱怨声此起彼伏^v^
  2. 另一款主流状态管理库 MobX 依赖装饰器和响应式思想,对部分开发者门槛较高
  3. Poimandres 团队的主力在开发3D和交互项目上(Three.js 生态中的 react-three-fiber 就出自这个团队),非常需要快速、轻量、性能好的状态库

而仅仅过去一年,Jotai 横空出世,它的出现也并非空穴来风。Zustand 的优点毋庸置疑,但是实际上它仍然采用了全局Store的思想,粒度依然是一个“大Box”。那 Jotai 的诞生主要是为了解决:

  1. 有些场景需要细粒度、可组合的状态,而当时的 Recoil(Facebook出品的状态管理库)绑定生态过深,达不到理想化的标准。
  2. Poimandres 想做一个更灵活、更可组合的响应式管理库,无缝配合 Suspense、并发模式等

尽管两者都以快速、轻量为特点,但它们解决的问题和背后的设计理念却截然不同。接下来,我将分享我对它们核心思想的理解。

Zustand

Flux 架构

React 诞生初期,大家很快遇到一个问题:组件间状态同步和数据流变复杂,尤其是多组件嵌套和兄弟组件通信时。据悉当时很多人都使用双向数据绑定,结果状态被到处修改,调试简直难如登天。

在当时 MVC 结构是一种主流的解决方案,在 MVC 架构中:

  1. Model 模型:负责管理应用的状态和数据,当 Model 发生变化时,它会通知 View 进行更新
  2. View 视图:负责展示数据
  3. Controller 控制器:负责处理逻辑并更新Model

然而,在 React 这样的组件化框架中,传统的 MVC 模式面临着一些挑战。随着应用变得越来越复杂,ViewController 的边界开始变得模糊,组件既是视图又可能包含业务逻辑。更重要的是,多组件间的复杂交互使得状态的变更路径变得错综复杂,难以追踪。

mvc.png

当一个状态发生变化时,很难确定是哪个组件触发了变更,以及变更会影响到哪些其他组件。这种混乱的状态更新路径,使得调试和维护变得尤为困难。

这时FaceBook想出一个简单但有效的方案:采用单向数据流和明确的角色划分。这也就是Flux的核心概念,它是一个的单向数据流的架构,主要组成有四个部分:

  1. Action 动作:描述发生了什么事情
  2. Dispatcher 调度器:负责分发 Action 和 Store
  3. Store 仓库:保存状态和业务逻辑
  4. View 视图:渲染UI并触发Action

flux.png

github.com/facebookarc…

从 MVC 流程图到 Flux 流程图中对比观察可以清楚看出:在 Flux 中,视图不能直接改状态,所有状态变更都需要走Action到Dispatcher再到Store中,状态变化后,View 自动根据新状态进行渲染。相较于MVC,每个部分各司其职,不混着协助工作,状态的流动变得更加“可控”。

Flux在初期只是一个概念,后来在2015的时候Facebook开源了flux这个套件,最后 Redux 成为了早期利用 Flux 概念被开发者广为使用的套件。在2023年3月,flux套件的官方开源库也被archived了。

flux-archived.png

对 Flux 设计理念的借鉴

相比严格遵循 Flux 理念的 Redux,Zustand 是继 Redux 后比较流行的具有 Flux 理念的状态管理套件,其设计受 Flux 启发但又极度精简,实属“精简的单项数据流变体”。在保持 Flux 简洁性的同时,大大降低了开发者的心智负担。

之所以说 Zustand 是基于 Flux 但并非完全履行 Flux 的设计是因为从使用的角度上面看:

  1. 它没有显式 Dispatcher 用于分发 Action
  2. Action 对象并非严格
  3. 不可变性是非强制的(鼓励你以不可变方式处理,但实际上可以直接修改状态)

创建 Store

在 Flux 中,Store 是单一的“真理来源”,它负责持有应用的状态和业务逻辑。在 Zustand 中,这个角色由 create 函数及其返回的 Hook 扮演。

import { create } from 'zustand'

const useCounterStore = create((set) => ({
  // 状态(state)
  count: 0,
  // ... 其他状态
}))

触发 Action

在 Flux 模式中,当用户与 View(视图)进行交互时,会产生一个 Action。这个 Action 是一个包含类型数据的普通 JavaScript 对象,用于描述“发生了什么”

Zustand没有显式的Action对象,而是通过在Store中定义的方法来模拟这一过程。

import { create } from 'zustand'

const useCounterStore = create((set) => ({
  // ... 状态
  count: 0,

  // Action
  // 描述了“增加”这个意图
  inc: () => set((state) => ({ count: state.count + 1 })),
  // 描述了“减少”这个意图
  dec: () => set((state) => ({ count: state.count - 1 })),
}))

分发 Action 并更新 Store

Flux 中的这一步是单向数据流的核心。Action 被 Dispatcher 分发到 Store,Store 接收到 Action 后,根据其类型数据来更新自身的状态。在 Zustand 中,这一过程被高度抽象和简化

import useCounterStore from './xxx'

function App() {
  const { count, inc, dec } = useCounterStore()
  
  return (
    <div>
      <p>我就是Count: {count}</p>
      {/* 视图交互触发Action:调用inc方法,这等同于向 Store 分发了一个 Action */}
      <button onClick={inc}>为Count+1</button>
      <button onClick={dect}>为Count-1</button>
    </div>
  )
}

更新 View 视图

最后一步是Store状态更新后,会通知订阅它的View进行重绘。在Zustand中,这一步是React Hook机制的自然延伸,并且是自动完成的。

在 Redux 里这步是要通过 store.subscribe() 进行订阅连接的

Jotai

Atomic 设计

Jotai 以及 Recoil 等沿用了 Atomic 这个概念但并非与设计领域中的 Atomic Design 一致

Atomic 设计的核心理念就是状态应该被拆分成最小不可再分的单元,也就是 Atom。每个 Atom 独立存储与更新,可以随意组合成更复杂的状态结构。

在 Flux 架构中,有明确的单向数据流结构,但是 Atomic 设计理念并非是严格的时间流架构,它更像是妆态单元组织方式,它的状态并非“线性”,而是声明式依赖关系

atom.png

与 Atomic 设计模式的契合

Jotai 的核心概念与 Atomic 严格对齐,它就是想让 React 的状态管理可以被分散起来(完全基于 React Hooks 构建,完美融入 React 生态),这些一个个的小单元就是 Atom,而 Atom 可以像是 Context 取得状态,同时又可以让 code-splitting 将元件切分的更细。

最小单元 Atom

在 Jotai 中,atom是状态的基本构建块。每一个 atom都代表了应用状态树中的一个最小、可自包含的节点。它拥有自己的值,可以独立存在,并且是可被组件订阅的最小单元。

import { atom } from 'jotai';

// 一个原子
export const HelloAtom = atom('Hello');

不同 atom 都拥有独立的生命周期和状态,他们之间默认是相互隔离的,这大大降低了状态管理的复杂性,避免全局状态树太大的难以维护。

从原子到“分子”

atom 是可以组合起来并创建出派生状态的。这种机制类似于化学中的原子组成分子,让你可以用简单的单元构建复杂的逻辑。

import { atom } from 'jotai';

const HelloAtom = atom('Hello');
const WorldAtom = atom('World');

const HelloWorldAtom = atom((get, set) => {
	get(HelloAtom) + ',' + get(WorldAtom)
})

Jotai 的内部机制会自动追踪 atom 之间的依赖关系,确保派生状态的实时更新。

细粒度的订阅机制

在 Jotai 中,组件不会订阅整个全局状态,而只订阅它所使用的特定 atom

import { useAtom } from 'jotai';
import { countAtom, isEvenAtom } from './atom';

// 只订阅 countAtom
function Counter() {
  const [count, setCount] = useAtom(countAtom);

  return (
    <div>
      <span>Count: {count}</span>
      <button onClick={() => setCount(c => c + 1)}>点我给Count+1</button>
    </div>
  );
}

// 只订阅 isEvenAtom
function Event() {
  const [isEven] = useAtom(isEvenAtom);

  return <p>{isEven ? 'Yes' : 'No'}</p>;
}

底层原理浅析

Zustand 的底层原理浅析

早期 Zustand 是采用 useReducer 管理状态更新并依赖 forceUpdate() 来触发组件的重新渲染。

具体来说,就是使用 useReducer 作为状态管理的机制,这在很多复杂的状态逻辑时确实是很有用的,但是它不直接管理 React 状态的订阅和更新,而是通过 forceUpdate() 来强制组件重新渲染,这个方法效率比较低,因为这个 API 底层绕过了 React 的优化机制,不够灵活。

在 React 18 之后,React 官方推出了 useSyncExternalStore ,提供了内建的解决方式,组件只有在其订阅的外部状态发生变化时才会重新渲染。并且 useSyncExternalStore 也天然支持 SSR、多订阅和批量更新等,真正成为 forceUpdate() 的替代者,Zustand 官方也很快切换成了这个 API 完美的去接纳 React 生态。

forceupdate.png

Zustand 的状态是在 React “外部”存储的,这很重要!

实际开发下,我们通常不会去使用 useStore ,它的作用是直接返回整个 Store 对象,用于**同步外部状态并保证 React 组件和外部状态的同步性。**这个核心函数是 create 的重要组成部分。

useSyncExternalStore.png

另一个比较常用的核心就是 create 函数的增强版 createWithEqualityFn ,在其核心 useStoreWithEqualityFn 中使用了 useSyncExternalStoreExports 用于多个订阅的场景,同时也允许让我们更精细的去控制订阅行为时。

create 就是普通的 store 创建方法,而 createWithEqualityFn 允许设定自定义比较函数的配置,用于判断状态是否发生变化

useSyncExternalStoreExports.png

在这里就不过多赘述 Zustand 的其他设计细节了,其他具体的可以查看 Zustand 源码,毕竟是个小而美的库,读起来相对没有那么吃力。

Jotai 的底层原理浅析

与 Zustand 直接使用 useSyncExternalStore 不同,Jotai 直接使用了 React 最最最纯原的 useStateuseEffect 进行状态更新和订阅。

当你创建一个 atom 时,这个被创建的 atom 本身是一个不可变的、唯一的对象引用。Jotai 会把这个状态存到位于 React 组件树之外的全局的 WeakMap

WeakMap 的好处一方面是状态与组件完全分离,另一方面是 WeakMap 优秀的垃圾回收机制(如果key不再被其他地方引用,那么这个key和value就会被垃圾回收器自动清理)

在状态存在全局 WeakMap 的基础上,Jotai 使用了 Context API 与 React 组件通信,在 Provider 中创建并维护这个独一无二的 WeakMap 存储。不过这不要求手动在根组件使用 Provider 包裹,详见文档

Jotai 还有一大亮点就是与 React 的 SuspenseError Boundary 机制深度集成。以 useAtomValue 的一个小片段为例:

useAtomValue.png

atom.read() 返回 Promise 时,useAtomValue 会自动抛出 Promise 触发上层的 Suspense 的 fallback,同时做了连续 Promise 处理和防止重复挂起的状态跟踪。如果 Promise 被reject的话,use 会抛出错误,错误会被上层的 Error Boundary 捕获,形成完美闭环。

到这里其实也解释了为什么没有使用和 Zustand 一样的策略来设计 Jotai,想知道更多细节的话可以参考作者的文章:blog.axlight.com/posts/why-u…

这里也推荐阅读 Jotai 的源码!!(我还没看完哈哈哈)

从使用角度对比 Look

Hooks

Jotai 和 Zustand 都提供了 Hooks 来实现在 React 组件内部使用 Hooks:

// jotai
const countAom = atom(0)
const [count, setCount] = useAtom(countAom)

// zustand
coonst useCountStore = create()((set) => ({
  count: 0,
  setCount: count => set({ count })
}))
const { count, setCount } = useCountStore()

外部 Store 实例

当需要在 React 组件外部操作状态时,可以采用提供的不同的 API 来访问状态,对比依然是按原子获取按整个对象状态获取

// jotai
const store = createStore();
store.get(atom1)
store.get(atom2)

// zustand
const store = createStore();
store.getState()

选择性订阅

只让依赖特定状态的组件重新渲染,二者的思路是不同的:

  • Jotai 的优化是基于其原子化设计:你订阅的是一个独立的原子,天然就是细粒度的
  • Zustand 的优化是基于其选择器设计:你从一个大的 Store 中“选择”需要的状态,Zustand 负责跟踪选择结果的变化
// jotai
const value1 = useAtomValue(atom1)
const value2 = useAtomValue(atom2)

// zustand
const useStore = create(...)
const value1 = useStore(state => state.key1)
const value2 = useStore(state => state.key2)

State Setter

当组件只负责触发状态更新而不需要读取状态值时,如何避免不必要的重新渲染是一个非常重要的点,二者的实现方式也是有区别的:

  • Jotai ****采用显式 API 的设计方式来解决这个问题
  • Zustand ****利用了选择器机制的特性,通过选择器过滤不需要订阅的状态
// jotai
const [,setValue] = useAtom(atom) // 极度不建议,set 时会发生 re-render
const setValue = useSetAtom(atom) // 建议,只拿取 state setter

// zustand
const setValue = useStore(state => state.setValue)

总结

在开发现代应用时,选择状态管理库时还是要结合具体情境分析,这本来就是一个仁者见仁智者见智的东西,我个人给不出什么理论性十足的建议,所以…

不做总结咯

参考

Why choose Zustand over Jotai?

Zustand: feat: use-sync-external-store#550

從 Jotai 到 Zustand

React 狀態管理套件比較與原理實現

两张图带你全面了解React状态管理库:zustand和jotai