Zustand实现computed的几种方式与踩坑指南
从 Vue 切到 React 18 Zustand 的团队十有八九会在一个月内问出同一个问题Zustand 里怎么实现 computed在 Vue 里写惯了computed(() ...)到了 Zustand 这边发现根本没有这个内置概念网上搜在 Zustand 中实现 computed 的方式出来的答案也五花八门有些能跑通有些一上线就在控制台刷computed 报错。我自己在这个问题上踩过不少坑也把团队里的状态层从到处写 selector 表达式重构到store 内部维护缓存派生值再到现在稳定使用的混合模式。这篇就把我验证过的几种实现方式、它们的性能特征以及几个高频computed 报错的完整排查链路一次说清楚。1. 先把 Zustand 的订阅模型讲透selector 就是最原始的 computed1.1 store 内部发生了什么Zustand 的 store 本质是一个普通对象加一套发布订阅机制。create返回的useStore是个 hook你每次调用useStore(selector)React 组件就算订阅了这个 store。store 上任何一个字段被set更新所有订阅者都会重新执行自己的 selector。这句话值得停下来想三秒Zustand 没有依赖追踪它做的是全量广播 浅比较。selector 执行完返回值内部用Object.is和上一次返回值比较如果不同这个组件就 re-render。所以你在 selector 里写的表达式本质上就是一次不被缓存的计算属性——每次任何状态变化都会重算。1.2 为什么 selector 计算只适合轻量场景很多新手以为useStore((s) s.price * s.count)就是 Vue 的 computed逻辑上没错但性能特征完全不同。Vue 的 computed 只有在依赖项变化时才重新求值而 Zustand 的 selector 在 store任何变化时都会执行。举个例子const useStore create((set) ({ a: 1, b: 2, heavyList: [], incA: () set((s) ({ a: s.a 1 })), updateList: (list) set({ heavyList: list }), })); function Total() { // 即使只改了 heavyList这里也会重新执行 const total useStore((s) s.a * s.b); return div{total}/div; }如果a * b换成一段遍历几千条数据的计算每次更新heavyList都会白白跑一遍这就是computed 没有缓存的代价。数据量小、派生逻辑简单时完全无所谓一旦派生逻辑变重就需要引入缓存方案也就是后面几章要讲的把派生值写进 store、使用中间件或自定义 hook。1.3 引用类型返回值是第一条高压线既然内部是Object.is浅比较那么 selector 里绝对不能现造对象// 每次执行都返回一个新对象Object.is 永远不同 const info useStore((s) ({ total: s.a * s.b, label: s.label }));这一行的后果不只是多余 re-render在 React 18 严格模式下会直接触发Maximum update depth exceeded的computed 报错后面第五章我们专门排查这个事故。现在你只需要记住Zustand 的 selector 协议是返回值即依赖项返回引用类型的值必须保证引用稳定。2. 入门做法直接用 selector 求值含 useShallow 的正确姿势2.1 最朴素的写法与适用边界最简单的方式就是在 selector 里做计算组件里直接用const useCartStore create((set) ({ items: [] as Item[], addItem: (item) set((s) ({ items: [...s.items, item] })), })); function CartCount() { const count useCartStore((s) s.items.reduce((sum, i) sum i.count, 0)); return span{count}/span; }这种写法的适用边界很清晰派生逻辑不重O(n) 在几千条数据以内派生值只有一个组件或少数几个组件在使用对无关状态变化导致重算不敏感。我见过不少服务端管理系统所有合计数量状态拼接都用这种方式跑得挺好。核心原因是这些派生计算廉价多执行几次根本感知不到。2.2 多个派生值如何合并取值问题出现在你要在同一个组件里取多个派生值时。常见错误是这样千万别这么写function Summary() { // 拆包返回对象每次都是新引用直接死循环 const { subtotal, discount, total } useCartStore((s) ({ subtotal: calcSubtotal(s.items), discount: calcDiscount(s.coupons), total: calcTotal(s.items, s.coupons), })); return ... }这里有两个问题引用不稳定导致的无限渲染以及三个派生函数互相独立、每次全量执行。正确姿势是用useShallowimport { useShallow } from zustand/react/shallow; function Summary() { const { subtotal, discount, total } useCartStore( useShallow((s) ({ subtotal: calcSubtotal(s.items), discount: calcDiscount(s.coupons), total: calcTotal(s.items, s.coupons), })) ); return ... }useShallow会对返回对象做一层浅比较如果新对象的每个属性都和老对象对应属性值相等就认为没有变化不触发 re-render。这样你把计算逻辑写在 selector 里就安全了。2.3 和 Vue computed 的本质差异没有依赖追踪只有全量执行用 selector 方案久了你会发现一个微妙问题Vue 的 computed 是按需重算a 变了只有依赖 a 的计算属性会重算Zustand 的 selector 是全量重算任何 store 变化都会重新执行所有 selector。这意味着你无法在 selector 层面做细粒度的依赖判断。就算你只依赖s.astore 里更新了s.z这个 selector 还是会跑一遍。所以当项目里出现派生计算很重 store 更新频繁 无关状态很多这三个特征同时存在时就该放弃纯 selector 方案上缓存方案了。3. 工程化做法把派生值写进 store用 action 维护缓存3.1 一个真实的购物车需求拆解先看一个我实际做过的场景购物车有商品列表items、选中项selectedIds、优惠券coupons三个基础状态页面任何位置都需要展示以下派生值商品总数、选中商品数原价合计、优惠金额、应付金额是否满减、是否包邮。如果把派生逻辑分散到组件里用 selector 写至少会有七八处重复计算且每个派生值都不是单独依赖某一个基础字段而是组合依赖。这时候我选择把派生值作为缓存字段直接写进 store用 action 统一维护。3.2 完整实现派生函数 action 更新import { create } from zustand; type Item { id: string; price: number; count: number }; type Coupon { type: percent | fixed; value: number }; // 纯函数把计算逻辑收敛到一个地方 function calcTotals(items: Item[], selectedIds: string[], coupons: Coupon[]) { const selectedItems items.filter((i) selectedIds.includes(i.id)); const origin selectedItems.reduce((sum, i) sum i.price * i.count, 0); const selectedCount selectedItems.reduce((sum, i) sum i.count, 0); let discount 0; for (const c of coupons) { discount c.type percent ? origin * c.value : c.value; } return { count: items.reduce((sum, i) sum i.count, 0), selectedCount, originPrice: origin, discount: Math.min(discount, origin), payable: Math.max(origin - discount, 0), }; } const useCartStore createCartStore()((set, get) ({ items: [], selectedIds: [], coupons: [] as Coupon[], totals: { count: 0, selectedCount: 0, originPrice: 0, discount: 0, payable: 0, }, addItem: (item: Item) set((s) { const items [...s.items, item]; return { items, totals: calcTotals(items, s.selectedIds, s.coupons) }; }), toggleSelect: (id: string) set((s) { const selectedIds s.selectedIds.includes(id) ? s.selectedIds.filter((x) x ! id) : [...s.selectedIds, id]; return { selectedIds, totals: calcTotals(s.items, selectedIds, s.coupons) }; }), setCoupons: (coupons: Coupon[]) set((s) { const totals calcTotals(s.items, s.selectedIds, coupons); return { coupons, totals }; }), }));使用方就非常干净了function Footer() { const totals useCartStore((s) s.totals); return div应付{totals.payable}/div; }注意totals是 store 里的字段引用是稳定的只有 action 显式替换它时才会变。组件里随便怎么取都不会有引用型死循环。3.3 维护成本与团队约束这套方案最大的问题不是写起来麻烦而是太依赖人的纪律。只要有一个 action 漏掉了totals的更新派生值就会出现静默过期——state 变了界面上数字还是旧的。我们团队后来立了几条硬性规则所有基础状态只能通过 action 修改禁止在组件里直接用useStore.setState改这些字段。这是这套方案的命门破坏了这条缓存就不可信。所有 action 的返回值结构保持基础字段 派生字段成对出现review 的时候一眼能看出来有没有漏。派生计算函数必须是纯函数参数显式传入不要内部偷偷get()依赖别的基础字段否则很难测试。3.4 用通用 updater 把漏改风险降下来为了进一步减少漏改可以做一个统一入口把改基础字段 重算派生值绑定成一个操作function updateCart(partial: PartialPickCartStore, items | selectedIds | coupons) { return useCartStore.setState((s) { const next { ...s, ...partial }; return { ...next, totals: calcTotals(next.items, next.selectedIds, next.coupons) }; }); }之后所有组件一律调updateCart({ items: [...] })不许直接调setState({ items: ... })。这样就把重算这个动作从每个 action 里抽了出来收口到单一函数。这个模式在表单类、列表类的 store 里都很管用。4. 中间件与自定义 hook两条更省心的路4.1 zustand-computed 中间件的用法与原理如果你实在怀念 Vue 的 computed 写法社区里有一个zustand-computed中间件API 长这样npm install zustand-computedimport { create } from zustand; import { computed } from zustand-computed; const useStore create( computed((set, get) ({ count: 1, double: () get().count * 2, })) ); function Counter() { const double useStore((s) s.double); return div{double}/div; }凡是返回值为() xxx形式的字段会被中间件当成计算属性处理。它的内部原理是拦截 set在基础状态更新后重新计算所有计算属性的值如果值和上次不同就写回 state。这样get().double拿到的是一个稳定的缓存值组件订阅它不会有多余 re-render。4.2 为什么这个库的Vue 味也是最危险的用过一阵你会发现这个中间件虽然写着舒服但有两个坑第一它在初始化阶段可能访问到未完整的 state。计算属性里如果写了get().someField而someField在 state 中的初始化顺序在它后面就有概率拿到undefined这就是computed 报错里比较典型的一类。解法是计算函数里做空值兜底computed((set, get) ({ count: 1, label: x, double: () (get().count ?? 0) * 2, }))第二它无法表达依赖另一个计算属性的链式关系。Vue 的 computed 天然支持 A computed 依赖 B computedzustand-computed 里你如果写bValue: () get().aValue * 10aValue本身也是计算属性中间件的执行顺序不一定能保证aValue先算完。我上过当之后就不再推荐把它作为核心状态层了只建议在小型 side project 里玩。4.3 自定义 hook 实现链式派生状态真正解决链式派生且不用中间件的思路是写一个组件外自定义 hook内部用 store 订阅驱动派生 store。主 store 管基础状态派生 store 只存计算结果主 store 每次变化时同步一次const useBaseStore create((set) ({ price: 10, count: 2, setPrice: (p) set({ price: p }), setCount: (c) set({ count: c }), })); // 派生 store只放计算后的缓存值 const useDerivedStore create{ total: number }(() ({ total: 0 })); // 订阅驱动主 store 任何变化都重算一次 useBaseStore.subscribe((s) { useDerivedStore.setState({ total: s.price * s.count }); }); // 初始化同步一次 useDerivedStore.setState({ total: useBaseStore.getState().price * useBaseStore.getState().count }); function useTotal() { return useDerivedStore((s) s.total); }这个模式的好处是useTotal()可以在任意多组件里复用每个组件拿到的是稳定的缓存值派生逻辑全部在订阅回调里和 React 组件生命周期无关如果你想做链式就让第二个派生 store 订阅第一个派生 store一层一层串下去。坏处也很明显多一层 store 就多一分不同步的风险。如果主 store 里多个字段频繁更新而它们都不影响派生结果订阅回调依然会全量重算。想优化就得自己在回调里判断相关字段等于把依赖追踪又手搓了一遍——一般项目没必要做到这一步。5. computed 报错现场三次真实的事故排查5.1 事故一Maximum update depth exceeded 的完整链路这是出现频率最高的computed 报错报错长这样Warning: Maximum update depth exceeded. This can happen when a component repeatedly calls setState inside componentWillUpdate or componentDidUpdate. React limits the number of nested updates to prevent infinite loops.我见过一个真实案例同事在组件里写了const { visibleItems } useStore((s) ({ visibleItems: s.items.filter((i) i.visible), }));排查链路是这样的先从报错堆栈定位到组件发现它只做了useStore没有在生命周期里setState排除事件循环触发更新的可能。再看 selector确定它返回了一个新的{ visibleItems: ... }对象。每次 store 变化selector 执行产生一个新对象Object.is比较必然不相等组件 re-renderre-render 后重新订阅触发 store 更新这例子里的 filter 本身不改状态但 React 18 的 useSyncExternalStore 在渲染后还会再校验一次于是形成死循环。修复很简单把对象拆开分别用基础状态再在组件里做计算或者用useShallowconst visibleItems useStore(useShallow((s) s.items.filter((i) i.visible)));这类问题的高频出现时间是上线前联调因为你本地数据少、re-render 层级浅不容易触发 React 的更新深度上限一接真实数据就炸。我的建议是团队里定死一条规矩selector 里不允许直接返回字面量对象、字面量数组或任何由 map/filter/展开运算符临时生成的结构除非外面包了useShallow。5.2 事故二初始化阶段 state 还是 undefined用 zustand-computed 或自定义计算属性时最容易遇到TypeError: Cannot read properties of undefined (reading xxx)我排查过的一个场景计算属性里依赖get().profile.name但初始化 state 里profile的默认值是null。中间件在 store 构造阶段就会去算计算属性一跑就拿到null.name直接抛错。根因是中间件/计算属性的求值时机早于你想要依赖的字段初始化完成。排查手段是三步看报错堆栈确定是哪个计算函数抛的看这个函数里get()拿了哪些字段逐个确认它们在初始化 state 里的顺序和默认值把所有可能为空的字段都加空值兜底或在get()里用可选链。修复示例computed((set, get) ({ profile: null, displayName: () get().profile?.name ?? 未登录, }))吃一堑长一智后来凡是写自定义计算字段我都默认任何依赖都可能先于其实例化绝不裸写get().xxx。5.3 事故三缓存派生值静默过期最头疼的报错是那种不报错的 bug用了第三章的冗余 store方案某个 action 忘了更新totals界面上的合计数字停留在旧值只有刷新页面才对。我排查过的案例购物车加了removeItem和clearSelected两个 actionreview 时都只更新了items漏了totals。页面里切换商品再切回来看起来没变实际数据早变了。排查链路先在控制台执行useCartStore.getState()对比items实际内容和totals算出来的期望值发现确实不一致确认是缓存过期。翻所有set调用点找出哪些 action 改了items/selectedIds/coupons但没有同步更新totals。补上缺失的totals计算。这类 bug 在代码 review 阶段很难看出来因为 action 多了以后人眼根本记不住每个字段的依赖关系。我在团队里推了两个应对措施一是给 store 挂一个开发环境的自校验订阅if (import.meta.env.DEV) { useCartStore.subscribe((s) { const expect calcTotals(s.items, s.selectedIds, s.coupons); if (JSON.stringify(expect) ! JSON.stringify(s.totals)) { console.error(totals 缓存过期请检查最近修改的 action, s); } }); }这样一旦有人在新增 action 时漏掉重算开发环境第一时间报警而不是上线后让用户发现数字不对。二是尽量用 3.4 节的updateCart统一入口减少绕过重算逻辑的路径。实践下来自校验 统一入口基本能消灭这一类静默 bug。6. 我的选型建议与最后几条心得以上几种方式我都实际用过把它们放在一起看会更清楚方案适合场景最大风险是否需要引入依赖selector 直接计算轻量派生、单组件使用引用不稳定、无关重算无useShallow selector合并多个派生值浅比较只解决一层引用问题zustand 内置派生值冗余进 store派生重、复用广、需要缓存action 漏改导致缓存过期无zustand-computed 中间件小项目快速复用 Vue 心智初始化时序、链式计算弱需要自定义 hook 订阅驱动链式派生、跨模块复用多层 store 同步成本高无我个人在团队里的最终选择是**默认用 selector 直接计算 useShallow 处理合并一旦发现派生计算超过几十毫秒或者同一个派生值被超过三个组件使用就升级到派生值冗余进 store方案链式派生用自定义 hook 订阅驱动。**中间件我很少在核心业务里用因为它在初始化时序和依赖追踪上给我的确定性不够。最后分享两个小经验。第一排除computed 报错的时候先搞清楚是真的抛错还是静默算错两种的排查路径完全不同前者看堆栈后者要用getState()对比期望值。第二无论选哪种方案派生计算函数要写成可独立测试的纯函数不要依赖 React 组件上下文这样单元测试可以直接对着函数跑不用挂载组件。状态管理这块没有银弹所谓在 Zustand 中实现 computed本质是在计算时机和缓存一致性之间做取舍。想清楚你的派生数据是贵还是便宜、依赖是简单还是链式选起型来就不会纠结了。