第十一章 Zustand 教程:React 轻量状态管理从入门到精通,掌握高效状态定义与组件通信


第十一章:Zustand:轻量高效的 React 状态管理

—— 现代 React 项目的状态管理最佳实践

摘要: 本章是为有经验的 Vue (Pinia) 开发者量身定制的 Zustand 实战指南。我们将跳过繁琐的理论铺垫,直击核心,将 Zustand 定位为 Pinia 在 React 生态中的“战略级对等物”。您将学习如何利用 Zustand 简洁的 API 实现状态管理、性能优化、异步处理,并解决从 Vue 的“可变”心智模型到 React “不可变”模型的平滑过渡。最终,您将掌握一套在企业级应用中组织、测试和扩展 Zustand Store 的现代化工程实践。


在本章中,我们将遵循一条为“转译者”精心设计的路线图,逐步攻克 React 状态管理:

  1. 首先,我们将从 定位与心智模型 出发,清晰界定 Zustand 的生态位,并建立它与 Pinia 的核心概念映射,彻底打消您“为什么选它”的疑虑。
  2. 接着,我们将进入 基础实践,以最快的速度创建并使用您的第一个 Store,并集成 TypeScript。
  3. 然后,我们将立即深入 React 性能的命脉——Selector 与渲染优化,这是从 Vue 自动追踪依赖到 React 手动优化的关键转变。
  4. 为了解决您最大的心智模型障碍,我们专门开辟一章讲解 不可变性,并引入 immer 作为平滑过渡的终极解决方案。
  5. 掌握核心后,我们将探索 派生状态 (Getters)、异步流程 和 核心中间件 等高级特性,让您具备应对复杂业务的能力。
  6. 最后,我们将聚焦于 企业级应用,学习如何通过切片模式(Slice Pattern)组织大型状态,并为其编写健壮的测试。

11.1. 核心定位与概念映射

11.1.1 核心定位:为什么选择 Zustand 而不是 Redux?

对于一位经验丰富的开发者而言,技术选型的核心在于理解其设计哲学与权衡。在 React 状态管理的“丛林”中,Redux 曾是事实上的标准,但它的时代背景和设计也带来了相应的“历史包袱”。

根据官方文档的描述,Zustand 是一个 小型、快速且可扩展的极简状态管理解决方案。它的核心优势在于提供了一个基于 Hooks 的舒适 API,并且 既没有繁重的模板代码,也没有强制性的设计范式。

这与 Redux 形成了鲜明对比。为了更直观地理解这一点,让我们模拟一段您在进行技术选型时可能会有的内心对话。

技术选型研讨
2025-10-04
我

我看很多 React 的招聘要求和老项目里都提到了 Redux。为什么我们的新项目要选择 Zustand 这个看起来更“小众”的库?它足够稳定和强大吗?

R
React 架构师

问得好。这正是关键所在。Redux 的核心是 Flux 架构,要求严格的单向数据流,这在大型、复杂的项目中非常稳健,但也带来了大量的“模板代码”——你需要定义 Actions, Reducers, Dispatchers, 甚至使用 Redux Toolkit (RTK) 来简化这个过程。

我

确实,我在 Vuex 的早期版本里也体会过类似的繁琐。

R
React 架构师

而 Zustand 的哲学完全不同。它认为对于大多数现代应用来说,这种严格的束缚是不必要的。它让你像使用一个普通的 JavaScript 对象一样管理状态,但又通过 Hooks 实现了与 React 组件的精确绑定。

我

听起来更像是 Pinia 的感觉,很直接。

R
React 架构师

完全正确!更重要的是,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 的入口函数。
statestate (在 create 的回调函数中)两者都是用于定义基础状态数据的对象。
actionsactions (在 create 的回调函数中)定义修改状态的方法。主要区别是 Zustand 的 action 必须通过 set() 函数来完成状态更新。
getters无直接对等物这是最大的区别之一。Zustand 推荐在组件内使用 useMemo 或创建可复用的 Selector 函数来处理派生状态。
pluginsmiddleware用于增强 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
2
3
4
5
6
# 创建一个名为 zustand-practice 的项目
pnpm create vite zustand-practice --template react-ts

