第十一章 Zustand 教程:React 轻量状态管理从入门到精通,掌握高效状态定义与组件通信
第十一章 Zustand 教程:React 轻量状态管理从入门到精通,掌握高效状态定义与组件通信
Prorise第十一章:Zustand:轻量高效的 React 状态管理
—— 现代 React 项目的状态管理最佳实践
摘要: 本章是为有经验的 Vue (Pinia) 开发者量身定制的 Zustand 实战指南。我们将跳过繁琐的理论铺垫,直击核心,将 Zustand 定位为 Pinia 在 React 生态中的“战略级对等物”。您将学习如何利用 Zustand 简洁的 API 实现状态管理、性能优化、异步处理,并解决从 Vue 的“可变”心智模型到 React “不可变”模型的平滑过渡。最终,您将掌握一套在企业级应用中组织、测试和扩展 Zustand Store 的现代化工程实践。
在本章中,我们将遵循一条为“转译者”精心设计的路线图,逐步攻克 React 状态管理:
- 首先,我们将从 定位与心智模型 出发,清晰界定 Zustand 的生态位,并建立它与 Pinia 的核心概念映射,彻底打消您“为什么选它”的疑虑。
- 接着,我们将进入 基础实践,以最快的速度创建并使用您的第一个 Store,并集成 TypeScript。
- 然后,我们将立即深入 React 性能的命脉——Selector 与渲染优化,这是从 Vue 自动追踪依赖到 React 手动优化的关键转变。
- 为了解决您最大的心智模型障碍,我们专门开辟一章讲解 不可变性,并引入
immer作为平滑过渡的终极解决方案。 - 掌握核心后,我们将探索 派生状态 (Getters)、异步流程 和 核心中间件 等高级特性,让您具备应对复杂业务的能力。
- 最后,我们将聚焦于 企业级应用,学习如何通过切片模式(Slice Pattern)组织大型状态,并为其编写健壮的测试。
11.1. 核心定位与概念映射
11.1.1 核心定位:为什么选择 Zustand 而不是 Redux?
对于一位经验丰富的开发者而言,技术选型的核心在于理解其设计哲学与权衡。在 React 状态管理的“丛林”中,Redux 曾是事实上的标准,但它的时代背景和设计也带来了相应的“历史包袱”。
根据官方文档的描述,Zustand 是一个 小型、快速且可扩展的极简状态管理解决方案。它的核心优势在于提供了一个基于 Hooks 的舒适 API,并且 既没有繁重的模板代码,也没有强制性的设计范式。
这与 Redux 形成了鲜明对比。为了更直观地理解这一点,让我们模拟一段您在进行技术选型时可能会有的内心对话。
我看很多 React 的招聘要求和老项目里都提到了 Redux。为什么我们的新项目要选择 Zustand 这个看起来更“小众”的库?它足够稳定和强大吗?
问得好。这正是关键所在。Redux 的核心是 Flux 架构,要求严格的单向数据流,这在大型、复杂的项目中非常稳健,但也带来了大量的“模板代码”——你需要定义 Actions, Reducers, Dispatchers, 甚至使用 Redux Toolkit (RTK) 来简化这个过程。
确实,我在 Vuex 的早期版本里也体会过类似的繁琐。
而 Zustand 的哲学完全不同。它认为对于大多数现代应用来说,这种严格的束缚是不必要的。它让你像使用一个普通的 JavaScript 对象一样管理状态,但又通过 Hooks 实现了与 React 组件的精确绑定。
听起来更像是 Pinia 的感觉,很直接。
完全正确!更重要的是,Zustand 在底层解决了很多棘手的并发问题,比如官方提到的“僵尸子节点问题”、“React 并发”和“上下文丢失”。它可能是目前唯一一个在这些方面都处理得非常完善的库。所以,它不是“小众”,而是“现代”和“精准”。我们选择它,是因为它在提供强大功能的同时,极大地提升了我们的开发体验。
总结来说,Zustand 与 Redux 在概念上都遵循 不可变状态模型,但在实践上却有天壤之别:
- 依赖与上下文: Redux 要求整个应用被一个
<Provider>包裹,而 Zustand 无需任何 Provider,真正做到了即用即走。 - 代码简洁度: Zustand 极大地减少了模板代码。一个简单的状态,Redux Toolkit 可能需要一个
createSlice的配置对象,而 Zustand 只需要一个create函数。
决策依据: 选择 Zustand,意味着我们选择了与 Redux 相似的稳定内核(不可变状态),但抛弃了其繁重的流程和模板代码,换来了更接近 Pinia 的简洁、直观的开发体验。
11.1.2 心智模型映射:Pinia 与 Zustand 的核心概念对比
对于您这位“决策驱动的转译者”而言,最快的学习方式就是建立新旧知识之间的映射。下面的表格就是您从 Pinia 到 Zustand 的“翻译词典”。
| Pinia 概念 | Zustand 对等物 | 核心翻译与注意点 |
|---|---|---|
defineStore() | create() | (核心) 两者都是创建 Store 的入口函数。 |
state | state (在 create 的回调函数中) | 两者都是用于定义基础状态数据的对象。 |
actions | actions (在 create 的回调函数中) | 定义修改状态的方法。主要区别是 Zustand 的 action 必须通过 set() 函数来完成状态更新。 |
getters | 无直接对等物 | 这是最大的区别之一。Zustand 推荐在组件内使用 useMemo 或创建可复用的 Selector 函数来处理派生状态。 |
plugins | middleware | 用于增强 Store 功能,如集成 DevTools、数据持久化等,概念高度一致。 |
storeToRefs() | 无直接对等物 | React 生态通过 Selector 机制 (useStore(state => state.someValue)) 来实现对状态的精确订阅和解构,从而保证渲染性能。 |
11.1.3 现代化工程实践:搭建集成 Tailwind & Antd 的开发环境
在深入 Zustand 的 API 之前,我们必须搭建一个符合 2025 年标准的、具备最佳开发体验的工程环境。本节将指导您从零开始,为一个 Vite + React + TS 项目,正确集成 Tailwind CSS v4 和 Ant Design 5,并配置好路径别名。
第一步:初始化 Vite + React + TS 项目
首先,我们使用 pnpm 创建一个纯净的 Vite 项目。
1 | # 创建一个名为 zustand-practice 的项目 |
第二步:集成 Tailwind CSS v4
我们将采用最新的 CSS-First 配置方式来集成 Tailwind。
安装核心依赖
在项目根目录,安装 Tailwind CSS v4 和官方 Vite 插件。
1
pnpm add -D tailwindcss@next @tailwindcss/vite
配置 Vite 插件
编辑
vite.config.ts文件,引入并使用@tailwindcss/vite插件。文件路径:
vite.config.ts1
2
3
4
5
6
7
8
9
10import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite' // 1. 导入插件
export default defineConfig({
plugins: [
react(),
tailwindcss(), // 2. 将插件添加到 plugins 数组
],
})在主 CSS 文件中引入 Tailwind
清空
src/index.css的所有内容,然后只添加以下一行。文件路径:
src/index.css1
@import "tailwindcss";
核心变化: 这一行
@import "tailwindcss";是 v4 Rust 引擎的唯一入口,它会智能处理base,components,utilities层的注入,无需任何额外配置。
第三步:配置 Vite 路径别名 (@/)
为了提升代码可维护性,我们配置 @ 别名指向 src 目录。
修改 Vite 配置
再次编辑
vite.config.ts,增加resolve.alias配置。文件路径:
vite.config.ts(修改后)1
2
3
4
5
6
7
8
9
10
11
12
13
14import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import path from 'path' // 1. 引入 Node.js 的 'path' 模块
export default defineConfig({
plugins: [react(), tailwindcss()],
// 2. 新增 resolve 配置
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
})同步 TypeScript 配置
为确保 VSCode 和 TypeScript 编译器能识别此别名,需同步修改
tsconfig.json。文件路径:
tsconfig.app.json(复制粘贴)1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
/* Path alias configuration */
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src"]
}关键一步: 修改
tsconfig.app.json后,您需要重启 TypeScript 服务。在 VSCode 中,按下Ctrl+Shift+P(或Cmd+Shift+P),然后选择TypeScript: Restart TS Server。
第四步:正确集成 Ant Design
我们将采用 Ant Design 官方推荐的、基于 <App> 组件的现代化集成方案。
安装 Ant Design
1
pnpm add antd
配置顶层
App组件这是最关键的一步。我们将使用 antd 的
<App>组件包裹整个应用,它能同时解决 静态方法上下文消费 和 全局样式重置 两大问题,无需手动引入任何 CSS 文件。文件路径:
src/main.tsx(修改)1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21import React from 'react'
import ReactDOM from 'react-dom/client'
import RootApp from './App.tsx'
import './index.css'
import { ConfigProvider, App as AntdApp } from 'antd'
import zhCN from 'antd/locale/zh_CN';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<ConfigProvider locale={zhCN}>
{/* 用 antd 的 <App> 组件包裹整个应用。
这是官方推荐的终极方案,用于解决 message, notification, Modal 等静态方法
无法消费 ConfigProvider 上下文的问题。
*/}
<AntdApp>
<RootApp />
</AntdApp>
</ConfigProvider>
</React.StrictMode>,
)
第五步:安装 Zustand 并创建第一个 Store
现在,环境就绪,我们引入 Zustand。
安装 Zustand
1
pnpm install zustand
创建 Store 文件
文件路径:
src/stores/bearStore.ts1
2
3
4
5
6
7
8
9
10
11import { create } from "zustand";
interface BearState {
bears: number;
increase: () => void;
}
export const useBearStore = create<BearState>((set) => ({
bears: 0,
increase: () => set((state) => ({ bears: state.bears + 1 })),
}));
第六步:验证所有集成
最后,我们修改 App.tsx,创建一个简洁的组件来同时使用 Zustand、Ant Design 和 Tailwind CSS,以验证所有配置都已生效。
文件路径: src/App.tsx (修改)

