资讯详情

Zustand状态管理实战:从store设计到性能优化

📅 2026/9/9 9:37:25 | 华诺云谱 👁 阅读
Zustand状态管理实战:从store设计到性能优化
好的这次咱们正儿八经聊聊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 createCounterState((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。解决办法是用useShallowimport { 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 就是普通的函数你完全可以在里面 awaitconst 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如果你项目用了 TypeScriptZustand 的类型支持是它另一个杀手锏。最简单的用法是给create传入泛型interface BearState { bears: number addBear: () void } const useBearStore createBearState((set) ({ bears: 0, addBear: () set((state) ({ bears: state.bears 1 })), }))但实际项目中你往往需要导出 store 的类型给外部模块使用。这时可以借助StoreApiimport { create, StoreApi, UseBoundStore } from zustand export type BearStore { bears: number addBear: () void } export const useBearStore: UseBoundStoreStoreApiBearStore createBearStore()((set) ({ bears: 0, addBear: () set((state) ({ bears: state.bears 1 })), }))看到中间那个奇怪的()()了吗这是因为 Zustand 对中间件和类型推导的兼容设计你给create传入中间件时需要用它。简单记一下只要用了泛型指示 store 形状且想完整保留类型推导就在createXXX()(...)这样写它叫 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 createUserSlice 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: StateCreatorT ): StateCreatorT (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和mergeconst useDemoStore create( persist( (set) ({ cachedAt: new Date(), count: 0, }), { name: demo-storage, partialize: (state) ({ count: state.count }), merge: (persisted, current) ({ ...current, ...(persisted as Partialtypeof current), cachedAt: new Date(), }), } ) )partialize控制哪些字段需要持久化merge控制如何把持久化的值和当前初始状态合并。默认是浅合并但遇到嵌套对象或需要特殊处理的字段比如日期恢复时一定要自己写merge。还有一个常见场景是“存量字段更新”你存了一个版本后来代码里删掉了某个字段persist 默认会把旧值继续保留。如果你想清理旧数据需要自己判断 version 或者做迁移逻辑我通常在persist配置里用versionmigrate来管理{ 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你可以直接修改嵌套属性不需要展开对象。它返回 voidimmer 内部自动收集变更并生成新的不可变状态。重度嵌套数据结构下代码可读性提升非常明显。需要注意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) Promisevoid 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: () Promisevoid }每个 slice 单独实现最后合并。为什么要拆开而不是一个大 store因为这三个域的生命周期和更新频率完全不同用户信息变化极低频购物车高频商品列表可能只在加载时变。拆开后各自模块维护自己的状态和 action组件订阅起来也更精确。5.2 同步 action 与异步 action 实现用户 store 里最核心的是异步 loginconst 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中间件包裹购物车 sliceconst 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默认监听任意状态变化。如果你只关心某个字段可以用第三个参数配合 selectoruseStore.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 用得顺手让它成为你状态管理工具箱里最顺手的那把扳手而不是多出来的又一个负担。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。