陌上人如玉
公子世无双

9.5 数据获取策略

9.5 数据获取策略

一句话:数据可以在「服务端(渲染前)」「客户端(交互后)」「构建时(静态)」三个位置获取——首选服务端(更快、更安全、利于 SEO、包更小),客户端只用于「必须交互后才发」或「频繁变化」的数据。

一、三个位置

位置 时机 优点 缺点
服务端(渲染前) 请求到达时 / 构建时 无瀑布、无闪烁、无客户端请求、安全(密钥不外泄)、利于 SEO 服务端成本、TTFB 稍慢、需处理缓存
客户端 组件挂载后 / 交互后 灵活(依赖交互)、可缓存复用、后端无压力 瀑布、骨架闪烁、暴露接口、SEO 不友好
构建时 构建阶段 最快最省(纯静态) 数据可能陈旧
⭐ 一个判断表:

① 数据是「页面首屏必须展示」的吗?
   ├─ 是 → 服务端获取(SSR/SSG/ISR)
   └─ 否 → 继续

② 数据是「用户交互后才需要」的吗?(搜索、筛选、下拉加载、Tab 切换)
   ├─ 是 → 客户端获取(TanStack Query)
   └─ 否 → 服务端获取

③ 数据「因人而异」且「必须实时」吗?
   ├─ 是 → 服务端获取(动态渲染,注意别用 ISR 的共享缓存)
   └─ 否 → ISR(静态 + 定期/按需再生,性价比最高)

二、服务端获取的优势

// ✅ Server Component 里直接 await(Next.js App Router)
export default async function PostsPage() {
  const posts = await db.post.findMany();       // ⭐ 可以直接访问数据库
  return <PostList posts={posts} />;
}
四个优势:

① 【没有请求瀑布】
   服务端可以「并行获取」「直接查库」,
   而客户端往往要「等 JS 加载 → 组件挂载 → 发请求 → 等响应」的串行链

② 【没有骨架闪烁】
   数据在「渲染 HTML 前」就绪 → 用户直接看到内容与服务端渲染

③ 【安全】
   接口地址、密钥、内部逻辑留在服务端 → 不暴露给客户端
   (尤其是「BFF 聚合层」:客户端只调自己的服务端,服务端再去调内部微服务)

④ 【包更小 / SEO 更好】
   服务端组件的代码不进客户端包;HTML 里已有内容 → 爬虫能读到
// ✅ 并行获取(避免瀑布)——这是服务端获取的关键优化
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;

  // ❌ 串行:总耗时 = a + b
  // const user = await getUser(id);
  // const posts = await getPosts(id);

  // ✅ 并行:总耗时 = max(a, b)
  const [user, posts] = await Promise.all([getUser(id), getPosts(id)]);

  return (
    <>
      <UserCard user={user} />
      <PostList posts={posts} />
    </>
  );
}
// ✅ 用 Suspense 让「慢的部分」不阻塞「快的部分」(流式渲染,见 9.6)
export default function Page() {
  return (
    <>
      <Header />
      <Suspense fallback={<PostsSkeleton />}>
        <SlowPosts />          {/* ⭐ 它自己 await(慢),但 shell 已经先发出去了 */}
      </Suspense>
    </>
  );
}

async function SlowPosts() {
  const posts = await getSlowPosts();
  return <PostList posts={posts} />;
}

三、客户端获取的场景

// ✅ 场景一:交互后才需要的数据(搜索、筛选、分页)
'use client';
import { useQuery } from '@tanstack/react-query';

function SearchResults({ keyword }: { keyword: string }) {
  const { data, isLoading } = useQuery({
    queryKey: ['search', keyword],
    queryFn: () => api.search(keyword),
    enabled: keyword.length > 0,               // ⭐ 有输入才请求
  });

  if (isLoading) return <Skeleton />;
  return <List items={data ?? []} />;
}
// ✅ 场景二:需要「实时/轮询」的数据
const { data } = useQuery({
  queryKey: ['notifications'],
  queryFn: fetchNotifications,
  refetchInterval: 30_000,                     // 轮询
});
// ✅ 场景三:需要「乐观更新/无限滚动/复杂缓存」的数据
const { data, fetchNextPage } = useInfiniteQuery({
  queryKey: ['feed', filters],
  queryFn: ({ pageParam }) => fetchFeed({ cursor: pageParam, ...filters }),
  initialPageParam: undefined as string | undefined,
  getNextPageParam: (last) => last.nextCursor ?? undefined,
});
// ✅ 场景四:数据「依赖客户端状态」(如用户本地的偏好、地图可视区域)
function MapPoints({ bounds }: { bounds: Bounds }) {
  const { data } = useQuery({
    queryKey: ['points', bounds],              // ⭐ bounds 变化 → 自动重新请求
    queryFn: () => fetchPoints(bounds),
    placeholderData: (prev) => prev,           // 避免闪白
  });
  return <Markers points={data ?? []} />;
}
场景 为什么用客户端
搜索/筛选/分页 依赖用户输入,无法在服务端预知
实时/轮询 需要持续更新(服务端渲染只给一次快照)
无限滚动 依赖滚动行为
依赖客户端状态 如地图可视区域、本地偏好
缓存复用 同一个数据在多个页面复用(Query 缓存)

