陌上人如玉
公子世无双

7.4 服务端状态管理

7.4 服务端状态管理

一句话:服务端状态是「别人拥有的数据的本地快照」,它需要「缓存、去重、失效、重试、重新验证」——这些用 useEffect 手写会写出一堆样板,所以应该交给 TanStack Query / SWR。

一、为什么需要专门的方案

// ❌ 手写 useFetch:看起来能用,但要自己补齐 8 件事
function useUsers() {
  const [data, setData] = useState<User[]>([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<Error | null>(null);

  useEffect(() => {
    let cancelled = false;
    setLoading(true);

    fetchUsers()
      .then((d) => { if (!cancelled) { setData(d); setError(null); } })
      .catch((e) => { if (!cancelled) setError(e); })
      .finally(() => { if (!cancelled) setLoading(false); });

    return () => { cancelled = true; };
  }, []);

  return { data, loading, error };
}
这个手写版本缺失的能力:

① 缓存 —— 两个组件用同一个 hook → 发两次请求
② 去重 —— 同时挂载的多个组件都会各自请求
③ 失效 —— 数据变了怎么重新拉?手动 refetch?
④ 重试 —— 失败要不要重试?退避策略?
⑤ 窗口聚焦重验证 —— 用户切回标签页要不要刷新?
⑥ 分页/无限滚动 —— 多页数据怎么合并?
⑦ 乐观更新 —— 提交后立刻反映,失败回滚?
⑧ 内存回收 —— 无人订阅的缓存什么时候清?
⑨ 竞态处理 —— 见 7.2(要自己写 AbortController)
⑩ 全局状态 —— 在「非组件」的地方读写缓存
⭐ 核心结论:
   服务端状态的生命周期(缓存 → 新鲜 → 过期 → 失效 → 回收)
   与客户端状态完全不同。
   用「客户端状态工具」(useState/Redux)管理它,等于要自己重写一遍查询库。

二、TanStack Query 的核心概念

概念 含义
queryKey 查询的唯一标识(也是依赖声明:key 变 → 自动重新请求)
queryFn 返回 Promise 的取数函数(接收 { queryKey, signal, meta })
staleTime 数据「新鲜期」——期间内不会重新请求(默认 0,即「立即过期」)
gcTime 无订阅后缓存的保留时间(默认 5 分钟),超时被回收
isLoading / isFetching 首次加载 / 任意时刻正在请求(含后台刷新)
refetch 手动重新请求
invalidateQueries 让匹配的查询「失效并重新获取」
QueryClient 缓存的容器(用 QueryClientProvider 提供)
queryKey 的三种写法:
['todos']                       —— 静态 key
['todos', { status, page }]     —— 带参数的 key(参数变化自动重新请求)
['todos', id]                   —— 详情

⚠️ 规范:
① 把「影响结果的所有参数」放进 key
② 只用【原始值】(不要放「每次渲染都新建的对象」)
③ key 应该是【可序列化】且【稳定】的

三、useQuery 用法

import { useQuery } from '@tanstack/react-query';

function UserList({ page, keyword }: { page: number; keyword: string }) {
  const {
    data,
    isLoading,          // 首次加载(没有缓存数据)
    isFetching,         // 任意请求进行中(含后台刷新)
    isError,
    error,
    refetch,
    isPlaceholderData,
  } = useQuery({
    queryKey: ['users', { page, keyword }],       // ⭐ key 决定缓存与依赖
    queryFn: ({ signal }) => api.getUsers({ page, keyword }, { signal }),
    staleTime: 60_000,                             // 1 分钟内认为数据新鲜
    gcTime: 5 * 60_000,                            // 5 分钟无订阅后回收
    enabled: keyword.length > 0,                   // 条件执行(为 false 时不请求)
    placeholderData: (prev) => prev,               // ⭐ 参数变化时保留上一份数据(避免闪白)
    select: (data) => data.items,                   // 转换(带记忆化)
    retry: 2,                                       // 失败重试 2 次
  });

  if (isLoading) return <Skeleton />;
  if (isError) return <ErrorView error={error} onRetry={refetch} />;

  return (
    <div style={{ opacity: isPlaceholderData ? 0.6 : 1 }}>
      {isFetching && <TopProgressBar />}
      {data.length === 0 ? <EmptyState /> : <List items={data} />}
    </div>
  );
}
// ✅ 组件外预取(如路由 loader / 鼠标悬停)
import { useQueryClient } from '@tanstack/react-query';

function NavWithPrefetch() {
  const queryClient = useQueryClient();

  return (
    <Link
      to="/users"
      onMouseEnter={() =>
        queryClient.prefetchQuery({
          queryKey: ['users', { page: 1, keyword: '' }],
          queryFn: () => api.getUsers({ page: 1, keyword: '' }),
          staleTime: 60_000,
        })
      }
    >
      用户
    </Link>
  );
}

四、useMutation

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

function useCreateUser() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (data: CreateUserInput) => api.createUser(data),

    // ① 乐观更新(见 7.6)
    onMutate: async (newUser) => {
      await queryClient.cancelQueries({ queryKey: ['users'] });          // 取消进行中的查询
      const previous = queryClient.getQueryData<User[]>(['users']);
      queryClient.setQueryData<User[]>(['users'], (old = []) => [...old, { ...newUser, id: 'temp' } as User]);
      return { previous };                                               // 传给 onError
    },
    onError: (_err, _vars, context) => {
      queryClient.setQueryData(['users'], context?.previous);             // ⭐ 回滚
      toast.error('创建失败');
    },
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: ['users'] });             // ⭐ 无论成败都重新拉取
    },
  });
}

