
一句话:数据可以在「服务端(渲染前)」「客户端(交互后)」「构建时(静态)」三个位置获取——首选服务端(更快、更安全、利于 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 />}
</>
);
}
八、面试延伸
- 「数据应该在服务端取还是客户端取?」
判断三步:① 首屏必须展示 → 服务端(避免瀑布与骨架闪烁、利于 SEO);② 交互后才需要(搜索、筛选、分页、无限滚动、Tab 切换) → 客户端(依赖用户输入,服务端预知不了);③ 需要实时/轮询 → 客户端(服务端渲染只给一次快照)。实践上最常用的是「混合」:首屏数据服务端取,交互数据客户端取。 -
「服务端获取数据有什么优势?」
四点:① 没有请求瀑布——服务端可以并行获取、直接访问数据库,而客户端往往要「等 JS → 组件挂载 → 发请求 → 等响应」;② 没有骨架闪烁——数据在渲染 HTML 前就绪,用户直接看到内容;③ 安全——接口地址、密钥、内部逻辑不暴露(尤其配合 BFF 聚合层);④ 包更小 + SEO 更好——服务端组件代码不进客户端 bundle,HTML 里已有内容供爬虫读取。 -
「什么是『请求瀑布』?怎么解决?」
指「多个请求必须串行等待」(后一个请求依赖前一个的结果),导致总耗时是「各请求之和」而不是「最大值」。三个层次与解法:① 服务端内部——串行await→ 用Promise.all并行;② 服务端 → 客户端——服务端只取一部分、客户端再补 → 尽量在服务端取全,或用「预取 +dehydrate」把数据交给客户端缓存;③ 客户端内部——父组件拿到id后子组件才请求 → 提前并发布局(如果id已经知道就同时发)或组件级 Suspense(让慢的部分不阻塞快的)。 -
「怎么避免『同一份数据被取两次』(服务端一次 + 客户端一次)?」
两种方式:① 把服务端数据作为 props 传给客户端组件(适用于「客户端组件只是展示」的场景);② 服务端预取进客户端缓存——用queryClient.prefetchQuery把数据放进 TanStack Query 的缓存,然后用<HydrationBoundary state={dehydrate(queryClient)}>包裹,客户端组件的useQuery会直接命中缓存(不重复请求)。第二种更优雅(客户端组件保持「自己取数据」的写法,但首屏不闪骨架)。 -
「Next.js 的缓存有哪几个层级?『按需失效』为什么更好?」
三层:① 页面级(export const revalidate);② 数据级(fetch的next: { revalidate, tags }/cache);③ 按需失效(revalidatePath/revalidateTag)。按需失效更好是因为:定时失效(revalidate: 60)无法知道「内容什么时候变了」,所以在「内容刚更新后」用户仍可能看到旧数据(最多要等一个周期);而按需失效是在「写操作」的 Server Action 里主动调用,能让缓存立刻失效,用户马上看到新内容。 -
「什么时候不该把数据放服务端获取?」
四种:① 依赖用户交互才产生的数据(搜索词、筛选条件、滚动加载的下一页)——服务端预知不了;② 需要实时更新/轮询的数据(通知、行情、在线状态)——服务端渲染只给「一次快照」;③ 服务端获取会成为瓶颈的数据(如「每个请求都要查一个慢接口」——会拖长 TTFB,此时更适合「静态外壳 + 客户端加载」或用 Suspense 隔离);④ 高度依赖客户端环境的数据(如地图可视区域内的点、localStorage里的偏好)。 -
「怎么在服务端获取时保证『页面不会因为一个慢接口而卡住』?」
四种手段:①Promise.all并行(减少总耗时);②<Suspense>边界隔离(让「慢的部分」流式后到,shell 先发出,见 9.6);③ 超时保护(AbortSignal.timeout(3000)——避免服务端被一个卡住的接口拖死);④ 优雅降级(Promise.allSettled+ 局部错误 UI,让「一个数据失败」不导致整页 500)。此外可以考虑loading.tsx给整段路由一个加载态。 -
「客户端获取的数据怎么和 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、服务端获取要做超时与降级。



最新评论
读过书不知道欧·亨利的人少。教科书上选文有
这小生活不错呀
不错,必须顶一下!
看着你还在坚持,很好
看来忙了也没时间更新博客了
NIce。学习了。。。。
网站不错!!!!
简洁实用,好文章!