在 Next.js App Router 中,经常会遇到两个概念:
- Server Component
- Client Component
很多人第一次接触时会有一个疑问:
既然 Client Component 也可以在服务端生成 HTML,然后到浏览器再水合,那为什么还要区分 Server Component 和 Client Component?为什么不全部都写成 Client Component?
这篇文章就围绕三个问题展开:
- 什么是 Server Component?
- 什么是 Client Component?
- 为什么不能全部用 Client Component?
什么是 Server Component?
Server Component,也就是服务端组件。
它的核心特点是:
组件只在服务端执行,组件代码不会发送到浏览器。
也就是说,Server Component 的职责是:
在服务端把数据和组件逻辑处理好,然后生成可以发送给浏览器的结果。
例如:
export default async function ArticlePage() {
const article = await getArticle();
return (
<article>
<h1>{article.title}</h1>
<div>{article.content}</div>
</article>
);
}
这个组件可以直接在服务端读取数据:
const article = await getArticle();
这里的 getArticle 可以访问数据库、调用内部服务、读取环境变量,甚至使用服务端密钥。因为这些代码不会被打包到浏览器里。
Server Component 适合用来渲染:
- 文章正文
- 商品详情
- 文档内容
- 静态布局
- 服务端数据列表
- SEO 友好的页面主体
只要一个组件不需要浏览器交互,通常都可以优先考虑做成 Server Component。
什么是 Client Component?
Client Component,也就是客户端组件。
在 Next.js 中,只要文件顶部写了:
"use client";
这个文件导出的组件就是 Client Component。
例如:
"use client";
import { useState } from "react";
export default function Counter() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(count + 1)}>
count: {count}
</button>
);
}
Client Component 的核心特点是:
组件代码会被打包发送到浏览器,并且可以在浏览器中完成 hydration 和交互。
它可以使用:
useStateuseEffectuseReduceronClickonChangewindowdocumentlocalStoragenavigator
这些能力都依赖浏览器运行时,所以必须放在 Client Component 中。
Client Component 适合用来处理:
- 按钮点击
- 表单输入
- 弹窗
- 下拉菜单
- 复制代码
- 点赞收藏
- 评论输入框
- 拖拽排序
- 页面滚动监听
- 本地缓存读取
简单说,凡是依赖用户交互或浏览器 API 的部分,都应该放到 Client Component 中。
Client Component 也会生成 HTML 吗?
会。
这是很多人容易误解的地方。
Client Component 不等于“只在浏览器里渲染”。
在 Next.js App Router 中,Client Component 通常也会参与服务端预渲染。服务端会先生成它的初始 HTML,然后浏览器拿到 HTML 后再加载对应的 JS,最后进行 hydration。
例如这个组件:
"use client";
export default function Counter() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(count + 1)}>
count: {count}
</button>
);
}
服务端返回的 HTML 里可能已经有:
<button>count: 0</button>
用户可以立刻看到按钮。
但在 hydration 完成之前,这个按钮还没有 React 事件绑定,点击不会触发 setCount。
等浏览器加载 JS 并完成 hydration 后:
onClick={() => setCount(count + 1)}
才会真正生效。
所以 Client Component 的准确理解不是“只能在客户端渲染”,而是:
Client Component 会发送 JS 到浏览器,并且可以在浏览器中恢复状态和绑定交互。
Client Component 服务端预渲染,不等于 CSR
前面提到,Client Component 通常也可以在服务端生成初始 HTML,然后到浏览器再 hydration。
但这里需要区分两个概念:
- 组件是不是 Client Component。
- 核心数据是在服务端获取,还是在浏览器运行时获取。
Client Component 可以服务端预渲染 HTML,但如果核心数据是在浏览器运行时通过 useEffect + fetch 请求回来再渲染,那这个页面的核心内容就会退化成 CSR。
例如文章页如果这样写:
"use client";
import { useEffect, useState } from "react";
export default function ArticlePage() {
const [article, setArticle] = useState(null);
useEffect(() => {
fetch("/api/articles/1")
.then(res => res.json())
.then(data => setArticle(data));
}, []);
if (!article) return <div>loading...</div>;
return (
<article>
<h1>{article.title}</h1>
<div>{article.content}</div>
</article>
);
}
这种情况下,服务端返回的初始 HTML 里很可能只有:
<div>loading...</div>
文章标题和正文并不在初始 HTML 中。
浏览器需要经历:
- 下载 JavaScript。
- React hydration。
- 执行
useEffect。 - 请求
/api/articles/1。 - 接口返回文章数据。
setState触发重新渲染。
文章正文才会真正出现在页面上。
这就是典型的 CSR 数据渲染路径。
对于文章页、商品详情页、文档页这类 SEO 核心页面来说,这种方式并不理想。因为搜索引擎和用户一开始拿到的 HTML 里没有核心内容,首屏展示也会更慢。
更好的方式是把核心数据获取放在服务端:
export default async function ArticlePage() {
const article = await getArticle();
return (
<article>
<h1>{article.title}</h1>
<div>{article.content}</div>
</article>
);
}
这样流程会变成:
服务端获取文章数据
服务端生成包含正文的 HTML
浏览器直接展示文章内容
需要交互的局部组件再 hydration
所以,判断一个页面是不是退化成 CSR,关键不只是看它用了 Server Component 还是 Client Component,而是看核心内容的数据获取发生在哪里。
如果文章正文、商品信息、文档内容这些核心数据在服务端获取,并参与初始 HTML 生成,那么它仍然具备 SSR、SSG 或 ISR 的优势。
如果这些核心数据要等浏览器运行时请求回来后才渲染,那么核心内容就退化成了 CSR。
这也是为什么文章详情页通常应该让正文服务端直出,而把点赞、收藏、复制代码、评论输入框这些交互能力拆成局部 Client Component。
Server Component 和 Client Component 的区别
两者最大的区别不是“有没有 HTML”,而是:
组件代码是否会发送到浏览器,以及是否需要 hydration。
可以这样对比:
| 对比项 | Server Component | Client Component |
|---|---|---|
| 是否在服务端执行 | 是 | 是,通常会预渲染初始 HTML |
| 是否发送组件代码到浏览器 | 否 | 是 |
| 是否需要 hydration | 否 | 是 |
是否能使用 useState | 否 | 是 |
是否能使用 useEffect | 否 | 是 |
是否能绑定 onClick | 否 | 是 |
是否能访问 window/document | 否 | 是 |
| 是否能直接访问数据库 | 是 | 否 |
| 是否能使用服务端密钥 | 是 | 否 |
| 适合场景 | 内容、数据、静态 UI | 交互、状态、浏览器 API |
为什么不能全部用 Client Component?
既然 Client Component 也能生成 HTML,也能交互,那为什么不全部用 Client Component?
原因是:这样会退回到传统 SSR 的模式,成本更高。
传统 SSR 的思路是:
服务端生成 HTML
客户端下载整页 JS
客户端整页 hydration
它的问题是:页面上所有组件的代码都要发给浏览器,所有组件都要参与 hydration。
但很多组件其实只是静态展示。
例如文章页中:
- 标题
- 作者名
- 发布时间
- 正文段落
- 列表
- 表格
- 静态图片
- SEO 信息
这些内容不需要在浏览器里执行组件逻辑,也不需要绑定事件。
如果全部写成 Client Component,这些静态内容的组件代码也会被打包进客户端 JS,浏览器还要参与 hydration。
这会带来几个问题。
一、客户端 JavaScript 变大
Client Component 的代码需要发送到浏览器。
如果整篇文章、Markdown 渲染器、语法解析器、布局组件、数据处理逻辑都写成 Client Component,那么这些代码都会进入客户端 bundle。
用户打开页面时,不仅要下载 HTML,还要下载更多 JS。
JS 越大,浏览器解析和执行成本越高,首屏越容易变慢。
对于内容站、文档站、博客这种页面来说,正文通常很多,但大部分内容不需要交互。把这些内容都变成客户端组件,是不必要的浪费。
Server Component 可以避免这个问题。
它只在服务端执行,浏览器拿到的是结果,不需要下载组件实现。
二、hydration 成本变高
Client Component 不只是下载 JS,还需要 hydration。
hydration 的过程大致包括:
- 加载组件代码。
- 在浏览器中重新构建 React 组件树。
- 和服务端生成的 HTML 对齐。
- 初始化状态。
- 绑定事件。
如果页面很大,hydration 成本也会很高。
例如一篇长文章中有大量标题、段落、列表、代码块和表格,如果整篇文章都作为 Client Component 渲染,浏览器就要为这些内容做更多 hydration 工作。
但实际上,很多内容只是展示,不需要交互。
Server Component 的优势是:
不需要 hydration。
它在服务端已经完成渲染,浏览器只需要展示 HTML。
三、容易暴露服务端逻辑
Server Component 可以直接使用服务端能力:
const article = await db.article.findUnique({
where: { id },
});
也可以安全读取服务端环境变量:
const secret = process.env.SECRET_KEY;
因为这些代码不会进入浏览器。
但 Client Component 会被打包发送到浏览器,所以不能直接访问数据库,也不能包含服务端密钥或内部逻辑。
如果全部写成 Client Component,就必须把数据获取改成接口请求:
useEffect(() => {
fetch("/api/article/1")
.then(res => res.json())
.then(data => setArticle(data));
}, []);
这样不仅多了一次客户端请求,也让页面初始内容更依赖浏览器执行 JS。
四、数据获取链路更绕
Server Component 可以直接在组件里获取数据:
export default async function ArticlePage() {
const article = await getArticle();
return <Article article={article} />;
}
这个流程是:
服务端获取数据
服务端生成 HTML
浏览器直接展示内容
如果全部用 Client Component,常见流程会变成:
服务端返回页面框架
浏览器下载 JS
浏览器执行 JS
客户端请求接口
接口返回数据
客户端 setState
页面渲染正文
这会让首屏内容出现得更晚,也不利于 SEO。
对于文章、商品、文档这类页面,核心内容越早出现在 HTML 中越好。
五、缓存能力更弱
Next.js 的 Server Component 可以很好地结合服务端缓存能力,例如:
- 静态生成
- ISR
revalidatePath- 服务端
fetch缓存 - 路由级缓存
比如文章详情页可以使用 ISR:
第一次请求生成 HTML
后续请求复用缓存
文章更新后按需 revalidate
这非常适合内容站。
如果全部改成 Client Component,很多数据就要在浏览器运行时请求,服务端页面缓存的价值会下降。
正确的组件拆分方式
比较好的做法不是“全部 Server Component”,也不是“全部 Client Component”。
而是:
能静态渲染的部分用 Server Component。 必须交互的最小部分用 Client Component。
例如一个文章阅读页可以这样拆:
ArticlePage Server Component
ArticleHeader Server Component
ArticleContent Server Component
MarkdownRenderer Server Component
CodeBlockCopyButton Client Component
LikeButton Client Component
CollectButton Client Component
CommentEditor Client Component
OutlineScrollSpy Client Component
这样做的好处是:
- 文章正文可以服务端直出。
- SEO 友好。
- 浏览器不用下载整篇 Markdown 渲染逻辑。
- 只有复制、点赞、收藏、评论这些交互部分需要 JS。
- 页面整体更轻。
一个代码块的例子
比如文章中的代码块。
代码内容本身是静态的,应该出现在服务端 HTML 中。
但“复制代码”按钮需要调用浏览器的 Clipboard API:
navigator.clipboard.writeText(code);
这就必须放在 Client Component 中。
所以可以这样设计:
MarkdownRenderer:Server Component
CodeBlock:Client Component
服务端先输出代码块 HTML:
<pre>
<code>console.log("hello");</code>
</pre>
<button>复制代码</button>
浏览器先看到代码和按钮。
hydration 完成后,按钮才具备复制能力。
这就是 Server Component 和 Client Component 配合的典型场景:
内容由服务端保证首屏和 SEO,交互由客户端增强。
如何判断一个组件该放哪边?
可以用几个问题判断:
- 这个组件需要
useState吗? - 这个组件需要
useEffect吗? - 这个组件需要绑定点击、输入、提交事件吗?
- 这个组件需要访问
window/document/localStorage/navigator吗? - 这个组件依赖浏览器-only 的第三方库吗?
如果答案是“是”,它通常应该是 Client Component。
再问几个问题:
- 这个组件只是展示数据吗?
- 这个组件可以直接在服务端拿到数据吗?
- 这个组件不需要用户交互吗?
- 这个组件是否希望减少客户端 JS?
- 这个组件是否涉及数据库、文件系统或服务端密钥?
如果答案是“是”,它更适合 Server Component。
总结
Server Component 和 Client Component 的区别,不是有没有 HTML,而是:
- Server Component 只在服务端执行,不发送组件代码到浏览器,不需要 hydration。
- Client Component 可以服务端预渲染初始 HTML,但组件代码会发送到浏览器,并在浏览器中 hydration。
不能全部用 Client Component,是因为这样会导致:
- 客户端 JS 变大。
- hydration 成本变高。
- 数据获取链路更绕。
- 服务端逻辑更难安全复用。
- 缓存和 SEO 效果变差。
更好的实践是:
静态内容、服务端数据和 SEO 主体用 Server Component。 用户交互、浏览器 API 和局部状态用 Client Component。
这样既能保留服务端渲染的性能和 SEO 优势,又能在需要的地方提供完整的客户端交互体验。