第十一章 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 v4Ant 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:getStatesetState

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

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

  1. 更新 Store (bearStore.ts)

    我们添加 reset 功能,并使用 setreplace 标志来完全替换状态。

    文件路径: 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) 进行 === 比较。因为计算结果是原始类型,只有在 bearshungry 真正改变导致计算结果变化时,才会触发重渲染。这个方案 自带了“记忆化”,无需任何额外优化。


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+ 提供的 loaderactionuseFetcher,正是为处理服务端状态而生的“官方利器”。

那么,Zustand 在这个体系中应该做什么?答案是 职责分离

工具核心职责典型场景
React Router (loader, action, useFetcher)服务端状态的“传输与事务管理”页面初始数据加载、表单提交、乐观更新、点赞、加入购物车等与后端直接交互的“一次性”操作。
Zustand客户端状态的“内存与缓存管理”用户信息、UI 状态(弹窗开关)、多页面共享的数据缓存、复杂表单的草稿状态等。

本节,我们将通过两个核心实战,来体验这两种工具如何协同工作。


11.5.1 场景一:页面“微交互” - useFetcher 的主场

痛点: 像“点赞”、“订阅”这类操作,我们不希望它引起页面导航,也不希望它的加载状态污染全局 Store。

解决方案: useFetcher Hook。它允许我们在组件内部,“无痕地”调用一个路由的 actionloader,并独立管理自己的 loading, error, data 状态。

知识转译: 您可以把 useFetcher 理解为 React Router 内置的、与路由数据流深度绑定的 axiosfetch 客户端。

实战:添加一个新的熊种群

我们将创建一个表单,用于向服务器“添加”一个新的熊种群。

安装 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>
    );
    };

关键洞察: 在整个“添加熊”的交互中,我们 完全没有使用 ZustanduseFetcher 完美地处理了与该交互相关的加载状态和服务器响应。这是最高效、最解耦的实践:组件级的服务端交互,由组件级的工具来处理。


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 如何通知一个与它毫无关系的兄弟(甚至叔伯)组件 HeaderCartloader 的数据流是单向且局限于路由树的,无法解决这个问题。

这,就是 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
  • ProductListHeaderCart 这两个 完全不相关的组件,都从 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 的条目,其中包含了您修改后的 themenotifications 状态。
  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。

从技术上讲,一个切片就是一个函数,它接收 setget 作为参数,并返回该切片的状态对象。

第一步:创建 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. 组合类型: 我们将 UserSliceCartSlice 的类型通过 & 操作符合并成一个完整的 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 开始变得臃肿和混乱时,就应该毫不犹豫地采用切片模式进行重构。