1 | import { useBearStore } from '@/stores/bearStore' // 1. 导入 Zustand store (使用路径别名) |
现在,运行 pnpm run dev。如果您的浏览器中显示了一个 Ant Design 卡片,卡片中的数字可以通过点击按钮增加,并且每次点击都会在页面顶部弹出一个 Ant Design 的成功提示,同时整个页面具有 Tailwind CSS 设置的灰色背景和居中布局,那么恭喜您!我们已经成功搭建了一个纯净、高效的现代化 React 开发环境。
11.2. 实战演练:构建结构化、可维护的 Store
我们将在 11.1 搭建好的项目中,通过一系列的功能迭代,将理论知识应用到实践中。我们将 一边讲解核心 API,一边创建独立的、遵循最佳规范的 React 组件 来消费这些 API,确保您能看到每个知识点带来的实际效果。
11.2.1 深入 set:掌握状态更新的艺术
set 函数是您与 Zustand Store 交互的 唯一写操作入口。理解它的行为模式,是精通 Zustand 的基石。
核心心智模型:不可变性
首先,我们必须再次强调:Zustand 和 React 一样,遵循 不可变 的状态更新模式。
在 Pinia 中,我可以很直观地 store.count++ 或者 store.user.name = 'new name' 来修改状态。这种“可变”操作非常符合直觉。
这是 Vue 响应式系统(基于 Proxy)和 React 状态系统(基于 Immutability)的根本区别。在 React 的世界里,我们从不“修改”原始状态,而是用一个“全新的”状态对象来替换它。
听起来效率很低,每次都要创建新对象?
恰恰相反,这正是 React 高效更新机制(Virtual DOM diffing)的基础。通过比较新旧两个状态对象的引用(地址),React 可以瞬间知道状态是否变更,从而决定是否需要更新 UI。Zustand 的 set 函数正是为这种模式而生,并且它还提供了一些便利的特性。
特性一:自动的浅层合并
为了简化操作,Zustand 的 set 函数默认会执行 浅层合并。这意味着您只需要提供要更新的字段,Zustand 会自动将它与现有状态合并,无需手动扩展 (...) 其他属性。
实战应用:创建熊计数器组件
更新 Store (
bearStore.ts)我们为 Store 添加
lastUpdated状态,用于演示浅层合并。文件路径:
src/stores/bearStore.ts1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18import { create } from "zustand";
interface BearState {
bears: number;
lastUpdated: number | null;
increase: () => void;
}
export const useBearStore = create<BearState>((set) => ({
bears: 0,
lastUpdated: null,
// 一般来说,要覆盖一个不可变对象需要使用如下的语法:
// set((state) => ({ ...state, bears: state.bears + 1, lastUpdated: Date.now() }))
// 我们永远都需要将 ...state 作为返回对象的第一项解构,而 zustand 帮我们优化了这一点
increase: () =>
set((state) => ({ bears: state.bears + 1, lastUpdated: Date.now() })),
}));创建消费组件 (
BearCounter.tsx)文件路径:
src/components/BearCounter.tsx(新建文件)1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24import { useBearStore } from "@/stores/bearStore";
import { Button, Card, Descriptions, Statistic } from "antd";
import React from "react";
export const BearCounter: React.FC = () => {
// 最佳实践:只订阅本组件需要的最小状态集,避免不必要的重渲染
const bears = useBearStore((state) => state.bears);
// const lastUpdated = useBearStore((state) => state.lastUpdated);
const increase = useBearStore((state) => state.increase);
return (
<Card title="熊的数量控制器 (浅层合并)">
<Descriptions bordered column={1}>
<Descriptions.Item label="当前数量">
<Statistic value={bears} />
</Descriptions.Item>
{/* <Descriptions.Item label="最后更新">{lastUpdated ? new Date(lastUpdated).toLocaleString() : "N/A"}</Descriptions.Item> */}
</Descriptions>
<Button type="primary" onClick={increase} className="mt-4">
增加一只熊
</Button>
</Card>
);
};
特性二:处理深度嵌套对象
set 函数的自动合并 只在一层深度有效。当您需要更新嵌套对象时,必须手动处理深层对象的不可变更新。
更新 Store (
bearStore.ts)文件路径:
src/stores/bearStore.ts(修改)1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26import { create } from "zustand";
interface BearState {
bears: number;
increase: () => void;
status: {
// 新增嵌套对象
hungry: number;
};
feed: (amount: number) => void;
}
export const useBearStore = create<BearState>((set) => ({
bears: 0,
status: { hungry: 100 },
increase: () =>
set((state) => ({ bears: state.bears + 1})),
feed: (amount: number) =>
set((state) => ({
status: {
...state.status, // 👈 必须手动展开旧的 stats 对象
hungry: state.status.hungry - amount,
},
})),
}));感受痛点: 正如您所见,手动展开 (
...state.stats) 非常繁琐。在后续讲解 中间件 的章节,我们将引入 Immer 作为解决此问题的终极方案。但现在,请务必理解这个手动过程。创建消费组件 (
BearFeeder.tsx)文件路径:
src/components/BearFeeder.tsx(新建文件)1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19import { useBearStore } from '@/stores/bearStore'
import { Button, Card, Statistic, message } from 'antd'
export const BearFeeder = () => {
const hungry = useBearStore((state) => state.stats.hungry)
const feed = useBearStore((state) => state.feed)
const handleFeed = () => {
if (hungry > 0) feed(10)
else message.warning('熊已经吃饱了!')
}
return (
<Card title="喂食器 (嵌套更新)">
<Statistic title="饥饿度" value={hungry} suffix="/ 100" />
<Button onClick={handleFeed} className="mt-4">喂食 (-10)</Button>
</Card>
)
}
11.2.2 使用 get 函数:在 Actions 内部读取最新状态
create 回调函数提供了第二个参数 get,允许 Action 在执行时,安全地 读取 到 Store 的最新状态,无需订阅。这对于实现 Action 之间的联动至关重要。
实战应用:实现一个更智能的 increase Action
我们重构 increase Action,让它在增加熊的数量后,还能更新时间戳。
文件路径: src/stores/bearStore.ts (修改)
1 | // ... interface BearState { ... } |
效果验证: 您无需修改 BearCounter.tsx 组件。现在当您点击“增加一只熊”按钮时(按钮文字可改回),会发现数量和时间戳同时被更新了。
11.2.3 组件外部的 API:getState 与 setState
useBearStore 不仅是一个 Hook,也是一个 Store 实例,允许我们在 React 组件外部与之交互,这在调试或与非 React 库集成时非常有用。
实战应用:创建外部调试与重置工具
更新 Store (
bearStore.ts)我们添加
reset功能,并使用set的replace标志来完全替换状态。文件路径:
src/stores/bearStore.ts(修改)1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45import { create } from "zustand";
const initialState = {
// 定义初始状态, 便于重置
bears: 0,
lastUpdated: null,
status: { hungry: 100 },
};
interface BearState {
bears: number;
lastUpdated: number | null;
increase: () => void;
status: {
// 新增嵌套对象
hungry: number;
};
feed: (amount: number) => void;
reset: () => void;
}
export const useBearStore = create<BearState>((set, get) => ({
...initialState,
increase: () => {
const newBears = get().bears + 1;
set({
bears: newBears,
lastUpdated: Date.now(),
});
},
feed: (amount: number) =>
set((state) => ({
status: {
...state.status, // 👈 必须手动展开旧的 stats 对象
hungry: state.status.hungry - amount,
},
})),
// 在复杂的情况下,我们需要将所有的状态作为一个对象,解构初始化对象并传入我们的函数,类似语法相当于是这样的:
// reset: () =>
// set({ ...initialState, increase: get().increase, feed: get().feed }, true),
// 这里的 True 标志代表 replace,替换整个对象,我们需要把整个对象进行解构并合并
// 但是 zustand 帮我们优化了这一点,我们只需要传入整个 initialState 即可
reset: () => set(initialState),
}));
为什么它能工作? 因为它巧妙地利用了我们前面提到的 浅层合并 特性。当您调用 set(initialState) 时,Zustand 在内部执行的逻辑是这样的:
- 获取当前 Store 的完整状态,它看起来像:
{ bears, lastUpdated, status, increase, feed, reset }。 - 获取您传入的
initialState对象:{ bears: 0, lastUpdated: null, status: { hungry: 100 } }。 - 执行一次
Object.assign({}, currentState, initialState)操作。 initialState里的bears,lastUpdated,status字段会覆盖掉currentState中对应的旧值。- 而
currentState中有、但initialState中没有的字段(也就是increase,feed,reset这些 Action)则 完全不受影响,被保留了下来。
11.3. 性能之钥:Selector 的工作原理与优化
到目前为止,我们已经掌握了如何读写 Store 的状态。但要真正精通 Zustand,我们必须理解它与 React 渲染周期之间的关系,这其中的关键就是 Selector (选择器)。可以说,是否能正确使用 Selector,是区分 Zustand 新手和专家的分水岭。
11.3.1 Selector 的核心机制:=== 严格引用比较
我们回顾一下在组件中读取状态的代码:
1 | const bears = useBearStore((state) => state.bears) |
这里的 (state) => state.bears 就是一个 Selector。Zustand 的工作机制非常纯粹:
它会在 每次 Store 发生变化时,重新执行这个 Selector 函数,然后将 上一次 的返回结果 prevResult 与 这一次 的返回结果 newResult 进行一次严格的比较,在 JavaScript 中,这等价于 Object.is(prevResult, newResult),对于大多数情况,您可以简单理解为 prevResult === newResult。
- 如果比较结果为
true(结果未变),组件 不会 重新渲染。 - 如果比较结果为
false(结果已变),组件 会 重新渲染。
这就是为什么我们之前的 BearCounter 组件是性能良好的:当 bears (一个数字) 的值没有变化时,prevResult === newResult 始终为 true,组件就不会重渲染。
11.3.2 性能陷阱:订阅非原始值导致的多余渲染
理解了上述核心机制后,一个最常见的性能陷阱就浮出水面了:当 Selector 的返回值是一个每次都新创建的对象或数组时,会发生什么?
实战场景:创建一个只显示所有熊名字的组件
更新 Store (
bearStore.ts)让我们为 Store 增加一个更复杂的状态,用来存储每只熊的名字和它的食物偏好。
文件路径:
src/stores/bearStore.ts(修改)1
2
3
4
5
6
7
8
9// ... imports
interface BearState {
bearHabits: { // 新增一个对象
'Smokey': { favoriteFood: 'Honey' },
'Paddington': { favoriteFood: 'Marmalade' },
}
updateTimestamp: () => void // 复用之前的 action 用于触发不相关的更新
}创建有性能问题的组件 (
BearNames.tsx)这个组件的职责很简单:只显示所有熊的名字。
文件路径:
src/components/BearNames.tsx(新建文件)1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18import { useBearStore } from '@/stores/bearStore'
import { Card, Tag } from 'antd'
import React from 'react'
export const BearNames = () => {
// 这个 Selector 每次执行都会返回一个新的数组
const names = useBearStore((state) => Object.keys(state.bearHabits))
return (
<Card title="所有熊的名字 (有性能问题)">
<div className="flex gap-2">
{names.map((name) => (
<Tag key={name} color="blue">{name}</Tag>
))}
</div>
</Card>
)
}在
App.tsx中验证问题我们将这个新组件加入到
App.tsx中,并复用BearCounter组件里的“更新时间戳”按钮来触发一次与bearHabits无关的 Store 更新。文件路径:
src/App.tsx(修改)1
2
3
4
5
6
7
8
9
10
11
12
13
14// ... imports
import { BearNames } from './components/BearNames' // 导入新组件
function App() {
return (
<div className="flex justify-center items-center h-screen bg-slate-100">
<Space align="start" size="large">
<BearCounter />
<BearFeeder />
<BearNames /> {/* 添加新组件 */}
</Space>
</div>
)
}
现在,打开浏览器的控制台,就能看到如下报错:

