跳到主要内容

Query Cancellation 与 Abort Signal

TanStack Query 的取消能力,平时不一定最显眼,但一旦碰到这些场景,就会非常有用:

  • 搜索框连续输入
  • 页面快速切换
  • 参数频繁变化
  • 列表和详情之间来回跳转
  • 请求本身很慢,用户又明确点了取消

这篇主要看两件事:

  1. TanStack Query 什么时候会发出取消信号
  2. 请求层怎样把这个信号继续传下去

默认行为先分清

官方文档里有一个很容易被忽略的点:

  • 查询变成 inactive 或过期时,TanStack Query 会发出 AbortSignal
  • 但如果查询函数根本没消费这个 signal,底层请求通常不会真的中断

也就是说:

  • Query 层“知道可以取消”
  • 底层请求层“愿不愿意停”,要看 signal 有没有被接进 fetchaxios 或其他客户端

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 的取消能力能自然往下传
  • fetchaxios 两条路线都能统一接

手动取消怎么做

有些场景里,除了“组件卸载自动取消”,还会需要用户主动点一个按钮。

这时一般直接用 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 上很重要,因为它意味着:

  • 取消不等于失败
  • 不一定要弹错误
  • 更像“这次取数被中断了”

所以页面里一般不要把取消当成普通错误提示。

错误处理要注意什么

fetchaxios 被取消后,抛出的错误往往和普通网络错误不一样。项目里如果有统一错误提示,最好把“取消请求”单独排除。

例如:

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 负责发出取消信号
  • 请求层负责把取消信号传给网络请求

这两层都接上之后,取消能力才算完整。

相关文章