---
title: TanStack Query 数据流：缓存键、失效与后台任务
description: 对照 ChatCrystal 的查询钩子和 API，用离线缓存实验验证参数键与前缀失效，再区分请求成功、后台完成和页面刷新。
image: "https://zengliangyi.com/og/blog/tanstack-query-data-flow.png"
---

列表筛选、笔记详情和后台总结会在不同时间改变页面数据。查询库能管理服务端状态，但只有缓存身份和完成事件都接对了，界面才会在需要时刷新。本文阅读 ChatCrystal 当前的查询路径，再用没有 React、HTTP 或真实数据的实验检查缓存行为。

## 先区分查询缓存和页面状态

[App.tsx](https://github.com/ZengLiangYi/ChatCrystal/blob/764276814e5c5a33ecbfeae63754f74b9261a3a8/client/src/App.tsx)在顶层创建 QueryClient，通过 QueryClientProvider 提供给页面。当前默认 staleTime 为 30,000ms，retry 为 1；这不是适合任何应用的通用最佳值，也不表示 30 秒内服务器数据不会改变。

查询结果是服务端状态。搜索输入、选中标签、页码、弹窗是否打开则是界面状态，不必为了统一而全部放进查询缓存。当前笔记页把这些筛选参数传给 useNotes；改动筛选后重置页码，避免沿用上一条件的偏移量。

## 缓存键必须覆盖请求参数

[use-notes.ts](https://github.com/ZengLiangYi/ChatCrystal/blob/764276814e5c5a33ecbfeae63754f74b9261a3a8/client/src/hooks/use-notes.ts)使用 `['notes', params]` 标识列表，`['note', id]` 标识详情。标签、搜索、来源类型、offset 和 limit 改变时应对应不同请求身份。只用 `['notes']` 缓存所有分页，会把不同页面的数据放进同一条记录。

对象字段顺序不等于键不同；数组元素的顺序仍有意义。详情钩子仅在 id > 0 时启用自动查询，小数或 Infinity 也可能满足这个条件，因此它不是完整的 ID 校验，也不能据此认为 TypeScript 已把任意数据或 undefined 都收窄成有效笔记。

下面使用与当前 React Query 相同的 query-core 5.102.8。在新临时目录安装，保存 `cache.mjs`，用 Node 24+ 运行。**这是没有观察者的离线缓存实验**，不是 React 页面或 HTTP 请求。为确定性关闭重试、禁用自动回收，和应用默认配置不同。



```sh
npm   init   -y  
  npm   install   --ignore-scripts   --no-audit   --no-fund   @tanstack/query-core@5.102.8
```





```js
import   assert   from   'node:assert/strict'  ;  
  import   { QueryClient }   from   '@tanstack/query-core'  ;  
  
  const   client   =   new   QueryClient  ({  
    defaultOptions: { queries: { staleTime:   30_000  , retry:   false  , gcTime:   Infinity   } },  
  });  
  let   reads   =   0  ;  
  const   queryFn   =   async   ()   =>   ({ revision:   ++  reads });  
  const   first   =   [  'notes'  , { tag:   'mcp'  , offset:   0  , limit:   20   }];  
  const   reordered   =   [  'notes'  , { limit:   20  , offset:   0  , tag:   'mcp'   }];  
  const   second   =   [  'notes'  , { tag:   'mcp'  , offset:   20  , limit:   20   }];  
  try   {  
    assert.  equal  ((  await   client.  fetchQuery  ({ queryKey: first, queryFn })).revision,   1  );  
    assert.  equal  ((  await   client.  fetchQuery  ({ queryKey: reordered, queryFn })).revision,   1  );  
    await   client.  fetchQuery  ({ queryKey: second, queryFn });  
    assert.  equal  (reads,   2  );  
    client.  setQueryData  ([  'note'  ,   7  ], { title:   'separate detail'   });  
    await   client.  invalidateQueries  ({ queryKey: [  'notes'  ] });  
    assert.  equal  (reads,   2  );  
    assert.  equal  (client.  getQueryState  (first).isInvalidated,   true  );  
    assert.  equal  (client.  getQueryState  (second).isInvalidated,   true  );  
    assert.  equal  (client.  getQueryState  ([  'note'  ,   7  ]).isInvalidated,   false  );  
    assert.  equal  ((  await   client.  fetchQuery  ({ queryKey: first, queryFn })).revision,   3  );  
    console.  log  (  'keys, cached reads, and inactive invalidation pass'  );  
  }   finally   {  
    client.  clear  ();  
  }
```



将 invalidateQueries 的前缀从 notes 改为 note，列表失效断言应失败；恢复后通过。两个不同页码都匹配列表前缀，详情不匹配。这里没有活动观察者，失效没有马上增加读取次数；下一次 fetchQuery 才重新读取。真实页面的活动查询通常还会在失效时后台重取，见[官方失效说明](https://tanstack.com/query/latest/docs/framework/react/guides/query-invalidation)。

## API 失败需要成为查询失败

当前 [api.ts](https://github.com/ZengLiangYi/ChatCrystal/blob/764276814e5c5a33ecbfeae63754f74b9261a3a8/client/src/lib/api.ts)封装请求：按需设置 Content-Type，带上已配置 token 的 Bearer 头，并将 HTTP 错误和业务 success:false 变成异常。HTTP 401 还会清除 token 并通知认证状态变更。泛型返回类型只是类型断言，不是对任意响应 JSON 的运行时验证。

这类封装让查询层看到失败，但页面仍须显示失败。比如 [SearchPage](https://github.com/ZengLiangYi/ChatCrystal/blob/764276814e5c5a33ecbfeae63754f74b9261a3a8/client/src/pages/SearchPage.tsx)处理 isError；笔记列表当前主要取 data 和 isLoading，不能因此把所有没有数据的显示都理解成已确认的空列表。排查时应分别检查首次加载、后台刷新、失败和真正零结果。

useMutation 也不按 HTTP 方法自动分配。SearchPage 使用它触发按钮提交的 GET 搜索，所以“查询都必须用 useQuery，所有 mutation 都写服务器”不符合现有代码。选择应依据自动缓存和触发方式，而不是只看函数名称。

## 提交成功与任务完成分开处理

当前总结 mutation 在请求成功后使 queue-status、conversation、conversations、notes、status 等查询失效；批量总结和取消有各自的键集合。但后端接受排队请求后，AI 工作仍可能在运行。这时刷新笔记列表，不保证已经能读到新笔记。

[use-queue.ts](https://github.com/ZengLiangYi/ChatCrystal/blob/764276814e5c5a33ecbfeae63754f74b9261a3a8/client/src/hooks/use-queue.ts)在有活动任务时每 2 秒查询状态，否则每 10 秒；[StatusBar](https://github.com/ZengLiangYi/ChatCrystal/blob/764276814e5c5a33ecbfeae63754f74b9261a3a8/client/src/components/StatusBar.tsx)显示任务进展与完成提示。这个完成提示没有同时让 notes 查询失效，不能把它描述为“后台结束后所有列表自动同步”。队列自身的边界见[任务队列设计](/blog/pqueue-task-queues/)。

导入的 [SSE 钩子](https://github.com/ZengLiangYi/ChatCrystal/blob/764276814e5c5a33ecbfeae63754f74b9261a3a8/client/src/hooks/use-import-stream.ts)又是一条独立 fetch 路径，在 done 时更新导入状态并使 conversations、status 失效。SSE 完成、队列完成和普通请求成功是不同事件，应逐条确认需要刷新哪些缓存。

## 把刷新约定写成可验证的行为

如果要补充后台完成后的刷新，可以让明确的完成事件按任务结果使相关键失效，再用延迟任务样本验证列表确实出现新数据。这是**改进建议，当前代码并未在此文章中实现**。同样，乐观更新需要回滚和并发策略，不能因为有 invalidateQueries 就宣布已有。

验收时至少区分：筛选改变是否使用新键，提交成功时任务是否仍在运行，完成后是否重新请求，失败是否显示错误而不是空状态。staleTime 与轮询间隔应结合这些事件选择；本文的离线调用次数不是网络性能或页面响应时间测量。

分享：

##  相关文章

[##  五种 AI 对话解析器：字段保留与损失边界

从 Claude Code、Codex、Cursor、Trae 和 Copilot 的实际解析器建立验收矩阵，检查工具标记、思考文本、顺序与快照覆盖范围。          曾亮懿       2026年10月7日      阅读约 4 分钟

#ChatCrystal

#TypeScript

#Node.js](/blog/ai-conversation-parser-boundaries)    [##  AI SDK Provider 抽象：能力表与协议边界

核对 ChatCrystal 的七项 Provider 注册、AI SDK 7 模型工厂和结构化输出，用无网络 mock 验证 Responses、Chat Completions、Embedding 与 schema 失败。          曾亮懿       2026年10月7日      阅读约 4 分钟

#ChatCrystal

#Node.js

#TypeScript](/blog/ai-sdk-provider-abstraction)    [##  SourceAdapter 内部链路：身份、内容哈希与重新导入

对照 ChatCrystal 的注册表、导入器和远端载荷，验证会话身份、内容哈希、元数据捷径与 parserVersion 的实际边界。          曾亮懿       2026年10月7日      阅读约 4 分钟

#ChatCrystal

#TypeScript

#Node.js](/blog/source-adapter-internals)

##  从文章到代码

到我的 GitHub 看看 ChatCrystal、开发者工具和日常学习记录，欢迎交流实现细节。

[访问我的 GitHub](https://github.com/ZengLiangYi)

```json
{
  "@context": "https://schema.org",
  "@type": "WebSite",
  "name": "ZengLiangYi Blog",
  "alternateName": [
    "Zeng Liangyi",
    "ZengLiangYi",
    "zengliangyi.com"
  ],
  "url": "https://zengliangyi.com",
  "description": "曾亮懿的个人网站。记录 AI Native 探索、开发者工具与全栈产品实践。"
}
{
  "@context": "https://schema.org",
  "@type": "BlogPosting",
  "headline": "TanStack Query 数据流：缓存键、失效与后台任务",
  "description": "对照 ChatCrystal 的查询钩子和 API，用离线缓存实验验证参数键与前缀失效，再区分请求成功、后台完成和页面刷新。",
  "url": "https://zengliangyi.com/blog/tanstack-query-data-flow/",
  "image": "https://zengliangyi.com/images/blog/tanstack-query-data-flow.svg",
  "datePublished": "2026-10-06T00:00:00.000Z",
  "dateModified": "2026-10-06T00:00:00.000Z",
  "author": {
    "@type": "Person",
    "name": "曾亮懿",
    "url": "https://zengliangyi.com/about"
  },
  "publisher": {
    "@type": "Organization",
    "name": "ZengLiangYi Blog",
    "logo": {
      "@type": "ImageObject",
      "url": "https://zengliangyi.com/favicon-96x96.png"
    }
  },
  "mainEntityOfPage": {
    "@type": "WebPage",
    "@id": "https://zengliangyi.com/blog/tanstack-query-data-flow/"
  }
}
{
  "@context": "https://schema.org",
  "@type": "BreadcrumbList",
  "itemListElement": [
    {
      "@type": "ListItem",
      "position": 1,
      "name": "首页",
      "item": "https://zengliangyi.com/"
    },
    {
      "@type": "ListItem",
      "position": 2,
      "name": "文章",
      "item": "https://zengliangyi.com/blog"
    },
    {
      "@type": "ListItem",
      "position": 3,
      "name": "TanStack Query 数据流：缓存键、失效与后台任务",
      "item": "https://zengliangyi.com/blog/tanstack-query-data-flow/"
    }
  ]
}
```
