Skip to content

深入理解 TanStack Query(React Query)

基础使用、缓存策略、查询生命周期、乐观更新、Infinite Query、SSR 集成与最佳实践

什么是 TanStack Query?

定义:TanStack Query(前身 React Query)是专注于服务端状态管理的库,提供数据获取、缓存、同步、后台刷新、错误重试等能力。它将服务端数据视为"缓存"而非"状态",从根本上改变了 React 应用处理异步数据的方式。

涉及场景

  • API 数据获取:替代 useEffect + useState 的手动请求模式
  • 缓存与去重:多个组件请求相同数据时自动去重,只发一次请求
  • 后台刷新:用户重新聚焦页面时自动刷新数据
  • 分页与无限滚动:Infinite Query 支持无限加载
  • 乐观更新:用户操作后立即更新 UI,请求失败时回滚
  • SSR / RSC:服务端预取数据,客户端直接使用缓存

作用

  1. 消除手写请求逻辑:不再需要 loading/error/data 三件套
  2. 自动缓存管理:staleTime + gcTime 控制数据新鲜度和生命周期
  3. 2026 年事实标准:替代了 Redux 中管理 API 数据的场景
  4. 面试热点:缓存策略、查询失效、乐观更新是常见考题

一、基础使用

安装与配置

bash
npm install @tanstack/react-query @tanstack/react-query-devtools
jsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 60 * 1000,        // 数据 60 秒内视为新鲜
      gcTime: 5 * 60 * 1000,       // 未使用的缓存 5 分钟后回收
      retry: 3,                     // 失败重试 3 次
      refetchOnWindowFocus: true,   // 窗口聚焦时重新获取
    },
  },
});

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <MainApp />
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  );
}

useQuery —— 数据查询

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

function UserProfile({ userId }) {
  const {
    data,           // 查询数据
    isLoading,      // 首次加载中(无缓存)
    isFetching,     // 任何请求进行中(包括后台刷新)
    isError,        // 是否出错
    error,          // 错误对象
    isSuccess,      // 是否成功
    isStale,        // 数据是否过期
    refetch,        // 手动重新获取
  } = useQuery({
    queryKey: ['user', userId],     // 缓存 key(数组格式)
    queryFn: () => fetch(`/api/users/${userId}`).then(r => r.json()),
    staleTime: 5 * 60 * 1000,      // 5分钟内视为新鲜
    enabled: !!userId,              // 条件查询:userId 存在时才发请求
    placeholderData: (prev) => prev, // 切换 userId 时保留旧数据
    select: (data) => data.profile,  // 数据转换(只返回 profile 部分)
  });

  if (isLoading) return <Skeleton />;
  if (isError) return <ErrorMessage error={error} />;

  return <div>{data.name}</div>;
}

useMutation —— 数据变更

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

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

  const mutation = useMutation({
    mutationFn: (newPost) => fetch('/api/posts', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(newPost),
    }).then(r => r.json()),

    onSuccess: (data) => {
      // 方式1:使相关查询失效(重新获取)
      queryClient.invalidateQueries({ queryKey: ['posts'] });

      // 方式2:直接更新缓存
      queryClient.setQueryData(['posts'], (old) => [...old, data]);
    },

    onError: (error) => {
      toast.error(`创建失败: ${error.message}`);
    },
  });

  const handleSubmit = (formData) => {
    mutation.mutate({
      title: formData.get('title'),
      content: formData.get('content'),
    });
  };

  return (
    <form onSubmit={handleSubmit}>
      <input name="title" />
      <textarea name="content" />
      <button disabled={mutation.isPending}>
        {mutation.isPending ? '创建中...' : '创建文章'}
      </button>
      {mutation.isError && <p className="error">{mutation.error.message}</p>}
    </form>
  );
}

二、缓存策略

queryKey 设计

jsx
// queryKey 是缓存的唯一标识,数组中每个元素参与缓存 key 的计算
useQuery({ queryKey: ['todos'], queryFn: fetchTodos });
useQuery({ queryKey: ['todos', 'active'], queryFn: fetchActiveTodos });
useQuery({ queryKey: ['todos', { status: 'done', page: 1 }], queryFn: fetchTodos });
useQuery({ queryKey: ['user', userId], queryFn: () => fetchUser(userId) });

// 层级关系(用于批量失效)
// invalidateQueries({ queryKey: ['todos'] })
// → 会失效所有以 ['todos'] 开头的查询:
//   ['todos']
//   ['todos', 'active']
//   ['todos', { status: 'done', page: 1 }]

staleTime vs gcTime

staleTime(新鲜时间):
  数据在多长时间内被认为是"新鲜"的
  新鲜 → 不会后台重新获取
  过期 → 下次使用时触发后台重新获取