**原因**: `Object.keys()` 函数在每次被调用时,都会返回一个 **全新的数组实例**。因此,`prevResult === newResult` 的比较结果永远是 `false`,导致了组件在 **任何** Store 状态变更时都会不必要地递归重渲染,在 JavaScript 中,使用 `===` 运算符进行严格比较时,如果比较的是两个数组,即使这两个数组的内容完全相同,其比较结果也是 `false`。这是因为 `===` 运算符比较的是两个对象的引用是否相同,即它们是否指向内存中的同一个地址,而不是比较它们的内容是否相同。
11.3.3 解决方案:使用 useShallow Hook 优化订阅
为了解决这个陷阱,Zustand 提供了一个专门的 Hook:useShallow。
useShallow 的作用是 将默认的严格引用比较 (===),替换为对数组或对象内部元素的“浅层比较”。它会逐一比较新旧两个数组(或对象)的成员,只有当成员发生变化时,才认为结果已改变。
实战应用:修复 BearNames 组件
修复过程极其简单,只需两步:
文件路径: src/components/BearNames.tsx (修改)
1 | import { useBearStore } from '@/stores/bearStore' |
现在,您会发现控制台不再有递归循环的报错,不必要的重渲染被成功阻止了!
最佳实践: 当您的 Selector 需要返回一个 非原始值(数组或对象)时,请始终使用 useShallow 来包裹它,这是 Zustand 性能优化的第一道防线。
11.3.4 (可选) 便捷技巧:自动生成 Selectors
虽然 (state) => state.bears 写起来不复杂,但在大型项目中,重复编写这些简单的选择器也有些繁琐。官方文档提供了一个便捷的辅助函数,可以为 Store 的每个顶级属性自动生成一个专用的 Selector Hook。
创建辅助函数 (
createSelectors.ts)文件路径:
src/utils/createSelectors.ts(新建文件)1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16import { type StoreApi, type UseBoundStore } from "zustand";
type WithSelectors<S> = S extends { getState: () => infer T }
? S & { use: { [K in keyof T]: () => T[K] } }
: never;
export const createSelectors = <S extends UseBoundStore<StoreApi<object>>>(
_store: S
) => {
const store = _store as WithSelectors<typeof _store>;
store.use = {};
for (const k of Object.keys(store.getState())) {
(store.use as any)[k] = () => store((s) => s[k as keyof typeof s]);
}
return store;
};应用到我们的 Store (
bearStore.ts)文件路径:
src/stores/bearStore.ts(修改)1
2
3
4
5
6
7
8
9
10
11
12// ... imports
import { createSelectors } from './createSelectors' // 导入辅助函数
// ... BearState 接口和 initialState
// 1. 先创建一个基础的 store
const useBearStoreBase = create<BearState>((set, get) => ({
// ... store 实现
}))
// 2. 使用辅助函数包裹基础 store,导出最终版本
export const useBearStore = createSelectors(useBearStoreBase)在组件中使用
现在,我们可以用更简洁的语法来订阅状态。
文件路径:
src/components/BearCounter.tsx(修改)1
2
3
4
5
6
7
8
9
10
11
12
13
14// ... imports
export const BearCounter = () => {
// Before: const bears = useBearStore((state) => state.bears)
const bears = useBearStore.use.bears() // ✨ After: 语法更简洁
// Before: const lastUpdated = useBearStore((state) => state.lastUpdated)
const lastUpdated = useBearStore.use.lastUpdated() // ✨ After
// Before: const increase = useBearStore((state) => state.increase)
const increase = useBearStore.use.increase() // ✨ After
// ... 组件 UI 不变
}
权衡: 自动生成 Selectors 是一种提升开发体验的“语法糖”。它非常适合订阅顶层的、简单的状态。但对于需要计算、组合或订阅深层嵌套属性的复杂场景,手写 Selector 依然是更灵活、更强大的选择。请务必先掌握手写 Selector 的核心原理。
11.4. 实现派生状态 —— Zustand 中的 “Getters”
本节,我们将探索实现派生状态的正确方案,并理解 useMemo 在 Zustand 场景下的真正价值。
11.4.1 派生状态方案一:可复用的外部 Selector (最佳实践)
鉴于 Zustand Selector 的精准渲染特性,对于派生状态,最高效、最简洁、最推荐 的方案,就是我们上一节提到的 可复用的外部 Selector。
让我们直接进入最佳实践。
实战场景:创建全局的“健康指数”
在 Store 文件中定义 Selector
我们将“健康指数 (
bears * status.hungry)”这个派生逻辑集中管理。文件路径:
src/stores/bearStore.ts(修改)1
2
3
4
5// ... imports, BearState interface, create...
// 👇 在 store 定义之外,创建一个可复用的 selector
// 它是一个纯函数,入参是完整的 state,返回值是计算结果
export const selectHealthIndex = (state: BearState) => state.bears * state.status.hungry创建消费组件 (
HealthTracker.tsx)文件路径:
src/components/HealthTracker.tsx(新建文件)1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19import { selectHealthIndex, useBearStore } from '@/stores/bearStore' // 1. 导入 selector
import { Card, Statistic } from 'antd'
import React from 'react'
export const HealthTracker = () => {
// 2. 直接将 selector 函数传入 useBearStore
const healthIndex = useBearStore(selectHealthIndex)
console.log('HealthTracker component is rendering...')
return (
<Card title="健康指数 (复用 Selector)">
<Statistic
title="指数 (熊数量 * 饥饿度)"
value={healthIndex}
/>
</Card>
)
}
效果验证:
将 <HealthTracker /> 添加到 App.tsx。现在,只有当您点击“增加一只熊”(改变 bears)或“喂食”(改变 status.hungry)时,您才会在控制台看到 HealthTracker component is rendering...。当您点击“更新时间戳”时,HealthTracker 不会 重渲染。
为什么这是最佳实践?
Zustand 内部会对 Selector 的 返回值 (healthIndex,一个 number) 进行 === 比较。因为计算结果是原始类型,只有在 bears 或 hungry 真正改变导致计算结果变化时,才会触发重渲染。这个方案 自带了“记忆化”,无需任何额外优化。
11.4.2 方案二:在组件内部计算与 useMemo 的正确使用场景
既然外部 Selector 这么好,那为什么我们还需要在组件内计算,甚至需要 useMemo 呢?
场景: 当一个派生状态的逻辑 非常简单,且 只在当前这一个组件 中使用,你不想为此在外部专门定义一个函数时,可以在组件内部计算。而 useMemo 则用于 当该组件因为自身状态而重渲染时,避免重计算。
实战场景:创建一个带有内部状态的“情绪”组件
创建
BearMood.tsx组件这次,我们的“情绪”组件将拥有自己的内部状态
showDetails。文件路径:
src/components/BearMood.tsx(新建文件)1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34import { useBearStore } from '@/stores/bearStore'
import { Button, Card, Space, Typography } from 'antd'
import React, { useMemo, useState } from 'react' // 导入 useMemo 和 useState
const { Text } = Typography
export const BearMood = () => {
// 1. 组件拥有自己的内部 state
const [showDetails, setShowDetails] = useState(false)
// 2. 订阅外部 Zustand state
const hungry = useBearStore((state) => state.status.hungry)
// 3. 使用 useMemo 包裹派生状态的计算
const mood = useMemo(() => {
// 只有当 hungry 变化时,这里的 console.log 和计算才会执行
console.log('Calculating mood...')
return hungry < 50 ? '开心 😊' : '暴躁 😠'
}, [hungry]) // 传入依赖项数组
console.log('BearMood component is rendering...')
return (
<Card title="熊的情绪 (useMemo 优化)">
<Space direction="vertical" align="center">
<Text style={{ fontSize: '24px' }}>{mood}</Text>
<Button onClick={() => setShowDetails(!showDetails)}>
{showDetails ? '隐藏详情' : '显示详情'}
</Button>
{showDetails && <Text type="secondary">熊的情绪由饥饿度决定</Text>}
</Space>
</Card>
)
}在
App.tsx中使用并验证将
<BearMood />添加到App.tsx中。现在进行两个操作:- 操作一: 点击
BearFeeder中的“喂食”按钮,改变hungry的值。您会看到控制台同时打印Calculating mood...和BearMood component is rendering...。这是正确的,因为依赖项变了,需要重计算和重渲染。 - 操作二: 点击
BearMood组件自己的“显示/隐藏详情”按钮。您会看到,控制台 只打印了BearMood component is rendering...,而 没有打印Calculating mood...。
结论: 我们成功地在组件 因自身状态 (
showDetails) 而重渲染 时,通过useMemo避免了不必要的派生状态重计算。这,才是useMemo在 Zustand 场景下的真正、也是唯一的用武之地。- 操作一: 点击
11.5. 架构整合:Zustand 与 React Router 的异步最佳实践
在真实的 React 应用中,异步操作的核心是 服务端状态 的管理。而 React Router v6.4+ 提供的 loader、action 和 useFetcher,正是为处理服务端状态而生的“官方利器”。
那么,Zustand 在这个体系中应该做什么?答案是 职责分离:
| 工具 | 核心职责 | 典型场景 |
|---|---|---|
React Router (loader, action, useFetcher) | 服务端状态的“传输与事务管理” | 页面初始数据加载、表单提交、乐观更新、点赞、加入购物车等与后端直接交互的“一次性”操作。 |
| Zustand | 客户端状态的“内存与缓存管理” | 用户信息、UI 状态(弹窗开关)、多页面共享的数据缓存、复杂表单的草稿状态等。 |
本节,我们将通过两个核心实战,来体验这两种工具如何协同工作。
11.5.1 场景一:页面“微交互” - useFetcher 的主场
痛点: 像“点赞”、“订阅”这类操作,我们不希望它引起页面导航,也不希望它的加载状态污染全局 Store。
解决方案: useFetcher Hook。它允许我们在组件内部,“无痕地”调用一个路由的 action 或 loader,并独立管理自己的 loading, error, data 状态。
知识转译: 您可以把
useFetcher理解为 React Router 内置的、与路由数据流深度绑定的axios或fetch客户端。
实战:添加一个新的熊种群
我们将创建一个表单,用于向服务器“添加”一个新的熊种群。
安装 React Router
如果您的项目中还没有,请先安装,然后按照如下流程配置路由
1 | pnpm add react-router-dom |
创建“资源路由”的 Action
最佳实践是将只提供数据处理端点的 Action 逻辑,封装在独立的模块中。
文件路径:
src/actions/bearActions.ts(新建文件夹和文件)1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23import type { ActionFunctionArgs } from "react-router-dom";
// 模拟的 API 函数
const addBearSpecies = async (species: string) => {
console.log(`正在向服务器添加: ${species}`);
await new Promise((r) => setTimeout(r, 1000)); // 模拟网络延迟
if (!species || species.length < 2) {
throw new Error("熊的名称太短了!");
}
return { ok: true, message: `“${species}”已成功添加!` };
};
export const addBearAction = async ({ request }: ActionFunctionArgs) => {
try {
const formData = await request.formData();
const species = formData.get("species") as string;
const result = await addBearSpecies(species);
return result;
} catch (error) {
return { ok: false, message: (error as Error).message };
}
};在路由配置中注册 Action
我们需要一个简单的路由配置文件。
文件路径:
src/router.tsx(修改文件)1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17import { createBrowserRouter } from "react-router-dom";
import App from "@/App";
import { addBearAction } from "@/actions/bearActions";
const router = createBrowserRouter([
{
path: "/",
element: <App />,
},
// 这是一个“资源路由”,它没有 element,只提供 action
{
path: "/api/bears",
action: addBearAction,
},
]);
export default router;创建使用
useFetcher的组件文件路径:
src/components/AddBearForm.tsx(新建文件)1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40import { useFetcher } from "react-router-dom";
import { Input, Button, Alert, Card } from "antd";
import { useEffect, useRef } from "react";
export const AddBearForm = () => {
const fetcher = useFetcher<{ ok: boolean; message: string }>();
const formRef = useRef<HTMLFormElement>(null);
const isSubmitting = fetcher.state === "submitting";
// 表单提交后重置
useEffect(() => {
if (fetcher.state === "idle" && fetcher.data?.ok) {
formRef.current?.reset();
}
}, [fetcher.state, fetcher.data]);
return (
<Card title="微交互:添加熊种群 (useFetcher)">
<fetcher.Form ref={formRef} method="post" action="/api/bears">
<Input name="species" placeholder="例如:黑熊" required />
<Button
htmlType="submit"
type="primary"
loading={isSubmitting}
className="mt-4"
>
{isSubmitting ? "添加中..." : "确认添加"}
</Button>
</fetcher.Form>
{fetcher.data && (
<Alert
message={fetcher.data.message}
type={fetcher.data.ok ? "success" : "error"}
className="mt-4"
/>
)}
</Card>
);
};
关键洞察: 在整个“添加熊”的交互中,我们 完全没有使用 Zustand。useFetcher 完美地处理了与该交互相关的加载状态和服务器响应。这是最高效、最解耦的实践:组件级的服务端交互,由组件级的工具来处理。
11.5.2 场景二:页面级 loader 与 Zustand 的“水合”模式
起点:一个“Zustand-Free”的商品列表页
首先,创建一个完全不依赖 Zustand 的、由 loader 驱动的页面,以此作为我们讨论的基准。
第一步:为“商品页”创建专属 loader
loader 的职责与页面紧密绑定,所以我们为 /products 页面创建一个专属的 loader。
文件路径: src/loaders/productsLoader.ts (新建文件夹和文件)
1 | export interface Product { |
第二步:创建页面组件并配置路由
文件路径: src/pages/ProductsPage.tsx (新建文件夹和文件)
1 | import { useLoaderData } from 'react-router-dom'; |
文件路径: src/router.tsx (修改)
1 | // ... imports |
注意: App.tsx 现在需要渲染 <Outlet /> 来展示子路由。
到目前为止,这个流程清晰、简单且高效。loader 负责获取数据,ProductsPage 负责通过 useLoaderData 消费并渲染数据。在此场景下,确实不需要 Zustand。
引入需求:一个全局的“购物车”
现在,复杂度来了。我们希望在 全局导航栏(位于 App.tsx 中,独立于页面)中有一个购物车图标,它需要实时显示购物车中的商品数量。当用户在 ProductsPage 点击“加入购物车”时,导航栏的图标需要立刻更新。
问题出现了:ProductsPage 如何通知一个与它毫无关系的兄弟(甚至叔伯)组件 HeaderCart?loader 的数据流是单向且局限于路由树的,无法解决这个问题。
这,就是 Zustand 登场的时刻。 它将作为跨组件通信的“全局事件总线”和“共享状态中心”。
最终方案:loader 水合 + Zustand 管理交互
第一步:创建一个 productStore
文件路径: src/stores/productStore.ts (新建文件)
1 | import { create } from "zustand"; |
第二步:在 ProductsPage 执行一次性水合
ProductsPage 的职责是:从 loader 拿到数据,然后把它“喂”给 Zustand,完成使命。
文件路径: src/pages/ProductsPage.tsx (修改)
1 | import { useLoaderData } from "react-router-dom"; |
第三步:创建并消费全局购物车组件
文件路径: src/components/HeaderCart.tsx (新建文件)
1 | import { useProductStore } from "@/stores/productStore"; |
第四步:组装应用并验证最终效果
现在我们已经拥有了 ProductsPage (负责水合)、ProductList (负责展示) 和 HeaderCart (负责全局展示) 三个核心组件,让我们将它们整合到一个完整的应用中。
创建应用布局 (App.tsx)
App.tsx 将作为我们应用的顶层“外壳”,它包含全局共享的布局(如顶部导航栏)和用于渲染子页面的 <Outlet />。
文件路径: src/App.tsx (修改)
1 | import { Layout, Menu } from 'antd'; |
现在,您的项目结构已经完整。请执行 pnpm run dev 并进行以下操作来验证我们的架构:
- 访问商品页: 在浏览器中打开
http://localhost:5173/products。 - 观察
loader工作: 您会看到页面首先处于加载状态(这取决于您的网络和loader中的setTimeout),随后ProductList组件被渲染出来,显示商品列表。同时,页面右上角的购物车图标显示徽标数字0。 - 触发跨组件更新: 点击任意商品的“加入购物车”按钮。
- 验证结果: 您会观察到,页面 没有刷新,但右上角的
<HeaderCart>组件中的徽标数字 立即增加 了。
这个过程完美地展示了我们设计的架构:
loader负责/products页面的 初始数据获取。ProductsPage作为“协调者”,将loader数据 水合 到productStore。ProductList和HeaderCart这两个 完全不相关的组件,都从productStore这同一个“事实来源”订阅数据,从而实现了状态的 全局同步。
这套清晰、解耦的架构,就是 Zustand 与 React Router 协同工作的最佳实践。
11.6. 使用中间件增强 Store
到目前为止,我们使用的 create 函数的核心功能是创建包含 state 和 action 的 Store。而“中间件”则像一系列可插拔的“增强插件”,它们可以包裹原始的 create 函数,为我们的 Store 赋予强大的新能力,如不可变状态的便捷更新、连接调试工具、本地持久化等。
11.6.1 中间件是什么?
您可以将 Zustand 的中间件,类比为您在 Vue 生态中熟悉的 Pinia Plugins。它们都遵循类似的“包装”模式,对 Store 的核心行为进行扩展。
Zustand 的中间件本质上是高阶函数,它们接收一个状态创建函数 ((set, get) => ({...})) 作为输入,并返回一个增强版的、新的状态创建函数。我们可以像“套娃”一样,将多个中间件组合在一起使用。
11.6.2 实战一:使用 immer 终结嵌套更新的痛苦
我们在 11.2 节已经深刻体会到,手动处理嵌套状态的更新是多么繁琐和易错。immer 中间件正是解决这一痛点的终极武器。
第一步:创建新的 settingsStore (未使用 Immer 的痛苦版本)
我们首先回顾一下,如果不使用 immer,更新嵌套状态是多么不便。
文件路径: src/stores/settingsStore.ts (新建文件)
1 | import { create } from 'zustand' |
第二步:安装并正确集成 immer
首先,安装 immer 依赖。
1 | pnpm add immer |
接下来,我们严格按照官方文档的规范,使用 create<T>()(immer(...)) 的柯里化语法来重构 Store。
文件路径: src/stores/settingsStore.ts (修改)
注意: Zustand 5.0 版本的已知 TypeScript 类型定义问题,使用 as any 是目前推荐的临时解决方案,直到官方修复这个类型问题。
1 | import { create } from "zustand"; |
心智模型回归: 集成 immer 后,您更新状态的方式几乎与 Pinia 完全一致,极大地降低了心智负担。这是处理任何嵌套状态的 首选方案。
11.6.4 实战三:使用 persist 实现状态持久化
persist 中间件可以将 Store 的状态自动保存到 localStorage (或其他存储) 中,并在页面刷新后自动恢复。
第一步:集成 persist 中间件
persist 也是内置的。最佳实践是将它包裹在 devtools 内部,形成 persist(devtools(immer(...))) 的结构,这样 persist 自身的水合 (hydration) 行为也能被 DevTools 捕获到。
文件路径: src/stores/settingsStore.ts (最终版)
1 | import { create } from "zustand"; |
核心配置:
name: (必需) 这是保存在localStorage中的键名,必须是唯一的。partialize: (强烈推荐) 这是一个函数,用于指定 哪些 state 需要被持久化。这非常重要,可以避免将临时的 UI 状态(如loading,error)或无需持久化的数据存入本地。
效果验证:
- 创建一个消费此 Store 的组件。
- 在页面上修改设置(如切换通知开关)。
- 打开浏览器的“开发者工具” -> “应用(Application)” -> “本地存储空间(Local Storage)”,您会看到一个名为
user-settings-storage的条目,其中包含了您修改后的theme和notifications状态。 - 刷新页面。您会发现,页面加载后,您之前的设置被完美地恢复了。
最终环节:创建消费组件并验证
文件路径: src/components/SettingsForm.tsx (新建文件)
1 | import { useSettingsStore } from '@/stores/settingsStore'; |
将 <SettingsForm /> 添加到 App.tsx 中即可看到最终效果。
11.7. 企业级应用:切片模式 与 Store 组织
随着应用的复杂度增加,将所有状态和 Action 都堆砌在同一个 create 函数中,会迅速演变成一场维护性的灾难。文件可能长达数百甚至上千行,逻辑彼此交错,多人协作时代码合并的冲突也会愈发频繁。
切片模式 是 Zustand 官方和社区公认的,解决大型 Store 组织问题的最佳架构实践。
11.7.1 痛点分析:为什么单一 Store 会“野蛮生长”?
想象一下,我们需要一个 Store 来同时管理用户认证 (user) 和购物车 (cart)。一个初期的、未经组织的 Store 可能会是这样:
反面教材:一个臃肿的 useAppStore.ts
1 | import { create } from 'zustand' |
问题所在:
- 职责混乱: 用户认证逻辑和购物车逻辑完全混在同一个文件中,没有任何物理隔离。
- 难以维护: 如果购物车的逻辑变得更复杂(例如,添加优惠券、库存检查),这个文件会迅速膨胀,难以阅读和修改。
- 协作冲突: 如果开发者 A 负责用户功能,开发者 B 负责购物车功能,他们将不断地在同一个文件上产生 Git 冲突。
11.7.2 创建独立的 State 切片
“切片”的核心思想,就是将 Store 按照业务领域(Domain)拆分成独立的、可维护的模块。每个“切片”文件只负责自己领域的状态和 Action。
从技术上讲,一个切片就是一个函数,它接收 set 和 get 作为参数,并返回该切片的状态对象。
第一步:创建 userSlice
文件路径: src/stores/slices/userSlice.ts (新建文件夹和文件)
StateCreator 是什么?StateCreator 是 Zustand 提供的一个工具类型,专门用于为切片提供准确的类型定义。它能确保我们的切片创建函数接收正确的 set, get, api 参数类型。
1 | import { type StateCreator } from "zustand"; |
第二步:创建 cartSlice
文件路径: src/stores/slices/cartSlice.ts (新建文件)
1 | import { type StateCreator } from "zustand"; |
11.7.3 组合多个切片,构建一个统一的、模块化的 Root Store
创建好独立的切片后,我们需要一个“主” Store 文件来将它们组合在一起。
文件路径: src/stores/useAppStore.ts (新建文件)
1 | import { create } from 'zustand' |
代码解读:
- 组合类型: 我们将
UserSlice和CartSlice的类型通过&操作符合并成一个完整的AppState类型。 - 组合实现:
create函数的回调现在变得极其简洁。它使用(...a)将接收到的set,get,api等参数,原封不动地传递给每个切片创建函数 (createUserSlice,createCartSlice)。 - 展开合并: 每个切片创建函数返回自己的状态对象,我们使用对象展开运算符 (
...) 将它们合并成一个最终的、完整的 Store 状态对象。
现在,我们的 Store 在物理上是分离的、模块化的,但在逻辑上又是一个统一的、完整的 Store。
11.7.4 切片模式的权衡与思考
优势:
- 关注点分离: 每个文件只关心自己的业务,代码清晰。
- 易于维护与协作: 不同功能的代码在不同文件,极大降低了维护成本和协作冲突。
- 可测试性: 可以对每个切片进行独立的单元测试。
成本与考量:
- 少量模板代码: 相较于单一 Store,切片模式引入了
StateCreator类型和切片创建函数等少量“模板”代码。 - 跨切片调用: 一个切片可以调用另一个切片的 Action 吗?可以。因为
get函数被传递给了每个切片,所以cartSlice可以通过get().user来获取用户状态,或通过get().logout()来调用用户 Action。但需要注意,过度地跨切片调用,会重新引入逻辑耦合,违背了切片模式的初衷。应谨慎使用。
结论: 切片模式是 Zustand 应用扩展到中大型项目时的 必然选择。当您的单一 Store 开始变得臃肿和混乱时,就应该毫不犹豫地采用切片模式进行重构。













