前端进阶之旅前端进阶之旅
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
原理篇
每日一题
小程序题库
知识卡片
  • 场景篇按分类整理的大前端场景考点
  • 历年面经按年份追踪真实考点
  • 算法题库NEW在线编码即时判题
  • 专项自测100 题快速查漏
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
  • 动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
  • AI 助手随时提问,即时解析
  • AI 模拟面试模拟真实面试 + 报告
  • AI 知识地图串起全站知识点
  • AI 定制路线按你的简历现排
AI 热点
旧版
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
原理篇
每日一题
小程序题库
知识卡片
  • 场景篇按分类整理的大前端场景考点
  • 历年面经按年份追踪真实考点
  • 算法题库NEW在线编码即时判题
  • 专项自测100 题快速查漏
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
  • 动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
  • AI 助手随时提问,即时解析
  • AI 模拟面试模拟真实面试 + 报告
  • AI 知识地图串起全站知识点
  • AI 定制路线按你的简历现排
AI 热点
旧版

Zustand 极简 React 状态管理使用指南与实战技巧

首页2025-01-14 14:40:12Front-End
React状态管理Zustand前端性能优化

用 Context 做全局状态,最难受的一刻是打开 React DevTools 的 Highlight updates,改一个主题色,整棵组件树全在闪。换 Redux 又是另一种难受,加一个字段要动 action type、action creator、reducer 三个文件。Zustand 走的是第三条路,一个 create() 就是一个 store,不用 Provider,不用 reducer,组件里想订阅哪个字段就订阅哪个字段。这篇从建第一个 store 开始,把 Selector 精准订阅、持久化、DevTools、Immer 这几件事讲透,顺带说清楚哪几个写法看着没问题但会悄悄失效。

在本篇文章中,我们将从浅入深,和大家一起学习以下知识:

  • Zustand 解决的是 Context 和 Redux 各自的什么问题
  • 用 create() 建 store 的基础写法,以及异步请求怎么放进去
  • Selector 精准订阅的原理,以及返回对象为什么会让优化失效
  • persist 中间件的基础用法和 partialize、storage 等高级配置
  • 接 Redux DevTools 做时间旅行调试,以及生产环境怎么关掉
  • 深层嵌套状态用 immer 中间件改写的收益和代价
  • store 模块化拆分的目录结构和 slice 组合模式
  • 四个高频踩坑点,包括 getState() 到底能不能用

# 一、为什么是 Zustand

先把两个老方案的问题说清楚,才知道 Zustand 在替谁擦屁股。

Context API 的问题在于它没有订阅粒度。Provider 的 value 一变,所有消费该 Context 的组件都会重新渲染,跟这个组件用没用到变化的那个字段完全无关。你可以拆成十几个 Context 来缓解,代价是 Provider 嵌套成一座金字塔。

Redux 的问题不在性能,在配置成本。一套 action type、action creator、reducer 写下来,中小项目的收益比很低。Redux Toolkit 之后好了很多,但心理门槛还在。

Zustand 由 React Three Fiber 团队做的,思路是「用最小的 API 拿到最大的效率」。它的核心能力有这么几条。

  • 极简 API,一个 create() 建 store,没有额外配置
  • 基于 Selector 的订阅机制,只订阅你要的那一片,避免多余渲染
  • 不依赖 Context,没有 Provider 嵌套
  • TypeScript 类型推导完整,IDE 提示到位
  • 内置 persist、devtools、immer 等中间件
  • store 就是闭包里的普通对象,初始化快、内存占用低

三大流派的位置关系大致是这样。

单向数据流 原子化 Proxy
redux Recoil Mobx
zustand jotai valtio

实际选型时基本是在 zustand、jotai、valtio 之间挑,它们分别是各自流派前一代方案的优化版本。

单向数据流的优势是数据本身干净、负担轻。但数据结构一复杂,不可变更新的展开写法会长得离谱,通常要配 immer.js 才能兼顾可读性和性能。原子化和 Proxy 的目标一致,都在收集数据与 UI 的绑定关系,数据变了 UI 自动更新。区别在写法,原子化是先定义原子再用原子管数据,Proxy 是先定义一个大对象再劫持属性做绑定。性能上原子化略好一点,它少了劫持这一步,但数据一复杂,原子化的写法也会变得繁琐。

处理大型复杂列表时,三者的表现是这样。

库 初始化速度 更新速度 内存占用 开发复杂度
Zustand 极快 快 极低,原生对象 较高
jotai 较慢 精准、快 最高,原子实例多 偏高
valtio 中等偏慢 精准、快 偏高,Proxy 开销大 低

更新机制上的细节差异。

库 通知复杂度 渲染复杂度 原理
Zustand O(N) O(1) 线性遍历订阅列表加 Selector 比对
Jotai O(1) O(1) 依赖图,原子 A 变了直接找到订阅 A 的那个组件
Valtio O(1) O(1) Proxy 追踪,属性 A 变了直接通知访问过 A 的组件

Zustand 的通知是 O(N),看着不如另外两个,但它换来的是极低的内存开销。这个取舍在万级数据场景下反而是优势。想看四个方案更完整的横向对比,可以翻这篇 React 状态管理库选型指南。

# 二、建第一个 store

# 基础写法

导入 create,把状态和操作方法一起写在返回的对象里。

import { create } from 'zustand'

interface TabItem {
  id: string
  title: string
}

