服务端组件和客户端组件是 Next.js 中非常重要的概念。
如果没有细致的了解过,你可能会简单的以为所谓服务端组件就是 SSR,客户端组件就是 CSR,服务端组件在服务端进行渲染,客户端组件在客户端进行渲染等等,实际上并非如此。
本篇就深入学习和探究 Next.js 的双组件模型!
服务端组件
介绍
在 Next.js 中,组件默认就是服务端组件 ,服务端组件一般会在function 前面加上async(不加也行)。往往意味着你需要利用服务端能力(比如异步数据获取),而 Next.js 的默认规则会让这类组件天然运行在服务端。
举个例子,新建 app/todo/page.js,代码如下:
js
export default async function Page() {
const res = await fetch('https://jsonplaceholder.typicode.com/todos')
const data = (await res.json()).slice(0, 10)
console.log(data)
return <ul>
{data.map(({ title, id }) => {
return <li key={id}>{title}</li>
})}
</ul>
}
请求会在服务端执行,并将渲染后的 HTML 发送给客户端:

因为在服务端执行,console 打印的结果也只可能会出现在命令行中,而非客户端浏览器中。
优势
-
数据获取:通常服务端环境(网络、性能等)更好,离数据源更近,在服务端获取数据会更快。通过减少数据加载时间以及客户端发出的请求数量来提高性能。
-
安全 :在服务端保留敏感数据和逻辑,不用担心暴露给客户端。服务端组件不会生成客户端 Chunk.js (仅在服务端渲染为 HTML,代码不暴露给浏览器)。
-
bundle 大小:服务端组件的代码不会打包到 bundle 中,减少了 bundle 包的大小。
-
初始页面加载和 FCP:服务端渲染生成 HTML,快速展示 UI。
-
Streaming:服务端组件可以将渲染工作拆分为 chunks,并在准备就绪时将它们流式传输到客户端。用户可以更早看到页面的部分内容,而不必等待整个页面渲染完毕。
因为服务端组件的诸多好处,在实际项目开发的时候,能使用服务端组件就尽可能使用服务端组件。
限制
虽然使用服务端组件有很多好处,但使用服务端组件也有一些限制,比如不能使用 useState 管理状态,不能使用浏览器的 API 等等。
RSC 与 SSR
了解了这两个基本概念,现在让我们来回顾下 React Server Components 和 Server-side Rendering,表面上看,RSC 和 SSR 非常相似,都发生在服务端,都涉及到渲染,目的都是更快的呈现内容。但实际上,这两个技术概念是相互独立的。
正如它们的名字所表明的那样,Server-side Rendering 的重点在于 Rendering ,React Server Components 的重点在于 Components。
简单来说:
- RSC 提供了更细粒度的组件渲染方式,可以在组件中直接获取数据,而不用像传统的 SSR 顶层获取数据。
- RSC 在服务端进行渲染,组件依赖的代码不会打包到 bundle 中,而 SSR 需要将组件的所有依赖都打包到 bundle 中。
当然两者最大的区别是:
SSR 是在服务端将组件渲染成 HTML 发送给客户端,而 RSC 是将组件渲染成一种特殊的格式,我们称之为 RSC Payload。
这个 RSC Payload 的渲染是在服务端,但不会一开始就返回给客户端,而是在客户端请求相关组件的时候才返回给客户端,RSC Payload 会包含组件渲染后的数据和样式,客户端收到 RSC Payload 后会重建 React 树,修改页面 DOM。
让我们本地开启一下当时 React 提供的 Server Components Demo:

你会发现 localhost 这个 HTML 页面的内容就跟 CSR 一样,都只有一个用于挂载的 DOM 节点。当点击左侧 Notes 列表的时候,会发送请求,这个请求的地址是http://localhost:4000/react?location={"selectedId":3,"isEditing":false,"searchText":""}
返回的结果是:

除此之外没有其他的请求了。其实这条请求返回的数据就是 RSC Payload。
让我们看下这条请求,我们请求的这条笔记的标题是 Make a thing,具体内容是 It's very easy to make some......,我们把返回的数据具体查看一下,你会发现,返回的请求里包含了这些数据:

