React × Vue

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.4px

Query Cache 决定立即使用缓存还是发起请求;Mutation 通过失效查询触发重新同步。

React 流程图
React 组件关系与数据流

滚轮缩放 · 按住拖动 · 双击重置 · Esc 关闭

完整 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 请求。

Astro API · Netlify Function首次加载
模拟接口失败

最近更新:尚未获取

Mutation:新增学习资料

React

Mutation 成功后调用 invalidateQueries,所有分类缓存都会重新同步。

观察重点

  • isPending 表示还没有可展示的数据;isFetching 表示查询函数正在运行,也包含后台更新。
  • 所有查询参数都必须进入 queryKey,否则不同筛选条件会错误共享缓存。
  • queryFn 接收 AbortSignal,过期请求可以被及时取消。
  • Mutation 成功后让查询失效,由资源自己的查询逻辑负责重新同步。
  • API Route 设置 prerender = false;其他 Fumadocs 页面仍然静态生成。

Demo 存储边界

示例数据只保存在 Function 进程内存中,冷启动或切换实例后可能恢复。正式数据必须使用持久化存储。

组件与外部系统同步的基础见useEffect 与 watch。