Preact Query 渲染优化权威指南:structural sharing、属性追踪与 select 的底层原理与实战
【免费下载链接】query🤖 Powerful asynchronous state management, server-state utilities and data fetching for the web. TS/JS, React Query, Solid Query, Svelte Query and Vue Query.项目地址: https://gitcode.com/GitHub_Trending/qu/query
TanStack Query 官方在@tanstack/preact-query中对查询结果的分发做了一系列自动渲染优化:只有组件真正"用到的"结果属性发生变化时,才会触发重新渲染;同时通过"结构性共享(structural sharing)"在两次渲染之间尽量复用旧数据的对象引用,配合select做细粒度订阅,让中等规模列表页、高频轮询页面都能在保持代码直观的前提下显著减少无效渲染。本文以仓库内 docs/framework/preact/guides/render-optimizations.md 指南为骨架,逐层拆解这三类优化手段的实现机制、可调参数与坑位,并结合packages/preact-query与packages/query-core的源码给出可验证的依据。读完你将能回答"Preact Query 什么时候会重新渲染、为什么、以及如何按需关掉或自定义这套机制"。
说明:本指南在仓库中是框架共享文档——Preact 版文件的 frontmatter 声明了
ref: docs/framework/react/guides/render-optimizations.md,并约定将react-query替换为preact-query、React替换为Preact后生成。因此它讨论的是@tanstack/preact-query这一适配层的真实行为;而其机制核心(observer、缓存、变更通知)位于跨框架共享的@tanstack/query-core,源码路径见下文标注。
为什么 Preact Query 要主动做渲染优化
在典型的服务端状态场景里,一个 query 的"状态对象"包含的字段远比单个组件关心的要多:data、status、error、isPending、isFetching、isStale、isRefetching、fetchStatus……其中isFetching、isStale这类标志在后台重取、窗口聚焦刷新时变化非常频繁,而多数组件其实根本不读取它们。如果任何字段变化都导致订阅方重渲染,应用会在高频刷新时白白浪费大量渲染帧。
Preact Query 的解决思路分三层、各司其职:
- structural sharing——稳定
data的对象引用,让"数据没变"这件事可以被廉价地比较出来; - tracked properties(属性追踪)——只在你真正读取某个属性时,才把该属性登记为"变更需要通知"的对象;
- select——把组件订阅的数据面从"整份缓存"收窄到"派生结果"。
三者叠加,最终让组件仅在"其实际观察到的数据片段"发生变化时才重渲染。
structural sharing:尽可能保留对象引用
机制与问题背景
通过网络请求拿到的数据,通常要经过JSON.parse/ 反序列化,得到的是一个全新的对象引用——哪怕服务端返回的内容与上一次逐字节相同。如果框架每次都把这份新对象塞给组件,data的引用会无条件变化,随之而来的就是无谓重渲染、子组件 memo 失效、useEffect/useMemo依赖连锁触发。
structural sharing 要解决的问题正是这一点:在更新缓存数据时,逐层比较新旧值,能复用旧引用的部分就复用旧引用,只有真正变化的子节点才替换为新值。
这一算法在核心包中对应
replaceEqualDeep,实现位于 packages/query-core/src/utils.ts;其基准行为在 packages/query-core/src/tests/utils.test.tsx 中有大量用例覆盖(例如对原始值、Date、对象、数组、undefined替换语义的断言)。
默认行为与启用前提
该优化默认开启,无需任何配置。它的生效有一个硬性前提:
只有当
queryFn返回的是JSON 兼容数据(普通对象/数组/原始值)时,structural sharing 才有意义。对Map、Set、类实例等非纯数据对象,参考级别的整体替换依然是正确行为。
从源码结构可以确认,该配置同时作用于 query 缓存写入(setQueryData/queryFn成功结果的缓存更新)与select派生值的处理:
- packages/query-core/src/tests/queryClient.test.tsx 验证了
structuralSharing: false时"不比较、直接写入新数据",以及传入自定义函数时按自定义逻辑执行; - packages/query-core/src/tests/queryObserver.test.tsx 验证了在默认开启时
select派生值同样会走深度相等比较,而structuralSharing: false或存在placeholderData时其行为会相应调整。
如何关闭或自定义
按官方指南,可以全局关闭、单 query 关闭,或传入自定义 sharing 函数:
import { QueryClient } from '@tanstack/preact-query' // 全局关闭 const queryClient = new QueryClient({ defaultOptions: { queries: { structuralSharing: false, }, }, }) // 单个 query 关闭 useQuery({ queryKey: ['todos'], queryFn: fetchTodos, structuralSharing: false, }) // 自定义共享策略(传入一个 (oldData, newData) => sharedData 的函数) useQuery({ queryKey: ['todos'], queryFn: fetchTodos, structuralSharing: (oldData, newData) => /* 自定义深度共享逻辑 */ newData, })什么时候值得关掉?当你的数据结构极其扁平、几乎每次都会整体变化,或数据结构复杂到深度遍历反而成为性能瓶颈时,关闭它可以省下比较的开销;反之,默认开启在绝大多数场景是净收益。
与"referential identity(引用同一性)"的关系
structural sharing 保证了data的稳定,但请注意结果的顶层容器并不稳定:
useQuery、useInfiniteQuery、useMutation返回的顶层对象,以及useQueries返回的数组,每次渲染都是新引用,不具备 referential stability。稳定的是这些 hook 返回的data字段本身。
在 packages/preact-query/src/useBaseQuery.ts 可以看到,每次渲染都会先通过observer.getOptimisticResult(defaultedOptions)现算结果,再经由useSyncExternalStore订阅。因此如果你把data整体放进某个 memo 依赖,它能保持稳定;但若把整个result对象或useQueries数组作为依赖,则每次都会变化。实践中常见的写法是单独解构出data后再交给 memo/子组件,让引用稳定性红利真正生效。
tracked properties:用 Proxy 只追踪你用到的属性
实现原理
从@tanstack/preact-query的角度看,这一特性是透明的:它不引入任何额外的渲染循环,而是在 hook 底部做一次"属性使用登记"。核心逻辑见 packages/preact-query/src/useBaseQuery.ts:
// Handle result property usage tracking return !defaultedOptions.notifyOnChangeProps ? observer.trackResult(result) : result即:当未手动设置notifyOnChangeProps时,返回给组件的是trackResult包了一层 Proxy 的结果对象。Proxy 的gettrap 会把被访问的每个属性名加入 observer 的追踪集合:
- packages/query-core/src/queryObserver.ts:
trackResult返回new Proxy(result, { get: (target, key) => { this.trackProp(key); return Reflect.get(target, key) } }); - 同一文件第 65 行的
#trackedProps = new Set<keyof QueryObserverResult>()记录"本次渲染实际访问过哪些属性"。
随后在观测者决定"是否通知监听者"时(queryObserver.ts),会检查被追踪属性集合与本次变化的属性集合是否有交集:只要组件用到的属性没变,就不会触发重渲染。于是isFetching、isStale这类频繁翻转的属性,若组件从不读取,就永远不会把组件"吵醒"。
两种触发登记的写法对比
Proxy 的gettrap 只有在"访问属性"时才会被调用——无论是通过解构,还是直接result.data。所以下面两种写法都能享受追踪优化:
import { useQuery } from '@tanstack/preact-query' // 写法一:解构(推荐,读取即登记) const { data, isPending } = useQuery({ queryKey: ['todos'], queryFn: fetchTodos }) // 写法二:直接访问 const result = useQuery({ queryKey: ['todos'], queryFn: fetchTodos }) // result.data / result.isPending 的访问会被登记但有一个高危坑位:rest 解构(object rest destructuring)会一次性"取走"剩余的所有属性,从而禁用追踪优化。
// ⚠️ 不要这样写:rest 解构会把整个结果"读一遍",追踪优化失效 const { data, ...rest } = useQuery({ queryKey: ['todos'], queryFn: fetchTodos })因为 rest 模式会遍历并读取目标对象上所有未显式命名的自有属性,导致所有属性都被登记为"被使用",Proxy 追踪形同虚设。仓库为此提供了一条专门的 ESLint 规则做静态拦截,详见 docs/eslint/no-rest-destructuring.md,实战中建议在eslint.config.js中启用该规则,让 CI 替团队把关。
关闭或自定义追踪
追踪并非"一刀切"的黑魔法,官方指南给出了两层旋钮:
// 单 query 关闭追踪,等价于"所有属性变更都通知" useQuery({ queryKey: ['todos'], queryFn: fetchTodos, notifyOnChangeProps: 'all', }) // 手动声明关心的属性,追踪范围即白名单 useQuery({ queryKey: ['todos'], queryFn: fetchTodos, notifyOnChangeProps: ['data', 'isPending'], })同样,可以在QueryClient的defaultOptions.queries里全局设置('all'或属性名数组)。从 useBaseQuery.ts 的分支可以看到:只要设置了notifyOnChangeProps,hook 就跳过trackResult,直接把结果原样返回——追踪逻辑完全按你给定的模式执行。
需要说明的是,手动白名单是静态的、需要你手动维护,而 Proxy 追踪是动态的、随组件代码自动收敛,因此日常开发优先依赖默认追踪;只有当你想精确锁定某几个属性、或遇到需要"全量变更都通知"的边界场景时,才去动notifyOnChangeProps。
select:把订阅收窄到派生结果
用法与收益
当组件只关心缓存数据的某个"切片"时,可以用select声明派生关系。缓存里始终保存完整数据,而组件拿到的data是select的输出。指南中给出了一个高度可复用的封装模式:
import { useQuery } from '@tanstack/preact-query' // 对外暴露"可注入 select"的 query hook export const useTodos = (select) => { return useQuery({ queryKey: ['todos'], queryFn: fetchTodos, select, }) } // 组件 A:只需要 todo 数量 export const useTodoCount = () => { return useTodos((data) => data.length) }使用useTodoCount的组件只在 todos 的 length 变化时才重渲染——比如仅仅某条 todo 的 name 改了,这个组件纹丝不动。这正是列表页"计数徽标"与"列表主体"解耦、避免互相拖累的经典姿势。
select 不是做错误处理的地方
指南对select的职责边界做了非常明确的提醒,值得照抄进团队规范:
select运行在已成功缓存的数据之上,它不是抛错的合适位置。错误的唯一事实来源(source of truth)是queryFn。如果一个select函数返回错误,结果会是data === undefined且isSuccess === true——一个"成功但没有数据"的诡异状态。建议:如果你希望"数据不正确"就让整个 query 失败,请在
queryFn内做校验并抛错;如果错误与缓存无关(例如纯粹的渲染期错误),请在 query hook 之外处理。
记忆化(memoization):为什么内联 select 会在每次渲染都执行
select函数并不是每次都重新执行。它只会在以下两个条件之一满足时重新运行:
select函数本身发生了引用变化;- 缓存
data发生了变化。
由此引出一个关键结论:内联的select函数每次渲染都是新引用,所以它会在每次渲染都执行一遍——这在数据量大时同样是一种浪费。指南给出两种修正方式:
import { useCallback } from 'preact/hooks' // 方式一:用 useCallback 包一层,空依赖保证引用稳定 export const useTodoCount = () => { return useTodos(useCallback((data) => data.length, [])) }// 方式二:抽成模块级稳定函数引用(无依赖时的最优解) const selectTodoCount = (data) => data.length export const useTodoCount = () => { return useTodos(selectTodoCount) }在源码层面,与select配合的 structural sharing 行为同样有测试背书:默认开启 structural sharing 时,select的派生值也会经replaceEqualDeep做相等比较(queryObserver.test.tsx),这意味着即便select每次执行都返回结构等价的新对象,只要内容相等,下游仍能拿到稳定引用——所以"用 useCallback 稳定 select"与"核心层的结构相等比较"是两层互补的优化。
组合起来:一个高吞吐场景的完整范例
把三项机制叠加,可以写出既直观又高效的组件。下面以"轮询刷新 + 计数徽标 + 列表"为例:
import { useCallback } from 'preact/hooks' import { useQuery } from '@tanstack/preact-query' const fetchTodos = () => fetch('/api/todos').then((r) => r.json()) // 列表主体:依赖默认的 Proxy 属性追踪, // 后台刷新导致的 isFetching 变化不会让整个列表重渲染 function TodoList() { const { data, isPending } = useQuery({ queryKey: ['todos'], queryFn: fetchTodos, refetchInterval: 30_000, // 30s 轮询 }) if (isPending) return '加载中...' return ( <ul> {data.map((todo) => ( <li key={todo.id}>{todo.name}</li> ))} </ul> ) } // 计数徽标:select + 稳定引用,只关心 length const selectCount = (data) => data.length function TodoCount() { const { data: count, isPending } = useQuery({ queryKey: ['todos'], queryFn: fetchTodos, select: selectCount, // 模块级稳定函数引用 }) if (isPending) return null return <span>共 {count} 项</span> }在这个例子里:
- structural sharing保证轮询返回的、内容未变的数据不会替换整个缓存引用;
- tracked properties保证
TodoList只在它真正读取的data/isPending变化时重渲染,isFetching的每秒翻转与之无关; - select + 稳定函数引用保证
TodoCount只对length变化敏感,某条 todo 的 name 修改不会打扰它。
与 query-core 的分工:为什么这套机制对每个框架都一致
值得强调的一点:上面拆解的#trackedProps、trackResult、replaceEqualDeep全部位于 packages/query-core/src 这个跨框架共享核心中(queryObserver.ts、utils.ts),而 packages/preact-query 作为薄适配层,只负责把 observer 接到 Preact 的useSyncExternalStore(见 utils.ts)与生命周期上。这也解释了为什么你从@tanstack/preact-query得到的优化行为,和 React/Vue/Solid/Svelte 各适配层在语义上完全同源——区别仅在于各框架的渲染与订阅原语。所有useQuery变体(含useInfiniteQuery、useQueries、useMutation)最终都汇聚到 useBaseQuery.ts 的同一套返回/追踪路径上,例如 useQuery.ts 中useQuery就是useBaseQuery(options, QueryObserver, queryClient)的一行转调。
进一步阅读
- 官方渲染优化细则的可复现主文档:docs/framework/react/guides/render-optimizations.md(本文档的生成来源)
- 追踪优化的静态防线(ESLint 规则):docs/eslint/no-rest-destructuring.md
trackResult/trackProp/变更通知判定源码:packages/query-core/src/queryObserver.ts- Preact 适配层追踪开关分支:packages/preact-query/src/useBaseQuery.ts
- structural sharing 基准算法与测试:packages/query-core/src/utils.ts、packages/query-core/src/tests/utils.test.tsx
- 应用级选项(全局默认值)入口:
new QueryClient({ defaultOptions: { queries: { ... } } })
实践结论可以浓缩为一句:默认开着就好,别动notifyOnChangeProps,别写内联select,绝不用 rest 解构拆查询结果——当渲染性能仍不达标时,优先怀疑组件自身的 memo 边界,而不是这套已经替你兜底的机制。
【免费下载链接】query🤖 Powerful asynchronous state management, server-state utilities and data fetching for the web. TS/JS, React Query, Solid Query, Svelte Query and Vue Query.项目地址: https://gitcode.com/GitHub_Trending/qu/query
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考