# 进入项目目录并安装依赖
cd zustand-practice
pnpm install

第二步:集成 Tailwind CSS v4

我们将采用最新的 CSS-First 配置方式来集成 Tailwind。

  1. 安装核心依赖

    在项目根目录,安装 Tailwind CSS v4 和官方 Vite 插件。

    1
    pnpm add -D tailwindcss@next @tailwindcss/vite
  2. 配置 Vite 插件

    编辑 vite.config.ts 文件,引入并使用 @tailwindcss/vite 插件。

    文件路径: vite.config.ts

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    import { defineConfig } from 'vite'
    import react from '@vitejs/plugin-react'
    import tailwindcss from '@tailwindcss/vite' // 1. 导入插件

    export default defineConfig({
    plugins: [
    react(),
    tailwindcss(), // 2. 将插件添加到 plugins 数组
    ],
    })
  3. 在主 CSS 文件中引入 Tailwind

    清空 src/index.css 的所有内容,然后只添加以下一行。

    文件路径: src/index.css

    1
    @import "tailwindcss";

    核心变化: 这一行 @import "tailwindcss"; 是 v4 Rust 引擎的唯一入口,它会智能处理 base, components, utilities 层的注入,无需任何额外配置。


第三步:配置 Vite 路径别名 (@/)

