TanStack Query 服务端状态与缓存
使用 TanStack Query 管理异步服务端状态,并通过 Astro API Endpoint 处理真实的查询、错误和 Mutation 请求。
核心结论
服务端状态拥有缓存、新鲜度、并发请求和重新同步等生命周期,不适合只用 useEffect + useState 手写。TanStack Query 用 queryKey 标识资源,用 staleTime 表达何时需要重新获取。
查询与 Mutation 流程
组件关系与数据流
Query CacheUIQuery / MutationServer Endpoint
缓存焦点浏览器发出真实 HTTP 请求;Netlify 只运行 API Function,其他文档页面仍然静态生成。
实线表示查询和响应路径,虚线表示 Mutation 与缓存失效路径。
flowchart TB
accTitle: TanStack Query 查询缓存与 Mutation 重新同步流程
accDescr: 组件用 queryKey 查询缓存,命中新鲜数据时直接渲染;未命中或缓存失效时通过 fetch 请求 Astro API Endpoint,并由 Netlify Function 返回响应。Mutation 成功后让列表查询失效,从而触发后台重新获取。
ui["筛选与列表 UI"] --> query{{"useQuery + queryKey"}}
query --> cache(["Query Cache"])
cache -->|"新鲜缓存"| ui
cache -->|"未命中 / stale"| fetch{{"queryFn → fetch"}}
fetch --> api[("Astro API / Netlify Function")]
api -->|"HTTP Response"| cache
form["新增资料表单"] -.-> mutation{{"useMutation"}}
mutation -.->|"POST"| api
mutation -.->|"onSuccess"| invalidate{{"invalidateQueries"}}
invalidate -.->|"标记 stale"| cache
class cache state
class ui,form component
class query,fetch,mutation,invalidate process
class api resource
classDef state fill:#fffdf8,stroke:#149eca,color:#25221d,stroke-width:1.2px
classDef component fill:#e4f5fa,stroke:#149eca,color:#25221d,stroke-width:2px,font-weight:600
classDef process fill:#f4fafb,stroke:#149eca,color:#25221d,stroke-width:1.6px
classDef resource fill:#eee9df,stroke:#817a70,color:#25221d,stroke-width:1.4pxQuery Cache 决定立即使用缓存还是发起请求;Mutation 通过失效查询触发重新同步。
完整 Demo 源码
以下代码直接读取在线 Demo、API Endpoint 与本地数据层,没有省略或改写。
React 与 API 源码
import { useState } from "react";import type { SubmitEvent } from "react";import { QueryClient, QueryClientProvider, useMutation, useQuery, useQueryClient,} from "@tanstack/react-query";import { Alert, Button, Empty, Input, Segmented, Select, Skeleton, Switch, Tag,} from "antd";import { SiteAntdProvider } from "@/components/SiteAntdProvider";import { createResource } from "./api";import { resourceKeys, resourceListOptions } from "./queryOptions";import type { LearningTopic, TopicFilter,} from "@/lib/learning-resources/types";import "./QueryDemo.css";const topicOptions = [ { label: "全部", value: "all" }, { label: "React", value: "react" }, { label: "Vue", value: "vue" },];const createTopicOptions = [ { label: "React", value: "react" }, { label: "Vue", value: "vue" },];const timeFormatter = new Intl.DateTimeFormat("zh-CN", { hour: "2-digit", minute: "2-digit", second: "2-digit",});function createQueryClient() { return new QueryClient({ defaultOptions: { queries: { refetchOnWindowFocus: false, }, }, });}function ResourcePanel() { const queryClient = useQueryClient(); const [topic, setTopic] = useState<TopicFilter>("all"); const [simulateError, setSimulateError] = useState(false); const [title, setTitle] = useState(""); const [createTopic, setCreateTopic] = useState<LearningTopic>("react"); const query = useQuery(resourceListOptions(topic, simulateError)); const mutation = useMutation({ mutationFn: createResource, onSuccess: async () => { setTitle(""); await queryClient.invalidateQueries({ queryKey: resourceKeys.lists() }); }, }); function changeTopic(value: string | number) { setTopic(value as TopicFilter); } function submit(event: SubmitEvent<HTMLFormElement>) { event.preventDefault(); const normalizedTitle = title.trim(); if (!normalizedTitle) return; mutation.mutate({ title: normalizedTitle, topic: createTopic }); } function refreshCurrentList() { void queryClient.invalidateQueries({ queryKey: resourceKeys.list(topic, simulateError), }); } const updatedAt = query.dataUpdatedAt ? timeFormatter.format(query.dataUpdatedAt) : "尚未获取"; return ( <div className="query-demo not-content"> <div className="query-demo__toolbar"> <Segmented className="query-demo__topic-filter" options={topicOptions} value={topic} onChange={changeTopic} /> <div className="query-demo__status"> <Tag color="cyan">Astro API · Netlify Function</Tag> <Tag color={ query.isError ? "error" : query.isFetching ? "processing" : "success" } > {query.isError ? "请求失败" : query.isPending ? "首次加载" : query.isFetching ? "后台更新" : query.isStale ? "缓存已过期" : "缓存新鲜"} </Tag> </div> </div> <div className="query-demo__status"> <Button size="small" onClick={refreshCurrentList}> 使当前缓存失效 </Button> <span>模拟接口失败</span> <Switch size="small" checked={simulateError} onChange={setSimulateError} /> <p className="query-demo__status-text" aria-live="polite"> 最近更新:{updatedAt} </p> </div> <div className="query-demo__panel" aria-busy={query.isFetching}> {query.isPending ? ( <Skeleton active paragraph={{ rows: 4 }} /> ) : query.isError ? ( <Alert type="error" showIcon title="学习资料加载失败" description={query.error.message} action={ <Button size="small" onClick={() => setSimulateError(false)}> 恢复接口 </Button> } /> ) : query.data.length === 0 ? ( <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无学习资料" /> ) : ( <ul className="query-demo__list"> {query.data.map((resource) => ( <li key={resource.id}> <div> <strong>{resource.title}</strong> <span>预计学习 {resource.minutes} 分钟</span> </div> <Tag color={resource.topic === "react" ? "blue" : "green"}> {resource.topic === "react" ? "React" : "Vue"} </Tag> </li> ))} </ul> )} </div> <section className="query-demo__create" aria-labelledby="create-title"> <h3 id="create-title">Mutation:新增学习资料</h3> <form className="query-demo__create-form" onSubmit={submit}> <Input className="query-demo__title-input" value={title} onChange={(event) => setTitle(event.target.value)} placeholder="输入资料标题" aria-label="资料标题" /> <Select className="query-demo__topic-select" value={createTopic} options={createTopicOptions} aria-label="资料所属方向" onChange={setCreateTopic} /> <Button type="primary" htmlType="submit" loading={mutation.isPending} disabled={!title.trim()} > 新增并重新同步 </Button> </form> {mutation.isError ? ( <Alert type="error" showIcon title={mutation.error.message} /> ) : mutation.isSuccess ? ( <Alert type="success" showIcon title="新增成功,相关查询已失效并重新获取" /> ) : ( <p className="query-demo__status-text"> Mutation 成功后调用 invalidateQueries,所有分类缓存都会重新同步。 </p> )} </section> </div> );}export function QueryDemo() { const [queryClient] = useState(createQueryClient); return ( <SiteAntdProvider> <QueryClientProvider client={queryClient}> <ResourcePanel /> </QueryClientProvider> </SiteAntdProvider> );}在线 Demo
切换主题过滤、使缓存失效、模拟接口错误或新增资料。浏览器会向真实 Astro API Endpoint 发出 HTTP 请求。
模拟接口失败
最近更新:尚未获取
Mutation:新增学习资料
Mutation 成功后调用 invalidateQueries,所有分类缓存都会重新同步。
观察重点
isPending表示还没有可展示的数据;isFetching表示查询函数正在运行,也包含后台更新。- 所有查询参数都必须进入
queryKey,否则不同筛选条件会错误共享缓存。 queryFn接收AbortSignal,过期请求可以被及时取消。- Mutation 成功后让查询失效,由资源自己的查询逻辑负责重新同步。
- API Route 设置
prerender = false;其他 Fumadocs 页面仍然静态生成。
Demo 存储边界
示例数据只保存在 Function 进程内存中,冷启动或切换实例后可能恢复。正式数据必须使用持久化存储。
组件与外部系统同步的基础见useEffect 与 watch。