好的,这次咱们正儿八经聊聊Zustand。如果你是一名 React 开发者,或者正在管理一个中大型前端项目,大概率已经听过这个库的名字;如果你刚从useState或者Redux阵营转过来,那这篇内容会把你脑子里关于“全局状态管理”、“store 设计”、“re-render 优化”这些概念重新梳理一遍。
我不会按官方文档的顺序把 API 罗列一遍,而是以我自己在实际项目里从零搭建、重构、踩坑的全过程为主线,拆解Zustand的 store 从“能跑”到“跑得稳、跑得快”到底要经历什么。文章里有原理分析、代码示例、参数取舍和真实翻车记录,适合想用Zustand但还没完全吃透核心机制的前端同学,也适合已经在用但总感觉某些地方不对劲的进阶选手。
1. 为什么是 Zustand 而不是其他方案
1.1 从 useState 到全局状态管理的必然
useState是 React 内置的状态方案,组件内部用起来确实爽,但一旦涉及跨组件共享、跨页面传递、异步更新后同步给多个模块,就会开始别扭。业务逻辑散落在各个组件里,数据流要一层层props往下传,兄弟组件之间得靠父级抬升 state,再不行就引入 Context,结果每次 context 值一变,所有消费这个 context 的组件全部 re-render。
Zustand解决的正是这个痛点。它不强制你把所有状态放进一个大的全局对象里,也没有 Provider 包裹的负担,你可以在任意位置定义独立的 store,在任意组件里订阅它。核心思路很简单:一个 hook + 一个 store 实例,用 selector 控制订阅粒度。
1.2 Zustand 和 Redux、Context 的核心差异
很多初学者会先学 Redux 再来对比 Zustand,但实际上两者的侧重点完全不同。Redux 追求的是“单一数据源 + reducer 纯函数 + 时间旅行调试”,它约束你所有状态变更都必须走 action,这个过程带来的是规范,代价是样板代码。而 Zustand 更像是把useState的体验直接搬到全局:可以直接调用 set 修改状态,不强制 reducer,不要求 action type 常量。
Context 的问题更务实:它本身不是状态管理库,只是一个依赖注入机制。每次 context value 变化,所有消费者都会重渲染,除非你手动把 value 拆成多个 context 再配合 memo,麻烦且容易遗漏。Zustand 则通过 selector + 浅比较(shallow)精确控制每个组件的重渲染粒度,整体机制更接近use-sync-external-store,也就是说它是从底层就对 React 并发渲染友好的。
1.3 什么时候该选 Zustand
这不是一个“谁更好”的问题,而是“谁更合适”。我个人判断标准是:
- 组件层级较深,需要跨越多层共享数据时,Zustand 比 props 钻洞舒服得多;
- 多个组件对同一份数据的不同字段感兴趣时,Zustand 的 selector 能避免多余渲染;
- 需要把状态变更逻辑从组件中抽离,单独维护时,Zustand 的 store 天然就是独立的模块;
- 不想引入 Redux 那套 action/reducer 概念,但仍然想要一个可预测、可调式的全局状态层时,Zustand 几乎是最低成本的方案。
当然,如果你的项目已经跑在 Redux Toolkit 上,团队习惯了它的模式,也没必要为了换而换。Zustand 不是银弹,但它确实把很多“理所当然应该很简单”的事情变得真的很简单。
2. store 的核心设计与 API 深度拆解
2.1 create:一个 store 是怎么生出来的
先看最基础的用法:
import { create } from 'zustand' interface CounterState { count: number increment: () => void decrement: () => void } const useCounterStore = create<CounterState>((set) => ({ count: 0, increment: () => set((state) => ({ count: state.count + 1 })), decrement: () => set((state) => ({ count: state.count - 1 })), }))这里的create接收一个初始化函数,函数参数是set、get、api三个能力。它返回的useCounterStore是一个可以在 React 组件里直接调用的 hook,同时这个 hook 上挂载了.getState()、.setState()、.subscribe()等静态方法。
有个细节很多新手没注意到:create并不依赖 React 的 Context 机制,它背后是一个独立于组件树的 store 对象。这意味着你可以在组件外直接调用useCounterStore.getState()拿数据,也能在 React 组件中订阅它,两者共享同一个状态源。
我在实际项目里经常用到这个特性,比如封装 axios 拦截器时,需要在请求失败后读取 token 并刷新,这时候直接在 TS 模块里useAuthStore.getState(),完全不需要经过 React 层,非常干净。
2.2 set 与 get:状态更新的两种路径
set是你更新状态的唯一入口。它有两种用法:
// 用法一:直接传部分状态 set({ count: 1 }) // 用法二:传函数基于当前状态计算新状态 set((state) => ({ count: state.count + 1 }))官方建议总是使用函数式用法,因为 Zustand 的set会做状态合并(类似useState的合并逻辑),函数式可以保证拿到的是最新状态,避免在异步回调或者连续触发时出现旧值覆盖的问题。
get则用于在当前 store 内部读取最新状态。比如先判断条件再更新:
const useOrderStore = create((set, get) => ({ orders: [], canSubmit: false, addOrder: (order) => { const { orders } = get() if (orders.length >= 10) return set({ orders: [...orders, order] }) }, }))这里有个关键点:get拿到的永远是当前 store 的实时状态,而不是闭包里的旧快照。在比较复杂的状态流转中,我习惯把get当作“只读快照”来用,保证判断逻辑和数据变更发生在同一个版本。
2.3 selector 与订阅机制:性能的关键
Zustand 的默认情况是:组件只要调用了useStore(),store 里任何状态变化都会触发它重新渲染。但实际业务里往往只需要关心某一个字段,这时就要用 selector。
const count = useCounterStore((state) => state.count)这个 selector 返回什么,组件就依赖什么。当 store 中其他字段变化时,只要 selector 的结果通过Object.is比较没有变化,组件就不会重渲染。这一点是 Zustand 和 Context 最大的性能差异所在。
但 selector 也有它自己的坑。如果你直接在 selector 里返回一个对象:
const { count, increment } = useCounterStore((state) => ({ count: state.count, increment: state.increment, }))每次 store 更新,这个 selector 都会返回一个新对象,默认比较会认为“变化了”,于是你的组件依然会频繁 re-render。解决办法是用useShallow:
import { useShallow } from 'zustand/react/shallow' const { count, increment } = useCounterStore( useShallow((state) => ({ count: state.count, increment: state.increment, })) )useShallow会对 selector 的返回值做一层浅比较,字段值都没有变化时,就不会触发重渲染。这是我在项目中高频使用的一个 API,尤其是读多个字段时几乎离不开它。
2.4 异步 action 与副作用处理
Zustand 本身不限制你写异步逻辑,因为 action 就是普通的函数,你完全可以在里面 await:
const useUserStore = create((set) => ({ user: null, loading: false, fetchUser: async (id: string) => { set({ loading: true }) try { const res = await fetch(`/api/user/${id}`) const user = await res.json() set({ user, loading: false }) } catch (error) { set({ loading: false, error: error.message }) } }, }))没有 middleware,不需要 Redux Thunk,也不需要 saga。这极大降低了心智负担。我个人的习惯是:在 action 内部管理loading、error、data三件套,组件只负责根据状态渲染,不感知异步细节。
不过要注意一个问题:异步回调里调用set时,不要依赖外层闭包里的状态变量,而是用函数式的写法读取最新值。比如做分页加载时:
loadMore: async () => { const { page, hasMore } = get() if (!hasMore) return const items = await fetchItems(page + 1) set((state) => ({ items: [...state.items, ...items], page: state.page + 1, hasMore: items.length > 0, })) }这里用get()判断当前状态,用函数式set合并新状态,两次读取都确保拿到的是最新版本,不会因为连续点击导致重复请求或数据错乱。
3. 结合 TypeScript 的 store 类型体系
3.1 从 StoreApi 到 bound store
如果你项目用了 TypeScript,Zustand 的类型支持是它另一个杀手锏。最简单的用法是给create传入泛型:
interface BearState { bears: number addBear: () => void } const useBearStore = create<BearState>((set) => ({ bears: 0, addBear: () => set((state) => ({ bears: state.bears + 1 })), }))但实际项目中,你往往需要导出 store 的类型给外部模块使用。这时可以借助StoreApi:
import { create, StoreApi, UseBoundStore } from 'zustand' export type BearStore = { bears: number addBear: () => void } export const useBearStore: UseBoundStore<StoreApi<BearStore>> = create<BearStore>()((set) => ({ bears: 0, addBear: () => set((state) => ({ bears: state.bears + 1 })), }))看到中间那个奇怪的()()了吗?这是因为 Zustand 对中间件和类型推导的兼容设计,你给create传入中间件时需要用它。简单记一下:只要用了泛型指示 store 形状,且想完整保留类型推导,就在create<XXX>()(...)这样写,它叫 currying 调用。
3.2 slice 模式:把大 store 拆开
状态多到一定程度时,把所有逻辑塞在一个 store 里会变得难以维护。这时候可以按业务域拆成多个 slice,最后合并到一个 store 中:
interface UserSlice { user: User | null setUser: (user: User) => void } interface CartSlice { cart: CartItem[] addToCart: (item: CartItem) => void } const createUserSlice = (set): UserSlice => ({ user: null, setUser: (user) => set({ user }), }) const createCartSlice = (set): CartSlice => ({ cart: [], addToCart: (item) => set((state) => ({ cart: [...state.cart, item] })), }) const useStore = create<UserSlice & CartSlice>()((...a) => ({ ...createUserSlice(...a), ...createCartSlice(...a), }))这种方式的好处是:每个 slice 独立可测试,类型合并天然准确,后续加新业务不影响其他模块。我比较推荐中型项目直接按这个模式组织 store,而不是把所有 reducers 都写在一个巨大的 type 里。
3.3 类型安全的中间件接入
Zustand 中间件本质是一个“加工函数”,它包装了set、get、api。TypeScript 下接入标准中间件通常不需要额外类型,但如果你写自定义中间件,需要理解StateCreator类型:
import { StateCreator } from 'zustand' const loggerMiddleware = <T>( config: StateCreator<T> ): StateCreator<T> => (set, get, api) => config( (args) => { console.log('before', get()) set(args) console.log('after', get()) }, get, api )这套类型推导会跟着 store 形状自动流动,只要你的中间件保持set、get、api的签名形状,就能无缝嵌入。我在项目里写过一个“限流 action”的中间件,效果很不错,具体怎么实现放在后面中间件章节讲。
4. 中间件与持久化、调试的实战细节
4.1 persist 中间件:状态持久化从来不是简单存个 localStorage
官方最常用的中间件是persist。用法很简单:
import { persist } from 'zustand/middleware' const useSettingsStore = create( persist( (set) => ({ theme: 'light', fontSize: 14, setTheme: (theme) => set({ theme }), }), { name: 'settings-storage', } ) )不过真实项目里有几个细节容易踩坑。默认情况下persist会把整个 store 状态序列化进localStorage,读取时做同步反序列化。如果你的 store 里存了Date、Map、Set这类非 JSON 对象,默认序列化会直接把它变成字符串,恢复时就废了。
解决办法是自定义partialize和merge:
const useDemoStore = create( persist( (set) => ({ cachedAt: new Date(), count: 0, }), { name: 'demo-storage', partialize: (state) => ({ count: state.count }), merge: (persisted, current) => ({ ...current, ...(persisted as Partial<typeof current>), cachedAt: new Date(), }), } ) )partialize控制哪些字段需要持久化,merge控制如何把持久化的值和当前初始状态合并。默认是浅合并,但遇到嵌套对象或需要特殊处理的字段(比如日期恢复)时,一定要自己写merge。
还有一个常见场景是“存量字段更新”:你存了一个版本,后来代码里删掉了某个字段,persist 默认会把旧值继续保留。如果你想清理旧数据,需要自己判断 version 或者做迁移逻辑,我通常在persist配置里用version+migrate来管理:
{ name: 'demo-storage', version: 2, migrate: (persistedState, version) => { if (version < 2) { // 手动处理旧数据,比如移除某个字段 delete (persistedState as any).deprecatedField return persistedState } return persistedState }, }4.2 devtools 中间件:状态一清二楚
想在 Redux DevTools 里查看 Zustand 的状态变化,直接用devtools中间件:
import { devtools } from 'zustand/middleware' const useStore = create( devtools( (set) => ({ count: 0, increment: () => set((state) => ({ count: state.count + 1 })), }), { name: 'AppStore' } ) )这就把每次set变成一个 Redux action 记录在 DevTools 里,可以时间旅行回溯。注意,你想看到有意义的名字,最好第三参数是set调用时传第二个参数作为 action 名称:
increment: () => set((state) => ({ count: state.count + 1 }), false, 'increment'),我实际使用中还有一个心得:devtools在开发环境会往window上挂一个 hook,线上环境建议通过判断import.meta.env.DEV或process.env.NODE_ENV来决定是否启用,避免不必要的开销。
4.3 immer 中间件:让嵌套更新不再是噩梦
这个中间件我不是经常用,但遇到真的需要深层次更新时它太香了:
import { immer } from 'zustand/middleware/immer' const useStore = create( immer((set) => ({ nested: { user: { profile: { name: '张三', }, }, }, rename: (name) => set((state) => { state.nested.user.profile.name = name }), })) )在 immer 中间件里,set的回调参数是一个可变的 draft,你可以直接修改嵌套属性,不需要展开对象。它返回 void,immer 内部自动收集变更并生成新的不可变状态。重度嵌套数据结构下,代码可读性提升非常明显。
需要注意:immer 和persist一起用时,merge的写法要留意类型,我建议先 immer 后 persist,或者 persist 后 immer,但不要写得太复杂,保持简单即可。
4.4 自定义中间件:从需求出发,别为造轮子造轮子
我在公司项目里写过一个简单的 action 防重复提交中间件,摘录核心逻辑:
const preventDouble = (config) => (set, get, api) => config( (args) => { if (get()._pendingAction) { console.warn('有请求正在执行中') return } set({ _pendingAction: true }) Promise.resolve( // 注意这里要调用原始 set,而不是包装后的 set,避免递归 config(set, get, api) ).finally(() => { set({ _pendingAction: false }) }) }, get, api )实际使用下来,自定义中间件最难的不是实现,而是搞清楚“你要拦截的是set还是get还是两者”。大多数场景下,包装set就够了,因为状态变更的入口就是 set。另外,中间件栈的执行顺序也有讲究,后传入的中间件会在外层,先执行。
5. 实战:一个完整 store 从零到一
5.1 业务场景与 store 设计
为了把前面这些 API 串起来,我们看一个购物车 + 用户信息的实际场景。假设要做一个带登录鉴权、商品列表、购物车功能的商城页面。我不打算把所有状态都塞进一个 store,而是拆成三个域:用户、购物车、商品。
先定义类型:
interface UserState { token: string | null userInfo: { name: string; id: string } | null login: (username: string, password: string) => Promise<void> logout: () => void } interface CartState { items: CartItem[] totalPrice: number addItem: (item: CartItem) => void removeItem: (id: string) => void clearCart: () => void } interface ProductState { products: Product[] loading: boolean fetchProducts: () => Promise<void> }每个 slice 单独实现,最后合并。为什么要拆开而不是一个大 store?因为这三个域的生命周期和更新频率完全不同:用户信息变化极低频,购物车高频,商品列表可能只在加载时变。拆开后,各自模块维护自己的状态和 action,组件订阅起来也更精确。
5.2 同步 action 与异步 action 实现
用户 store 里最核心的是异步 login:
const createUserSlice = (set, get) => ({ token: localStorage.getItem('token') || null, userInfo: null, login: async (username, password) => { // 模拟请求 const res = await loginApi(username, password) localStorage.setItem('token', res.token) set({ token: res.token, userInfo: res.user }) }, logout: () => { localStorage.removeItem('token') set({ token: null, userInfo: null }) }, })这里有个容易踩的坑:初始 token 从 localStorage 读取是同步操作,但如果 SSR 场景下 localStorage 不存在,就会报错。解决办法是初始化时做环境判断,或者用persist中间件管理 token。
购物车 slice 是典型的同步高频更新:
const createCartSlice = (set, get) => ({ items: [], get totalPrice() { return get().items.reduce((sum, item) => sum + item.price * item.quantity, 0) }, addItem: (item) => { const current = get().items const existed = current.find((i) => i.id === item.id) if (existed) { set({ items: current.map((i) => i.id === item.id ? { ...i, quantity: i.quantity + item.quantity } : i ), }) } else { set({ items: [...current, item] }) } }, removeItem: (id) => { set({ items: get().items.filter((i) => i.id !== id) }) }, })把totalPrice写成 getter 看起来很优雅,但需要注意:组件里如果写useStore((state) => state.totalPrice),当购物车数据变化时,getter 会动态计算并返回新值,这是能正常触发更新的。不过如果你把它当作普通状态去依赖,它本身不参与set,也没有存储,所以不会有“暴露一个 setter 去改 totalPrice”这种场景。
5.3 持久化与恢复细节
购物车数据通常要持久化,否则刷新页面就丢。用persist中间件包裹购物车 slice:
const useStore = create( persist( (...args) => ({ ...createUserSlice(...args), ...createCartSlice(...args), ...createProductSlice(...args), }), { name: 'shop-store', partialize: (state) => ({ cart: state.items, token: state.token, }), } ) )这里我没有整个 store 进行持久化,而是用partialize只持久化购物车列表和 token。用户信息、商品列表这些缓存价值不高或者需要新鲜度的数据就不存了,避免出现旧数据。
恢复时我坚持一个原则:只相信持久化的原始数据,不信任它会自动帮你恢复复杂的派生状态。比如totalPrice是派生状态,永远不要持久化它,而是恢复 items 后实时计算。还有用户登录态,最好在应用启动时请求接口验证 token,而不是直接信任 localStorage 里的 token。
5.4 组件内订阅与外部模块调用的差异
组件内使用 hook 方式订阅:
function CartButton() { const count = useStore((state) => state.items.reduce((sum, i) => sum + i.quantity, 0) ) return <span>购物车({count})</span> }外部模块(比如路由守卫)则用getState:
// router.ts router.beforeEach((to) => { const { token } = useStore.getState() if (to.meta.requiresAuth && !token) { return '/login' } })两种方式互不干扰。组件内的 selector 让 UI 保持精确更新;外部模块直接用 getState 读取当下的完整快照,不需要订阅,逻辑也更独立。这也是 Zustand 和很多“纯 hook”状态库最大的区别。
6. 常见问题与排查技巧实录
6.1 Store 不更新的几种奇怪情况
我见过最典型的案例是“明明 set 了但组件不重渲染”。排查步骤基本固定:
- 确认是不是在组件外调用了
set?在组件外直接调用 store 的 action 是能触发更新的,因为 Zustand 内部维护了订阅列表,但如果你在非 React 环境(比如普通工具函数)里临时创建了一个 store,并没有组件订阅它,那你当然看不到更新。 - 确认 selector 是否返回了引用不变的新值?尤其是返回嵌套对象时,如果你做了类似
state.user.address.city这种深层 selector,只要city没变就不会更新,这是正常的。但如果你的 set 是用普通展开生成的新对象,组件没有响应,往往是因为 selector 写法漏掉了那个字段。
还有一种情况是搭配 React 并发模式时出现“闪烁”,通常发生在异步 action 里多次 set 中间。我建议把相关联的状态放在一次 set 中完成,减少中间态的暴露。
6.2 关于持久化的严重翻车现场
有次上线后用户反馈“购物车怎么清空了”。查了半天发现是 persist 版本升级,旧版本存入的数据跟新版本的默认结构不匹配,shared state 在 merge 时出现异常。从那以后我养成了两个习惯:
- persist 配置永远带上
version,修改 store 形状时递增版本号; - 用
migrate处理旧数据,避免 merge 失败导致白屏。
另外 localStorage 本身有 5MB 限制,购物车数据大了之后可能写入失败,没关系,Zustand 的 set 不会因此抛错,但你的数据就丢了。我一般会再封装一层storage,自己实现一个带错误捕获的 localStorage 适配器。
6.3 调试技巧:subscribe 与 DevTools 双管齐下
如果某个状态值在 DevTools 里看不到,可以临时加一层 subscribe 日志:
useStore.subscribe((state, prevState) => { console.log('state changed', prevState, state) })注意,subscribe默认监听任意状态变化。如果你只关心某个字段,可以用第三个参数配合 selector:
useStore.subscribe( (state) => state.items.length, (length, prevLength) => { console.log('购物车数量变化', prevLength, '->', length) } )这是我在查“购物车数量为什么会突然变成 0”时最常用的手段。有了它,你能精确知道是哪一步 set 改了 items。
6.4 小技巧:selector 返回函数时怎么处理
有时候你会这么写:
const addItem = useStore((state) => state.addItem)这看起来没问题,因为 action 函数在 store 生命周期内是稳定的引用(除非你每次 set 都生成新函数)。但如果你在 store 里用了中间件包裹 action,并且每次 set 都会产生新的 action 引用,就会导致组件反复重渲染。解决办法是尽量让 action 保持引用稳定,不在 create 里用内联函数生成 action,或者用useShallow包一层。
我在实际项目里还见过一种错误写法:在 selector 里调用 action,比如useStore((state) => state.addItem(item)),这会导致 selector 每次渲染都执行 addItem,疯狂改状态,然后无限循环。如果你踩过类似的坑,大概率就是这种写法造成的。
7. 再谈几个容易忽略的设计细节
7.1 不要在组件里直接解构 store
一个非常反直觉的细节是useStore()不带 selector 时,返回的是整个 store 状态对象。有些同学图方便直接解构:
const { count, increment } = useCounterStore()这样写的问题在于:只要 store 任何值变化,这个组件就会重新渲染。你只关心count,但某个无关字段被修改了,这个组件也跟着刷。对于简单 demo 无伤大雅,但到了真实项目,这就是性能开销的来源。所以我从一开始就养成一个习惯:永远优先使用 selector 订阅具体字段,不要图省事解构整个 store。
7.2 Store 拆分粒度与业务生命周期对齐
Zustand 不限制你建多少个 store,想建几个建几个。我在组件拆分时有一个参考:如果一个 store 里的状态总是成组变化,那就放一起;如果两个字段几乎同时变化但频率极低,放一起也问题不大。但如果一个 store 里既有高频的交互状态(比如弹窗开关)又有低频的登录信息,建议拆开,因为组件订阅时可以减少被无关状态变更波及的概率。
7.3 在“实用主义”和“框架约束”之间找平衡
Zustand 最大的魅力在于它几乎不给你条条框框。但自由度大也意味着设计全靠自觉。我给团队定过三条约定:第一,所有异步请求逻辑放 store action 里,不放组件;第二,store 里的状态字段尽量少,能派生的就用 selector 或 getter 计算;第三,任何跨模块共享的可变状态,先考虑 Zustand,而不是 useState 抬升。
这三条约定执行了一年,项目从几万行代码到十几万行,状态这块没有出过大乱子。比技巧更重要的是约定和习惯。
写在最后的几个经验
聊到这儿,Zustand 的核心机制、中间件、类型推导、实战套路讲得差不多了。最后分享几个我一直在用的实用习惯。
第一个习惯是“先写状态形状,再写 action”。每当我新建一个 store,第一件事不是写 create 的回调函数,而是先把 TypeScript 的 interface 定义清楚,明确哪些是原始状态、哪些是派生数据、哪些是外部传入的 action 参数。这样三个维度分好类,后面写逻辑会顺很多,也方便团队其他人 review。
第二个习惯是“不要迷信持久化”。能服务端同步的数据尽量从服务端读取,localStorage 只是降级方案。我之前在某个功能里过度使用 persist,导致老用户升级新版本后经常出现脏数据,最后不得已写迁移脚本逐个修。后来这类需求我基本会先问一句:数据没了又怎样?如果重新拉取接口就行,那就不值得为它引入持久化复杂度。
第三个习惯是把“调试链路”提前准备好。项目里是否接入 devtools、是否在关键 action 上增加日志、是否保留 subscribe 的临时排查能力,这些都应该在日常开发中养成习惯,而不是出了问题再到处翻代码。状态管理看似简单,真正决定项目质量的往往是这些不起眼的细节。
希望大家在真正业务里都能把 Zustand 用得顺手,让它成为你状态管理工具箱里最顺手的那把扳手,而不是多出来的又一个负担。