四、避免「请求瀑布」

// ❌ 客户端瀑布:父组件拿到数据 → 子组件才知道要请求什么
function BadPage() {
  const { data: user } = useQuery({ queryKey: ['user'], queryFn: fetchUser });

  if (!user) return <Skeleton />;

  return <UserPosts userId={user.id} />;         {/* ⚠️ 要等 user 才有 userId */}
}

function UserPosts({ userId }: { userId: string }) {
  const { data } = useQuery({ queryKey: ['posts', userId], queryFn: () => fetchPosts(userId) });
  // ⚠️ 串行: getUser → 渲染 → getPosts
  return <List items={data ?? []} />;
}
// ✅ 修复一:在服务端「并行获取」后一起传下来
export default async function Page({ params }) {
  const { id } = await params;
  const [user, posts] = await Promise.all([getUser(id), getPosts(id)]);
  return (
    <>
      <UserCard user={user} />
      <PostList posts={posts} />
    </>
  );
}
// ✅ 修复二:客户端用「并行请求」(不依赖前一个结果)
function GoodPage({ userId }: { userId: string }) {
  const userQuery = useQuery({ queryKey: ['user', userId], queryFn: () => fetchUser(userId) });
  const postsQuery = useQuery({ queryKey: ['posts', userId], queryFn: () => fetchPosts(userId) });
  // ⭐ 两个请求【同时】发出(因为 userId 已知)
  return (
    <>
      {userQuery.data && <UserCard user={userQuery.data} />}
      {postsQuery.data && <PostList posts={postsQuery.data} />}
    </>
  );
}
// ✅ 修复三:组件级 Suspense(让「慢的部分」不阻塞「快的部分」)
<Suspense fallback={<Skeleton />}>
  <UserCard />                          {/* 各自独立流式 */}
</Suspense>
<Suspense fallback={<Skeleton />}>
  <PostList />
</Suspense>
⭐ 瀑布的三个层次:
① 【服务端内部】——串行 await → 用 Promise.all
② 【服务端 → 客户端】——服务端只取了一部分,客户端再补 → 尽量在服务端取全
③ 【客户端内部】——父组件拿到 id 后子组件才请求 → 提前并发布局,或用组件级 Suspense

五、缓存与重新验证

// Next.js 的三种缓存粒度
export const revalidate = 60;                       // ① 页面级

const data = await fetch(url, {
  next: { revalidate: 60, tags: ['posts'] },        // ② 数据级
});
const fresh = await fetch(url, { cache: 'no-store' });   // 不缓存(动态)

revalidatePath('/posts');                            // ③ 按需失效
revalidateTag('posts');
⭐ 「按需失效」比「定时失效」更好:
① 定时(revalidate: 60)——简单,但「内容刚更新时用户可能仍看到旧的」
② 按需(revalidatePath/Tag)——内容变更时立刻失效 → 用户看到最新的

✅ 实践组合:
   · 列表/详情:ISR + revalidateTag(在「创建/更新/删除」的 Server Action 里失效)
   · 频繁变化的数据:短 revalidate 或客户端获取
   · 必须实时:no-store(动态渲染)
// ✅ 在一个 Server Action 里「写数据 + 失效缓存」
'use server';
import { revalidateTag } from 'next/cache';

export async function updatePost(id: string, formData: FormData) {
  await db.post.update({ where: { id }, data: { title: formData.get('title') as string } });
  revalidateTag('posts');                    // ⭐ 让所有带 'posts' tag 的缓存失效
}

六、混合策略(最常用)

