Zustand 渲染优化指南:用 useShallow 避免不必要的组件重渲染
【免费下载链接】zustand🐻 Bear necessities for state management in React项目地址: https://gitcode.com/gh_mirrors/zu/zustand
useShallow是 Zustand 提供的一个 React Hook,用于在基于 store 计算派生状态时,以浅比较(shallow comparison)替代默认的Object.is引用比较,从而跳过“结果未变化”的重新渲染。本文将围绕 docs/learn/guides/prevent-rerenders-with-use-shallow.md 的完整示例,结合仓库中的 useShallow 实现 与 shallow 核心算法 及测试用例,讲清它的工作原理、适用场景、边界限制,以及 v5 中与之强相关的“Maximum update depth exceeded”问题。读完本文,你将能在日常开发中精准判断何时该用useShallow,并写出既稳定又高效的 selector。
为什么会出现“不必要的重渲染”:selector 输出与默认比较规则
在 Zustand 的 React 绑定中,hook 通过useSyncExternalStore订阅 store,并传入一个 selector 来从整个 state 中提取组件关心的片段。相关实现位于 src/react.ts:
const slice = React.useSyncExternalStore( api.subscribe, React.useCallback(() => selector(api.getState()), [api, selector]), React.useCallback(() => selector(api.getInitialState()), [api, selector]), )Zustand 默认使用Object.is来判断 selector 的输出是否变化:只有当输出与上一次相比不相等时,订阅组件才会重渲染。
问题恰恰出在这里:很多“计算型 selector”每次执行都会生成一个全新的值。例如Object.keys(state)返回一个新数组,state.items.map(...)返回新数组,{ ... }对象字面量也是新对象。虽然内容上“相等”,但引用上每次都不同,Object.is认为它们发生了变化,于是组件被反复重渲染。
完整示例:Bear 一家的餐点列表
为了直观理解,原文档给出了一个经典场景:一个 store 把每只熊与它的餐点关联起来,组件只关心熊的名字列表。
先看未优化版本:
import { create } from 'zustand' const useMeals = create(() => ({ papaBear: 'large porridge-pot', mamaBear: 'middle-size porridge pot', littleBear: 'A little, small, wee pot', })) export const BearNames = () => { const names = useMeals((state) => Object.keys(state)) return <div>{names.join(', ')}</div> }现在 papa bear 想改吃披萨:
useMeals.setState({ papaBear: 'a large pizza', })这行代码会让BearNames组件重渲染——尽管names(即Object.keys(state)的结果['papaBear', 'mamaBear', 'littleBear'])在浅比较下其实没有发生变化,key 的数量与顺序完全没变。原因就是上面说的:Object.keys每次都返回新数组引用,默认的Object.is比较判定为“变化了”。
用 useShallow 修复:只关心“浅比较相等”的结果
useShallow做的事情很简单:它把传入的 selector 包装成一个带记忆(memoized)能力的 selector,用shallow浅比较新旧输出,只要浅比较相等,就返回上一次的结果引用。修改后的代码:
import { create } from 'zustand' import { useShallow } from 'zustand/react/shallow' const useMeals = create(() => ({ papaBear: 'large porridge-pot', mamaBear: 'middle-size porridge pot', littleBear: 'A little, small, wee pot', })) export const BearNames = () => { const names = useMeals(useShallow((state) => Object.keys(state))) return <div>{names.join(', ')}</div> }现在的效果是:setState修改任意熊的餐点时,只要 key 集合(浅比较结果)没变,BearNames就不再重渲染。大家想吃什么就点什么,组件始终保持“按需渲染”。
注意导入路径是zustand/react/shallow,从仓库的 src/shallow.ts 可以看到同时导出了shallow与useShallow,其中 React 版本位于 src/react/shallow.ts。在 v4 时代useShallow也常从zustand/shallow导入,具体以你所装版本的导出为准。
源码剖析:useShallow 只有十几行
打开 src/react/shallow.ts,它的完整实现如下:
import React from 'react' import { shallow } from '../vanilla/shallow.ts' 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) } }逐行拆解:
React.useRef<U>(undefined)保存上一次返回的 selector 输出,初始为undefined。- 返回的新函数每次被调用时,先执行原始 selector 得到
next。 - 用
shallow(prev.current, next)做浅比较:相等则原样返回上一次的引用(prev.current),不相等才更新缓存并返回新值。 - 由于
useShallow返回的包装函数在组件多次渲染之间保持同一个引用(useRef数据挂在其闭包上),交给useStore后不会破坏订阅的稳定性。
类型签名(见 useShallow 参考文档)为:
useShallow<T, U = T>(selectorFn: (state: T) => U): (state: T) => U即“传入 selector,返回一个被记忆化的 selector”。它本身只在 React 组件内调用(因为依赖useRef),且必须与useStore类 hook 组合使用。
shallow 底层算法:它到底怎么比较
useShallow的判定全部委托给shallow,核心实现位于 src/vanilla/shallow.ts(该函数不依赖 React,可独立使用)。算法流程如下:
Object.is短路:两者引用相同直接返回true;NaN === NaN、+0 === -0等语义也与Object.is保持一致。- 非对象类型:只要有一方不是对象或是
null,返回false。 - 原型检查:
Object.getPrototypeOf(a) !== Object.getPrototypeOf(b)直接返回false——用Object.create({})创建的对象与普通{}字面量即使内容相同也不相等(shallow 文档 有专门说明)。 - 可迭代对象分流:若双方都是可迭代对象(实现了
Symbol.iterator):- 若两者都拥有
entries()方法(如Map、Set、FormData等),走compareEntries:比较元素个数,再逐个用Object.is比较键值。这里对Map直接复用原实例,其余类型先转成Map再比较。 - 否则(如数组、字符串等有序可迭代对象),走
compareIterables:用迭代器同步推进,逐元素Object.is比较,且要求长度一致(nextA.done与nextB.done同时为真)。
- 若两者都拥有
- 普通对象:回退到
Object.entries包装后同样走compareEntries,即比较顶层键集合与顶层值。
由此可以总结出shallow的行为边界:
shallow(['papaBear','mamaBear','littleBear'], ['papaBear','mamaBear','littleBear'])→true(数组逐元素比较)。shallow(new Set([1,2,3]), new Set([1,2,3]))→true(官方文档中的 Set 示例)。shallow(new Map([...]), new Map([...]))→true(同样见官方文档)。- 两个结构相同但嵌套对象引用不同的普通对象 →
false:浅比较只到顶层,address: { street: ... }这类嵌套对象比的是引用而不是内容,这正是 shallow 文档 Troubleshooting 中反复强调的局限。
测试验证:重渲染次数被严格控制
仓库的 tests/shallow.test.tsx 用 Vitest + Testing Library 对useShallow的行为做了精确验证,其中最核心的一条测试:
it('only re-renders if selector output has changed according to shallow', () => { let countRenders = 0 const useMyStore = create((): Record<string, unknown> => ({ a: 1, b: 2, c: 3, })) const TestShallow = ({ selector = (state) => Object.keys(state).sort() }) => { const output = useMyStore(useShallow(selector)) ++countRenders return <div>const { searchValue, setSearchValue } = useStore((state) => ({ searchValue: state.searchValue, setSearchValue: state.setSearchValue, }))包装对象每次渲染都被重建,比较永远失败,组件陷入订阅—更新循环。修复方式之一就是用useShallow:
const { searchValue, setSearchValue } = useStore( useShallow((state) => ({ searchValue: state.searchValue, setSearchValue: state.setSearchValue, })), )这样比较的是包装对象各属性的引用而非包装对象本身,从而打破循环。useShallow参考文档的 Troubleshooting 章节 也记录了同样的场景。
另一个等价修复是拆成多次订阅,各自返回原始字段:
const searchValue = useStore((state) => state.searchValue) const setSearchValue = useStore((state) => state.setSearchValue)两者没有绝对优劣:useShallow少写几行订阅代码,多次订阅则更细粒度、语义更直白。
相关 API:createWithEqualityFn 与 useStoreWithEqualityFn
如果你需要的是自定义任意相等函数(而不只是浅比较),v5 提供了传统 APIzustand/traditional下的createWithEqualityFn和useStoreWithEqualityFn。v5 迁移指南给出的替换示例:
import { createWithEqualityFn as create } from 'zustand/traditional' const useCountStore = create((set) => ({ count: 0, text: 'hello', // ... }))使用时把shallow作为第二个参数传入订阅调用(参见 tests/shallow.test.tsx 中createWithEqualityFn与shallow配合的用例)。而useShallow的价值在于:不需要改变 store 的创建方式,直接包裹在create创建的普通 store 之上即可,是侵入性最小的优化手段。
实践要点小结
- 记住默认规则:Zustand 默认用
Object.is比较 selector 输出,引用变化即触发重渲染(src/react.ts 中useSyncExternalStore的订阅逻辑)。 - 判断输出类型:selector 输出为数组、对象字面量、Map、Set 等“每次新建引用”的值时,考虑
useShallow;输出为原始值或单个字段时,无需使用。 - 理解浅比较边界:
shallow只比较顶层(src/vanilla/shallow.ts),嵌套对象按引用比较,深比较需求不在其职责范围内(见 shallow 文档 的示例与 Troubleshooting)。 - 警惕 v5 无限更新:返回新引用的 selector 在 v5 中可能触发
Maximum update depth exceeded,useShallow是标准解法之一(useShallow 参考文档)。 - 以测试为契约:仓库的 tests/shallow.test.tsx 精确固化了“浅比较相等不重渲染、不相等才重渲染”以及“返回记忆化实例”的行为,可作为你实现同款逻辑时的参考基准。
掌握了useShallow的判定机制与适用边界,你就能在 Zustand 应用中把重渲染精确控制在“数据真正变化”的时刻,让 Bear 一家(以及你的业务组件)都只在该动的时候才动。
【免费下载链接】zustand🐻 Bear necessities for state management in React项目地址: https://gitcode.com/gh_mirrors/zu/zustand
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考