迁移到 TanStack Query v5
破坏性变更
v5 是一个主版本,因此存在一些你需要了解的破坏性变更:
仅支持一种签名:单个对象
useQuery 及其相关函数在 TypeScript 中曾提供多个重载:即函数可被调用的不同方式。这不仅在类型层面难以维护,还要求运行时检查第一个和第二个参数的类型,以正确构造选项。
现在我们仅支持对象格式。
1useQuery(key, fn, options) // [!code --]2useQuery({ queryKey, queryFn, ...options }) // [!code ++]3useInfiniteQuery(key, fn, options) // [!code --]4useInfiniteQuery({ queryKey, queryFn, ...options }) // [!code ++]5useMutation(fn, options) // [!code --]6useMutation({ mutationFn, ...options }) // [!code ++]7useIsFetching(key, filters) // [!code --]8useIsFetching({ queryKey, ...filters }) // [!code ++]9useIsMutating(key, filters) // [!code --]10useIsMutating({ mutationKey, ...filters }) // [!code ++]1queryClient.isFetching(key, filters) // [!code --]2queryClient.isFetching({ queryKey, ...filters }) // [!code ++]3queryClient.getQueriesData(key, filters) // [!code --]4queryClient.getQueriesData({ queryKey, ...filters }) // [!code ++]5queryClient.setQueriesData(key, updater, filters, options) // [!code --]6queryClient.setQueriesData({ queryKey, ...filters }, updater, options) // [!code ++]7queryClient.removeQueries(key, filters) // [!code --]8queryClient.removeQueries({ queryKey, ...filters }) // [!code ++]9queryClient.resetQueries(key, filters, options) // [!code --]10queryClient.resetQueries({ queryKey, ...filters }, options) // [!code ++]11queryClient.cancelQueries(key, filters, options) // [!code --]12queryClient.cancelQueries({ queryKey, ...filters }, options) // [!code ++]13queryClient.invalidateQueries(key, filters, options) // [!code --]14queryClient.invalidateQueries({ queryKey, ...filters }, options) // [!code ++]15queryClient.refetchQueries(key, filters, options) // [!code --]16queryClient.refetchQueries({ queryKey, ...filters }, options) // [!code ++]1queryCache.find(key, filters) // [!code --]2queryCache.find({ queryKey, ...filters }) // [!code ++]3queryCache.findAll(key, filters) // [!code --]4queryCache.findAll({ queryKey, ...filters }) // [!code ++]命令式 QueryClient 方法
随着 queryClient.query 和 queryClient.infiniteQuery 的引入,这些方法已被弃用,并将在 v6 中移除。
如果你从 v4 或更早版本升级而来:
1queryClient.fetchQuery(key, fn, options) // [!code --]2queryClient.query({ queryKey: key, queryFn: fn, ...options }) // [!code ++]3queryClient.fetchInfiniteQuery(key, fn, options) // [!code --]4queryClient.infiniteQuery({5 queryKey: key,6 queryFn: fn,7 ...options,8}) // [!code ++]9 10queryClient.prefetchQuery(key, fn, options) // [!code --]11queryClient.query({ queryKey: key, queryFn: fn, ...options }).catch(noop) // [!code ++]12 13queryClient.prefetchInfiniteQuery(key, fn, options) // [!code --]14queryClient15 .infiniteQuery({ queryKey: key, queryFn: fn, ...options })16 .catch(noop) // [!code ++]17 18queryClient.ensureQueryData(key, options) // [!code --]19queryClient.query({ queryKey: key, ...options, staleTime: 'static' }) // [!code ++]20 21queryClient.ensureInfiniteQueryData(key, options) // [!code --]22queryClient.infiniteQuery({ queryKey: key, ...options, staleTime: 'static' }) // [!code ++]如果你正在更新较旧的 v5 代码,则与上述相同,只需保留单个选项对象即可。
queryClient.getQueryData 现在仅接受 queryKey 作为参数
queryClient.getQueryData 的参数已更改为仅接受一个 queryKey。
1queryClient.getQueryData(queryKey, filters) // [!code --]2queryClient.getQueryData(queryKey) // [!code ++]queryClient.getQueryState 现在仅接受 queryKey 作为参数
queryClient.getQueryState 的参数已更改为仅接受一个 queryKey。
1queryClient.getQueryState(queryKey, filters) // [!code --]2queryClient.getQueryState(queryKey) // [!code ++]代码迁移工具(Codemod)
为简化移除重载的迁移过程,v5 提供了一个 codemod。
该 codemod 旨在尽最大努力协助你完成此项破坏性变更的迁移。请务必仔细审查生成的代码!此外,某些边界情况无法被 codemod 检测到,因此请留意日志输出。
若要在 .js 或 .jsx 文件上运行该 codemod,请使用以下命令:
1npx jscodeshift@latest ./path/to/src/ \2 --extensions=js,jsx \3 --transform=./node_modules/@tanstack/react-query/build/codemods/v5/remove-overloads/remove-overloads.cjs若要在 .ts 或 .tsx 文件上运行该 codemod,请使用以下命令:
1npx jscodeshift@latest ./path/to/src/ \2 --extensions=ts,tsx \3 --parser=tsx \4 --transform=./node_modules/@tanstack/react-query/build/codemods/v5/remove-overloads/remove-overloads.cjs请注意:对于 TypeScript,你必须使用 tsx 作为解析器;否则 codemod 将无法正确应用!
注意: 应用 codemod 可能会破坏你的代码格式,因此请务必在应用 codemod 后运行 prettier 和/或 eslint!
关于 codemod 工作方式的几点说明:
- 通常,我们首先寻找理想情况:第一个参数是一个对象表达式,且包含
"queryKey"或"mutationKey"属性(取决于正在转换的是哪个 Hook/方法调用)。若满足此条件,你的代码已符合新签名,codemod 将不会改动它。🎉 - 若不满足上述条件,codemod 将检查第一个参数是否为数组表达式,或是否为引用数组表达式的标识符。若是,则 codemod 会将其包装进一个对象表达式,并作为第一个参数。
- 若能推断出对象参数,codemod 将尝试将已存在的属性复制到新创建的对象中。
- 若 codemod 无法推断用法,它将在控制台输出一条提示信息,其中包含该用法所在的文件名和行号。此时你需要手动完成迁移。
- 若转换过程中发生错误,你也会在控制台看到提示信息。该信息将通知你发生了意外情况,请手动完成迁移。
useQuery(及 QueryObserver)中的回调函数已被移除
onSuccess、onError 和 onSettled 已从查询(Queries)中移除。修改(Mutations)不受影响。有关此项变更的动机及替代方案,请参阅 此 RFC。
refetchInterval 回调函数现在仅接收 query 作为参数
此举统一了回调函数的调用方式(refetchOnWindowFocus、refetchOnMount 和 refetchOnReconnect 回调函数同样仅接收查询对象作为参数),并修复了当数据经 select 转换后回调函数所遇到的部分类型问题。
1- refetchInterval: number | false | ((data: TData | undefined, query: Query) => number | false | undefined) // [!code --]2+ refetchInterval: number | false | ((query: Query) => number | false | undefined) // [!code ++]你仍可通过 query.state.data 访问数据,但该数据未经过 select 转换。若需访问已转换的数据,你可在 query.state.data 上再次调用转换逻辑。
useQuery 中的 remove 方法已被移除
此前,remove 方法用于将查询从 queryCache 中移除,且不通知观察者。它最适合用于强制移除不再需要的数据,例如用户登出时。
但在查询仍处于活跃状态时执行此操作意义不大,因为下一次重新渲染将直接触发硬加载状态。
若你仍需移除某个查询,可使用 queryClient.removeQueries({queryKey: key})。
1const queryClient = useQueryClient()2const query = useQuery({ queryKey, queryFn })3 4query.remove() // [!code --]5queryClient.removeQueries({ queryKey }) // [!code ++]最低所需 TypeScript 版本现为 4.7
主要原因是 TypeScript 围绕类型推断发布了一项重要修复。更多信息请参阅此 TypeScript 问题。
useQuery 中的 isDataEqual 选项已被移除
此前,该函数用于指示查询应使用先前的 data(true)还是新数据(false)作为已解析的数据。
你可通过向 structuralSharing 传入一个函数来实现相同功能:
1import { replaceEqualDeep } from '@tanstack/react-query'2 3- isDataEqual: (oldData, newData) => customCheck(oldData, newData) // [!code --]4+ structuralSharing: (oldData, newData) => customCheck(oldData, newData) ? oldData : replaceEqualDeep(oldData, newData) // [!code ++]已弃用的自定义日志记录器已被移除
自定义日志记录器在 v4 中已被弃用,本版本中正式移除。日志记录仅在开发模式下生效,而开发模式下无需传入自定义日志记录器。
支持的浏览器
我们已更新 browserslist,以生成更现代、性能更高、体积更小的打包产物。你可在此处查阅具体要求。
私有类字段与方法
TanStack Query 一直对类使用私有字段和方法,但它们并非真正私有——仅在 TypeScript 中表现为私有。我们现在采用 ECMAScript 私有类特性,这意味着这些字段现在真正私有,在运行时无法从外部访问。
将 cacheTime 重命名为 gcTime
几乎所有人都会误读 cacheTime。它听起来像是“数据被缓存的时间长度”,但这并不正确。
cacheTime 在查询仍被使用期间完全不生效。它仅在查询变为未使用后才开始计时。一旦超时,数据将被“垃圾回收”,以避免缓存无限增长。
gc 指的是“垃圾回收”时间。它略显技术化,但在计算机科学中是一个相当广为人知的缩写。
1const MINUTE = 60 * 1000;2 3const queryClient = new QueryClient({4 defaultOptions: {5 queries: {6- cacheTime: 10 * MINUTE, // [!code --]7+ gcTime: 10 * MINUTE, // [!code ++]8 },9 },10})useErrorBoundary 选项已重命名为 throwOnError
为使 useErrorBoundary 选项更具框架无关性,并避免与已确立的 React 函数前缀“use”(用于 Hooks)及 ErrorBoundary 组件名称混淆,它已被重命名为 throwOnError,以更准确地反映其功能。
TypeScript:Error 现在是错误的默认类型,而非 any
尽管在 JavaScript 中你可以 throw 任意值(这使得 unknown 成为最严谨的类型),但绝大多数情况下抛出的是 Errors(或 Error 的子类)。此项变更使大多数场景下在 TypeScript 中处理 error 字段更为便捷。
若你想抛出非 Error 类型的值,则现在需自行设置泛型:
1useQuery<number, string>({2 queryKey: ['some-query'],3 queryFn: async () => {4 if (Math.random() > 0.5) {5 throw 'some error'6 }7 return 428 },9})如需全局设置其他类型的 Error,请参阅 TypeScript 指南。
eslint prefer-query-object-syntax 规则已被移除
由于当前唯一支持的语法是对象语法,此规则已不再需要。
移除 keepPreviousData,改用 placeholderData 恒等函数
我们已移除 keepPreviousData 选项及 isPreviousData 标志,因为它们的功能与 placeholderData 及 isPlaceholderData 标志基本重复。
为实现与 keepPreviousData 相同的功能,我们已将上一个查询的 data 作为参数添加至 placeholderData(后者接受一个恒等函数)。因此你只需向 placeholderData 提供一个恒等函数,或直接使用 TanStack Query 提供的内置 keepPreviousData 函数。
此处需注意:
useQueries不会在placeholderData函数中接收到previousData作为参数。这是由于以数组形式传入的查询具有动态性,可能导致占位数据与queryFn返回结果的结构不同。
1import {2 useQuery,3+ keepPreviousData // [!code ++]4} from "@tanstack/react-query";5 6const {7 data,8- isPreviousData, // [!code --]9+ isPlaceholderData, // [!code ++]10} = useQuery({11 queryKey,12 queryFn,13- keepPreviousData: true, // [!code --]14+ placeholderData: keepPreviousData // [!code ++]15});在 TanStack Query 的上下文中,“恒等函数”指始终返回其输入参数(即数据)且不做任何更改的函数。
1useQuery({2 queryKey,3 queryFn,4 placeholderData: (previousData, previousQuery) => previousData, // identity function with the same behavior as `keepPreviousData`5})不过,此项变更存在一些需要注意的细节:
placeholderData总是会使你进入success状态,而keepPreviousData则会继承上一个查询的状态。该状态可能是error(例如:我们已成功获取数据,随后后台重新拉取时发生错误)。但该错误本身并未共享,因此我们决定沿用placeholderData的行为。keepPreviousData会提供上一个数据的dataUpdatedAt时间戳,而使用placeholderData时,dataUpdatedAt将保持为0。若你希望在屏幕上持续显示该时间戳,这可能令人困扰。但你可通过useEffect规避此问题。
1 const [updatedAt, setUpdatedAt] = useState(0)2 3 const { data, dataUpdatedAt } = useQuery({4 queryKey: ['projects', page],5 queryFn: () => fetchProjects(page),6 })7 8 useEffect(() => {9 if (dataUpdatedAt > updatedAt) {10 setUpdatedAt(dataUpdatedAt)11 }12 }, [dataUpdatedAt])窗口焦点重新拉取不再监听 focus 事件
现在仅使用 visibilitychange 事件。这是因为目前我们仅支持原生支持 visibilitychange 事件的浏览器。此举修复了此处列出的诸多问题。
网络状态不再依赖 navigator.onLine 属性
navigator.onLine 在基于 Chromium 的浏览器中表现不佳。围绕其存在大量问题,导致虚假否定(false negatives),进而使查询被错误地标记为 offline。
为规避此问题,我们现在始终以 online: true 为初始状态,并仅监听 online 和 offline 事件来更新网络状态。
此举应能降低虚假否定发生的概率,但对通过 Service Worker 加载的离线应用而言,可能会增加虚假肯定(false positives)的风险——这类应用即使没有互联网连接也能正常工作。
移除自定义 context prop,改用自定义 queryClient 实例
在 v4 版本中,我们引入了向所有 react-query Hook 传入自定义 context 的能力。这使得在使用微前端(MicroFrontends)时能够实现正确的隔离。
然而,context 是一个仅适用于 React 的特性。context 所做的全部工作就是让我们能够访问 queryClient。我们可以通过直接传入自定义 queryClient 的方式来实现相同的隔离效果。
这反过来将使其他框架也能以与框架无关的方式获得相同的功能。
1import { queryClient } from './my-client'2 3const { data } = useQuery(4 {5 queryKey: ['users', id],6 queryFn: () => fetch(...),7- context: customContext // [!code --]8 },9+ queryClient, // [!code ++]10)移除了 refetchPage,改用 maxPages
在 v4 中,我们引入了通过 refetchPage 函数为无限查询(infinite queries)指定需重新拉取(refetch)的页数的能力。
然而,重新拉取所有页面可能导致 UI 不一致。此外,该选项(例如在 queryClient.refetchQueries 上)虽然可用,但仅对无限查询生效,对“普通”查询无效。
v5 引入了一个新的 maxPages 选项,专用于无限查询,以限制查询数据中存储及重新拉取的页数。这一新特性在避免 refetchPage 页面功能相关问题的同时,覆盖了其最初设计的所有使用场景。
新的 dehydrate API
可传给 dehydrate 的选项已被简化。查询(queries)和修改(mutations)始终按默认函数实现进行脱水(dehydrate)。若要更改此行为,不再使用已移除的布尔型选项 dehydrateMutations 和 dehydrateQueries,而是改用对应的函数形式 shouldDehydrateQuery 或 shouldDehydrateMutation。若要完全禁用查询/修改的注水(hydration),请传入 () => false。
1- dehydrateMutations?: boolean // [!code --]2- dehydrateQueries?: boolean // [!code --]无限查询现在必须提供 initialPageParam
此前,我们将 undefined 作为 pageParam 传入 queryFn,你可以在 queryFn 的函数签名中为 pageParam 参数指定默认值。这种做法的缺点是会将 undefined 存入 queryCache,而 queryCache 不可序列化。
现在,你必须显式地将 initialPageParam 传入无限查询选项。它将被用作第一页的 pageParam:
1useInfiniteQuery({2 queryKey,3- queryFn: ({ pageParam = 0 }) => fetchSomething(pageParam), // [!code --]4+ queryFn: ({ pageParam }) => fetchSomething(pageParam), // [!code ++]5+ initialPageParam: 0, // [!code ++]6 getNextPageParam: (lastPage) => lastPage.next,7})已移除无限查询的手动模式
此前,我们允许通过直接向 fetchNextPage 或 fetchPreviousPage 传入 pageParam 值,来覆写 getNextPageParam 或 getPreviousPageParam 返回的 pageParams。该特性在重新拉取(refetch)时完全无法工作,且鲜为人知、极少使用。这也意味着 getNextPageParam 现在对无限查询而言是必需的。
从 getNextPageParam 或 getPreviousPageParam 返回 null 现在表示无更多页面可用
在 v4 中,你需要显式返回 undefined 来表明无更多页面可用。我们已将该判断条件扩展为也包含 null。
服务端不再重试
在服务端,retry 现在默认为 0,而非 3。对于预取(prefetching),我们一直默认使用 0 次重试;但由于启用了 suspense 的查询现在也可直接在服务端执行(得益于 React 18),我们必须确保服务端完全不进行重试。
status: loading 已更改为 status: pending,isLoading 已更改为 isPending,isInitialLoading 现已重命名为 isLoading
loading 状态已重命名为 pending,同理,派生出的 isLoading 标志也已重命名为 isPending。
对于修改(mutations),status 也已从 loading 更改为 pending,且 isLoading 标志已更改为 isPending。
最后,查询(queries)新增了一个派生的 isLoading 标志,其实现为 isPending && isFetching。这意味着 isLoading 和 isInitialLoading 表达相同含义,但 isInitialLoading 已被弃用,将在下一个主版本中移除。
如需了解此项变更背后的考量,请参阅 v5 路线图讨论。
hashQueryKey 已重命名为 hashKey
因为它不仅会对查询键(query keys)进行哈希运算,还可用于 predicate 函数中(如 useIsMutating 和 useMutationState 的 predicate 函数),而这些函数接收的是修改(mutations)。
Vue Query 破坏性变更
useQueries 组合式函数(composable)现返回 ref,而非 reactive
为修复与 Vue 2 的兼容性问题,useQueries 组合式函数现在返回包裹在 ref 中的 queries 数组。
此前返回 reactive,导致多个问题:
- 用户展开返回值时会丢失响应性。
- 用于返回值的
readonly包装器会破坏 Vue 2 的响应性检测机制。该问题在 Vue 2.6 中静默存在,但在 Vue 2.7 中表现为报错。 - Vue 2 不支持将数组作为
reactive的根值。
此次变更已修复上述所有问题。
此外,此举也使 useQueries 与其他返回全部值为 refs 的组合式函数保持一致。
现在要求 Vue v3.3 及以上版本
为能紧跟 Vue 版本发布节奏并提供新特性,我们现在要求 Vue 3 至少为 v3.3 版本。
Vue 2.x 的要求保持不变。
新特性 🚀
v5 还带来了以下新特性:
简化的乐观更新(optimistic updates)
我们提供了一种新且更简化的乐观更新方式,利用 useMutation 返回的 variables:
1const queryInfo = useTodos()2const addTodoMutation = useMutation({3 mutationFn: (newTodo: string) => axios.post('/api/data', { text: newTodo }),4 onSettled: () => queryClient.invalidateQueries({ queryKey: ['todos'] }),5})6 7if (queryInfo.data) {8 return (9 <ul>10 {queryInfo.data.items.map((todo) => (11 <li key={todo.id}>{todo.text}</li>12 ))}13 {addTodoMutation.isPending && (14 <li key={String(addTodoMutation.submittedAt)} style={{ opacity: 0.5 }}>15 {addTodoMutation.variables}16 </li>17 )}18 </ul>19 )20}此处,我们仅在修改运行期间更改 UI 的外观,而非直接向缓存写入数据。这种方式最适合仅需在一个位置展示乐观更新的场景。更多细节请参阅 乐观更新文档。
带新 maxPages 选项的受限无限查询(Limited, Infinite Queries)
当需要无限滚动或分页时,无限查询非常有用。
然而,拉取的页面越多,内存消耗越大,同时也会拖慢查询的重新拉取过程——因为所有页面均需顺序重新拉取。
v5 为无限查询引入了新的 maxPages 选项,允许开发者限制查询数据中存储及后续重新拉取的页面数量。
你可以根据所需的用户体验(UX)和重新拉取性能来调整 maxPages 的值。
注意:无限列表必须支持双向滚动,因此必须同时定义 getNextPageParam 和 getPreviousPageParam。
无限查询可预取多页
无限查询可像普通查询一样被预取(prefetch)。默认情况下,仅预取查询的第一页,并将其存储在指定的查询键(QueryKey)下。若需预取多于一页的内容,可使用 pages 选项。更多信息请参阅 预取指南。
useQueries 的新 combine 选项
详见 useQueries 文档。
实验性 fine grained storage persister
详见 experimental_createPersister 文档。
支持在 injectionContext 中运行 vue-query 组合式函数
此前 vue-query 组合式函数只能在组件的 setup 函数内运行。
我们曾提供一种“逃生舱口”(escape hatch)机制:若用户将 queryClient 作为组合式函数的选项传入,则可在任意位置运行这些 Hook。
现在,你可在任何支持 injectionContext 的函数中使用 vue-query 组合式函数,例如路由导航守卫(router navigation guards)。
使用此新特性时,请确保 vue-query 组合式函数运行于 effectScope 内。否则可能导致内存泄漏。
我们已添加 dev-only 警告,以提醒用户潜在的误用情况。