// ✅ 首屏数据服务端拿(快、SEO 好),交互数据客户端拿(灵活)
export default async function ProductPage({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  const product = await getProduct(id);        // ⭐ 首屏:服务端获取

  return (
    <>
      <ProductInfo product={product} />         {/* 服务端渲染 */}
      <Reviews productId={id} />                {/* 客户端组件:分页/排序/加载更多 */}
      <Recommendations productId={id} />        {/* 客户端组件:个性化推荐 */}
    </>
  );
}
// ✅ 更进一步:用「预取 + 缓存」让客户端组件也有首屏数据(避免闪骨架)
// 服务端预取到 Query 缓存
import { dehydrate, HydrationBoundary, QueryClient } from '@tanstack/react-query';

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

  await queryClient.prefetchQuery({
    queryKey: ['reviews', productId],
    queryFn: () => fetchReviews(productId),
  });

  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      <Reviews productId={productId} />        {/* ⭐ 客户端组件首屏就有数据(不闪骨架)*/}
    </HydrationBoundary>
  );
}
组合方式 说明
纯服务端 最简单,但交互数据要「页面刷新」才能更新
纯客户端 首屏闪骨架、SEO 差
服务端首屏 + 客户端交互 最常用
服务端预取进 Query 缓存 + 客户端组件 兼顾「首屏就绪」与「客户端缓存/交互」

七、常见坑

// ① 在服务端「串行 await」(应该用 Promise.all)
// ② 用「客户端 useEffect」获取「首屏必须有」的数据(多了瀑布 + 骷髅闪烁)
// ③ 在同一份数据上「服务端拿一次、客户端又拿一次」(重复请求)
// ✅ 用「预取 + dehydrate」或让客户端组件接收服务端数据
// ④ 在服务端渲染「个性化内容」却用了 ISR(共享缓存 → 串用户)
// ⑤ 忘记「按需失效」(提交后列表还是旧数据)
// ⑥ 把所有请求都设成 no-store(服务端压力大、失去静态化收益)
// ⑦ 客户端请求「接口太多、太碎」(应该用 BFF 聚合)
// ⑧ 在 Server Component 里「读 window/localStorage」(不存在)
// ⑨ 把「大依赖」引入客户端组件(包体积暴涨)
// ⑩ 在「根布局」读 cookies/headers(让整个应用动态化)
// ⑪ 客户端拿数据时「不做竞态处理」(见 7.2)
// ⑫ 用「客户端获取」渲染「SEO 关键内容」(爬虫可能读不到)
// ⑬ 服务端获取时「没做错误处理」(一个请求失败 → 整页 500)
//    ✅ 用 error.tsx / ErrorBoundary / 或者「捕获后渲染降级 UI」
// ⑭ 用「服务端获取」但没设置「超时」(慢接口拖住整页)
//    ✅ 用 AbortSignal.timeout() 或 Suspense 边界隔离
// ⑮ 把「敏感 token」从服务端传给客户端组件(会进 RSC payload / HTML)
// ⑯ 认为「服务端获取就一定更快」(服务端到数据库慢/串行时反而更慢)
// ⑬ 的写法:服务端获取要「优雅降级」
export default async function Page() {
  const [user, posts] = await Promise.allSettled([getUser(), getPosts()]);

  return (
    <>
      {user.status === 'fulfilled' ? <UserCard user={user.value} /> : <UserError />}
      {posts.status === 'fulfilled' ? <PostList posts={posts.value} /> : <PostsError />}
    </>
  );
}

