预取策略与 Suspense

TanStack Query 的预取策略:在用户实际需要数据之前提前获取,消除 loading 状态,提升感知性能。

#type / howto #status / evergreen #tech / dev / frontend #resource / react

[!info] related notes

预取策略与 Suspense

核心问题

用户点击列表项查看详情时,需要先加载数据,出现 loading 状态。如果能提前预取数据,用户点击时直接从缓存读取,体验会好很多。

预取方式

queryClient.prefetchQuery

const queryClient = useQueryClient();

// 预取用户详情
await queryClient.prefetchQuery({
  queryKey: ['user', userId],
  queryFn: () => fetchUser(userId),
  staleTime: 60_000,  // 预取的数据 1 分钟内不重新请求
});

prefetchQuery 的行为:

  • 如果缓存中已有新鲜数据 → 什么都不做
  • 如果缓存中没有或已 stale → 发请求并写入缓存
  • 返回 Promise,可以 await

Hover 预取

function UserList({ users }) {
  const queryClient = useQueryClient();

  return (
    <ul>
      {users.map(user => (
        <li
          key={user.id}
          // 鼠标悬停时预取详情
          onMouseEnter={() => {
            queryClient.prefetchQuery({
              queryKey: ['user', user.id],
              queryFn: () => fetchUser(user.id),
              staleTime: 60_000,
            });
          }}
        >
          <Link to={`/users/${user.id}`}>{user.name}</Link>
        </li>
      ))}
    </ul>
  );
}

路由级预取(React Router)

// 在路由配置中预取
const router = createBrowserRouter([
  {
    path: '/users/:id',
    element: <UserProfile />,
    loader: async ({ params }) => {
      // 路由 loader 中预取
      await queryClient.prefetchQuery({
        queryKey: ['user', params.id],
        queryFn: () => fetchUser(params.id),
      });
      return null;
    },
  },
]);

分页预取

function PaginatedList() {
  const [page, setPage] = useState(1);
  const queryClient = useQueryClient();

  const { data, isPlaceholderData } = useQuery({
    queryKey: ['items', page],
    queryFn: () => fetchItems(page),
    placeholderData: keepPreviousData,
  });

  // 预取下一页
  useEffect(() => {
    if (!isPlaceholderData) {
      queryClient.prefetchQuery({
        queryKey: ['items', page + 1],
        queryFn: () => fetchItems(page + 1),
      });
    }
  }, [page, isPlaceholderData, queryClient]);

  // ...
}

无限滚动预取

function InfiniteList() {
  const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
    queryKey: ['posts'],
    queryFn: ({ pageParam }) => fetchPosts({ cursor: pageParam }),
    initialPageParam: undefined,
    getNextPageParam: (lastPage) => lastPage.nextCursor,
  });

  // 当快到底部时预取下一页
  const lastPostIndex = data?.pages.flatMap(p => p.posts).length ?? 0;
  useEffect(() => {
    if (hasNextPage && lastPostIndex > 0) {
      // 最后 3 个元素内就开始预取
      queryClient.prefetchInfiniteQuery({
        queryKey: ['posts'],
        initialPageParam: undefined,
        queryFn: ({ pageParam }) => fetchPosts({ cursor: pageParam }),
      });
    }
  }, [lastPostIndex, hasNextPage]);

  // ...
}

预取配置

staleTime 控制预取有效期

// 预取时设置较长的 staleTime
queryClient.prefetchQuery({
  queryKey: ['user', id],
  queryFn: () => fetchUser(id),
  staleTime: 5 * 60 * 1000,  // 5 分钟内不重新请求
});

全局默认 staleTime

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 60_000,  // 全局默认 1 分钟
    },
  },
});

与 React Router Data Router 集成

import { createBrowserRouter, RouterProvider } from 'react-router-dom';

const queryClient = new QueryClient();

const router = createBrowserRouter([
  {
    path: '/',
    element: <Layout />,
    children: [
      {
        index: true,
        element: <Home />,
      },
      {
        path: 'users',
        element: <UserList />,
        // loader 自动在路由匹配时执行
        loader: async () => {
          await queryClient.ensureQueryData({
            queryKey: ['users'],
            queryFn: fetchUsers,
          });
          return null;
        },
      },
      {
        path: 'users/:id',
        element: <UserProfile />,
        loader: async ({ params }) => {
          await queryClient.ensureQueryData({
            queryKey: ['user', params.id],
            queryFn: () => fetchUser(params.id!),
          });
          return null;
        },
      },
    ],
  },
]);

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <RouterProvider router={router} />
    </QueryClientProvider>
  );
}

ensureQueryData vs prefetchQuery

// prefetchQuery:只预取,不等待
queryClient.prefetchQuery({ queryKey: ['user', id], queryFn: () => fetchUser(id) });

// ensureQueryData:确保有数据,返回数据
// 如果缓存新鲜 → 直接返回缓存
// 如果缓存 stale → 等待请求完成后返回
const data = await queryClient.ensureQueryData({
  queryKey: ['user', id],
  queryFn: () => fetchUser(id),
});
方法等待?用途
prefetchQuery后台预取,不等待hover 预取、分页预取
ensureQueryData等待数据就绪路由 loader、SSR

常见错误

预取但不消费

// ❌ 预取了但组件没用这个 queryKey
queryClient.prefetchQuery({ queryKey: ['user', id], queryFn: ... });
// 组件里用的是不同的 key
useQuery({ queryKey: ['userDetail', id], ... });  // 不会命中缓存

// ✅ key 必须一致
queryClient.prefetchQuery({ queryKey: ['user', id], queryFn: ... });
useQuery({ queryKey: ['user', id], ... });

预取时机太晚

// ❌ 点击时才预取,和不预取一样
onClick={() => {
  queryClient.prefetchQuery({ ... });
  navigate(`/users/${id}`);
}}

// ✅ hover 时预取
onMouseEnter={() => {
  queryClient.prefetchQuery({ ... });
}}

预取但 staleTime 为 0

// ❌ 预取后立即 stale,用到时又重新请求
queryClient.prefetchQuery({
  queryKey: ['user', id],
  queryFn: () => fetchUser(id),
  staleTime: 0,  // 默认值
});

// ✅ 设置合理的 staleTime
queryClient.prefetchQuery({
  queryKey: ['user', id],
  queryFn: () => fetchUser(id),
  staleTime: 60_000,
});
创建于 2026/7/3 更新于 2026/7/15