Preact Query 渲染优化权威指南:structural sharing、属性追踪与 select 的底层原理与实战
2026/9/10 5:44:38 网站建设 项目流程

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-querypackages/query-core的源码给出可验证的依据。读完你将能回答"Preact Query 什么时候会重新渲染、为什么、以及如何按需关掉或自定义这套机制"。

说明:本指南在仓库中是框架共享文档——Preact 版文件的 frontmatter 声明了ref: docs/framework/react/guides/render-optimizations.md,并约定将react-query替换为preact-queryReact替换为Preact后生成。因此它讨论的是@tanstack/preact-query这一适配层的真实行为;而其机制核心(observer、缓存、变更通知)位于跨框架共享的@tanstack/query-core,源码路径见下文标注。

为什么 Preact Query 要主动做渲染优化

在典型的服务端状态场景里,一个 query 的"状态对象"包含的字段远比单个组件关心的要多:datastatuserrorisPendingisFetchingisStaleisRefetchingfetchStatus……其中isFetchingisStale这类标志在后台重取、窗口聚焦刷新时变化非常频繁,而多数组件其实根本不读取它们。如果任何字段变化都导致订阅方重渲染,应用会在高频刷新时白白浪费大量渲染帧。

Preact Query 的解决思路分三层、各司其职:

  1. structural sharing——稳定data的对象引用,让"数据没变"这件事可以被廉价地比较出来;
  2. tracked properties(属性追踪)——只在你真正读取某个属性时,才把该属性登记为"变更需要通知"的对象;
  3. 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 才有意义。对MapSet、类实例等非纯数据对象,参考级别的整体替换依然是正确行为。

从源码结构可以确认,该配置同时作用于 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的稳定,但请注意结果的顶层容器并不稳定

useQueryuseInfiniteQueryuseMutation返回的顶层对象,以及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),会检查被追踪属性集合与本次变化的属性集合是否有交集:只要组件用到的属性没变,就不会触发重渲染。于是isFetchingisStale这类频繁翻转的属性,若组件从不读取,就永远不会把组件"吵醒"。

两种触发登记的写法对比

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'], })

同样,可以在QueryClientdefaultOptions.queries里全局设置('all'或属性名数组)。从 useBaseQuery.ts 的分支可以看到:只要设置了notifyOnChangeProps,hook 就跳过trackResult,直接把结果原样返回——追踪逻辑完全按你给定的模式执行。

需要说明的是,手动白名单是静态的、需要你手动维护,而 Proxy 追踪是动态的、随组件代码自动收敛,因此日常开发优先依赖默认追踪;只有当你想精确锁定某几个属性、或遇到需要"全量变更都通知"的边界场景时,才去动notifyOnChangeProps

select:把订阅收窄到派生结果

用法与收益

当组件只关心缓存数据的某个"切片"时,可以用select声明派生关系。缓存里始终保存完整数据,而组件拿到的dataselect的输出。指南中给出了一个高度可复用的封装模式:

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 === undefinedisSuccess === true——一个"成功但没有数据"的诡异状态。

建议:如果你希望"数据不正确"就让整个 query 失败,请在queryFn内做校验并抛错;如果错误与缓存无关(例如纯粹的渲染期错误),请在 query hook 之外处理。

记忆化(memoization):为什么内联 select 会在每次渲染都执行

select函数并不是每次都重新执行。它只会在以下两个条件之一满足时重新运行

  1. select函数本身发生了引用变化;
  2. 缓存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 的分工:为什么这套机制对每个框架都一致

值得强调的一点:上面拆解的#trackedPropstrackResultreplaceEqualDeep全部位于 packages/query-core/src 这个跨框架共享核心中(queryObserver.ts、utils.ts),而 packages/preact-query 作为薄适配层,只负责把 observer 接到 Preact 的useSyncExternalStore(见 utils.ts)与生命周期上。这也解释了为什么你从@tanstack/preact-query得到的优化行为,和 React/Vue/Solid/Svelte 各适配层在语义上完全同源——区别仅在于各框架的渲染与订阅原语。所有useQuery变体(含useInfiniteQueryuseQueriesuseMutation)最终都汇聚到 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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询