前端知库

为什么要用 TanStack Query 或 SWR 管理服务端状态?

服务端数据的真实来源在服务器,前端拿到的只是某一时刻的缓存副本,会过期、会被别人修改。TanStack Query 和 SWR 按 key 缓存请求结果,负责去重、过期判断、重新获取、重试和失效后刷新,组件只声明需要哪份数据。它们不适合用来存放纯客户端的界面状态。

参考答案

简答

  • 服务端状态与界面状态不同:数据由服务器持有,前端副本可能已经过期,需要决定何时重新获取,以及修改后哪些缓存要失效。
  • 两个库都按 key 缓存:相同 key 的组件共享同一份数据和同一个进行中的请求,key 变化时自动请求新数据。
  • TanStack Query 默认 staleTime 为 0,数据一到手就视为过期;组件挂载、窗口重新聚焦、网络恢复时会在后台重新获取。没有组件使用的缓存 5 分钟后回收,失败的查询默认重试 3 次。
  • SWR 按"先返回缓存,再重新验证"工作,窗口聚焦、网络恢复时可以重新验证,短时间内相同 key 的请求会去重。key 传 null 时不发请求,用于条件请求。
  • 修改数据后要让相关缓存失效:TanStack Query 用 queryClient.invalidateQueries,SWR 用 mutate。

手写的问题

用 useEffect 加 useState 请求数据时,每个组件各自请求,多处使用同一接口会重复请求;切换参数时旧响应可能晚到并覆盖新数据;回到页面时数据不会刷新;修改之后要手动通知所有使用方。把这些逻辑放进全局 store 也只是换了地方写。

TanStack Query

JSX
import { keepPreviousData, useQuery, useMutation, useQueryClient } from '@tanstack/react-query';

function useTodos(filter) {
  return useQuery({
    queryKey: ['todos', filter],
    queryFn: ({ signal }) =>
      fetch(`/api/todos?filter=${encodeURIComponent(filter)}`, { signal }).then((res) => {
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        return res.json();
      }),
    staleTime: 30_000, // 30 秒内视为新鲜,不因挂载或聚焦重新请求
    placeholderData: keepPreviousData, // 切换 filter 时先显示上一份结果,避免闪回加载态
  });
}

function useAddTodo() {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: async (todo) => {
      const res = await fetch('/api/todos', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(todo),
      });
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      return res.json();
    },
    // 前缀匹配:所有以 ['todos'] 开头的查询都会失效并重新获取
    onSuccess: () => queryClient.invalidateQueries({ queryKey: ['todos'] }),
  });
}

queryKey 要包含 queryFn 用到的所有参数,否则参数变化时会命中旧缓存。fetch 遇到 4xx/5xx 不会拒绝,要自己抛错,查询才会进入错误状态并触发重试。返回结果默认做结构共享,内容没变时保持原引用。

一个查询依赖另一个查询的结果时,用 enabled 控制何时执行:

JSX
const { data: user } = useQuery({ queryKey: ['user', email], queryFn: getUserByEmail });
const userId = user?.id;
const { data: projects } = useQuery({
  queryKey: ['projects', userId],
  queryFn: () => getProjectsByUser(userId),
  enabled: !!userId, // userId 就绪前不请求,状态为 pending、fetchStatus 为 idle
});

placeholderData: keepPreviousData 让新 key 的数据到达前继续显示上一次成功的结果,isPlaceholderData 可以区分当前是不是占位数据,适合分页和筛选切换。

SWR

JSX
import useSWR from 'swr';

const fetcher = async (url) => {
  const res = await fetch(url);
  if (!res.ok) throw new Error(`HTTP ${res.status}`); // 抛错后 SWR 才会进入 error 状态
  return res.json();
};

function Profile({ userId }) {
  // userId 为空时 key 为 null,不发请求
  const { data, error, isLoading, mutate } = useSWR(userId ? `/api/users/${userId}` : null, fetcher);
  // ...修改成功后调用 mutate() 重新验证这一个 key
}

useSWR 返回的 mutate 已绑定当前 key;要更新其他 key,用 useSWRConfig() 拿到全局 mutate 并传入 key。revalidateOnFocus、revalidateOnReconnect、dedupingInterval 等选项控制重新验证和去重,默认值以 SWR 官方 API 文档为准。

和全局 store 的分工

服务端数据交给查询库,弹窗开关、表单草稿、主题这类只存在于客户端的状态仍放在组件状态或 store 中,选择方法见《Zustand、Jotai 和 Redux Toolkit 应该怎样选择?》。

常见追问

  • staleTime 和 gcTime 有什么区别?staleTime 决定数据多久后需要重新获取,gcTime 决定没有组件使用的缓存多久后从内存删除。
  • 怎样做乐观更新?在修改前写入预期结果,失败时回滚到之前的快照,成功或失败后都让缓存失效,以服务器数据为准。
  • 快速切换参数会不会出现旧响应覆盖?查询库按 key 存放结果,旧 key 的响应写入旧 key 的缓存,不会显示到新 key 上。