八、面试延伸

  1. 「数据应该在服务端取还是客户端取?」
    判断三步:① 首屏必须展示 → 服务端(避免瀑布与骨架闪烁、利于 SEO);② 交互后才需要(搜索、筛选、分页、无限滚动、Tab 切换) → 客户端(依赖用户输入,服务端预知不了);③ 需要实时/轮询 → 客户端(服务端渲染只给一次快照)。实践上最常用的是「混合」:首屏数据服务端取,交互数据客户端取。

  2. 「服务端获取数据有什么优势?」
    四点:① 没有请求瀑布——服务端可以并行获取、直接访问数据库,而客户端往往要「等 JS → 组件挂载 → 发请求 → 等响应」;② 没有骨架闪烁——数据在渲染 HTML 前就绪,用户直接看到内容;③ 安全——接口地址、密钥、内部逻辑不暴露(尤其配合 BFF 聚合层);④ 包更小 + SEO 更好——服务端组件代码不进客户端 bundle,HTML 里已有内容供爬虫读取。

  3. 「什么是『请求瀑布』?怎么解决?」
    指「多个请求必须串行等待」(后一个请求依赖前一个的结果),导致总耗时是「各请求之和」而不是「最大值」。三个层次与解法:① 服务端内部——串行 await → 用 Promise.all 并行;② 服务端 → 客户端——服务端只取一部分、客户端再补 → 尽量在服务端取全,或用「预取 + dehydrate」把数据交给客户端缓存;③ 客户端内部——父组件拿到 id 后子组件才请求 → 提前并发布局(如果 id 已经知道就同时发)或组件级 Suspense(让慢的部分不阻塞快的)。

  4. 「怎么避免『同一份数据被取两次』(服务端一次 + 客户端一次)?」
    两种方式:① 把服务端数据作为 props 传给客户端组件(适用于「客户端组件只是展示」的场景);② 服务端预取进客户端缓存——用 queryClient.prefetchQuery 把数据放进 TanStack Query 的缓存,然后用 <HydrationBoundary state={dehydrate(queryClient)}> 包裹,客户端组件的 useQuery 会直接命中缓存(不重复请求)。第二种更优雅(客户端组件保持「自己取数据」的写法,但首屏不闪骨架)。

  5. 「Next.js 的缓存有哪几个层级?『按需失效』为什么更好?」
    三层:① 页面级(export const revalidate);② 数据级(fetch 的 next: { revalidate, tags } / cache);③ 按需失效(revalidatePath / revalidateTag)。按需失效更好是因为:定时失效(revalidate: 60)无法知道「内容什么时候变了」,所以在「内容刚更新后」用户仍可能看到旧数据(最多要等一个周期);而按需失效是在「写操作」的 Server Action 里主动调用,能让缓存立刻失效,用户马上看到新内容。

  6. 「什么时候不该把数据放服务端获取?」
    四种:① 依赖用户交互才产生的数据(搜索词、筛选条件、滚动加载的下一页)——服务端预知不了;② 需要实时更新/轮询的数据(通知、行情、在线状态)——服务端渲染只给「一次快照」;③ 服务端获取会成为瓶颈的数据(如「每个请求都要查一个慢接口」——会拖长 TTFB,此时更适合「静态外壳 + 客户端加载」或用 Suspense 隔离);④ 高度依赖客户端环境的数据(如地图可视区域内的点、localStorage 里的偏好)。

  7. 「怎么在服务端获取时保证『页面不会因为一个慢接口而卡住』?」
    四种手段:① Promise.all 并行(减少总耗时);② <Suspense> 边界隔离(让「慢的部分」流式后到,shell 先发出,见 9.6);③ 超时保护(AbortSignal.timeout(3000)——避免服务端被一个卡住的接口拖死);④ 优雅降级(Promise.allSettled + 局部错误 UI,让「一个数据失败」不导致整页 500)。此外可以考虑 loading.tsx 给整段路由一个加载态。

  8. 「客户端获取的数据怎么和 SSR 数据协同?」
    三种方式:① props 传递——服务端取到的数据作为 props 传给客户端组件(简单,但客户端不能「独立刷新」);② 预取 + dehydrate(推荐)——服务端 prefetchQuery 把数据放进 Query 缓存,客户端组件用 useQuery 读同一 queryKey(首屏命中缓存,之后可独立刷新/失效);③ 共享 queryKey 约定——让「服务端预取」与「客户端使用」用同一套 queryKey(配合 queryKeys 常量模块,避免写错)。核心是「让服务端与客户端共享同一个缓存标识」。

一句话速记

数据获取三个位置:服务端(渲染前,无瀑布/无闪烁/安全/SEO 好)、客户端(交互后,灵活但会闪骨架且 SEO 差)、构建时(最快最省但可能陈旧);首选服务端(首屏数据、需并行用 Promise.all、慢的用 Suspense 隔离),客户端只用于「交互后才需要/实时/依赖客户端状态/需缓存复用」;避免三层瀑布(服务端内并行 → 尽量在服务端取全 → 客户端并发布局或组件级 Suspense);缓存三粒度是「页面级 revalidate / 数据级 fetch 选项 / 按需 revalidatePath|Tag(更好,内容变更即失效)」;最佳组合是「服务端首屏 + 客户端交互」,并用「prefetchQuery + dehydrate + HydrationBoundary」让客户端组件首屏就有数据(共享 queryKey);别用 ISR 渲染个性化内容、别在根布局读 cookies/headers、服务端获取要做超时与降级。

赞(0) 打赏
未经允许不得转载:陌上寒 » 9.5 数据获取策略

评论 抢沙发

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

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

微信扫一扫

支付宝扫一扫