// 使用
function CreateUserForm() {
  const { mutate, isPending } = useCreateUser();

  const handleSubmit = (data: CreateUserInput) => {
    mutate(data, {
      onSuccess: () => navigate('/users'),
    });
  };

  return <Form onSubmit={handleSubmit} loading={isPending} />;
}
回调 时机 典型用途
onMutate 请求发出前 乐观更新(保存旧数据供回滚)
onError 失败 回滚 + 提示
onSuccess 成功 提示、跳转
onSettled 无论成败 失效查询(最常用)

五、QueryClient 与 Provider

// main.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 60_000,                      // 全局默认:1 分钟新鲜期
      gcTime: 5 * 60_000,
      retry: (failureCount, error) => {
        // ⭐ 只重试网络错误与 5xx
        const status = (error as { status?: number }).status;
        if (status && status >= 400 && status < 500) return false;
        return failureCount < 2;
      },
      refetchOnWindowFocus: true,             // 切回标签页时重新验证
    },
  },
});

createRoot(root).render(
  <QueryClientProvider client={queryClient}>
    <App />
    <ReactQueryDevtools />                    {/* 开发环境调试面板 */}
  </QueryClientProvider>
);
// ✅ SSR 场景下要「每个请求一个 QueryClient」(避免请求间串数据)
// Next.js App Router 的常见写法
function Providers({ children }: { children: React.ReactNode }) {
  const [queryClient] = useState(() => new QueryClient({
    defaultOptions: { queries: { staleTime: 60_000 } },
  }));

  return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}
// ⚠️ 不要定义成「模块级单例」(会在多个用户/请求间共享缓存)

六、与 SWR 的对比

维度 TanStack Query SWR
定位 完整的「服务端状态管理」 轻量的「数据请求 Hook」(Vercel)
功能 更全(mutation、无限查询、乐观更新、离线支持) 更简洁、体积更小
DevTools ✅ 完整 基础
无限滚动 useInfiniteQuery useSWRInfinite
生态 框架无关(React/Vue/Solid/Svelte) 主要 React
学习成本 中 低
适合 中大型项目、复杂缓存需求 简单场景、Next.js 项目
// SWR 的写法(更简洁)
import useSWR from 'swr';

function UserList() {
  const { data, error, isLoading, mutate } = useSWR('/api/users', fetcher, {
    revalidateOnFocus: true,
  });

  if (isLoading) return <Skeleton />;
  if (error) return <ErrorView error={error} onRetry={() => mutate()} />;
  return <List items={data} />;
}

七、与 useEffect 手写的对比

