用 Context 做全局状态,最难受的一刻是打开 React DevTools 的 Highlight updates,改一个主题色,整棵组件树全在闪。换 Redux 又是另一种难受,加一个字段要动 action type、action creator、reducer 三个文件。Zustand 走的是第三条路,一个 create() 就是一个 store,不用 Provider,不用 reducer,组件里想订阅哪个字段就订阅哪个字段。这篇从建第一个 store 开始,把 Selector 精准订阅、持久化、DevTools、Immer 这几件事讲透,顺带说清楚哪几个写法看着没问题但会悄悄失效。
在本篇文章中,我们将从浅入深,和大家一起学习以下知识:
- Zustand 解决的是 Context 和 Redux 各自的什么问题
- 用
create()建 store 的基础写法,以及异步请求怎么放进去 - Selector 精准订阅的原理,以及返回对象为什么会让优化失效
persist中间件的基础用法和partialize、storage等高级配置- 接 Redux DevTools 做时间旅行调试,以及生产环境怎么关掉
- 深层嵌套状态用
immer中间件改写的收益和代价 - store 模块化拆分的目录结构和 slice 组合模式
- 四个高频踩坑点,包括
getState()到底能不能用
# 一、为什么是 Zustand
先把两个老方案的问题说清楚,才知道 Zustand 在替谁擦屁股。
Context API 的问题在于它没有订阅粒度。Provider 的 value 一变,所有消费该 Context 的组件都会重新渲染,跟这个组件用没用到变化的那个字段完全无关。你可以拆成十几个 Context 来缓解,代价是 Provider 嵌套成一座金字塔。
Redux 的问题不在性能,在配置成本。一套 action type、action creator、reducer 写下来,中小项目的收益比很低。Redux Toolkit 之后好了很多,但心理门槛还在。
Zustand 由 React Three Fiber 团队做的,思路是「用最小的 API 拿到最大的效率」。它的核心能力有这么几条。
- 极简 API,一个
create()建 store,没有额外配置 - 基于 Selector 的订阅机制,只订阅你要的那一片,避免多余渲染
- 不依赖 Context,没有 Provider 嵌套
- TypeScript 类型推导完整,IDE 提示到位
- 内置
persist、devtools、immer等中间件 - store 就是闭包里的普通对象,初始化快、内存占用低
三大流派的位置关系大致是这样。
| 单向数据流 | 原子化 | Proxy |
|---|---|---|
| redux | Recoil | Mobx |
| zustand | jotai | valtio |
实际选型时基本是在 zustand、jotai、valtio 之间挑,它们分别是各自流派前一代方案的优化版本。
单向数据流的优势是数据本身干净、负担轻。但数据结构一复杂,不可变更新的展开写法会长得离谱,通常要配 immer.js 才能兼顾可读性和性能。原子化和 Proxy 的目标一致,都在收集数据与 UI 的绑定关系,数据变了 UI 自动更新。区别在写法,原子化是先定义原子再用原子管数据,Proxy 是先定义一个大对象再劫持属性做绑定。性能上原子化略好一点,它少了劫持这一步,但数据一复杂,原子化的写法也会变得繁琐。
处理大型复杂列表时,三者的表现是这样。
| 库 | 初始化速度 | 更新速度 | 内存占用 | 开发复杂度 |
|---|---|---|---|---|
| Zustand | 极快 | 快 | 极低,原生对象 | 较高 |
| jotai | 较慢 | 精准、快 | 最高,原子实例多 | 偏高 |
| valtio | 中等偏慢 | 精准、快 | 偏高,Proxy 开销大 | 低 |
更新机制上的细节差异。
| 库 | 通知复杂度 | 渲染复杂度 | 原理 |
|---|---|---|---|
| Zustand | O(N) | O(1) | 线性遍历订阅列表加 Selector 比对 |
| Jotai | O(1) | O(1) | 依赖图,原子 A 变了直接找到订阅 A 的那个组件 |
| Valtio | O(1) | O(1) | Proxy 追踪,属性 A 变了直接通知访问过 A 的组件 |
Zustand 的通知是 O(N),看着不如另外两个,但它换来的是极低的内存开销。这个取舍在万级数据场景下反而是优势。想看四个方案更完整的横向对比,可以翻这篇 React 状态管理库选型指南。
# 二、建第一个 store
# 基础写法
导入 create,把状态和操作方法一起写在返回的对象里。
import { create } from 'zustand'
interface TabItem {
id: string
title: string
}
interface TabState {
tabs: TabItem[]
currentTabId: string
addTab: (tab: TabItem) => void
removeTab: (id: string) => void
setCurrentTab: (id: string) => void
}
export const useTabStore = create<TabState>((set) => ({
tabs: [],
currentTabId: '',
addTab: (tab) => set((state) => ({
tabs: [...state.tabs, tab]
})),
removeTab: (id) => set((state) => ({
tabs: state.tabs.filter((t) => t.id !== id),
currentTabId: state.currentTabId === id ? '' : state.currentTabId
})),
setCurrentTab: (id) => set({ currentTabId: id })
}))
这里有个 Zustand 和 Redux 很不一样的点。set 传对象时是浅合并,不是替换。上面 setCurrentTab 只写了 currentTabId,tabs 会原样保留,不需要你手动展开。想要整体替换,得传第二个参数 true。
组件里直接调这个 hook 就行。
import { useTabStore } from './store/tabStore'
export default function TabView() {
const { tabs, currentTabId, addTab, setCurrentTab } = useTabStore()
return (
<div>
<button onClick={() => addTab({ id: 'settings', title: '设置' })}>
新增 Tab
</button>
<div style={{ display: 'flex', gap: 8 }}>
{tabs.map((tab) => (
<div
key={tab.id}
onClick={() => setCurrentTab(tab.id)}
style={{
padding: 4,
borderBottom: tab.id === currentTabId ? '2px solid blue' : 'none',
cursor: 'pointer'
}}
>
{tab.title}
</div>
))}
</div>
</div>
)
}
到这里 store 就能用了,任何组件里引进来就能读能改,不需要在外层套任何东西。
不过上面这个写法有个隐患,useTabStore() 不传参数等于订阅整个 store,store 里任何字段变了这个组件都会重渲。小组件无所谓,稍微大一点就得改成 Selector 写法,第三节会详细说。
# 异步操作怎么放
Zustand 没有为异步单独设计 API,因为不需要。action 就是普通函数,里面爱写什么写什么。
import { create } from 'zustand'
interface Todo {
id: number
title: string
completed: boolean
}
interface TodoState {
todos: Todo[]
error: string | null
isLoading: boolean
fetchTodos: () => Promise<void>
toggleTodo: (id: number) => void
}
export const useTodoStore = create<TodoState>((set) => ({
todos: [],
error: null,
isLoading: false,
fetchTodos: async () => {
set({ isLoading: true, error: null })
try {
const res = await fetch('https://jsonplaceholder.typicode.com/todos')
const data = await res.json()
set({ todos: data.slice(0, 10), isLoading: false })
} catch (error) {
set({ error: (error as Error).message, isLoading: false })
}
},
toggleTodo: (id) => set((state) => ({
todos: state.todos.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
)
}))
}))
这段代码能跑,但我得说句实话,这种把接口数据往 store 里塞的写法,做到后面 store 会长满 xxxLoading、xxxError 这样的伴生字段。真正的服务端状态还是交给 TanStack Query 这类专门方案更合适,Zustand 留着管纯客户端状态。
# 三、Selector 精准订阅
# 先看问题长什么样
假设 store 里同时放了用户信息和主题设置。
import { create } from 'zustand'
const useAppStore = create((set) => ({
user: {
name: '张三',
age: 25,
isLogin: true
},
theme: 'light',
setTheme: (newTheme: string) => set({ theme: newTheme }),
setUserAge: (age: number) => set((state) => ({
user: { ...state.user, age }
}))
}))
两个组件都用解构的方式取值。
// 反面教材,订阅了整个 store
function UserName() {
const { user } = useAppStore()
console.log('UserName 渲染了')
return <div>用户名 {user.name}</div>
}
function ThemeDisplay() {
const { theme } = useAppStore()
console.log('ThemeDisplay 渲染了')
return <div>当前主题 {theme}</div>
}