interface TabState {
  tabs: TabItem[]
  currentTabId: string
  addTab: (tab: TabItem) => void
  removeTab: (id: string) => void
  setCurrentTab: (id: string) => void
}

export const useTabStore = create<TabState>((set) => ({
  tabs: [],
  currentTabId: '',
  addTab: (tab) => set((state) => ({
    tabs: [...state.tabs, tab]
  })),
  removeTab: (id) => set((state) => ({
    tabs: state.tabs.filter((t) => t.id !== id),
    currentTabId: state.currentTabId === id ? '' : state.currentTabId
  })),
  setCurrentTab: (id) => set({ currentTabId: id })
}))
@前端进阶之旅: 代码已经复制到剪贴板

这里有个 Zustand 和 Redux 很不一样的点。set 传对象时是浅合并,不是替换。上面 setCurrentTab 只写了 currentTabId,tabs 会原样保留,不需要你手动展开。想要整体替换,得传第二个参数 true。

组件里直接调这个 hook 就行。

import { useTabStore } from './store/tabStore'

export default function TabView() {
  const { tabs, currentTabId, addTab, setCurrentTab } = useTabStore()

  return (
    <div>
      <button onClick={() => addTab({ id: 'settings', title: '设置' })}>
        新增 Tab
      </button>
      <div style={{ display: 'flex', gap: 8 }}>
        {tabs.map((tab) => (
          <div
            key={tab.id}
            onClick={() => setCurrentTab(tab.id)}
            style={{
              padding: 4,
              borderBottom: tab.id === currentTabId ? '2px solid blue' : 'none',
              cursor: 'pointer'
            }}
          >
            {tab.title}
          </div>
        ))}
      </div>
    </div>
  )
}
@前端进阶之旅: 代码已经复制到剪贴板

到这里 store 就能用了,任何组件里引进来就能读能改,不需要在外层套任何东西。

不过上面这个写法有个隐患,useTabStore() 不传参数等于订阅整个 store,store 里任何字段变了这个组件都会重渲。小组件无所谓,稍微大一点就得改成 Selector 写法,第三节会详细说。

# 异步操作怎么放

Zustand 没有为异步单独设计 API,因为不需要。action 就是普通函数,里面爱写什么写什么。

import { create } from 'zustand'

interface Todo {
  id: number
  title: string
  completed: boolean
}

interface TodoState {
  todos: Todo[]
  error: string | null
  isLoading: boolean
  fetchTodos: () => Promise<void>
  toggleTodo: (id: number) => void
}

export const useTodoStore = create<TodoState>((set) => ({
  todos: [],
  error: null,
  isLoading: false,
  fetchTodos: async () => {
    set({ isLoading: true, error: null })
    try {
      const res = await fetch('https://jsonplaceholder.typicode.com/todos')
      const data = await res.json()
      set({ todos: data.slice(0, 10), isLoading: false })
    } catch (error) {
      set({ error: (error as Error).message, isLoading: false })
    }
  },
  toggleTodo: (id) => set((state) => ({
    todos: state.todos.map(todo =>
      todo.id === id ? { ...todo, completed: !todo.completed } : todo
    )
  }))
}))
@前端进阶之旅: 代码已经复制到剪贴板

这段代码能跑,但我得说句实话,这种把接口数据往 store 里塞的写法,做到后面 store 会长满 xxxLoading、xxxError 这样的伴生字段。真正的服务端状态还是交给 TanStack Query 这类专门方案更合适,Zustand 留着管纯客户端状态。

# 三、Selector 精准订阅

# 先看问题长什么样

假设 store 里同时放了用户信息和主题设置。

import { create } from 'zustand'

const useAppStore = create((set) => ({
  user: {
    name: '张三',
    age: 25,
    isLogin: true
  },
  theme: 'light',
  setTheme: (newTheme: string) => set({ theme: newTheme }),
  setUserAge: (age: number) => set((state) => ({
    user: { ...state.user, age }
  }))
}))
@前端进阶之旅: 代码已经复制到剪贴板

两个组件都用解构的方式取值。

// 反面教材,订阅了整个 store
function UserName() {
  const { user } = useAppStore()
  console.log('UserName 渲染了')
  return <div>用户名 {user.name}</div>
}

function ThemeDisplay() {
  const { theme } = useAppStore()
  console.log('ThemeDisplay 渲染了')
  return <div>当前主题 {theme}</div>
}
@前端进阶之旅: 代码已经复制到剪贴板
fe
  • 一、为什么是 Zustand
  • 二、建第一个 store
    • 基础写法
    • 异步操作怎么放
  • 三、Selector 精准订阅
    • 先看问题长什么样
    • 传 Selector 才是正确姿势
    • 要读多个字段怎么办
    • 派生状态
  • 四、持久化
    • 基础用法
    • 常用配置
  • 五、接上 Redux DevTools
  • 六、Immer 处理深层嵌套
    • 问题场景
    • 换成 immer
  • 七、模块化拆分
    • 目录结构
    • 需要跨域组合时用 slice 模式
  • 八、四个高频踩坑点
    • getState() 到底能不能用
    • 持久化的数据必须能序列化
    • Selector 里别造新对象
    • 中间件别叠太厚
  • 总结
  • 参考

← React 19 新特性全解析与升级避坑指南React 18 并发机制深度解析,Fiber 可中断渲染与时间切片原理 →