Zustand shallow 全面解析:用浅比较优化 React 状态选择器与订阅逻辑
【免费下载链接】zustand🐻 Bear necessities for state management in React项目地址: https://gitcode.com/gh_mirrors/zu/zustand
shallow是 Zustand 提供的轻量级比较工具,它基于Object.is对两个值做顶层属性级(top-level)的浅比较,用于快速判断简单数据结构是否发生变化。在 Zustand 的 React 绑定、useShallow选择器缓存、subscribeWithSelector订阅去重以及useStoreWithEqualityFn自定义相等性判断中,它都是最核心的默认/推荐比较函数。读完本文,你将掌握shallow的类型签名、四种数据类型的比较规则、底层实现原理(含源码逐段解析),以及它在 Zustand 各模块中的真实应用方式。
什么是shallow
shallow的功能非常聚焦:对不包含嵌套对象或数组的简单数据结构做快速比较。它只比较两个值的顶层属性,一旦发现顶层键或值不一致,立即返回false;如果两个值在顶层完全一致,则返回true。
const equal = shallow(a, b)[!NOTE]
shallow追求的是“快速比较”,使用时必须牢记它的局限性:嵌套结构的对象/数组不会被递归比较,因此嵌套内容相同但引用不同的对象会被判定为不相等(详见故障排查一节)。
类型签名
shallow<T>(a: T, b: T): booleana:参与比较的第一个值。b:参与比较的第二个值。- 返回值:当
a与b基于顶层属性的浅比较结果相等时返回true,否则返回false。
shallow与Object.is的区别
shallow的第一个快速路径就是Object.is(见下文源码分析),但二者定位完全不同:
| 比较方式 | 原始值 | 相同引用的对象 | 内容相同但引用不同的对象 |
|---|---|---|---|
Object.is | 按值比较 | true | false |
shallow | 按值比较 | true | 顶层属性相同时为true |
也就是说,Object.is是“引用/值”的严格同一性判断,而shallow在Object.is判等失败后,还会进一步对顶层属性逐项比较,这正是它作为状态变化检测工具的价值所在。
使用方法
比较原始类型(Primitives)
比较string、number、boolean、BigInt等原始值时,Object.is与shallow的结果完全一致:相同值返回true。这是因为原始值按实际值而非引用进行比较。
const stringLeft = 'John Doe' const stringRight = 'John Doe' Object.is(stringLeft, stringRight) // -> true shallow(stringLeft, stringRight) // -> true const numberLeft = 10 const numberRight = 10 Object.is(numberLeft, numberRight) // -> true shallow(numberLeft, numberRight) // -> true const booleanLeft = true const booleanRight = true Object.is(booleanLeft, booleanRight) // -> true shallow(booleanLeft, booleanRight) // -> true const bigIntLeft = 1n const bigIntRight = 1n Object.is(bigIntLeft, bigIntRight) // -> true shallow(bigIntLeft, bigIntRight) // -> true这一点在 shallow 的单元测试中也有直接覆盖:shallow(1, 1)、shallow('zustand', 'zustand')、shallow(true, true)均为true,而shallow(1, 2)、shallow('zustand', 'redux')、shallow(true, false)均为false。
比较对象(Objects)
两个内容相同但引用不同的对象,Object.is返回false,而shallow会逐项检查顶层属性(firstName、lastName、age)及其值,全部相同则返回true:
const objectLeft = { firstName: 'John', lastName: 'Doe', age: 30, } const objectRight = { firstName: 'John', lastName: 'Doe', age: 30, } Object.is(objectLeft, objectRight) // -> false shallow(objectLeft, objectRight) // -> true需要特别强调的是:只要顶层键集合不同,哪怕值有重叠也会返回false。测试 tests/vanilla/shallow.test.tsx 验证了三种场景:
shallow({ foo: 'bar', asd: 123 }, { foo: 'bar', asd: 123 }) // -> true(键值完全一致) shallow({ foo: 'bar', asd: 123 }, { foo: 'bar', foobar: true }) // -> false(键不一致) shallow({ foo: 'bar', asd: 123 }, { foo: 'bar', asd: 123, foobar: true }) // -> false(多了一个键)比较 Set
shallow比较两个Set时,检查它们是否都是Set实例且包含相同元素(不要求插入顺序一致),内容一致则返回true:
const setLeft = new Set([1, 2, 3]) const setRight = new Set([1, 2, 3]) Object.is(setLeft, setRight) // -> false shallow(setLeft, setRight) // -> true测试还覆盖了更多细节(tests/vanilla/shallow.test.tsx):
shallow(new Set(['bar', 123]), new Set([123, 'bar'])) // -> true(顺序无关) shallow(new Set(['bar', 123]), new Set(['bar', 2])) // -> false(元素不同) shallow(new Set(['bar', 123]), new Set(['bar', 123, true])) // -> false(元素个数不同) // 元素按引用比较:同一对象引用才相等 const obj = {} const obj2 = {} shallow(new Set([obj]), new Set([obj])) // -> true shallow(new Set([obj]), new Set([obj2])) // -> false比较 Map
shallow比较两个Map时,检查键值对是否完全一致(同样不要求插入顺序一致),一致则返回true:
const mapLeft = new Map([ [1, 'one'], [2, 'two'], [3, 'three'], ]) const mapRight = new Map([ [1, 'one'], [2, 'two'], [3, 'three'], ]) Object.is(mapLeft, mapRight) // -> false shallow(mapLeft, mapRight) // -> true测试 tests/vanilla/shallow.test.tsx 验证了:键值完全一致为true、插入顺序不同仍为true、键或值不同为false、大小不同为false,并且键按引用比较——两个不同的空对象作为键时结果为false:
const obj = {} const obj2 = {} shallow( new Map<object, unknown>([[obj, 'foo']]), new Map<object, unknown>([[obj2, 'foo']]), ) // -> false(键的引用不同)源码级原理:shallow的完整实现
shallow的核心实现位于 src/vanilla/shallow.ts,整个判断流程可分为五个阶段:
export function shallow<T>(valueA: T, valueB: T): boolean { // 阶段一:Object.is 快速路径 if (Object.is(valueA, valueB)) { return true } // 阶段二:非对象短路 if ( typeof valueA !== 'object' || valueA === null || typeof valueB !== 'object' || valueB === null ) { return false } // 阶段三:原型一致性检查 if (Object.getPrototypeOf(valueA) !== Object.getPrototypeOf(valueB)) { return false } // 阶段四:可迭代对象分派 if (isIterable(valueA) && isIterable(valueB)) { if (hasIterableEntries(valueA) && hasIterableEntries(valueB)) { return compareEntries(valueA, valueB) } return compareIterables(valueA, valueB) } // 阶段五:按纯对象处理 return compareEntries( { entries: () => Object.entries(valueA) }, { entries: () => Object.entries(valueB) }, ) }阶段一:Object.is快速路径
两个值引用相同(或原始值相等)时直接返回true,这是最昂贵的检查(深比较)之前的最快退出。
阶段二:非对象短路
只要有一方不是object类型或是null,在已经通过Object.is的前提下必然不相等,直接返回false。函数不满足typeof === 'object',因此两个内容相同但声明不同的函数也会在这里返回false;测试 tests/vanilla/shallow.test.tsx 验证了同一函数引用比较为true、不同函数声明为false。
阶段三:原型一致性检查
Object.getPrototypeOf(valueA) !== Object.getPrototypeOf(valueB)两个对象原型不同直接返回false。这是文档中“不同原型对象比较”故障排查场景的实现来源(详见下文)。
阶段四:可迭代对象分派
实现首先用辅助函数判断类型(src/vanilla/shallow.ts):
const isIterable = (obj: object): obj is Iterable<unknown> => Symbol.iterator in obj const hasIterableEntries = ( value: Iterable<unknown>, ): value is Iterable<unknown> & { entries(): Iterable<[unknown, unknown]> } => // HACK: avoid checking entries type 'entries' in value- 若双方都具备
entries()方法(如Map、Set、URLSearchParams、纯对象包装),走compareEntries; - 否则走
compareIterables(如生成器、数组)。
compareEntries逐项比较键值对(src/vanilla/shallow.ts):先把非Map的值转成Map,若size不同直接返回false,再遍历第一个Map的每一项,要求第二个Map中存在相同键,且值通过Object.is相等:
const compareEntries = (valueA, valueB) => { const mapA = valueA instanceof Map ? valueA : new Map(valueA.entries()) const mapB = valueB instanceof Map ? valueB : new Map(valueB.entries()) if (mapA.size !== mapB.size) { return false } for (const [key, value] of mapA) { if (!mapB.has(key) || !Object.is(value, mapB.get(key))) { return false } } return true }这就是为什么文档示例中两个“内容相同”的Set/Map会返回true:Set通过entries()拿到[value, value]键值对后统一按条目比较,因此无序性天然成立。
compareIterables按顺序逐项比较(src/vanilla/shallow.ts),适用于数组、生成器这类“有序可迭代对象”。它同时推进两个迭代器,逐项做Object.is,任何一项不同立即返回false,最后要求双方同时迭代完毕:
const compareIterables = (valueA, valueB) => { const iteratorA = valueA[Symbol.iterator]() const iteratorB = valueB[Symbol.iterator]() let nextA = iteratorA.next() let nextB = iteratorB.next() while (!nextA.done && !nextB.done) { if (!Object.is(nextA.value, nextB.value)) { return false } nextA = iteratorA.next() nextB = iteratorB.next() } return !!nextA.done && !!nextB.done }阶段五:纯对象按Object.entries比较
普通对象没有Symbol.iterator,进入最后的兜底分支:把Object.entries(value)包装成伪entries()迭代器,再交给compareEntries逐键比较顶层属性。注意这里每个属性值仍然只做Object.is引用比较——这正是“嵌套对象内容相同但引用不同时返回false”的根本原因(详见故障排查)。
额外覆盖的数据类型
由于实现基于“迭代器 + entries”抽象,shallow还天然支持了文档示例之外的多种类型,均由 tests/vanilla/shallow.test.tsx 佐证:
- 数组(走
compareIterables,有序且敏感于顺序):shallow([1, 2, 3], [1, 2, 3])为true,shallow([1, 2, 3], [2, 3, 1])为false;数组内嵌对象同样只做引用比较,两个内容相同的新对象数组结果为false; - URLSearchParams(走
compareEntries,键值对比较且顺序无关):shallow(new URLSearchParams({ a: 'a' }), new URLSearchParams({ a: 'a' }))为true; - 生成器/纯可迭代对象(走
compareIterables,tests/vanilla/shallow.test.tsx):两个产出相同序列的生成器比较为true; - 跨类型比较一律为
false(tests/vanilla/shallow.test.tsx):对象 vs 数组、对象 vs Set、数组 vs Map 等混合结构均返回false; - 嵌套数组引用优化:
shallow([arr, 1], [arr, 1])(同一个arr引用)为true(回归用例 #2794)。
在 Zustand 中的应用场景
shallow本身是一个通用比较函数,但它之所以在 Zustand 中高频出现,是因为它是以下三个优化场景的“粘合剂”。
场景一:useShallow—— 记忆化选择器
src/react/shallow.ts 基于shallow实现了useShallowHook:它把用户传入的selector包装成“带缓存的选择器”,缓存上一次的选择结果,用shallow判断新结果与缓存是否相等,相等则继续返回旧引用,避免每次渲染都产生新引用导致组件无限重渲染:
export function useShallow<S, U>(selector: (state: S) => U): (state: S) => U { const prev = React.useRef<U>(undefined) return (state) => { const next = selector(state) return shallow(prev.current, next) ? (prev.current as U) : (prev.current = next) } }典型用法是在选择器中构造新对象/数组时包一层useShallow(完整示例见 useShallow 文档):
const names = useBearFamilyMealsStore( useShallow((state) => Object.keys(state)), )自 v5 起,选择器若在每次渲染返回新引用,默认的Object.is相等性检查会触发 “Maximum update depth exceeded” 无限循环;用useShallow包裹后,比较的是包装对象的顶层属性(按引用),从而打破循环(详见 v5 迁移指南中的稳定选择器输出要求)。
场景二:subscribeWithSelector的equalityFn
src/middleware/subscribeWithSelector.ts 允许对 store 的subscribe传入选择器与自定义equalityFn,默认值为Object.is:
const equalityFn = options?.equalityFn || Object.is当你订阅的切片是每次重新构建的对象时,把equalityFn设为shallow即可让订阅只在顶层属性真正变化时触发,避免频繁回调:
import { subscribeWithSelector } from 'zustand/middleware' import { shallow } from 'zustand/shallow' const unsubscribe = useStore.subscribe( (state) => ({ a: state.a, b: state.b }), (slice, prevSlice) => console.log('changed', slice, prevSlice), { equalityFn: shallow }, )场景三:useStoreWithEqualityFn的第三个参数
zustand/traditional中的useStoreWithEqualityFn(store, selectorFn, equalityFn)同样接收自定义相等性函数(见 useStoreWithEqualityFn 文档),传入shallow即可实现“选择器返回新对象但顶层相等时不重渲染”的效果,与useShallow的目标一致。
导出路径与包结构
shallow及useShallow通过 src/shallow.ts 统一导出:
export { shallow } from './vanilla/shallow.ts' export { useShallow } from './react/shallow.ts'在包中对应的导入路径为:
import { shallow } from 'zustand/shallow' // 仅比较函数 import { useShallow } from 'zustand/react/shallow' // React Hook 版本测试文件中的import { shallow } from 'zustand/shallow'(tests/vanilla/shallow.test.tsx)印证了第一条导出路径的有效性。
故障排查
嵌套对象比较返回false,即使内容完全一致
shallow只比较顶层属性,不检查嵌套对象或深层属性,本质上是对每个顶层属性做引用比较。在下面的例子中,两个对象的address都是嵌套对象,虽然内部内容逐字相同,但各自拥有不同的引用,因此shallow判定它们不同,返回false:
const objectLeft = { firstName: 'John', lastName: 'Doe', age: 30, address: { street: 'Kulas Light', suite: 'Apt. 556', city: 'Gwenborough', zipcode: '92998-3874', geo: { lat: '-37.3159', lng: '81.1496', }, }, } const objectRight = { firstName: 'John', lastName: 'Doe', age: 30, address: { street: 'Kulas Light', suite: 'Apt. 556', city: 'Gwenborough', zipcode: '92998-3874', geo: { lat: '-37.3159', lng: '81.1496', }, }, } Object.is(objectLeft, objectRight) // -> false shallow(objectLeft, objectRight) // -> false移除address属性后,顶层属性全部是原始值,浅比较即可正常工作:
const objectLeft = { firstName: 'John', lastName: 'Doe', age: 30, } const objectRight = { firstName: 'John', lastName: 'Doe', age: 30, } Object.is(objectLeft, objectRight) // -> false shallow(objectLeft, objectRight) // -> true结论与应对:如果状态结构存在嵌套对象,请:
- 对嵌套数据使用
JSON.stringify之外的专用深比较库,或 - 在放入 store 前保证嵌套对象引用稳定(复用同一引用),或
- 拆分顶层状态,让每个选择器只取原始值字段。
比较不同原型的对象
shallow会先检查两个对象是否具有相同原型(Object.getPrototypeOf(a) === Object.getPrototypeOf(b),见 src/vanilla/shallow.ts),原型引用不同则直接返回false。
[!IMPORTANT] 用对象字面量
{}或new Object()创建的对象默认继承自Object.prototype;而用Object.create(proto)创建的对象继承自传入的proto——它可能不是Object.prototype。
const a = Object.create({}) // -> prototype 是 `{}` const b = {} // -> prototype 是 `Object.prototype` shallow(a, b) // -> false这也解释了为什么Date等内置类型不在支持范围内:两个不同时刻的Date原型虽相同,但其没有entries()也非纯对象可迭代,测试 tests/vanilla/shallow.test.tsx 明确标注Date为unsupported cases(比较结果不可预期,不应依赖)。
小结
shallow是 Zustand 性能优化体系中最基础也最常用的一环:它以Object.is为快速路径,配合原型检查、可迭代对象分派(compareEntries/compareIterables)和Object.entries兜底,实现了对原始类型、普通对象、Set、Map、数组、URLSearchParams、生成器等多种数据结构的顶层浅比较。牢记它的边界——嵌套结构按引用比较、不同原型直接不等——就能在useShallow、subscribeWithSelector与useStoreWithEqualityFn中安全地用它消除无谓的重渲染与订阅回调。想深入验证其行为,可直接阅读 src/vanilla/shallow.ts、src/react/shallow.ts 及配套测试 tests/vanilla/shallow.test.tsx。
【免费下载链接】zustand🐻 Bear necessities for state management in React项目地址: https://gitcode.com/gh_mirrors/zu/zustand
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考