为了提升代码可维护性,我们配置 @ 别名指向 src 目录。

  1. 修改 Vite 配置

    再次编辑 vite.config.ts,增加 resolve.alias 配置。

    文件路径: vite.config.ts (修改后)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    import { 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'),
    },
    },
    })
  2. 同步 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> 组件的现代化集成方案。

  1. 安装 Ant Design

    1
    pnpm add antd
  2. 配置顶层 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
    21
    import 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。

  1. 安装 Zustand

    1
    pnpm install zustand
  2. 创建 Store 文件

    文件路径: src/stores/bearStore.ts

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    import { 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 (修改)

img

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
import { useBearStore } from '@/stores/bearStore' // 1. 导入 Zustand store (使用路径别名)
import { App as AntdApp, Button, Card, Flex, Statistic } from 'antd' // 2. 导入 Antd 组件

function App() {
// 从 antd 的 App context 中获取 message API
const { message } = AntdApp.useApp()

// 从 Zustand store 中获取状态和 action
const bears = useBearStore((state) => state.bears)
const increase = useBearStore((state) => state.increase)

const handleIncrease = () => {
increase()
message.success(`熊的数量增加到了 ${bears + 1} 只!`)
}

return (
// 3. 使用 Tailwind CSS 进行全局页面布局
<div className="flex justify-center items-center h-screen bg-slate-100 p-8">
{/* 4. 使用 Ant Design 组件构建 UI */}
<Card title="Zustant快速上手" style={{ width: 400 }}>
<Flex vertical align="center" gap="large">
<Statistic title="当前熊的数量 (来自 Zustand)" value={bears} />
<Button type="primary" size="large" onClick={handleIncrease}>
增加一只熊
</Button>
</Flex>
</Card>
</div>
)
}

export default App

现在,运行 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 一样,遵循 不可变 的状态更新模式。

心智模型对比
2025-10-04 21:28
我

在 Pinia 中,我可以很直观地 store.count++ 或者 store.user.name = 'new name' 来修改状态。这种“可变”操作非常符合直觉。

R
React 架构师

这是 Vue 响应式系统(基于 Proxy)和 React 状态系统(基于 Immutability)的根本区别。在 React 的世界里,我们从不“修改”原始状态,而是用一个“全新的”状态对象来替换它。

我

听起来效率很低,每次都要创建新对象?

R
React 架构师

恰恰相反,这正是 React 高效更新机制(Virtual DOM diffing)的基础。通过比较新旧两个状态对象的引用(地址),React 可以瞬间知道状态是否变更,从而决定是否需要更新 UI。Zustand 的 set 函数正是为这种模式而生,并且它还提供了一些便利的特性。

特性一:自动的浅层合并

为了简化操作,Zustand 的 set 函数默认会执行 浅层合并。这意味着您只需要提供要更新的字段,Zustand 会自动将它与现有状态合并,无需手动扩展 (...) 其他属性。

实战应用:创建熊计数器组件

  1. 更新 Store (bearStore.ts)

    我们为 Store 添加 lastUpdated 状态,用于演示浅层合并。

    文件路径: src/stores/bearStore.ts

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    import { 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() })),
    }));

  2. 创建消费组件 (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
    24
        import { 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 函数的自动合并 只在一层深度有效。当您需要更新嵌套对象时,必须手动处理深层对象的不可变更新。

  1. 更新 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
    26
    import { 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 作为解决此问题的终极方案。但现在,请务必理解这个手动过程。

  2. 创建消费组件 (BearFeeder.tsx)

    文件路径: src/components/BearFeeder.tsx (新建文件)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    import { 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// ... interface BearState { ... }

// 1. 在 create 的回调中接收 get 参数
export const useBearStore = create<BearState>((set, get) => ({
bears: 0,
lastUpdated: number | null;
stats: { hungry: 100 },

increase: () => {
// 2. 使用 get() 安全地获取最新 bears 值来计算新值
const newBears = get().bears + 1
set({
bears: newBears,
lastUpdated: Date.now() // 同时更新时间戳
})
},
feed: (amount) => { /* ... */ },
}))

效果验证: 您无需修改 BearCounter.tsx 组件。现在当您点击“增加一只熊”按钮时(按钮文字可改回),会发现数量和时间戳同时被更新了。


11.2.3 组件外部的 API:getState 与 setState

useBearStore 不仅是一个 Hook,也是一个 Store 实例,允许我们在 React 组件外部与之交互,这在调试或与非 React 库集成时非常有用。

实战应用:创建外部调试与重置工具

  1. 更新 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
    45
    import { 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 在内部执行的逻辑是这样的:

  1. 获取当前 Store 的完整状态,它看起来像:{ bears, lastUpdated, status, increase, feed, reset }。
  2. 获取您传入的 initialState 对象:{ bears: 0, lastUpdated: null, status: { hungry: 100 } }。
  3. 执行一次 Object.assign({}, currentState, initialState) 操作。
  4. initialState 里的 bears, lastUpdated, status 字段会覆盖掉 currentState 中对应的旧值。
  5. 而 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 的返回值是一个每次都新创建的对象或数组时,会发生什么?

实战场景:创建一个只显示所有熊名字的组件

  1. 更新 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 用于触发不相关的更新
    }
  2. 创建有性能问题的组件 (BearNames.tsx)

    这个组件的职责很简单:只显示所有熊的名字。

    文件路径: src/components/BearNames.tsx (新建文件)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    import { 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>
    )
    }
  3. 在 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>
    )
    }

现在,打开浏览器的控制台,就能看到如下报错:

image-20251005095120982

**原因**: `Object.keys()` 函数在每次被调用时,都会返回一个 **全新的数组实例**。因此,`prevResult === newResult` 的比较结果永远是 `false`,导致了组件在 **任何** Store 状态变更时都会不必要地递归重渲染,在 JavaScript 中,使用 `===` 运算符进行严格比较时,如果比较的是两个数组,即使这两个数组的内容完全相同,其比较结果也是 `false`。这是因为 `===` 运算符比较的是两个对象的引用是否相同,即它们是否指向内存中的同一个地址,而不是比较它们的内容是否相同。

11.3.3 解决方案:使用 useShallow Hook 优化订阅

为了解决这个陷阱,Zustand 提供了一个专门的 Hook:useShallow。

useShallow 的作用是 将默认的严格引用比较 (===),替换为对数组或对象内部元素的“浅层比较”。它会逐一比较新旧两个数组(或对象)的成员,只有当成员发生变化时,才认为结果已改变。

实战应用:修复 BearNames 组件

修复过程极其简单,只需两步:

文件路径: src/components/BearNames.tsx (修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { useBearStore } from '@/stores/bearStore'
import { Card, Tag } from 'antd'
import React from 'react'
import { useShallow } from 'zustand/react/shallow' // 1. 导入 useShallow

export const BearNames = () => {
console.log('BearNames component is rendering...')

// 2. 使用 useShallow 包裹原来的 Selector
const names = useBearStore(useShallow((state) => Object.keys(state.bearHabits)))

return (
<Card title="所有熊的名字 (性能已优化)">
{/* ... UI 不变 ... */}
</Card>
)
}

现在,您会发现控制台不再有递归循环的报错,不必要的重渲染被成功阻止了!

最佳实践: 当您的 Selector 需要返回一个 非原始值(数组或对象)时,请始终使用 useShallow 来包裹它,这是 Zustand 性能优化的第一道防线。


11.3.4 (可选) 便捷技巧:自动生成 Selectors

虽然 (state) => state.bears 写起来不复杂,但在大型项目中,重复编写这些简单的选择器也有些繁琐。官方文档提供了一个便捷的辅助函数,可以为 Store 的每个顶级属性自动生成一个专用的 Selector Hook。

  1. 创建辅助函数 (createSelectors.ts)

    文件路径: src/utils/createSelectors.ts (新建文件)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    import { 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;
    };
  2. 应用到我们的 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)
  3. 在组件中使用

    现在,我们可以用更简洁的语法来订阅状态。

    文件路径: 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。

让我们直接进入最佳实践。

实战场景:创建全局的“健康指数”

  1. 在 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
  2. 创建消费组件 (HealthTracker.tsx)

    文件路径: src/components/HealthTracker.tsx (新建文件)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    import { 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 则用于 当该组件因为自身状态而重渲染时,避免重计算。

实战场景:创建一个带有内部状态的“情绪”组件

  1. 创建 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
    34
    import { 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>
    )
    }
  2. 在 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

文件路径: src/router/index.tsx (新建文件夹和文件)

1
2
3
4
5
6
7
8
9
10
11
import { createBrowserRouter } from "react-router-dom";
import App from "@/App";

const router = createBrowserRouter([
{
path: "/",
element: <App />,
},
]);

export default router;

文件路径: src/main.tsx (修改文件)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import React from "react";
import ReactDOM from "react-dom/client";
import "./index.css";
import { RouterProvider } from "react-router-dom";

import router from "@/router";

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}>
<AntdApp>
<RouterProvider router={router} />
</AntdApp>
</ConfigProvider>
</React.StrictMode>
);
  1. 创建“资源路由”的 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
    23
    import 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 };
    }
    };

  2. 在路由配置中注册 Action

    我们需要一个简单的路由配置文件。

    文件路径: src/router.tsx (修改文件)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    import { 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;
  3. 创建使用 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
    40
    import { 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
export interface Product {
id: number;
name: string;
price: number;
}

// 模拟 API
export const productsLoader = async (): Promise<Product[]> => {
console.log('Fetching products for the page...');
await new Promise(r => setTimeout(r, 1000));
return [
{ id: 1, name: '超级蜂蜜', price: 99 },
{ id: 2, name: '豪华果酱', price: 128 },
{ id: 3, name: '至尊熊掌(人造)', price: 599 },
];
};

第二步:创建页面组件并配置路由

文件路径: src/pages/ProductsPage.tsx (新建文件夹和文件)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import { useLoaderData } from 'react-router-dom';
import { List, Card } from 'antd';
import type { Product } from '@/loaders/productsLoader';

export const ProductsPage = () => {
const products = useLoaderData() as Product[];

return (
<Card title="商品列表 (由 Loader 驱动)">
<List
dataSource={products}
renderItem={(item) => (
<List.Item>
<List.Item.Meta title={item.name} description={`¥ ${item.price}`} />
</List.Item>
)}
/>
</Card>
);
};

文件路径: src/router.tsx (修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// ... imports
import { ProductsPage } from './pages/ProductsPage';
import { productsLoader } from './loaders/productsLoader';

const router = createBrowserRouter([
{
path: '/',
element: <App />, // App 现在是布局组件
children: [
{
path: '/products',
element: <ProductsPage />,
loader: productsLoader, // 👈 loader 与页面元素绑定
},
],
},
// ... 其他路由
]);

注意: App.tsx 现在需要渲染 <Outlet /> 来展示子路由。

到目前为止,这个流程清晰、简单且高效。loader 负责获取数据,ProductsPage 负责通过 useLoaderData 消费并渲染数据。在此场景下,确实不需要 Zustand。


引入需求:一个全局的“购物车”

现在,复杂度来了。我们希望在 全局导航栏(位于 App.tsx 中,独立于页面)中有一个购物车图标,它需要实时显示购物车中的商品数量。当用户在 ProductsPage 点击“加入购物车”时,导航栏的图标需要立刻更新。

问题出现了:ProductsPage 如何通知一个与它毫无关系的兄弟(甚至叔伯)组件 HeaderCart?loader 的数据流是单向且局限于路由树的,无法解决这个问题。

这,就是 Zustand 登场的时刻。 它将作为跨组件通信的“全局事件总线”和“共享状态中心”。


最终方案:loader 水合 + Zustand 管理交互

第一步:创建一个 productStore

文件路径: src/stores/productStore.ts (新建文件)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { create } from "zustand";
import type { Product } from "@/loaders/productsLoader";

interface ProductState {
products: Product[];
cart: { id: number; count: number }[];
setProducts: (products: Product[]) => void;
addToCart: (productId: number) => void;
}

export const useProductStore = create<ProductState>((set) => {
return {
products: [],
cart: [],
setProducts: (products) => set({ products }),
addToCart: (productId) =>
set((state) => ({ cart: [...state.cart, { id: productId, count: 1 }] })),
};
});

第二步:在 ProductsPage 执行一次性水合

ProductsPage 的职责是:从 loader 拿到数据,然后把它“喂”给 Zustand,完成使命。

文件路径: src/pages/ProductsPage.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
40
41
42
43
44
45
46
47
import { useLoaderData } from "react-router-dom";
import { useProductStore } from "@/stores/productStore";
import React, { useRef } from "react";
import { List, Card, Button } from "antd";
import type { Product } from "@/loaders/productsLoader";

export const ProductsPage = () => {
// 1.将原来的 loader数据传递给zustand
const initialProducts = useLoaderData() as Product[];
const setProducts = useProductStore((state) => state.setProducts);

// 使用 useRef 来标记是否已经将 loader 数据同步到 Zustand store
// 为什么需要这个?
// - React 组件在开发模式下会渲染两次(Strict Mode)
// - 每次渲染都会执行 setProducts,导致重复设置
// - useRef 的值在组件的整个生命周期中保持不变,不会因为重新渲染而重置
// - 这样可以确保 setProducts 只在组件首次挂载时执行一次
const isHydrated = useRef(false);

if (!isHydrated.current) {
setProducts(initialProducts);
isHydrated.current = true;
}

return <ProductList />;
};

const ProductList = () => {
const { products, addToCart } = useProductStore();

return (
<Card title="商品列表 (Zustand 驱动)">
<List
dataSource={products}
renderItem={(item) => (
<List.Item
actions={[
<Button onClick={() => addToCart(item.id)}>加入购物车</Button>,
]}
>
<List.Item.Meta title={item.name} description={`¥ ${item.price}`} />
</List.Item>
)}
/>
</Card>
);
};
第三步:创建并消费全局购物车组件

文件路径: src/components/HeaderCart.tsx (新建文件)

1
2
3
4
5
6
7
8
9
10
11
12
13
import { useProductStore } from "@/stores/productStore";
import { Badge } from "antd";
import { ShoppingCartOutlined } from '@ant-design/icons';

export const HeaderCart = () => {
const cartCount = useProductStore((state) => state.cart.length);

return (
<Badge count={cartCount}>
<ShoppingCartOutlined style={{ fontSize: '24px' }} />
</Badge>
)
}

第四步:组装应用并验证最终效果

现在我们已经拥有了 ProductsPage (负责水合)、ProductList (负责展示) 和 HeaderCart (负责全局展示) 三个核心组件,让我们将它们整合到一个完整的应用中。

创建应用布局 (App.tsx)

App.tsx 将作为我们应用的顶层“外壳”,它包含全局共享的布局(如顶部导航栏)和用于渲染子页面的 <Outlet />。

文件路径: src/App.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
import { Layout, Menu } from 'antd';
import { NavLink, Outlet } from 'react-router-dom';
import { HeaderCart } from './components/HeaderCart';

const { Header, Content } = Layout;

const App = () => {
return (
<Layout style={{ minHeight: '100vh' }}>
<Header style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Menu theme="dark" mode="horizontal" defaultSelectedKeys={['/products']}>
<Menu.Item key="/">
<NavLink to="/">首页</NavLink>
</Menu.Item>
<Menu.Item key="/products">
<NavLink to="/products">商品</NavLink>
</Menu.Item>
</Menu>
{/* 将全局购物车图标放置在布局的顶层 */}
<HeaderCart />
</Header>
<Content style={{ padding: '24px' }}>
{/* Outlet 是 React Router 的占位符,用于渲染匹配到的子路由组件 */}
<Outlet />
</Content>
</Layout>
);
};

export default App;

现在,您的项目结构已经完整。请执行 pnpm run dev 并进行以下操作来验证我们的架构:

  1. 访问商品页: 在浏览器中打开 http://localhost:5173/products。
  2. 观察 loader 工作: 您会看到页面首先处于加载状态(这取决于您的网络和 loader 中的 setTimeout),随后 ProductList 组件被渲染出来,显示商品列表。同时,页面右上角的购物车图标显示徽标数字 0。
  3. 触发跨组件更新: 点击任意商品的“加入购物车”按钮。
  4. 验证结果: 您会观察到,页面 没有刷新,但右上角的 <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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import { create } from 'zustand'

interface SettingsState {
theme: {
mode: 'light' | 'dark'
primaryColor: string
}
setPrimaryColor: (newColor: string) => void
}

// 这是手动处理嵌套更新的“痛苦”版本
export const useSettingsStore_v1 = create<SettingsState>((set) => ({
theme: {
mode: 'light',
primaryColor: '#1677ff',
},
setPrimaryColor: (newColor) =>
set((state) => ({
theme: {
...state.theme, // 👈 必须手动展开
primaryColor: newColor,
},
})),
}))

第二步:安装并正确集成 immer

首先,安装 immer 依赖。

1
pnpm add immer

接下来,我们严格按照官方文档的规范,使用 create<T>()(immer(...)) 的柯里化语法来重构 Store。

文件路径: src/stores/settingsStore.ts (修改)

注意: Zustand 5.0 版本的已知 TypeScript 类型定义问题,使用 as any 是目前推荐的临时解决方案,直到官方修复这个类型问题。

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
import { create } from "zustand";
import { immer } from "zustand/middleware/immer";

interface SettingsState {
theme: {
mode: "light" | "dark";
primaryColor: string;
};
notifications: {
email: boolean;
push: boolean;
};
setPrimaryColor: (newColor: string) => void;
toggleEmailNotifications: () => void;
}

// Zustand 5.0 immer 正确语法 (使用类型断言解决 TypeScript 错误)
export const useSettingsStore = create<SettingsState>()(
immer((set) => ({
theme: {
mode: "light",
primaryColor: "#1677ff",
},
notifications: {
email: true,
push: false,
},
setPrimaryColor: (newColor: string) =>
set((state: SettingsState) => {
// 3. ✨ 现在可以像在 Pinia 中一样,直接 "修改" 状态!
state.theme.primaryColor = newColor;
}),
toggleEmailNotifications: () =>
set((state: SettingsState) => {
state.notifications.email = !state.notifications.email;
}),
})) as any
);

心智模型回归: 集成 immer 后,您更新状态的方式几乎与 Pinia 完全一致,极大地降低了心智负担。这是处理任何嵌套状态的 首选方案。


11.6.4 实战三:使用 persist 实现状态持久化

persist 中间件可以将 Store 的状态自动保存到 localStorage (或其他存储) 中,并在页面刷新后自动恢复。

第一步:集成 persist 中间件

persist 也是内置的。最佳实践是将它包裹在 devtools 内部,形成 persist(devtools(immer(...))) 的结构,这样 persist 自身的水合 (hydration) 行为也能被 DevTools 捕获到。

文件路径: src/stores/settingsStore.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
45
46
47
48
49
50
51
import { create } from "zustand";
import { immer } from "zustand/middleware/immer";
import { devtools, persist } from "zustand/middleware";

interface SettingsState {
theme: {
mode: "light" | "dark";
primaryColor: string;
};
notifications: {
email: boolean;
push: boolean;
};
setPrimaryColor: (newColor: string) => void;
toggleEmailNotifications: () => void;
}

// Zustand 5.0 多中间件组合 (使用类型断言解决 TypeScript 错误)
export const useSettingsStore = create<SettingsState>()(
persist(
devtools(
immer<SettingsState>((set) => ({
theme: {
mode: "light",
primaryColor: "#1677ff",
},
notifications: {
email: true,
push: false,
},
setPrimaryColor: (newColor: string) =>
set((state: SettingsState) => {
// 3. ✨ 现在可以像在 Pinia 中一样,直接 "修改" 状态!
state.theme.primaryColor = newColor;
}),
toggleEmailNotifications: () =>
set((state: SettingsState) => {
state.notifications.email = !state.notifications.email;
}),
})),
{ name: "UserSettingsStore" }
),
{
name: "user-settings-storage",
partialize: (state: SettingsState) => ({
theme: state.theme,
notifications: state.notifications,
}),
}
) as any
);

核心配置:

  • name: (必需) 这是保存在 localStorage 中的键名,必须是唯一的。
  • partialize: (强烈推荐) 这是一个函数,用于指定 哪些 state 需要被持久化。这非常重要,可以避免将临时的 UI 状态(如 loading, error)或无需持久化的数据存入本地。

效果验证:

  1. 创建一个消费此 Store 的组件。
  2. 在页面上修改设置(如切换通知开关)。
  3. 打开浏览器的“开发者工具” -> “应用(Application)” -> “本地存储空间(Local Storage)”,您会看到一个名为 user-settings-storage 的条目,其中包含了您修改后的 theme 和 notifications 状态。
  4. 刷新页面。您会发现,页面加载后,您之前的设置被完美地恢复了。

最终环节:创建消费组件并验证

文件路径: src/components/SettingsForm.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
import { useSettingsStore } from '@/stores/settingsStore';
import { Card, ColorPicker, Switch, Form, Typography } from 'antd';

export const SettingsForm = () => {
// 使用 useShallow 订阅多个状态
const { theme, notifications, setPrimaryColor, toggleEmailNotifications } =
useSettingsStore((state) => state);

return (
<Card title="用户偏好设置 (由中间件增强)">
<Form layout="vertical">
<Form.Item label="主题主色">
<ColorPicker
value={theme.primaryColor}
onChange={(color) => setPrimaryColor(color.toHexString())}
/>
</Form.Item>
<Form.Item label="接收邮件通知">
<Switch
checked={notifications.email}
onChange={toggleEmailNotifications}
/>
</Form.Item>
<Typography.Text type="secondary">
尝试修改设置,然后刷新页面查看持久化效果。同时观察 Redux DevTools。
</Typography.Text>
</Form>
</Card>
);
};

将 <SettingsForm /> 添加到 App.tsx 中即可看到最终效果。


11.7. 企业级应用:切片模式 与 Store 组织

随着应用的复杂度增加,将所有状态和 Action 都堆砌在同一个 create 函数中,会迅速演变成一场维护性的灾难。文件可能长达数百甚至上千行,逻辑彼此交错,多人协作时代码合并的冲突也会愈发频繁。

切片模式 是 Zustand 官方和社区公认的,解决大型 Store 组织问题的最佳架构实践。

11.7.1 痛点分析:为什么单一 Store 会“野蛮生长”?

想象一下,我们需要一个 Store 来同时管理用户认证 (user) 和购物车 (cart)。一个初期的、未经组织的 Store 可能会是这样:

反面教材:一个臃肿的 useAppStore.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
import { create } from 'zustand'

// 状态和 Action 的类型定义混在一起
interface AppState {
// User apecific state
user: { name: string } | null;
// Cart specific state
cart: { items: { id: number; name: string }[]; total: number };

// User specific actions
login: (username: string) => void;
logout: () => void;
// Cart specific actions
addToCart: (item: { id: number; name: string }) => void;
clearCart: () => void;
}

export const useAppStore = create<AppState>((set, get) => ({
user: null,
cart: { items: [], total: 0 },

login: (username) => set({ user: { name: username } }),
logout: () => set({ user: null }),

addToCart: (item) => set((state) => ({
cart: {
...state.cart,
items: [...state.cart.items, item],
},
})),

clearCart: () => set((state) => ({
cart: {
...state.cart,
items: [],
},
})),
}))

问题所在:

  • 职责混乱: 用户认证逻辑和购物车逻辑完全混在同一个文件中,没有任何物理隔离。
  • 难以维护: 如果购物车的逻辑变得更复杂(例如,添加优惠券、库存检查),这个文件会迅速膨胀,难以阅读和修改。
  • 协作冲突: 如果开发者 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
2
3
4
5
6
7
8
9
10
11
12
13
import { type StateCreator } from "zustand";

export interface UserSlice {
user: { name: string } | null;
login: (username: string) => void;
logout: () => void;
}

export const createUserSlice: StateCreator<UserSlice> = (set) => ({
user: null,
login: (username) => set({ user: { name: username } }),
logout: () => set({ user: null }),
});
第二步:创建 cartSlice

文件路径: src/stores/slices/cartSlice.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
import { type StateCreator } from "zustand";

export interface CartSlice {
cart: {
items: { id: number; name: string }[];
};
addToCart: (item: { id: number; name: string }) => void;
clearCart: () => void;
}

export const createCartSlice: StateCreator<CartSlice> = (set) => ({
cart: { items: [] },
addToCart: (item) =>
set((state) => ({
cart: {
...state.cart, // 👈 暂时的手动展开
items: [...state.cart.items, item],
},
})),
clearCart: () =>
set((state) => ({
cart: {
...state.cart, // 👈 暂时的手动展开
items: [],
},
})),
});


11.7.3 组合多个切片,构建一个统一的、模块化的 Root Store

创建好独立的切片后,我们需要一个“主” Store 文件来将它们组合在一起。

文件路径: src/stores/useAppStore.ts (新建文件)

1
2
3
4
5
6
7
8
9
10
11
import { create } from 'zustand'
import { type UserSlice, createUserSlice } from './slices/userSlice'
import { type CartSlice, createCartSlice } from './slices/cartSlice'

// 组合后的完整 State 类型
type AppState = UserSlice & CartSlice;

export const useAppStore = create<AppState>()((...a) => ({
...createUserSlice(...a),
...createCartSlice(...a),
}))

代码解读:

  1. 组合类型: 我们将 UserSlice 和 CartSlice 的类型通过 & 操作符合并成一个完整的 AppState 类型。
  2. 组合实现: create 函数的回调现在变得极其简洁。它使用 (...a) 将接收到的 set, get, api 等参数,原封不动地传递给每个切片创建函数 (createUserSlice, createCartSlice)。
  3. 展开合并: 每个切片创建函数返回自己的状态对象,我们使用对象展开运算符 (...) 将它们合并成一个最终的、完整的 Store 状态对象。

现在,我们的 Store 在物理上是分离的、模块化的,但在逻辑上又是一个统一的、完整的 Store。


11.7.4 切片模式的权衡与思考

优势:

  • 关注点分离: 每个文件只关心自己的业务,代码清晰。
  • 易于维护与协作: 不同功能的代码在不同文件,极大降低了维护成本和协作冲突。
  • 可测试性: 可以对每个切片进行独立的单元测试。

成本与考量:

  • 少量模板代码: 相较于单一 Store,切片模式引入了 StateCreator 类型和切片创建函数等少量“模板”代码。
  • 跨切片调用: 一个切片可以调用另一个切片的 Action 吗?可以。因为 get 函数被传递给了每个切片,所以 cartSlice 可以通过 get().user 来获取用户状态,或通过 get().logout() 来调用用户 Action。但需要注意,过度地跨切片调用,会重新引入逻辑耦合,违背了切片模式的初衷。应谨慎使用。

结论: 切片模式是 Zustand 应用扩展到中大型项目时的 必然选择。当您的单一 Store 开始变得臃肿和混乱时,就应该毫不犹豫地采用切片模式进行重构。