不仅包含数据,完整渲染后的 DOM 结构也都包含了。
客户端收到 RSC Payload 后就会根据这其中的内容修改 DOM。而且在这个过程,页面不会刷新,页面实现了 partial rendering(部分更新)。
这也就带来了我们常说的 SSR 和 RSC 的最大区别,那就是状态的保持。SSR 每次都是一个新的 HTML 页面,所以状态不会保持(传统的做法是 SSR 初次渲染,然后 CSR 更新,这种情况,状态可以保持,不过现在讨论的是 SSR,对于两次 SSR,状态是无法维持的)。
但是 RSC 不同,RSC 会被渲染成一种特殊的格式(RSC Payload),可以多次重新获取,然后客户端根据这个特殊格式更新 UI,而不会丢失客户端状态。
客户端组件
使用客户端组件,你需要在文件顶部添加一个 "use client" 声明,修改 app/todo/page.js,代码如下:
js
'use client'
import { useEffect, useState } from 'react';
function getRandomInt(min, max) {
const minCeiled = Math.ceil(min);
const maxFloored = Math.floor(max);
return Math.floor(Math.random() * (maxFloored - minCeiled) + minCeiled);
}
export default function Page() {
const [list, setList] = useState([]);
const fetchData = async () => {
const res = await fetch('https://jsonplaceholder.typicode.com/todos')
const data = (await res.json()).slice(0, getRandomInt(1, 10))
setList(data)
}
useEffect(() => {
fetchData()
}, [])
return (
<>
<ul>
{list.map(({ title, id }) => {
return <li key={id}>{title}</li>
})}
</ul>
<button onClick={() => {
location.reload()
}}>换一批</button>
</>
)
}
在这个例子中,我们使用了 useEffect、useState 等 React API,也给按钮添加了点击事件、使用了浏览器的 API。无论使用哪个都需要先声明为客户端组件。
注意:"use client"用于声明服务端和客户端组件模块之间的边界。当你在文件中定义了一个 "use client",导入的其他模块包括子组件,都会被视为客户端 bundle 的一部分。
它的优势是:
- 交互性:客户端组件可以使用 state、effects 和事件监听器,意味着用户可以与之交互;
- 浏览器 API:客户端组件可以使用浏览器 API 如地理位置、localStorage 等;
服务端组件 VS 客户端组件
1、如何选择使用?

| 组件类型 | 执行 / 渲染位置 | 核心特征 |
|---|---|---|
| 服务端组件(SC) | 仅在服务端(Node.js 环境)执行,渲染为 HTML 片段 / React 服务端数据结构 | 可直接访问数据库、后端接口,无浏览器 API 限制,代码不会发送到客户端 |
| 客户端组件(CC) | 先在服务端做 "首屏渲染"(生成 HTML),再在客户端(浏览器)hydrate(水合)并运行 | 可使用 useState/useEffect 等 Hooks、访问 window/document,代码会打包发送到客户端 |
2、渲染环境
服务端组件只会在服务端渲染,但客户端组件会在服务端渲染一次,然后在客户端渲染。
这是什么意思呢?让我们写个例子,新建 app/client/page.js,代码如下:
js
'use client'
import { useState } from 'react';
console.log('client')
export default function Page() {
console.log('client Page')
const [text, setText] = useState('init text');
return (
<button onClick={() => {
setText('change text')
}}>{text}</button>
)
}
新建 app/server/page.js,代码如下:
js
console.log('server')
export default function Page() {
console.log('server Page')
return (
<button>button</button>
)
}
现在运行 npm run build,会打印哪些数据呢?
答案是无论客户端组件还是服务端组件,都会打印:

而且根据输出的结果,无论是 /client还是 /server走的都是静态渲染。
当运行 npm run start的时候,又会打印哪些数据呢?
答案是命令行中并不会有输出,访问 /client的时候,浏览器会有打印:

访问 /server的时候,浏览器不会有任何打印:

客户端组件在浏览器中打印,这可以理解,毕竟它是客户端组件,当然要在客户端运行。可是客户端组件为什么在编译的时候会运行一次呢?
让我们看下 /client 的返回:

你会发现 init text其实是来自于 useState 中的值,但是却依然输出在 HTML 中。
这就是编译客户端组件的作用,为了第一次加载的时候能更快的展示出内容。
所以,其实所谓服务端组件、客户端组件并不直接对应于物理上的服务器和客户端。服务端组件运行在构建时和服务端,客户端组件运行在构建时、服务端(生成初始 HTML)和客户端(管理 DOM)。
3、交替使用服务端组件和客户端组件
实际开发的时候,不可能纯用服务端组件或者客户端组件,当交替使用的时候,一定要注意一点,那就是:
服务端组件可以直接导入客户端组件,但客户端组件并不能导入服务端组件。
1. 服务端组件能导入客户端组件:符合 "渲染流向"
服务端组件的核心作用是在服务端组装页面骨架、获取数据 ,而客户端组件是为了处理交互(点击、输入、状态) 。Next.js 设计时,把 SC 作为 "页面的根 / 容器",CC 作为 "交互子节点",这种 "父(SC)包含子(CC)" 的结构完全契合渲染逻辑:
执行过程:
- 服务端执行 SC 时,遇到导入的 CC,不会直接执行CC 的代码(CC 的代码是给浏览器用的),而是将 CC 标记为 需要客户端水合的组件;
- 服务端把 SC 渲染为 HTML 片段,同时把 CC 的占位标记和 CC 的打包代码路径一起发给客户端;
- 客户端接收到页面后,先渲染 SC 生成的静态内容,再加载 CC 的代码并完成水合,让 CC 具备交互能力。
整个React 树会变成这样:

其中黄色节点表示 React Server Component。在服务端,React 会将其渲染会一个包含基础 HTML 标签和客户端组件占位的树。
因为客户端组件的数据和结构在客户端渲染的时候才知道,所以客户端组件此时在树中使用特殊的占位进行替代。
当然这个树不可能直接就发给客户端,React 会做序列化处理,客户端收到后会在客户端根据这个数据重构 React 树,然后用真正的客户端组件填充占位,渲染最终的结果。

2. 客户端组件不能能导入客户端组件
js
'use client'
// 这是不可以的
import ServerComponent from './Server-Component'
export default function ClientComponent({ children }) {
const [count, setCount] = useState(0)
return (
<>
<button onClick={() => setCount(count + 1)}>{count}</button>
<ServerComponent />
</>
)
}
正如介绍客户端组件时所说:
"use client"用于声明服务端和客户端组件模块之间的边界。当你在文件中定义了一个 "use client",导入的其他模块包括子组件,都会被视为客户端 bundle 的一部分。
组件默认是服务端组件 ,但当组件导入到客户端组件中会被认为是客户端组件。客户端组件不能导入服务端组件,其实是在告诉你,如果你在服务端组件中使用了诸如 Node API 等,该组件可千万不要导入到客户端组件中。
另外,渲染逻辑闭环被打破,Next.js 的渲染逻辑是 "服务端先处理静态 / 数据层(SC)→ 客户端再处理交互层(CC)",是单向的 "服务端 → 客户端" 流向。如果允许 CC 导入 SC,相当于让 "客户端" 反向控制 "服务端"。
但你可以将服务端组件以 props 的形式传给客户端组件:
js
'use client'
import { useState } from 'react'
export default function ClientComponent({ children }) {
const [count, setCount] = useState(0)
return (
<>
<button onClick={() => setCount(count + 1)}>{count}</button>
{children}
</>
)
}
import ClientComponent from './client-component'
import ServerComponent from './server-component'
export default function Page() {
return (
<ClientComponent>
<ServerComponent />
</ClientComponent>
)
}
使用这种方式,<ClientComponent> 和 <ServerComponent> 代码解耦且独立渲染。
4、组件渲染原理
1. 在服务端
Next.js 使用 React API 编排渲染,渲染工作会根据路由和 Suspense 拆分成多个块(chunks),每个块分两步进行渲染:
- React 将服务端组件渲染成一个特殊的数据格式称为 React Server Component Payload (RSC Payload);
- Next.js 使用 RSC Payload 和客户端组件代码在服务端渲染 HTML;
RSC payload 中包含如下这些信息:
- 服务端组件的渲染结果
- 客户端组件占位符和引用文件
- 从服务端组件传给客户端组件的数据
为什么会包含"客户端组件占位符和引用文件"呢?
1. 占位符:告诉客户端 "这里有个需要交互的组件,先留位置"
其实在上面我们已经说了,服务端组件(SC)执行在服务端,客户端组件(CC)执行在浏览器,两者的职责边界是:SC 负责搭骨架,CC 负责加交互 。但 SC 在服务端渲染时,根本无法执行 CC 的代码(CC 依赖浏览器 API、React 状态等),只能做 "标记",这就是 "占位符 + 引用文件" 的核心作用。
SC 渲染时,遇到导入的 CC,不会生成 CC 的真实 DOM(因为 CC 还没在客户端激活),而是生成一个特殊的占位标记(RSC 协议里的 JSON 标记) ,比如:
js
// 简化的 RSC payload 片段
{
"type": "client.component",
"id": "cc-123", // 唯一标识
"fallback": "<div>加载中...</div>" // 可选的占位内容
}
这个占位符的作用:
- 保证页面结构完整:客户端拿到 payload 后,先渲染 SC 生成的静态内容 + CC 的占位符,不会出现 "交互组件位置空白" 的情况,避免布局错乱;
- 标记待激活区域:告诉 React 运行时这个位置的组件需要后续加载客户端代码并水合,是客户端激活 CC 的 锚点。
2. 引用文件:告诉客户端去哪找这个 CC 的交互代码
CC 的代码会被 Next.js 打包成独立的客户端 JS 包(比如 static/chunks/cc-123.js),RSC payload 中会附带这个包的引用路径和哈希值,比如:
js
{
"type": "client.reference",
"id": "cc-123",
"filePath": "/_next/static/chunks/cc-123.js",
"name": "ClientButton"
}
这个引用的核心价值:
- 按需加载:客户端只会加载页面中实际用到的 CC 代码,而不是把所有 CC 代码都打包进首屏(比如页面有 10 个 CC,但首屏只显示 2 个,就只加载这 2 个的代码),减少客户端 JS 体积;
- 精准激活:React 运行时根据引用路径下载对应的 CC 代码后,能精准替换掉之前的占位符,完成 CC 的水合(让 CC 具备 useState/useEffect 等交互能力);
- 版本控制 :通过哈希值(比如
cc-123.abc123.js)实现缓存复用,后续页面如果用到同一个 CC,客户端不用重复下载。
为什么包含从服务端组件传给客户端组件的数据?
如果 RSC payload 不附带这份数据,CC 激活后只能自己通过 fetch 去请求相同的数据,会导致:
- 重复的网络请求: 服务端查一次数据库,客户端又查一次,浪费服务器资源;
- 额外的网络延迟 :CC 要等
fetch返回才能渲染,出现 "占位符→加载中→真实内容" 的二次等待。而 SC 把数据直接塞进 payload,CC 激活后能直接用。
2. 在客户端
- 加载渲染的 HTML 快速展示一个非交互界面(Non-interactive UI)
- RSC Payload 会被用于协调(reconcile)客户端和服务端组件树,并更新 DOM
- JavaScript 代码被用于水合客户端组件,使应用程序具有交互性(Interactive UI)

