第九章: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)组织大型状态,并为其编写健壮的测试。

9.1. 核心定位与概念映射

9.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 的简洁、直观的开发体验。


9.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)) 来实现对状态的精确订阅和解构,从而保证渲染性能。

9.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 开发环境。


9.2. 实战演练:构建结构化、可维护的 Store

我们将在 9.1 搭建好的项目中,通过一系列的功能迭代,将理论知识应用到实践中。我们将 一边讲解核心 API,一边创建独立的、遵循最佳规范的 React 组件 来消费这些 API,确保您能看到每个知识点带来的实际效果。

9.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>
    )
    }

9.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 组件。现在当您点击“增加一只熊”按钮时(按钮文字可改回),会发现数量和时间戳同时被更新了。


9.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)则 完全不受影响,被保留了下来

9.3. 性能之钥:Selector 的工作原理与优化

到目前为止,我们已经掌握了如何读写 Store 的状态。但要真正精通 Zustand,我们必须理解它与 React 渲染周期之间的关系,这其中的关键就是 Selector (选择器)。可以说,是否能正确使用 Selector,是区分 Zustand 新手和专家的分水岭

9.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,组件就不会重渲染。


9.3.2 性能陷阱:订阅非原始值导致的多余渲染

理解了上述核心机制后,一个最常见的性能陷阱就浮出水面了:当 Selector 的返回值是一个每次都新创建的对象或数组时,会发生什么?

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

  1. 更新 Store (bearStore.ts)

    让我们为 Store 增加一个更复杂的状态,用来存储每只熊的名字和它的食物偏好。

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

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    // ... imports

    interface BearState {
    // ... a bunch of other state
    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
    19
    20
    import { useBearStore } from '@/stores/bearStore'
    import { Card, Tag } from 'antd'
    import React from 'react'

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

    // 这个 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
    15
    // ... 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 /> {/* 添加新组件 */}
    <DebugTools />
    </Space>
    </div>
    )
    }

    现在,打开浏览器的控制台,然后点击 BearCounter 组件中的“更新时间戳”按钮。您会发现,即使熊的名字列表 names内容完全没有变化,控制台依然打印出了 BearNames component is rendering...

    原因: Object.keys() 函数在每次被调用时,都会返回一个全新的数组实例。因此,prevResult === newResult 的比较结果永远是 false,导致了组件在任何 Store 状态变更时都会不必要地重渲染。


9.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>
)
}

现在,再次点击“更新时间戳”按钮,您会发现控制台不再打印 BearNames component is rendering...。不必要的重渲染被成功阻止了!

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


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

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

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

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

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    import { StoreApi, 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 的核心原理。