news 2026/9/18 10:23:38

Zustand 渲染优化指南:用 useShallow 避免不必要的组件重渲染

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Zustand 渲染优化指南:用 useShallow 避免不必要的组件重渲染

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 可以看到同时导出了shallowuseShallow,其中 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) } }

逐行拆解:

  1. React.useRef<U>(undefined)保存上一次返回的 selector 输出,初始为undefined
  2. 返回的新函数每次被调用时,先执行原始 selector 得到next
  3. shallow(prev.current, next)做浅比较:相等则原样返回上一次的引用prev.current),不相等才更新缓存并返回新值。
  4. 由于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,可独立使用)。算法流程如下:

  1. Object.is短路:两者引用相同直接返回trueNaN === NaN+0 === -0等语义也与Object.is保持一致。
  2. 非对象类型:只要有一方不是对象或是null,返回false
  3. 原型检查Object.getPrototypeOf(a) !== Object.getPrototypeOf(b)直接返回false——用Object.create({})创建的对象与普通{}字面量即使内容相同也不相等(shallow 文档 有专门说明)。
  4. 可迭代对象分流:若双方都是可迭代对象(实现了Symbol.iterator):
    • 若两者都拥有entries()方法(如MapSetFormData等),走compareEntries:比较元素个数,再逐个用Object.is比较键值。这里对Map直接复用原实例,其余类型先转成Map再比较。
    • 否则(如数组、字符串等有序可迭代对象),走compareIterables:用迭代器同步推进,逐元素Object.is比较,且要求长度一致(nextA.donenextB.done同时为真)。
  5. 普通对象:回退到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下的createWithEqualityFnuseStoreWithEqualityFn。v5 迁移指南给出的替换示例:

import { createWithEqualityFn as create } from 'zustand/traditional' const useCountStore = create((set) => ({ count: 0, text: 'hello', // ... }))

使用时把shallow作为第二个参数传入订阅调用(参见 tests/shallow.test.tsx 中createWithEqualityFnshallow配合的用例)。而useShallow的价值在于:不需要改变 store 的创建方式,直接包裹在create创建的普通 store 之上即可,是侵入性最小的优化手段。

实践要点小结

  1. 记住默认规则:Zustand 默认用Object.is比较 selector 输出,引用变化即触发重渲染(src/react.ts 中useSyncExternalStore的订阅逻辑)。
  2. 判断输出类型:selector 输出为数组、对象字面量、Map、Set 等“每次新建引用”的值时,考虑useShallow;输出为原始值或单个字段时,无需使用。
  3. 理解浅比较边界shallow只比较顶层(src/vanilla/shallow.ts),嵌套对象按引用比较,深比较需求不在其职责范围内(见 shallow 文档 的示例与 Troubleshooting)。
  4. 警惕 v5 无限更新:返回新引用的 selector 在 v5 中可能触发Maximum update depth exceededuseShallow是标准解法之一(useShallow 参考文档)。
  5. 以测试为契约:仓库的 tests/shallow.test.tsx 精确固化了“浅比较相等不重渲染、不相等才重渲染”以及“返回记忆化实例”的行为,可作为你实现同款逻辑时的参考基准。

掌握了useShallow的判定机制与适用边界,你就能在 Zustand 应用中把重渲染精确控制在“数据真正变化”的时刻,让 Bear 一家(以及你的业务组件)都只在该动的时候才动。

【免费下载链接】zustand🐻 Bear necessities for state management in React项目地址: https://gitcode.com/gh_mirrors/zu/zustand

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/18 10:21:20

申通快递3亿诉讼案背后的桐庐帮商业江湖

1. 事件背景与核心人物关系梳理2023年8月&#xff0c;国内快递行业爆出重大商业纠纷——申通快递实际控制人陈小英被其前夫奚春阳提起诉讼&#xff0c;索赔金额高达近3亿元人民币。这起案件之所以引发业界广泛关注&#xff0c;不仅因为涉案金额巨大&#xff0c;更因其背后牵扯出…

作者头像 李华
网站建设 2026/9/18 10:21:12

Gumroad 新手怎么上架第一件商品

Gumroad 新手怎么上架第一件商品 【免费下载链接】gumroad See what sticks 项目地址: https://gitcode.com/GitHub_Trending/gumr/gumroad 你手上有一套录好的课程想卖&#xff0c;但卡在三件事上&#xff1a;文件放在哪、钱打给谁、货怎么交。Gumroad 就是干这个的&am…

作者头像 李华
网站建设 2026/9/18 10:20:51

FPGA以太网UDP协议栈实战:verilog-ethernet仿真到上板

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/18 10:18:32

Colibri:面向MoE模型的C语言级轻量推理引擎

1. 项目概述&#xff1a;Colibri 是什么&#xff0c;它解决的不是“跑模型”而是“让模型在真实设备上稳住”Colibri 这个名字乍一听像某种蜂鸟——轻盈、敏捷、高频振翅。这恰恰是它最核心的设计隐喻。它不是另一个大语言模型&#xff08;LLM&#xff09;本体&#xff0c;也不…

作者头像 李华
网站建设 2026/9/18 10:18:26

Jetson Xavier NX 刷 JetPack 5.1.7 到 NVMe

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华