管理后台的请求问题,很少是「怎么发出去」的问题,而是「发出去之后这份数据归谁管」。这篇以自托管博客的 frontend/admin 为例,把 TanStack Query 的边界拆成六件事:缓存键、新鲜度、失效、写入回滚、认证重试和错误状态,并说明哪些地方该用它、哪些地方用了反而更乱。

1. 手写 useEffect + fetch 会踩到的四个坑

先看一版常见的实现:

function ArticleList() {
  const [pageNum, setPageNum] = useState(1);
  const [rows, setRows] = useState<ArticleListItem[]>([]);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    setLoading(true);
    fetch(`/api/admin/articles?pageNum=${pageNum}&pageSize=10`)
      .then((res) => res.json())
      .then((body) => setRows(body.data.list))
      .finally(() => setLoading(false));
  }, [pageNum]);
}

这段代码在本地演示时没问题,连续点几次分页就会暴露四个坑:

  1. 竞态:第 1 页的响应可能晚于第 2 页返回,最后写入 rows 的是旧数据。页码切换越快越容易复现。
  2. 缓存缺失:从详情页返回列表要重新请求一次;同样的筛选条件来回切换,每次都打网络。
  3. 状态不可区分:loading 是布尔值,请求失败后 finally 把它关掉,页面显示「暂无文章」,和「加载失败」长得一样。
  4. 副作用不收敛:开发模式下 StrictMode 会故意执行两次 effect;组件卸载后请求还在飞。真实项目还要补 401、取消、超时,最后就是手写一个残缺的缓存层。

TanStack Query 值钱的地方不是 fetch 封装,而是把「服务端状态」当成一类独立状态来管理。既然是状态,就必须先回答:主键是什么、什么时候算新鲜、写完要通知谁。

2. 边界一:queryKey 是缓存主键,也是失效前缀

仓库里的文章列表是这么用的:

const PAGE_SIZE = 10;

const query = useQuery({
  queryKey: ['articles', pageNum, status, search],
  queryFn: () =>
    pageArticles({
      pageNum,
      pageSize: PAGE_SIZE,
      status,
      keyword: search,
    }),
});

这里有几个刻意的选择:

  • PAGE_SIZE 是常量,不进 key;它变了就是另一份缓存,但在这个页面里它不会变。
  • 进 key 的是 search 而不是输入框的 keyword。搜索框每敲一个字都请求一次既浪费又制造竞态,所以表单提交时才 setSearch(keyword.trim()),并顺手把页码重置为 1。
  • status 的空字符串表示「全部状态」,它本身也是筛选条件的一部分。
  • pageNum 从 1 开始,和后端 minimum:"1" default:"1" 的契约一致。

更推荐的做法是把 key 收进一个工厂,让「读」和「失效」共用同一份命名:

import type { ArticleListParams } from '@/lib/admin-api';

export const articleKeys = {
  all: ['articles'] as const,
  lists: () => [...articleKeys.all, 'list'] as const,
  list: (params: ArticleListParams) =>
    [...articleKeys.lists(), params] as const,
  detail: (articleId: number) => ['article', articleId] as const,
};

注意 key 的层级就是失效的层级:['articles'] 会前缀匹配所有文章列表,['articles', 'list', params] 只匹配具体某一页。把对象放进 key 不会破坏缓存,TanStack Query 会做结构化哈希,字段顺序和对象引用都不影响匹配;真正影响匹配的是前缀。所以「列表」和「详情」不要共用一个前缀,否则一次删除要把详情也重取一遍。

详情页的 key 则独立成 ['article', articleId],和列表的 ['articles', ...] 只差一个字母,但语义完全分开。这种命名看起来吹毛求疵,等到你写 invalidateQueries 时就知道值不值。

3. 边界二:staleTime 与 gcTime 不是一回事

仓库的全局配置很克制:只设了 retry: 1 和 refetchOnWindowFocus: false,没有改 staleTime,也就是默认的 0;gcTime 保持默认的 5 分钟。这两个值经常被混为一谈,实际职责完全不同:

  • staleTime 决定数据「多久算新鲜」。新鲜期内重新挂载不会请求;过了这个点,数据仍然在缓存里,只是标记为 stale,遇到挂载、聚焦、重连才可能重取。
  • gcTime 决定「没有组件观察之后,缓存还能活多久」。它管的是内存回收,不是数据正确性。

对管理后台,我会按资源的变更频率分别设置,而不是在全局一刀切:

useQuery({
  queryKey: articleKeys.list({ pageNum, status, keyword: search }),
  queryFn: () => pageArticles({ pageNum, pageSize: PAGE_SIZE, status, keyword: search }),
  staleTime: 30_000,
  placeholderData: keepPreviousData,
});
  • 文章列表:30 秒。后台可能多人同时操作,但也不至于每次切页都重取。
  • 文章详情:保持 0。编辑页只在挂载时把首次数据灌进表单,后续 refetch 不会覆盖正在编辑的内容;代价是重新进入页面时可能先显示缓存版本。
  • 分类树、标签:5 分钟。它们变更频率低,且被多个页面共用。
  • Dashboard 概览:15 秒左右,或者干脆手动刷新。

placeholderData: keepPreviousData 是分页表格的另一个关键。它让翻页时保留上一页数据,避免整张表闪成骨架屏;代价是 query.data 在请求期间仍是旧页,isPlaceholderData 为 true,底部的「第 x / y 页」也会短暂显示旧值。要接受这个中间态,或者用 isFetching 单独显示一个细进度条。

4. 边界三:失效粒度决定一次操作打几个请求

仓库里删除、恢复文章后的写法很直接:接口成功后 invalidateQueries({ queryKey: ['articles'] })。['articles'] 前缀会命中所有文章列表缓存,包括不同页码、不同状态。这个粒度在这里是对的:一篇文章进回收站,列表的总数、当前页内容、回收站列表都变了。

创建文章则更适合「先写详情,再失效列表」:

const create = useMutation({
  mutationFn: (input: CreateArticleInput) => createArticle(input),
  onSuccess: (created) => {
    queryClient.setQueryData(articleKeys.detail(created.articleId), created);
    queryClient.invalidateQueries({ queryKey: articleKeys.lists() });
  },
});

createArticle 返回的就是 ArticleDetailData,和详情查询的类型一致,所以可以直接 setQueryData。这样跳到编辑页时详情已经有数据,不会先闪一下 loading;列表则标记为 stale,等用户返回时再取。

几条判断粒度的经验:

  • 只失效「读模型真的受影响」的 key。改一篇文章不需要重取分类树。
  • invalidateQueries 默认只重取当前有组件观察的查询,未挂载的查询只是被标记为 stale,下次进入页面才请求。这不是 bug,是省流量的设计。
  • 在 onSuccess 里不必 await 失效,否则跳转要等网络;只有需要「确认列表已更新再提示」的场景才等。
  • 新增分类、标签后,仓库里分别失效 ['categories'] 和 ['tags'],而不是顺手清空整个 queryClient。清空是核武器,用多了页面会集体转圈。

5. 边界四:乐观更新只在能回滚时使用

乐观更新的本质是「先替服务端做承诺」。承诺兑现不了,就得在用户眼皮底下收回。所以它适合失败率低、回滚可表达、操作高频的场景,比如置顶、点赞、勾选已读;不适合创建文章这种失败后无法撤销的操作。

以移入回收站为例,一个完整的乐观更新长这样:

const trash = useMutation({
  mutationFn: (articleId: number) => trashArticle(articleId),
  onMutate: async (articleId) => {
    await queryClient.cancelQueries({ queryKey: articleKeys.lists() });
    const previous = queryClient.getQueriesData<ArticleAdminPageData>({
      queryKey: articleKeys.lists(),
    });
    queryClient.setQueriesData<ArticleAdminPageData>(
      { queryKey: articleKeys.lists() },
      (page) =>
        page && {
          ...page,
          list: page.list.filter((item) => item.articleId !== articleId),
          total: page.total - 1,
        },
    );
    return { previous };
  },
  onError: (_error, _id, context) => {
    context?.previous.forEach(([key, data]) => queryClient.setQueryData(key, data));
  },
  onSettled: () =>
    queryClient.invalidateQueries({ queryKey: articleKeys.lists() }),
});

这段代码有四个必须做对的点:

  1. cancelQueries 要在改缓存之前调用,否则一个正在飞的列表请求返回后会把乐观结果覆盖掉。
  2. 快照要按「所有匹配的列表」保存,不能只保存当前页;用户可能在请求期间切页。
  3. 回滚用快照,不要用「反向操作」重算,重算在筛选条件下几乎一定错。
  4. onSettled 里统一失效,让服务端返回的 total、totalPage 修正本地的乐观值。

最后一点尤其重要:上面把 total 减了 1,但 totalPage 没有重算,翻页器可能短暂显示不一致。乐观更新允许这种不一致存在一两秒,但不能让它成为最终状态,所以 onSettled 的失效不是可选项。

如果团队不想维护这套状态,保持仓库现在的写法(等接口成功再 invalidateQueries)也完全合理,只是点击后要有一个明确的 busy 态。乐观更新是体验优化,不是正确性要求。

6. 边界五:401 刷新 token 与重试策略

认证是请求层的事,缓存层不该知道 token。仓库把刷新逻辑放在 lib/api.ts:

const unauthorized = response.status === 401 || envelope?.code === 401;
if (unauthorized && auth && !retried) {
  if (getRefreshToken()) {
    await refreshAccessToken();
    return apiRequestOnce(path, options, true);
  }
  clearAuthTokens();
}

refreshAccessToken 内部把 promise 存进模块级变量,保证同一时刻只有一个刷新请求在飞,其他请求复用同一个 promise。这段单飞不是锦上添花:刷新 token 通常会轮换,后一个请求拿旧 refresh token 再刷一次,会把前一个刚拿到的新 token 顶掉,表现为「刷新成功但立刻又 401」。retried 参数则防止刷新接口自己 401 时无限递归。

问题出在查询层的重试配置。retry: 1 对所有错误一视同仁,包括 404、409、422 这些重试也不会变的错误。更合理的是按状态码分流:

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      retry: (failureCount, error) =>
        error instanceof ApiError && error.status < 500
          ? false
          : failureCount < 1,
      refetchOnWindowFocus: false,
    },
  },
});

4xx 直接失败,5xx 和网络错误重试一次。写操作不要配重试:useMutation 默认就不重试,这是对的,创建文章重试两次可能得到三篇草稿。

还有一个容易漏的点:退出登录时只清了 localStorage 里的 token,没有清查询缓存。换账号登录后,上一账号的文章列表可能先命中缓存再刷新,出现一瞬间的越权数据。仓库目前只调用了 logout() 和 navigate('/login'),更稳妥的做法是在登出和刷新失败时执行 queryClient.clear(),把「身份」也当成缓存失效条件。

7. 边界六:loading 三态与错误边界

TanStack Query 的加载状态有三个常用值,混用会写出奇怪的界面:

  • isPending:还没有任何数据,首次请求进行中。
  • isLoading:isPending && isFetching,等价于「首次加载中」。
  • isFetching:任何一次请求进行中,包括后台 refetch 和翻页。

文章列表用 query.isLoading 显示「加载中…」是对的,但配上 keepPreviousData 后,翻页时 isLoading 是 false、isFetching 是 true,这时该显示的是行内提示而不是整页骨架。

错误处理上,仓库目前是每个页面自己判断 isError 并渲染一行红字。这种「局部错误局部处理」适合表格和表单,但渲染期异常(比如后端返回的字段缺失导致 map 崩溃)需要错误边界兜住。TanStack Query 提供 QueryErrorResetBoundary,配合路由的 key 可以让「重试」重新执行失败的查询:

ErrorBoundary 仍然得是 class 组件(getDerivedStateFromError + componentDidCatch),React 19 没有提供函数式替代;外面再包一层 QueryErrorResetBoundary,把它的 reset 接到「重试」按钮上,就能让失败的查询重新执行。key 用路径名是为了在切换路由时自动复位,否则用户点「重试」会回到上一个页面的错误状态。

8. SSR / SSG 场景的差异

这个管理后台是纯客户端 SPA:Vite 构建、createBrowserRouter、token 存在 localStorage,没有 SSR。所以上面所有结论都成立。但如果哪天要做首屏 SSR,有三处必须改:

  1. QueryClient 不能放在模块顶层共享,否则多个请求会共用同一份缓存,造成数据串号。要按请求创建,渲染完再 dehydrate。
  2. 服务端不要重试。SSR 里一个慢查询会拖住整个响应,retry 应设为 false,失败就让客户端接管。
  3. token 不能再从 localStorage 读,要改成 cookie 透传;否则服务端渲染时拿不到登录态,查询必然 401。

预取本身很简单:服务端用 prefetchQuery 把详情写进请求私有的 QueryClient,再 dehydrate 成状态树,客户端用 HydrationBoundary 接管。真正麻烦的是隔离和鉴权,不是 API。

公开站是另一条链路:frontend/web 的 Astro 配置是 output: 'static',文章页在构建期取数据、生成静态 HTML,浏览器里根本没有 TanStack Query 的缓存。它的「新鲜度」由构建流水线决定,而不是 staleTime。把这两套模型混在一起谈,是很多「为什么线上没更新」问题的根源。

9. 小结

TanStack Query 的边界可以用一句话概括:queryKey 决定「这是谁」,staleTime 决定「什么时候可以不信它」,invalidate 决定「写完通知谁」,mutation 决定「失败了怎么退」。

它不替你做鉴权,也不替你做 SSR 的请求隔离。把认证留在请求层、把缓存失效写成显式规则、把乐观更新限制在可回滚的操作上,这套组合才能在管理后台这种「多人、多页、长会话」的场景里站得住。

下一篇会写同一后台里的 Tiptap 编辑器:当 Markdown、HTML 和 ProseMirror 文档同时存在时,状态到底该放在哪一层。