能力 手写 useEffect TanStack Query
加载/错误状态 手动 ✅ 内置
缓存(跨组件共享) ❌ ✅ 按 queryKey
请求去重 ❌ ✅ 自动
竞态处理 手动(AbortController) ✅ 内置(丢弃过期结果)
失效与重新验证 手动 ✅ invalidateQueries + 聚焦/重连
重试与退避 手动 ✅ retry + retryDelay
分页/无限滚动 手动合并 ✅ useInfiniteQuery
乐观更新 手动 ✅ onMutate
内存回收 ❌ ✅ gcTime
组件外读写 ❌ ✅ queryClient
代码量 多 少
// ✅ 从手写迁移到 Query 的对照
// 手写:
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
useEffect(() => { /* 请求 + 竞态处理 + 状态管理 */ }, [id]);

// Query:
const { data, isLoading } = useQuery({
  queryKey: ['user', id],
  queryFn: () => fetchUser(id),
});
// ⭐ 竞态、缓存、去重、重试、内存回收全部由库处理

八、常见坑

// ① queryKey 没包含「影响结果的参数」→ 参数变了但数据不更新
useQuery({ queryKey: ['user'], queryFn: () => fetchUser(id) });     // ❌ id 没进 key
useQuery({ queryKey: ['user', id], queryFn: () => fetchUser(id) }); // ✅

// ② queryKey 里有「每次渲染新建的对象」
useQuery({ queryKey: ['user', { filters: { a: 1 } }] });            // ❌ 每次都变
// ✅ 只放原始值

// ③ 把 `staleTime` 当成「缓存时间」
// → staleTime 是「新鲜期」;缓存回收由 gcTime 控制

// ④ 在 queryFn 里用「闭包捕获的外部变量」而没放进 key
// ⑤ 用 Redux/Zustand 管服务端数据(等于重写查询库)
// ⑥ mutation 后忘记 invalidate → 列表不更新
// ⑦ 在 onSuccess 里手动 setQueryData(不如 invalidate 简单可靠)
// ⑧ 把 QueryClient 定义成「模块级单例」并在 SSR 里共用(串数据)
// ⑨ queryFn 返回的不是 Promise(如忘记 return)
useQuery({ queryKey: ['x'], queryFn: () => { api.get() } });        // ❌ 少 return
// ⑩ 忘记 `enabled`(依赖参数但参数还为空时就发请求)
// ⑪ 分页用 useQuery 而不是 useInfiniteQuery(自己合并缓存很麻烦)
// ⑫ 在「非 React 环境」里直接用 hook(应该用 queryClient 的 API)
// ⑬ 认为「Query 能替代全局状态」(客户端状态仍需 Zustand/Redux)
// ⑭ 忘记配置 `retry` 的「不重试 4xx」(白白重试参数错误)
// ⑮ 对「实时性要求高」的数据用默认 staleTime(应该设 0 或加轮询)
// ⑯ 在测试里忘记包 QueryClientProvider(报错)
// ⑰ 把「大对象」存进缓存但不设 gcTime(内存增长)
// ⑱ 忽略 `isFetching` 与 `isLoading` 的区别(用错导致闪烁)
// ⑨ 的正确写法
useQuery({
  queryKey: ['users'],
  queryFn: () => api.getUsers(),            // ✅ 返回 Promise(箭头函数直接返回)
});

// ⑩ 的写法
useQuery({
  queryKey: ['user', id],
  queryFn: () => fetchUser(id),
  enabled: !!id,                             // ⭐ id 存在才请求
});