注意:上图描述的是页面初始加载的过程。其中 SC 表示 Server Components 服务端组件,CC 表示 Client Components 客户端组件。
在前一篇文章中讲到 Suspense 和 Streaming 也有一些问题没有解决,比如该加载的 JavaScript 代码没有少、所有组件都必须水合,即使组件不需要水合。
使用服务端组件和客户端组件就可以解决这个问题,服务端组件的代码不会打包到客户端 bundle 中。渲染的时候,只有客户端组件需要进行水合,服务端组件无须水合。
而在后续导航的时候:

后续导航(客户端路由导航) 则是 Next.js 基于 next/navigation(App Router)实现的客户端侧无刷新导航 ,核心是 "按需加载资源 + 局部更新页面 + 保留客户端状态",全程不触发浏览器的整页刷新。
核心前提:后续导航的触发条件
用户点击 Next.js 提供的 <Link> 组件(而非原生 <a> 标签)、调用 useRouter().push()/replace() 等客户端路由方法时,会触发后续导航;
如果直接刷新页面 / 输入 URL,仍会走首次导航流程。
完整流程(App Router)
-
Next.js 的客户端路由运行时(
next/navigation底层)会拦截<Link>点击事件,阻止浏览器的默认页面跳转(event.preventDefault()); -
客户端向服务端发起一个轻量的 RSC 请求 (不是整页 HTML 请求),请求目标路由的 Server Components 渲染结果(即 RSC Payload,格式是特殊的 JSON 流);这个请求只会获取目标路由的 Server Components 渲染出的静态内容、客户端组件的占位符 + 代码引用、服务端传给客户端组件的数据;
-
React 运行时接收 RSC Payload 后,RSC Payload 内容如下:

不仅包含数据,完整渲染后的 DOM 结构也都包含了。
客户端收到 RSC Payload 后就会根据这其中的内容修改 DOM。而且在这个过程,页面不会刷新,页面实现了 partial rendering(部分更新)。
也就是,先渲染 SC 生成的静态内容,替换当前页面的主内容区域,同时保留页面的公共布局(比如导航栏、页脚),这就是局部更新,公共部分不重新渲染。
如果目标路由包含新的客户端组件(未在当前页面加载过),Next.js 会根据 RSC Payload 中的 "客户端组件引用路径",异步加载对应的客户端 JS 包(体积很小,按需加载);已加载过的客户端组件会复用缓存,不会重复下载。
-
对客户端组件来说:先渲染占位符(比如加载中),等对应的 JS 包下载完成后,完成 "水合"(激活交互,比如 useState/useEffect 生效),替换占位符为真实交互组件;
-
整个过程中,页面的
<head>标签(标题、meta 等)会被 Next.js 自动更新(基于目标路由的generateMetadata或metadata配置),但不会刷新页面。 -
Next.js 调用浏览器的
history.pushState()/replaceState()API,更新地址栏 URL,但不会触发浏览器的popstate整页刷新; -
客户端状态(比如全局 Redux 状态、组件内的 useState、表单输入值)会被保留(除非主动重置),比如从
/home跳转到/post/123,导航栏的登录状态、全局主题设置不会丢失。