Zustand useStoreWithEqualityFn 完全指南:为 vanilla store 定制 React 重渲染的相等性判断
【免费下载链接】zustand🐻 Bear necessities for state management in React项目地址: https://gitcode.com/gh_mirrors/zu/zustand
useStoreWithEqualityFn是 Zustand 中用于把 vanilla store(createStore创建的纯 store)接入 React 组件的 Hook,它与useStore用法一致,但额外接受一个自定义相等性函数(equality function),让你可以更精细地控制组件何时重渲染,从而提升性能与响应性。读完本文,你将掌握该 Hook 的签名、底层实现原理,以及四种典型实战场景(全局 store、动态 store、Context 作用域 store、动态作用域 store)的完整可运行代码。
概览:它和useStore有什么区别
在 Zustand 中,useStore允许你在 React 组件里订阅一个 vanilla store,并配合 selector 函数选取需要的状态切片。但useStore内部基于React.useSyncExternalStore,它的重渲染判定比较简单:每次状态更新后,比较 selector 返回值是否发生了引用变化(默认按Object.is语义),一旦引用不同就触发重渲染。
而useStoreWithEqualityFn在useStore的基础上增加了一个equalityFn参数。当 store 状态更新、selector 返回新值后,Hook 会先调用equalityFn(oldValue, newValue),只有该函数返回false才认为“结果变化了”,进而触发组件重渲染。这让你可以:
- 当 selector 返回新对象(每次都是新引用)但内容其实没变时,阻止无谓的重渲染;
- 用
shallow对对象、数组、Map、Set 做浅比较,实现“内容相同则不重渲染”; - 传入完全自定义的比较逻辑,比如忽略大小写、比较日期、比较深层字段等。
典型调用形式:
const someState = useStoreWithEqualityFn(store, selectorFn, equalityFn)它与createWithEqualityFn(在 createWithEqualityFn 文档 中有详细介绍)是一对配套 API:前者面向已有的 vanilla store,后者面向创建绑定式 Hook,两者都支持自定义相等性判断。
安装与前置条件
[!IMPORTANT] 要从
zustand/traditional导入useStoreWithEqualityFn,你必须额外安装use-sync-external-store库。因为zustand/traditional的实现依赖于useSyncExternalStoreWithSelector,而不是 React 内建的useSyncExternalStore。
npm install zustand use-sync-external-store # 或 pnpm add zustand use-sync-external-store这一点在仓库的 package.json 中也能印证:use-sync-external-store被声明为可选 peer dependency("use-sync-external-store": ">=1.2.0"),也就是说只有使用zustand/traditional入口时才需要它。如果你的项目中只用了默认的zustand入口(useStore、create),则不需要安装。
对应的导入语句:
import { useStoreWithEqualityFn } from 'zustand/traditional' import { createStore } from 'zustand' import { shallow } from 'zustand/shallow'注意:useStoreWithEqualityFn只负责“订阅”,它并不创建 store。创建 vanilla store 仍然使用createStore(见 vanilla.ts 中的实现)。
类型签名与参数说明
Signature
useStoreWithEqualityFn<T, U = T>(store: StoreApi<T>, selectorFn: (state: T) => U, equalityFn?: (a: U, b: U) => boolean): U参数
store:store API 实例,由createStore创建,内部至少提供getState、getInitialState、subscribe三个只读能力。从源码看,Hook 对参数类型的要求是ReadonlyStoreApi<T>,即仅需要这三个方法(见 traditional.ts),setState并不参与订阅逻辑。selectorFn:纯函数,接收当前状态state: T,返回你需要的状态切片U。组件最终渲染的就是这个返回值。equalityFn(可选):比较函数(a: U, b: U) => boolean,返回true表示新旧结果“相等”,跳过本次重渲染;返回false表示结果变化,触发重渲染。不传时,行为由底层的useSyncExternalStoreWithSelector决定(默认按Object.is语义比较)。
返回值
返回selectorFn基于当前状态计算出的数据U。当 store 状态更新时,Hook 会重新执行 selector,并用equalityFn判断是否需要让组件重渲染。
工作原理:从源码看它的底层实现
useStoreWithEqualityFn的实现非常精简,核心就在 src/traditional.ts:
export function useStoreWithEqualityFn<TState, StateSlice>( api: ReadonlyStoreApi<TState>, selector: (state: TState) => StateSlice = identity as any, equalityFn?: (a: StateSlice, b: StateSlice) => boolean, ) { const slice = useSyncExternalStoreWithSelector( api.subscribe, api.getState, api.getInitialState, selector, equalityFn, ) React.useDebugValue(slice) return slice }它把 store 的subscribe、getState、getInitialState以及用户提供的selector、equalityFn全部透传给 React 官方推荐的useSyncExternalStoreWithSelector。这条调用链值得注意:
api.subscribe:订阅 store 状态变化。vanilla store 在setState时通过listeners.forEach通知所有订阅者(见 vanilla.ts),并且只有当新状态与旧状态不满足Object.is时才触发通知。api.getState/api.getInitialState:分别用于获取当前状态和初始状态,保证并发渲染与 hydration 场景下拿到一致快照。selector与equalityFn:状态变化时,useSyncExternalStoreWithSelector会重新执行 selector 得到新切片,再调用equalityFn与上一次的切片做比较,决定是否重渲染。
作为对比,useStore(默认入口,见 src/react.ts)使用的是React.useSyncExternalStore,它没有 selector 结果级比较的能力——每次状态变化时组件都会按新切片重渲染。这正是“traditional(传统)入口”存在的原因:为需要精确控制重渲染的场景提供一个带相等性判断的订阅 Hook。
另外一个细节:useStoreWithEqualityFn的默认selector是恒等函数identity((arg) => arg),因此当你不传 selector 时,它返回整个状态对象,此时equalityFn会比较整个状态。
配套的createWithEqualityFn
同一份 traditional.ts 还导出了createWithEqualityFn,它创建的是一个“自带 API 工具方法”的绑定式 Hook,并支持传入一个defaultEqualityFn作为默认相等性函数:
const createWithEqualityFnImpl = <T>( createState: StateCreator<T, [], []>, defaultEqualityFn?: <U>(a: U, b: U) => boolean, ) => { const api = createStore(createState) const useBoundStoreWithEqualityFn: any = ( selector?: any, equalityFn = defaultEqualityFn, ) => useStoreWithEqualityFn(api, selector, equalityFn) Object.assign(useBoundStoreWithEqualityFn, api) return useBoundStoreWithEqualityFn }可以看到,它内部就是把createStore创建的 store 与useStoreWithEqualityFn组合起来,并把defaultEqualityFn作为每次调用时的默认第三参数。如果你的 store 是“一次性创建、全局共享”的,用createWithEqualityFn更省事;如果你的 store 是动态创建、按需传入的,则直接使用useStoreWithEqualityFn。
实战一:在 React 中使用全局 vanilla store(MovingDot)
本场景对应原文档的 "Using a global vanilla store in React"。假设我们要做一个跟随鼠标移动的小圆点,先把圆点的位置状态放进一个全局 vanilla store 里。
第一步:创建 store
store 管理x、y坐标,并提供一个更新坐标的 action:
import { createStore } from 'zustand' type PositionStoreState = { position: { x: number; y: number } } type PositionStoreActions = { setPosition: (nextPosition: PositionStoreState['position']) => void } type PositionStore = PositionStoreState & PositionStoreActions const positionStore = createStore<PositionStore>()((set) => ({ position: { x: 0, y: 0 }, setPosition: (position) => set({ position }), }))这里createStore返回的是一个纯粹的 store API(setState、getState、getInitialState、subscribe),不含 React 绑定,因此可以被任何环境复用。
第二步:组件内订阅
MovingDot组件通过useStoreWithEqualityFn分别订阅position(状态)和setPosition(action),第三个参数都传入shallow:
import { useStoreWithEqualityFn } from 'zustand/traditional' import { shallow } from 'zustand/shallow' function MovingDot() { const position = useStoreWithEqualityFn( positionStore, (state) => state.position, shallow, ) const setPosition = useStoreWithEqualityFn( positionStore, (state) => state.setPosition, shallow, ) return ( <div onPointerMove={(e) => { setPosition({ x: e.clientX, y: e.clientY, }) }} style={{ position: 'relative', width: '100vw', height: '100vh', }} > <div style={{ position: 'absolute', backgroundColor: 'red', borderRadius: '50%', transform: `translate(${position.x}px, ${position.y}px)`, left: -10, top: -10, width: 20, height: 20, }} /> </div> ) }为什么这里要用shallow?因为onPointerMove每次都会调用setPosition传入一个全新的对象字面量{ x, y }。如果没有equalityFn,useStore会认为position引用每次都在变,从而每次指针移动都重渲染;而shallow会逐属性比较position.x与position.y,只有坐标真的变了才触发重渲染,极大减少了高频事件下的渲染次数。
第三步:渲染
export default function App() { return <MovingDot /> }完整代码合并后即为原文档给出的版本:createStore创建 store,useStoreWithEqualityFn+shallow订阅,App渲染MovingDot。
实战二:在 React 中使用动态全局 vanilla store(Tabs 计数器)
本场景对应原文档的 "Using dynamic global vanilla stores in React"。核心需求:多个 Tab 各自拥有独立的计数器实例,切换 Tab 时订阅对应实例。做法是“工厂函数 +Map缓存”。
第一步:store 工厂
import { createStore } from 'zustand' type CounterState = { count: number } type CounterActions = { increment: () => void } type CounterStore = CounterState & CounterActions const createCounterStore = () => { return createStore<CounterStore>()((set) => ({ count: 0, increment: () => { set((state) => ({ count: state.count + 1 })) }, })) }第二步:按 key 获取或创建 store
用一个模块级Map缓存所有已创建的 store,保证同一个 key 永远拿到同一个实例:
const defaultCounterStores = new Map< string, ReturnType<typeof createCounterStore> >() const createCounterStoreFactory = ( counterStores: typeof defaultCounterStores, ) => { return (counterStoreKey: string) => { if (!counterStores.has(counterStoreKey)) { counterStores.set(counterStoreKey, createCounterStore()) } return counterStores.get(counterStoreKey)! } } const getOrCreateCounterStoreByKey = createCounterStoreFactory(defaultCounterStores)第三步:在组件中按当前 Tab 订阅
切换 Tab 时,currentTabIndex变化,于是getOrCreateCounterStoreByKey会返回不同(或新建)的 store,useStoreWithEqualityFn随即切换到该实例。这里 selector 返回整个state,配合shallow比较count与increment:
import { useState } from 'react' function Tabs() { const [currentTabIndex, setCurrentTabIndex] = useState(0) const counterState = useStoreWithEqualityFn( getOrCreateCounterStoreByKey(`tab-${currentTabIndex}`), (state) => state, shallow, ) return ( <div style={{ fontFamily: 'monospace' }}> <div style={{ display: 'flex', gap: '0.5rem', borderBottom: '1px solid salmon', paddingBottom: 4, }} > <button type="button" style={{ border: '1px solid salmon', backgroundColor: '#fff', cursor: 'pointer', }} onClick={() => setCurrentTabIndex(0)} > Tab 1 </button> <button type="button" style={{ border: '1px solid salmon', backgroundColor: '#fff', cursor: 'pointer', }} onClick={() => setCurrentTabIndex(1)} > Tab 2 </button> <button type="button" style={{ border: '1px solid salmon', backgroundColor: '#fff', cursor: 'pointer', }} onClick={() => setCurrentTabIndex(2)} > Tab 3 </button> </div> <div style={{ padding: 4 }}> Content of Tab {currentTabIndex + 1} <br /> <br /> <button type="button" onClick={() => counterState.increment()}> Count: {counterState.count} </button> </div> </div> ) } export default function App() { return <Tabs /> }注意:当currentTabIndex变化导致传入的 store 实例改变时,useSyncExternalStoreWithSelector会重新订阅新的 store,这是该 Hook 支持“动态 store”的关键。原文档中给出了合并后的完整代码(含import { useState } from 'react'与import { createStore } from 'zustand'等全部导入),可直接复制运行。
实战三:在 React 中使用局部(非全局)vanilla store(Context 作用域)
本场景对应原文档的 "Using scoped (non-global) vanilla store in React"。当同一个组件树的多个实例需要相互独立的状态时(例如两个颜色不同的小圆点各自跟随鼠标),不能使用模块级单例,而要把 store 放进 React Context,实现“每个 Provider 一份状态”。
第一步:store 工厂
import { createStore } from 'zustand' type PositionStoreState = { position: { x: number; y: number } } type PositionStoreActions = { setPosition: (nextPosition: PositionStoreState['position']) => void } type PositionStore = PositionStoreState & PositionStoreActions const createPositionStore = () => { return createStore<PositionStore>()((set) => ({ position: { x: 0, y: 0 }, setPosition: (position) => set({ position }), })) }第二步:Context 与 Provider
import { type ReactNode, useState, createContext, useContext } from 'react' const PositionStoreContext = createContext<ReturnType< typeof createPositionStore > | null>(null) function PositionStoreProvider({ children }: { children: ReactNode }) { const [store] = useState(() => createPositionStore()) return ( <PositionStoreContext.Provider value={store}> {children} </PositionStoreContext.Provider> ) }用useState(() => createPositionStore())惰性创建 store,保证 Provider 挂载期间 store 实例稳定不变。
第三步:封装自定义 Hook
把“从 Context 取 store + 用useStoreWithEqualityFn订阅”封装成usePositionStore,同时处理 Context 为空的情况:
function usePositionStore<U>(selector: (state: PositionStore) => U) { const store = useContext(PositionStoreContext) if (store === null) { throw new Error( 'usePositionStore must be used within PositionStoreProvider', ) } return useStoreWithEqualityFn(store, selector, shallow) }第四步:组件与组合
function MovingDot({ color }: { color: string }) { const position = usePositionStore((state) => state.position) const setPosition = usePositionStore((state) => state.setPosition) return ( <div onPointerMove={(e) => { setPosition({ x: e.clientX > e.currentTarget.clientWidth ? e.clientX - e.currentTarget.clientWidth : e.clientX, y: e.clientY, }) }} style={{ position: 'relative', width: '50vw', height: '100vh', }} > <div style={{ position: 'absolute', backgroundColor: color, borderRadius: '50%', transform: `translate(${position.x}px, ${position.y}px)`, left: -10, top: -10, width: 20, height: 20, }} /> </div> ) } export default function App() { return ( <div style={{ display: 'flex' }}> <PositionStoreProvider> <MovingDot color="red" /> </PositionStoreProvider> <PositionStoreProvider> <MovingDot color="blue" /> </PositionStoreProvider> </div> ) }每个PositionStoreProvider内部都有一份独立的position状态,两个圆点互不干扰。这就是“scoped(局部)store”与“全局 store”的核心区别:作用域由 Provider 的挂载位置决定。
实战四:在 React 中使用动态局部 vanilla store(Context + Map 缓存)
本场景对应原文档的 "Using dynamic scoped (non-global) vanilla stores in React",是“动态 store”与“局部 store”两种需求的叠加:每个 Provider 内部按 key 缓存多个 store 实例,Tab 切换时切换订阅目标。
第一步:store 工厂与工厂函数
const createCounterStore = () => { return createStore<CounterStore>()((set) => ({ count: 0, increment: () => { set((state) => ({ count: state.count + 1 })) }, })) } const createCounterStoreFactory = ( counterStores: Map<string, ReturnType<typeof createCounterStore>>, ) => { return (counterStoreKey: string) => { if (!counterStores.has(counterStoreKey)) { counterStores.set(counterStoreKey, createCounterStore()) } return counterStores.get(counterStoreKey)! } }第二步:Context 承载Map
与全局版不同,这里Map不再放在模块级,而是放进 Context,让每个 Provider 拥有自己的缓存:
import { type ReactNode, useState, useCallback, useContext, createContext } from 'react' const CounterStoresContext = createContext<Map< string, ReturnType<typeof createCounterStore> > | null>(null) const CounterStoresProvider = ({ children }: { children: ReactNode }) => { const [stores] = useState( () => new Map<string, ReturnType<typeof createCounterStore>>(), ) return ( <CounterStoresContext.Provider value={stores}> {children} </CounterStoresContext.Provider> ) }第三步:自定义 Hook 按 key 订阅
const useCounterStore = <U,>( key: string, selector: (state: CounterStore) => U, ) => { const stores = useContext(CounterStoresContext) if (stores === undefined) { throw new Error('useCounterStore must be used within CounterStoresProvider') } const getOrCreateCounterStoreByKey = useCallback( (key: string) => createCounterStoreFactory(stores!)(key), [stores], ) return useStore(getOrCreateCounterStoreByKey(key), selector) }[!NOTE] 原文档此例的最终合并版本中,
useCounterStore内部调用的是useStore(来自zustand),即“Context 按需取 store + 默认订阅”。你也可以按需替换为useStoreWithEqualityFn(store, selector, equalityFn),为局部动态 store 同样加上相等性判断。
第四步:Tabs 组件与 App
function Tabs() { const [currentTabIndex, setCurrentTabIndex] = useState(0) const counterState = useCounterStore( `tab-${currentTabIndex}`, (state) => state, ) return ( <div style={{ fontFamily: 'monospace' }}> <div style={{ display: 'flex', gap: '0.5rem', borderBottom: '1px solid salmon', paddingBottom: 4, }} > <button type="button" style={{ border: '1px solid salmon', backgroundColor: '#fff', cursor: 'pointer', }} onClick={() => setCurrentTabIndex(0)} > Tab 1 </button> <button type="button" style={{ border: '1px solid salmon', backgroundColor: '#fff', cursor: 'pointer', }} onClick={() => setCurrentTabIndex(1)} > Tab 2 </button> <button type="button" style={{ border: '1px solid salmon', backgroundColor: '#fff', cursor: 'pointer', }} onClick={() => setCurrentTabIndex(2)} > Tab 3 </button> </div> <div style={{ padding: 4 }}> Content of Tab {currentTabIndex + 1} <br /> <br /> <button type="button" onClick={() => counterState.increment()}> Count: {counterState.count} </button> </div> </div> ) } export default function App() { return ( <CounterStoresProvider> <Tabs /> </CounterStoresProvider> ) }这套组合覆盖了 Zustand 官方推荐的所有“vanilla store 接入 React”的形态:全局静态、全局动态、局部静态、局部动态。
深入理解 equalityFn:从Object.is到shallow
equalityFn是useStoreWithEqualityFn的灵魂参数。理解它的三种常用形态,能帮你写出更精准的重渲染控制。
1. 默认行为:Object.is
当不传equalityFn时,底层useSyncExternalStoreWithSelector按Object.is语义比较新旧 selector 结果。这意味着:
- 原始类型(number、string、boolean)按值比较;
- 对象、数组按引用比较——只要引用不同就重渲染。
2. 内置shallow:浅比较
shallow由 Zustand 提供,可以从zustand/shallow(React 与 vanilla 通用的聚合入口,见 src/shallow.ts)或zustand/vanilla/shallow导入。它的实现位于 src/vanilla/shallow.ts,比较逻辑为:
- 先用
Object.is判断,相同直接返回true; - 若任一值不是对象或为
null,返回false; - 若两者原型不同(
Object.getPrototypeOf(a) !== Object.getPrototypeOf(b)),返回false; - 对可迭代对象(含
entries的 Map 类、Set 类、数组等)逐一比较条目或元素; - 对普通对象逐属性比较顶层键值。
因此shallow适合 selector 返回扁平对象、数组、Set、Map的场景——只要顶层内容一致就认为相等,忽略嵌套结构的变化。更完整的比较语义可以参阅 shallow 文档 中的对比示例。
3. 完全自定义
你完全可以传入自己的比较函数,例如:
useStoreWithEqualityFn( store, (state) => state.user.name, (a, b) => a.toLowerCase() === b.toLowerCase(), )只要equalityFn满足(a: U, b: U) => boolean的签名即可。
测试用例验证
仓库测试 tests/basic.test.tsx 直接验证了createWithEqualityFn(内部即useStoreWithEqualityFn)的 selector 调用行为:
- 静态 selector(模块级定义):只有真正需要时(初始渲染 + 选中值变化)才执行;
- 内联 selector(组件内定义):每次组件渲染都会重新执行(测试中
rerender后内联 selector 调用次数从 1 变 2,而静态 selector 保持 1)。
这个测试从行为层面证实了文档中的建议:把 selector 定义在组件外部(静态)可以减少不必要的计算,也是使用本 Hook 时最重要的性能实践之一。
性能实践:静态 selector 与重渲染控制
结合 tests/basic.test.tsx 的另一个用例可以总结出两条实战准则:
- selector 尽量静态化:把 selector 提到组件外部或模块级,避免每次渲染都重新创建函数导致底层重复求值。测试证明静态 selector 在无状态变化时只执行一次。
- 让 equalityFn 服务于“内容比较”:当 selector 返回新引用但语义相同的数据(如
state.position每次都是新对象)时,shallow或自定义比较函数可以把“无意义的重渲染”挡在门外;反之,若你希望每次状态变化都精确同步(比如选中一个实时变化的原始值),保持默认的Object.is即可。
另外需要注意 selector 的返回值语义:如果 selector 每次返回新数组(如(s) => s.items.filter(...)),即使加了shallow也只有在浅层内容变化时才重渲染——这与“只做浅比较”的语义一致,深层变化不会触发。
Troubleshooting(常见问题排查)
原文档的 Troubleshooting 章节目前标记为 TBD(待补充)。结合源码与测试,这里整理几个基于实现事实的常见问题与应对思路,供参考:
1. 我更新了状态,但屏幕不更新
useStoreWithEqualityFn的重渲染由equalityFn把关。如果组件不更新,先检查:
- selector 返回的值是否被
equalityFn判定为“相等”(例如shallow只比较顶层,嵌套对象内容变了但顶层引用没变时会被判定相等,这是符合预期的行为); - vanilla store 的
setState是否真的产生了新状态——vanilla.ts 中只有!Object.is(nextState, state)时才会通知订阅者,原地修改对象不会触发更新。
2. 无限重渲染 / 渲染次数异常
当 selector 返回新对象且未提供合适的equalityFn时,可能出现意外高频重渲染。解决思路:为该 selector 提供shallow(浅比较)或自定义比较函数。仓库测试中还有一类边界情况:equalityFn内部抛错时,错误会沿 React 渲染链路传播,测试通过 ErrorBoundary 捕获并展示错误页(见 tests/basic.test.tsx),说明 equalityFn 是同步调用且其异常会影响渲染,编写时应当保证其健壮性。
3. 忘了安装use-sync-external-store
如果你从zustand/traditional导入时报模块解析错误,请确认已安装use-sync-external-store(版本不低于 1.2.0),这是zustand/traditional的运行时依赖。
相关资源
- useStoreWithEqualityFn 源码实现:包含
useStoreWithEqualityFn与createWithEqualityFn的完整定义 - useStore 文档:不带 equalityFn 的对应 Hook
- createWithEqualityFn 文档:创建带默认相等性函数的绑定式 Hook
- shallow 文档:浅比较函数的完整语义说明
- vanilla store 实现:
createStore的setState/subscribe底层逻辑 - 传统入口行为测试:验证 selector 调用时机与重渲染行为
【免费下载链接】zustand🐻 Bear necessities for state management in React项目地址: https://gitcode.com/gh_mirrors/zu/zustand
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考