九、面试延伸

  1. 「什么是服务端状态?为什么需要专门的管理方案?」
    服务端状态指「服务端拥有的数据在本地的快照」(列表、详情、统计),特点是「可能随时过期」(被别的用户/设备/后台改掉)。它需要的能力与客户端状态完全不同:缓存(跨组件共享)、去重(同时请求只发一次)、失效与重新验证(什么时候重新拉)、重试与退避、分页缓存合并、乐观更新与回滚、内存回收。这些用 useEffect 手写会写一堆样板且容易漏,所以应该交给 TanStack Query / SWR。

  2. 「queryKey 的作用是什么?」
    三个:① 缓存标识——相同 key 共享同一份缓存(多组件不会重复请求);② 依赖声明——key 里任意值变化就自动重新请求(这是「参数变化自动更新」的机制);③ 缓存定位——setQueryData / invalidateQueries / getQueryData 都靠 key 找到目标。所以规范是「把影响结果的所有参数放进 key,且只放原始值」(不要放「每次渲染新建的对象」),否则会出现「参数变了数据没更新」或「每次都重新请求」。

  3. 「staleTime 和 gcTime 有什么区别?」
    staleTime 是「新鲜期」——数据在新鲜期内不会因为「组件挂载/重新挂载/窗口聚焦」而重新请求(直接用缓存)。gcTime 是「回收时间」——当某个查询没有任何组件订阅时,缓存还能保留多久(超过就被删除,下次请求重新开始)。所以:staleTime 短 → 数据更实时但请求更多;gcTime 短 → 内存占用低但「返回页面要重新请求」。默认 staleTime = 0(立即过期)、gcTime = 5 分钟。

  4. 「isLoading 和 isFetching 有什么区别?」
    isLoading 表示「首次加载」——即「没有缓存数据 + 正在请求」(只有这一种情况为 true);isFetching 表示「任意时刻正在请求」——包括「后台刷新」「参数变化后的重新请求」「手动 refetch」等。所以:isLoading 用来决定「是否显示骨架屏」(首次才会没数据);isFetching 用来显示「顶部进度条/半透明」这类轻提示(因为旧数据还在,不该清空界面)。

  5. 「mutation 的四个回调分别在什么时候执行?」
    onMutate——请求发出前执行,用于「乐观更新」(先改本地缓存,并返回旧数据供回滚);onError——请求失败时执行,用于「回滚 + 提示」(接收 onMutate 的返回值);onSuccess——请求成功时执行,用于「提示/跳转」;onSettled——无论成功失败都执行,最常用于「invalidateQueries(失效并重新拉取)」。实践中的标准组合是「onMutate 乐观更新 → onError 回滚 → onSettled 失效」。

  6. 「Query 能替代 Redux 吗?」
    部分可以(服务端数据部分),但不能完全替代。Query 管的是「服务端状态」;客户端状态(主题、UI 状态、购物车草稿、多步表单)仍然需要 useState / Context / Zustand / Redux。而且 Query 的缓存是「按 queryKey 组织的」,不适合表达「复杂的客户端业务逻辑」(如「编辑器状态机」「撤销重做」)。所以正确的划分是「服务端状态用 Query,客户端状态用 store」,两者互补(见 3.10)。

  7. 「怎么在路由切换时预取数据?」
    三种方式:① queryClient.prefetchQuery——在 onMouseEnter(悬停链接)或路由 loader 里调用,提前把数据放进缓存,目标页面渲染时直接命中;② 数据路由的 loader——loader: () => queryClient.ensureQueryData({ queryKey, queryFn })(既有「渲染前就绪」又有 Query 缓存,见 6.9);③ <Suspense> + useSuspenseQuery——让「数据未就绪」时显示 fallback(而不是组件内部判断 loading)。

  8. 「什么数据不适合用 Query?」
    ① 客户端状态(UI 状态、偏好、编辑器内容)——它不是「服务端数据的快照」,用 store;② 实时性极高且变化频繁的数据(如 WebSocket 推流、协同编辑的字符)——Query 的「请求-缓存」模型不适用,应该用「订阅 + 本地状态」;③ 一次性、不需要缓存的请求(如埋点上报、日志)——直接调用请求层即可;④ 需要复杂本地变换的数据(多步编辑后再保存)——应该用「草稿状态 + mutation 提交」。

一句话速记

服务端状态是「别人数据的本地快照」(随时可能过期),需要缓存/去重/失效/重试/重新验证/分页/乐观更新/GC → 用 TanStack Query / SWR(不要手写 useEffect,也不要塞进 Redux);queryKey 是「缓存标识 + 依赖声明」(参数必须进 key、只放原始值);staleTime 管「新鲜期」、gcTime 管「无订阅后保留多久」,默认 staleTime = 0;isLoading 是首次加载(决定骨架)、isFetching 是任意请求中(决定轻提示);useMutation 的标准组合是「onMutate 乐观更新 → onError 回滚 → onSettled 失效」;客户端状态仍用 store(两者互补);预取用 prefetchQuery / loader 里 ensureQueryData;SSR 下 每个请求一个 QueryClient(别用模块级单例)。

赞(0) 打赏
未经允许不得转载:陌上寒 » 7.4 服务端状态管理

评论 抢沙发

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

微信扫一扫

支付宝扫一扫