首页 / 文章 / React 的 TanStack Query:缓存、重新获取数据与数据变更

React 的 TanStack Query:缓存、重新获取数据与数据变更

用 TanStack Query 替代 useEffect 中的 fetch 标准代码:查询键、staleTime、gcTime、mutations,以及何时不需要该库。

1840 词

异步服务器状态的缓存、刷新与更新方式——以及何时值得引入相关库。

许多 React 应用都采用相同的数据加载结构:一个 useEffect、一次 fetch 请求,以及几个用于处理待处理状态和错误界面的 useState 变量。这种模式在初步开发时是可行的,但也会导致重复请求、过时的界面以及大量复制粘贴的样板代码出现。

以下内容将逐一分析这些问题,并展示 TanStack Query 如何解决它们。所需的前提条件仅为 React 钩子函数和基础 TypeScript 知识。示例中使用的 DummyJSON 是一个无需 API 密钥的公共 API。

1. 基础模式

使用常规钩子函数编写的产品列表看起来是这样的。

import { useEffect, useState } from "react";
type Product = {
  id: number;
  title: string;
  price: number;
};
function ProductList() {
  const [products, setProducts] = useState<Product[]>([]);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);
  useEffect(() => {
    let cancelled = false;
    setIsLoading(true);
    fetch("https://dummyjson.com/products?limit=10")
      .then((res) => {
        if (!res.ok) throw new Error("Request failed");
        return res.json();
      })
      .then((data: { products: Product[] }) => {
        if (!cancelled) setProducts(data.products);
      })
      .catch((err: Error) => {
        if (!cancelled) setError(err.message);
      })
      .finally(() => {
        if (!cancelled) setIsLoading(false);
      });
    return () => {
      cancelled = true;
    };
  }, []);
  if (isLoading) return <p>Loading…</p>;
  if (error) return <p>{error}</p>;
  return (
    <ul>
      {products.map((product) => (
        <li key={product.id}>
          {product.title} - ${product.price}
        </li>
      ))}
    </ul>
  );
}

那个实现已经很谨慎了:它使用了cancelled标志,这样在组件卸载后即使响应缓慢也无法更新状态。很多实际项目却忽略了这一保护措施。

2. 该代码未处理的问题

