Query Cancellation 与 Abort Signal
TanStack Query 的取消能力,平时不一定最显眼,但一旦碰到这些场景,就会非常有用:
- 搜索框连续输入
- 页面快速切换
- 参数频繁变化
- 列表和详情之间来回跳转
- 请求本身很慢,用户又明确点了取消
这篇主要看两件事:
- TanStack Query 什么时候会发出取消信号
- 请求层怎样把这个信号继续传下去
默认行为先分清
官方文档里有一个很容易被忽略的点:
- 查询变成 inactive 或过期时,TanStack Query 会发出
AbortSignal - 但如果查询函数 根本没消费这个
signal,底层请求通常不会真的中断
也就是说:
- Query 层“知道可以取消”
- 底层请求层“愿不愿意停”,要看
signal有没有被接进fetch、axios或其他客户端
fetch 场景
最直接的写法就是把 signal 原样传给 fetch。
import { useQuery } from '@tanstack/react-query'
function useTodoList(keyword: string) {
return useQuery({
queryKey: ['todos', keyword],
queryFn: async ({ signal }) => {
const res = await fetch(`/api/todos?keyword=${keyword}`, { signal })
if (!res.ok) {
throw new Error('request failed')
}
return res.json()
},
})
}
这样一来,只要 Query 被取消,请求也会跟着 abort。
一个请求里串多个 fetch
官方文档也特别强调过,signal 不一定只传一次。只要这一条查询链上的多个请求都属于同一次查询,就都可以共用这个 signal。
useQuery({
queryKey: ['todos-with-detail'],
queryFn: async ({ signal }) => {
const todosRes = await fetch('/api/todos', { signal })
const todos = await todosRes.json()
return Promise.all(
todos.map((todo: { detailUrl: string }) =>
fetch(todo.detailUrl, { signal }).then((res) => res.json())
)
)
},
})
这种写法很适合“先拉列表,再补详情”的场景。
axios 场景
如果项目已经是 axios,也可以直接接 signal。
import axios from 'axios'
useQuery({
queryKey: ['users'],
queryFn: ({ signal }) =>
axios.get('/api/users', {
signal,
}),
})
这套写法适用于较新的 axios 版本。官方文档也保留了旧版 CancelToken 的兼容写法,但新项目通常优先直接用 signal。
请求封装层怎么接
如果项目已经有统一请求函数,最稳的方式不是在页面里每次手动传,而是把 signal 放进请求函数签名。
interface RequestOptions {
signal?: AbortSignal
}
export async function getUserDetail(id: string, options: RequestOptions = {}) {
return http.get(`/users/${id}`, {
signal: options.signal,
})
}
然后在 Query 层消费:
useQuery({
queryKey: ['user', id],
queryFn: ({ signal }) => getUserDetail(id, { signal }),
})
这样做的好处很直接:
- 页面层不会充满请求细节
- Query 的取消能力能自然往下传
fetch和axios两条路线都能统一接
手动取消怎么做
有些场景里,除了“组件卸载自动取消”,还会需要用户主动点一个按钮。
这时一般直接用 queryClient.cancelQueries。
import { useQuery, useQueryClient } from '@tanstack/react-query'
function TodoPanel() {
const queryClient = useQueryClient()
const query = useQuery({
queryKey: ['todos'],
queryFn: async ({ signal }) => {
const res = await fetch('/api/todos', { signal })
return res.json()
},
})
return (
<button onClick={() => queryClient.cancelQueries({ queryKey: ['todos'] })}>
Cancel
</button>
)
}
这时如果底层请求已经消费了 signal,请求本身也会停下来。
搜索框场景最适合取消
取消查询最常见、也最能立刻感受到价值的,就是搜索联想。
function useSearchUsers(keyword: string) {
return useQuery({
queryKey: ['search-users', keyword],
enabled: keyword.trim().length > 0,
queryFn: async ({ signal }) => {
const res = await fetch(`/api/users/search?q=${keyword}`, { signal })
return res.json()
},
})
}
输入连续变化时:
- 旧查询会变成过期或失活
- 新查询接手
- 旧请求如果接了
signal,就不会继续浪费网络
取消和缓存不是一回事
这里最容易混的是:
- “不再显示这个查询了”
- “把网络请求真的停掉”
TanStack Query 的默认行为里,就算组件卸载,如果 Promise 还会继续完成,那数据依然可能进缓存。
只有底层请求消费了 signal,才会真的 abort。
被取消后,状态会怎样
官方文档里提到,如果 Query 的取消真的生效,查询状态会回退到之前的状态。
这点在 UI 上很重要,因为它意味着:
- 取消不等于失败
- 不一定要弹错误
- 更像“这次取数被中断了”
所以页面里一般不要把取消当成普通错误提示。
错误处理要注意什么
fetch 或 axios 被取消后,抛出的错误往往和普通网络错误不一样。项目里如果有统一错误提示,最好把“取消请求”单独排除。
例如:
try {
await fetch(url, { signal })
} catch (error: any) {
if (error.name === 'AbortError') {
return
}
throw error
}
如果是 axios,也要看当前版本的错误识别方式。
Infinite Query 里也一样适用
Infinite Query 其实更需要取消能力。
典型场景:
- 用户快速切换筛选条件
- 页面滚动到底部时连续触发多次加载
- 下一个游标请求已经没意义了
这时如果底层请求层已经统一接了 signal,Infinite Query 不需要额外发明另一套取消写法。
什么时候值得优先接好取消能力
- 搜索框
- 级联筛选
- 图表面板
- 列表条件切换频繁的后台系统
- 大文件或慢接口场景
这几类页面里,取消能力接好之后,体感通常会非常明显。
更适合先记住的主线
可以直接把它理解成一句话:
- TanStack Query 负责发出取消信号
- 请求层负责把取消信号传给网络请求
这两层都接上之后,取消能力才算完整。