gcTime(垃圾回收时间,原 cacheTime):
  查询没有活跃观察者后,缓存在内存中保留多长时间
  超时后缓存被删除

时间线示例(staleTime=60s, gcTime=300s):
  0s:   组件 A 发起查询 → 请求 → 缓存数据
  30s:  组件 B 使用相同 key → 直接返回缓存(数据仍新鲜)
  60s:  数据过期(stale)
  90s:  组件 C 使用相同 key → 先返回缓存 → 后台重新获取 → 更新
  120s: 所有组件卸载(无观察者)
  420s: gcTime 到期 → 缓存删除
  421s: 新组件查询 → 无缓存 → isLoading=true → 重新请求

查询状态流转

                    ┌──────────┐
                    │  fresh   │ ← staleTime 内
                    └────┬─────┘
                         │ staleTime 过期
                    ┌────▼─────┐
              ┌────→│  stale   │ ← 下次使用时后台刷新
              │     └────┬─────┘
              │          │ 组件卸载(无观察者)
              │     ┌────▼─────┐
              │     │ inactive │ ← gcTime 倒计时
              │     └────┬─────┘
              │          │ gcTime 过期
              │     ┌────▼─────┐
              │     │ deleted  │ ← 缓存清除
              │     └──────────┘

              └── refetch / invalidate → 重新获取后回到 fresh

三、查询失效与重新获取

jsx
const queryClient = useQueryClient();

// 1. 失效所有查询
queryClient.invalidateQueries();

// 2. 失效特定查询(前缀匹配)
queryClient.invalidateQueries({ queryKey: ['todos'] });

// 3. 精确匹配
queryClient.invalidateQueries({ queryKey: ['todos', 1], exact: true });

// 4. 使用 predicate
queryClient.invalidateQueries({
  predicate: (query) => query.queryKey[0] === 'todos' && query.state.data?.length > 10,
});

// 5. 直接设置缓存数据
queryClient.setQueryData(['user', userId], (old) => ({
  ...old,
  name: newName,
}));

// 6. 预取数据(如鼠标 hover 时)
queryClient.prefetchQuery({
  queryKey: ['user', userId],
  queryFn: () => fetchUser(userId),
  staleTime: 60 * 1000,
});

四、乐观更新

jsx
const queryClient = useQueryClient();

const updateTodo = useMutation({
  mutationFn: (updatedTodo) =>
    fetch(`/api/todos/${updatedTodo.id}`, {
      method: 'PUT',
      body: JSON.stringify(updatedTodo),
    }),

  // 乐观更新:请求发出前立即更新 UI
  onMutate: async (updatedTodo) => {
    // 1. 取消正在进行的查询(防止覆盖乐观更新)
    await queryClient.cancelQueries({ queryKey: ['todos'] });

    // 2. 保存当前数据快照(用于回滚)
    const previousTodos = queryClient.getQueryData(['todos']);

    // 3. 乐观更新缓存
    queryClient.setQueryData(['todos'], (old) =>
      old.map(todo => todo.id === updatedTodo.id ? updatedTodo : todo)
    );

    // 4. 返回上下文(包含快照)
    return { previousTodos };
  },

  // 请求失败:回滚
  onError: (err, updatedTodo, context) => {
    queryClient.setQueryData(['todos'], context.previousTodos);
    toast.error('更新失败,已恢复');
  },

  // 请求完成(成功或失败):确保数据最终一致
  onSettled: () => {
    queryClient.invalidateQueries({ queryKey: ['todos'] });
  },
});

五、Infinite Query(无限滚动)

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

function InfinitePostList() {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
    isLoading,
  } = useInfiniteQuery({
    queryKey: ['posts'],
    queryFn: ({ pageParam }) =>
      fetch(`/api/posts?cursor=${pageParam}&limit=20`).then(r => r.json()),
    initialPageParam: 0,
    getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
    // getPreviousPageParam 用于双向滚动
  });

  // data.pages 是所有页面数据的数组
  const allPosts = data?.pages.flatMap(page => page.items) ?? [];

  return (
    <div>
      {isLoading && <Skeleton count={5} />}

      {allPosts.map(post => (
        <PostCard key={post.id} post={post} />
      ))}

      <button
        onClick={() => fetchNextPage()}
        disabled={!hasNextPage || isFetchingNextPage}
      >
        {isFetchingNextPage ? '加载中...'
          : hasNextPage ? '加载更多'
          : '没有更多了'}
      </button>
    </div>
  );
}

// 结合 Intersection Observer 实现自动加载
function useInfiniteScroll(ref, { hasNextPage, fetchNextPage, isFetchingNextPage }) {
  useEffect(() => {
    if (!ref.current) return;
    const observer = new IntersectionObserver(([entry]) => {
      if (entry.isIntersecting && hasNextPage && !isFetchingNextPage) {
        fetchNextPage();
      }
    });
    observer.observe(ref.current);
    return () => observer.disconnect();
  }, [ref, hasNextPage, fetchNextPage, isFetchingNextPage]);
}