获取数据本身的操作没有问题,问题出在相关处理流程上。

  • 没有缓存机制。离开该页面后再返回时,即使数据内容未变,网络请求仍会再次发起。
  • 没有请求去重功能。三个需要相同产品列表的组件会各自发送完全相同的请求。
  • 没有重试机制。一次连接中断就会导致界面显示错误,即便再次尝试就能成功。
  • 没有数据重新验证机制。一个标签页打开一小时,仍会持续显示一小时前的数据,直到有其他操作触发数据加载。
  • 竞态条件。当搜索词等输入内容快速变化时,旧的响应可能会覆盖新的响应。cancelled标志有助于处理卸载场景,但无法完全解决正在处理的请求重叠问题。
  • 重复的样板代码。每个数据加载组件中都存在相同的加载、错误处理和清理逻辑。
  • 每个问题都有已知的解决方案。手动实现这些解决方案意味着需要自行构建缓存层。

    3. 该库的设计理念

    可以将状态分为两类。

    客户端状态由用户界面控制:比如模态框是否打开、当前的表单字段、选定的主题等。这些状态只有在代码主动修改时才会改变。useState正好适用于处理这类需求。

    服务器状态是借来的。它存储在你无法控制的仓库中。其他用户可以更改它,而浏览器中的副本仅是快照而已。仅仅将这个快照保存在useState中,就相当于把临时视图当作权威数据。

    借来的数据需要一个专用的存储机制:用于存放副本、记录有效期,以及设定何时再次获取数据的规则。

    冰箱是个恰当的类比。牛奶放在家里,这样就不必为每杯咖啡都去商店购买,但它会有保质期,因此需要查看日期并在变质前补充。TanStack Query就是为API响应承担这一角色的工具。

    4. TanStack Query是什么

    TanStack Query用于管理浏览器应用中的异步远程状态。该项目采用MIT许可证,可免费使用,并广泛应用于各种React项目中。

    多年前编写的指南中仍使用React Query这一名称。该名称一直沿用至3版本。在为Vue、Svelte、Solid和Angular开发出适配器后,4版本将项目名称改为TanStack Query。在React项目中仍需安装@tanstack/react-query,当前最新版本为5。

    它不能替代fetch或axios,请求功能仍由你自己控制。该库负责处理与这个函数相关的定时、存储、数据新鲜度以及错误处理等问题。

    5. 设置

    只需两步即可开始使用。

    npm install @tanstack/react-query
    

    接着在根节点处将整个请求结构包裹起来:

    // main.tsx
    import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
    import App from "./App";
    
    const queryClient = new QueryClient();
    
    export default function Root() {
      return (
        <QueryClientProvider client={queryClient}>
          <App />
        </QueryClientProvider>
      );
    }
    

    QueryClient是缓存实例,而QueryClientProvider则将其暴露给所有子组件。

    6. 重新编写的同一组件

    import { useQuery } from "@tanstack/react-query";
    
    type Product = {
      id: number;
      title: string;
      price: number;
    };
    
    async function fetchProducts(): Promise<Product[]> {
    
      const res = await fetch("https://dummyjson.com/products?limit=10");
    
      if (!res.ok) throw new Error("Request failed");
      const data: { products: Product[] } = await res.json();
      return data.products;
    }
    
    function ProductList() {
    
      const { data, isPending, isError, error } = useQuery({
        queryKey: ["products"],
        queryFn: fetchProducts,
      });
    
      if (isPending) return <p>Loading…</p>;
      if (isError) return <p>{error.message}</p>;
      return (
        <ul>
          {data.map((product) => (
            <li key={product.id}>
              {product.title} - ${product.price}
            </li>
          ))}
        </ul>
      );
    }
    

    原本大约四十行的代码缩减到了十五行左右。data被直接定义为Product[],无需额外注释,因为其类型已由fetchProducts确定。在处理完isPending和isError这两种情况后,TypeScript会认为data已被定义,因此无需在map方法上使用可选链操作符,也不需要进行非空断言。

    7. 简化版本的优势

    与第2节中提到的不足相比,这些默认设置已经能够覆盖大多数常见场景:

    • Remount会立即显示缓存的数据,并在后台重新验证。
    • 相同的正在处理中的请求会被合并为单个请求。
    • 失败的请求会自动重试(默认尝试三次,并带有延迟机制)。
    • 过期的数据会在窗口获得焦点、网络重新连接或Remount时重新获取。
  • 只有某个键的最新响应会被写入缓存,从而降低竞态条件带来的问题。
  • 通过一个钩子功能替代了手动加载和错误状态处理。
  • 在重写的组件中并未对这些行为进行配置,它们属于库的默认设置。

    8. 三件值得了解的内容

    早期的许多困惑都源于以下三个概念。

    查询键

    queryKey 就是缓存地址。使用相同 ["products"] 的两个组件会共享同一个缓存条目及一次网络请求。

    经验法则:**查询函数所依赖的每一个值都必须出现在键中。**

    function ProductList({ category }: { category: string }) {
      const { data } = useQuery({
        queryKey: ["products", category],
        queryFn: () => fetchProductsByCategory(category),
      });
      // …
    }
    

    如果键中未包含 category,更改分类后仍可能显示之前分类的缓存列表。这种错误在初次使用该功能的人中极为常见。

    staleTime与gcTime

    这两个名称听起来相似,但含义不同。

    staleTime用于设置数据有效性窗口。在該窗口内,库会跳过网络请求操作。默认值为0,此时数据立即被视为过期:界面仍可显示缓存值,但任何触发事件都会启动后台刷新。当数据内容很少变化时,应提高该值:

    useQuery({
      queryKey: ["products"],
      queryFn: fetchProducts,
      staleTime: 5 * 60 * 1000, // fresh for five minutes
    });
    

    gcTime表示在最后一位订阅者取消连接后,未使用数据在内存中保留的时间。默认值为五分钟。当该时间到期后,相关数据将被删除,下次访问时需重新获取数据。

    简而言之:staleTime控制数据的重新获取,而gcTime则控制数据的内存清除。

    何时会重新获取数据

    默认情况下,过期的查询会在组件加载时、窗口重新获得焦点时以及网络重新连接时重新获取数据。每种行为都可以在客户端或针对单个查询进行关闭:

    useQuery({
      queryKey: ["products"],
      queryFn: fetchProducts,
      refetchOnWindowFocus: false,
    });
    

    首次看到“焦点恢复时重新获取数据”功能时,很多人会感到意外。通常正是由于启用了该功能,长期打开的标签页才能保持最新状态。

    9. 使用 useMutation 更改数据

    useQuery 用于读取数据,而 useMutation 用于写入数据。

    import { useMutation, useQueryClient } from "@tanstack/react-query";
    
    type NewProduct = {
      title: string;
      price: number;
    };
    
    function AddProductButton() {
    
      const queryClient = useQueryClient();
    
      const { mutate, isPending } = useMutation({
    
        mutationFn: async (product: NewProduct) => {
          const res = await fetch("https://dummyjson.com/products/add", {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify(product),
          });
    
          if (!res.ok) throw new Error("Could not add product");
          return res.json();
        },
    
        onSuccess: () => {
          queryClient.invalidateQueries({ queryKey: ["products"] });
        },
      });
    
      return (
        <button
          onClick={() => mutate({ title: "New product", price: 25 })}
          disabled={isPending}
        >
          {isPending ? "Saving…" : "Add product"}
        </button>
      );
    }
    

    关键所在是 invalidateQueries 这一行代码。它会将所有以 ["products"] 为前缀的缓存条目标记为过期,从而使已加载的观察者立即请求最新数据。无需手动修改本地数组,页面也不需要完全重新加载。

    10. 开发工具

    npm install @tanstack/react-query-devtools
    
    import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
    
    <QueryClientProvider client={queryClient}>
      <App />
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
    

    当前正在开发中,该面板会列出所有的查询键、状态、负载数据以及最后一次获取时间。通过点击查看各项数据在“最新”与“过期”状态之间的变化,比单纯阅读更能快速理解缓存机制。该组件会自动从生产版本中移除。

    常见错误

    • 未将变量包含在查询键中。如果查询函数会读取某个值,那么查询键就必须包含该值。
    • 将useQuery放在useEffect内部。该钩子在渲染时就已经会执行,无需再额外“触发”它。
    • 将该库用于纯客户端状态管理。表单字段和模态框相关状态应使用useState来管理。
    • 到处都设置staleTime: Infinity。这样会关闭重新验证功能,从而丧失该机制的大部分优势。
  • 将data复制到本地状态中。这样就会存在两个副本,而已渲染的部分则不再跟踪缓存。
  • 12. 何时不需要它

    如果应用在单个页面上仅访问一个接口,那么使用provider和hooks可能会显得过于繁琐。

    如果框架已经提供了数据层——比如Next.js的服务器组件或带有加载器的路由器——那么部分工作就已经完成。TanStack Query仍可用于处理交互式的客户端数据获取,但并非必需。

    对于永远不会离开浏览器的状态,应选择其他工具。

    13. 接下来该学什么

    日常开发工作已涵盖在上述概念中,更深入的主题可在其他地方学习:

    • 官方文档——参考资料与交互式演示
    • 分页列表与无限滚动列表——如需在滚动时加载更多页面,请使用 useInfiniteQuery
    • 等待其他操作完成的查询——通过设置 enabled 来控制后续请求,直到前置操作完成为止
    • 乐观式 UI——在数据变更响应返回之前就渲染预期的结果

    请牢记一个核心原则:远程数据应存储在缓存中,而非临时组件状态中。有了这一理念作为指导,其余的 API 设计也会显得合乎逻辑。