六、SSR 集成

Next.js App Router(推荐方式)

jsx
// app/layout.tsx
import { QueryProvider } from './providers';

export default function RootLayout({ children }) {
  return <html><body><QueryProvider>{children}</QueryProvider></body></html>;
}

// app/providers.tsx
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useState } from 'react';

export function QueryProvider({ children }) {
  const [queryClient] = useState(() => new QueryClient({
    defaultOptions: {
      queries: { staleTime: 60 * 1000 },
    },
  }));

  return (
    <QueryClientProvider client={queryClient}>
      {children}
    </QueryClientProvider>
  );
}

// app/users/page.tsx(Server Component)
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query';

export default async function UsersPage() {
  const queryClient = new QueryClient();

  // 服务端预取
  await queryClient.prefetchQuery({
    queryKey: ['users'],
    queryFn: () => fetch('https://api.example.com/users').then(r => r.json()),
  });

  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      <UserList />  {/* Client Component,直接使用预取的缓存 */}
    </HydrationBoundary>
  );
}

// components/UserList.tsx
'use client';
function UserList() {
  const { data: users } = useQuery({
    queryKey: ['users'],
    queryFn: () => fetch('/api/users').then(r => r.json()),
    // 服务端已预取,客户端直接使用缓存,不会重新请求
  });

  return <ul>{users?.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
}

七、实用模式

依赖查询

jsx
// 先获取 user,再根据 user.teamId 获取 team
function UserTeam({ userId }) {
  const { data: user } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId),
  });

  const { data: team } = useQuery({
    queryKey: ['team', user?.teamId],
    queryFn: () => fetchTeam(user.teamId),
    enabled: !!user?.teamId, // user 加载完成且有 teamId 时才查询
  });
}

并行查询

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

function MultiUserProfile({ userIds }) {
  const results = useQueries({
    queries: userIds.map(id => ({
      queryKey: ['user', id],
      queryFn: () => fetchUser(id),
    })),
  });

  const isLoading = results.some(r => r.isLoading);
  const users = results.map(r => r.data).filter(Boolean);
}

轮询

jsx
useQuery({
  queryKey: ['notifications'],
  queryFn: fetchNotifications,
  refetchInterval: 30 * 1000, // 每 30 秒轮询
  refetchIntervalInBackground: false, // 页面不可见时停止
});

自定义 Hook 封装

jsx
// hooks/useUser.ts
export function useUser(userId: string) {
  return useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId),
    staleTime: 5 * 60 * 1000,
    select: (data) => ({
      ...data,
      displayName: `${data.firstName} ${data.lastName}`,
    }),
  });
}

// hooks/useUpdateUser.ts
export function useUpdateUser() {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: updateUser,
    onSuccess: (data, variables) => {
      queryClient.setQueryData(['user', variables.id], data);
      queryClient.invalidateQueries({ queryKey: ['users'] });
    },
  });
}

八、TanStack Query vs SWR

维度TanStack QuerySWR
功能完整度非常完整轻量精简
Mutation内置 useMutation需手动实现
乐观更新内置 onMutate/onError手动管理
Infinite Query内置内置(useSWRInfinite)
DevTools官方 DevTools社区方案
离线支持内置需插件
SSR完善(dehydrate/hydrate)支持
包体积~13KB~4KB
适合复杂应用简单场景

面试高频题

Q: staleTime 和 gcTime 的区别?

  • staleTime:数据被认为"新鲜"的持续时间。新鲜数据不会触发后台重新获取
  • gcTime:查询无活跃观察者(组件卸载)后,缓存在内存中保留的时间。超时后删除
  • 类比:staleTime 是"保质期",gcTime 是"超市下架时间"

Q: 为什么 TanStack Query 比 useEffect + useState 好?

  1. 自动缓存:相同 key 的请求自动去重和复用
  2. 后台刷新:数据过期后自动在后台更新
  3. 竞态处理:自动取消过时的请求(userId 快速切换时)
  4. 重试:请求失败自动重试
  5. DevTools:可视化查看所有查询的状态和缓存
  6. 减少样板:不需要手动管理 loading/error/data

Q: queryKey 的设计原则?

  1. 数组格式['entity', id, { filters }]
  2. 层级结构:便于批量失效(invalidateQueries(['posts']) 失效所有 posts 相关查询)
  3. 包含所有依赖:queryFn 中使用的参数必须出现在 queryKey 中
  4. 确定性:对象的属性顺序不影响(React Query 会深度比较)