Server 与 Client 组件
这一课你将能做到
- 一眼判断一段代码该是 Server 还是 Client 组件,并说出判断依据
- 熟练使用
'use client' 声明边界,掌握「服务端取数据 + 客户端管交互」的标准拆分套路
- 能把 RSC 与 SSR、CSR 的区别讲到面试官点头的程度(本 track 面试权重最高的一课)
为什么要学
App Router 下,你写的每个组件默认都在服务器上执行——这是和过去 React 最大的不同。很多初学者迁移到 Next.js 后的第一个报错就是 useState 用不了,根因就是没理解 Server Component(RSC)。
为什么框架要这样做?看账本:一个中等 React 页面如果全在客户端渲染,JS 包动辄 300KB+,用户要下载、解析、执行完才能看到内容;而页面里真正需要交互的往往只有按钮、弹窗、表单这 20%。RSC 让那 80% 的「展示型代码」留在服务器上跑——不进 JS 包、不发到浏览器,HTML 直接带着内容到达。结果是首屏更快、包更小,还能在组件里直接用只有服务端能用的能力(读文件、查数据库、拿密钥)。
本站课时页就是教科书案例:页面主体(取课时数据、渲染 MDX、判断权限)全是 Server 组件,只有做题判分、进度打钩这些要点击的部分拆成了 Client 组件。
面试角度:「讲讲 Server Component 和 Client Component 的区别」是 Next.js 岗位的第一高频题,出现率不低于闭包之于 JS 岗。这课的目标是让你答出层次感。
先建立图像
两类组件的能力对照表:
| 能力 | Server 组件(默认) | Client 组件 |
|---|
useState / useEffect / 事件监听 | ❌ | ✅ |
async/await 直接取数据 | ✅ | ❌(用 useEffect 或上移) |
| 访问后端资源(数据库、文件、密钥) | ✅ | ❌ |
| 打进客户端 JS 包 | ❌(零包体积) | ✅ |
| 浏览器 API(window、localStorage) | ❌ | ✅ |
水合(hydration)一图流:
服务器:执行 Server 组件树 → 输出完整 HTML ──→ 浏览器立即显示内容
↓
Client 组件的 JS 包到达 → React「接水」:把事件、状态接到对应 DOM 上
↓
此刻起按钮才能点、输入框才能敲
组件树的组成规则:
app/page.tsx (Server)
├── <Header/> (Server:纯展示,零 JS)
├── <LessonContent/> (Server:服务端读 MDX 渲染)
└── <QuizPanel/> ('use client':答题交互)
├── <Question/> (自动也是 Client:被 Client 组件导入即入包)
└── <SubmitButton/> (同上)
关键:边界是传染的。一个文件标了 'use client',它导入的所有组件都变成 Client 组件,除非作为 children 从外部传入。
跟着做
步骤 1:体会默认是 Server
新建 (不加任何指令):
src/app/rsc-demo/page.tsx
// 这就是 Server Component:没有 'use client',async 也合法
export default async function RscDemoPage() {
const time = new Date().toLocaleString("zh-CN");
return (
<main className="p-6">
<h1 className="text-2xl font-bold">服务端渲染时间</h1>
<p className="mt-2">{time}</p>
</main>
);
}
new Date() 在服务器上执行,用户看到的是 HTML 里已经写死的时间字符串。这行代码如果在 Client 组件里,执行时机就变成了用户浏览器。理解这个差异比记规则重要。
步骤 2:第一个 Client 组件
新建 src/components/LikeButton.tsx:
"use client";
import { useState } from "react";
export default function LikeButton() {
const [count, setCount] = useState(0);
return (
<button
className="rounded border px-3 py-1 hover:bg-gray-50"
onClick={() => setCount(count + 1)}
>
👍 点赞 {count}
</button>
);
}
在 rsc-demo/page.tsx 顶部下方引入:
import LikeButton from "@/components/LikeButton";
// export default async function RscDemoPage() 里加:
// <div className="mt-4"><LikeButton /></div>
流程:Server 组件渲染页面 → 发现有 Client 组件 → 它的位置在 HTML 里留好,JS 单独下发 → 水合后可交互。Server 组件里可以直接 import Client 组件,这是最常用的组合。
步骤 3:反方向报错——故意踩一次坑
// ❌ 這樣会编译报错
export default function Bad() {
const [n, setN] = useState(0); // Error: useState only works in Client Components
return <button onClick={() => setN(1)}>{n}</button>;
}
记住报错长相。修复方式:把有交互的叶子拆成独立文件加 'use client',页面保持服务端。
步骤 4:children 模式——Server 组件作为 Client 组件的孩子
需求:一个可折叠的 Client 容器,里面装 Server 渲染的重内容。
src/components/Collapsible.tsx:
"use client";
import { useState } from "react";
export default function Collapsible({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
const [open, setOpen] = useState(false);
return (
<div className="border rounded-lg">
<button className="w-full p-3 text-left font-medium" onClick={() => setOpen(!open)}>
{open ? "▾" : "▸"} {title}
</button>
{open && <div className="p-3 border-t">{children}</div>}
</div>
);
}
import Collapsible from "@/components/Collapsible";
import HeavyMarkdownBody from "@/components/HeavyMarkdownBody"; // Server 组件
export default async function Page() {
const body = await renderSomething(); // 服务端算好的 React 元素
return (
<Collapsible title="查看详细解析">
<HeavyMarkdownBody content={body} />
</Collapsible>
);
}
精髓:HeavyMarkdownBody 由 Server 页面渲染成已完成的元素再传给 children,它不进入 Client 包。若 Collapsible 内部直接 import 它,就会被拖进客户端——两种写法包体积天差地别。这是面试进阶加分点。
步骤 5:把数据取在服务端,把交互留在客户端
// src/app/counter-demo/page.tsx(Server)
import CountBox from "@/components/CountBox";
export default async function Page() {
// 服务端调用 API(真实项目里对接 Spring Boot)
const res = await fetch("https://api.example.com/stats");
const stats = await res.json();
return <CountBox initialCount={stats.visitors} />;
}
// src/components/CountBox.tsx(Client)
"use client";
import { useState } from "react";
export default function CountBox({ initialCount }: { initialCount: number }) {
const [count, setCount] = useState(initialCount);
return (
<button className="rounded bg-blue-600 px-4 py-2 text-white" onClick={() => setCount(count + 1)}>
当前 {count} 人在线,点我 +1
</button>
);
}
数据在服务端取好,作为 props 流进 Client 组件——这条「服务端取数 → props 下传」的单向管道,是 App Router 最核心的工程套路。
讲清楚要点
- 默认 Server,显式声明才是 Client:一个文件最高处的
"use client" 把整个模块标记为客户端边界。没有它,一切都在服务端跑。
- Server 组件的输出是序列化后的 UI,不是函数:它执行一次、产出元素,之后服务器不再保存它的实例。所以没有 state、没有 effect、没有浏览器内存——这些概念属于「持续存活的客户端实例」。
- props 必须可序列化:从 Server 传给 Client 的 props 只能是字符串/数字/普通对象/数组/Date 等,函数(除 Server Action)、class 实例、Symbol 不行。报错关键词
Functions cannot be passed directly to Client Components。
- children 模式是包体积优化利器:Client 组件通过
children 接收的 React 元素,由父层(往往是 Server)预先渲染,不进客户端包。复杂布局组件(侧边栏、Tabs)常用此模式收纳 Server 内容。
- 什么时候必须 Client:用了
useState/useEffect/useReducer、onClick/onChange 等事件、useRef、浏览器 API、第三方需要浏览器环境的库(图表、编辑器、地图)。没有这些需求就别加 'use client'——多余声明会把整棵子树拖进 JS 包。
你可能会懵的地方
-
RSC(Server Component)和 SSR 是一回事吗?(面试最爱追问)
不是。SSR 是「渲染时机」技术:把组件在服务器渲染成 HTML 发给浏览器,随后整棵树水合、JS 全量下发。RSC 是「组件架构」:组件树一部分永远留在服务器(Server 组件不水合、JS 不下发),只有标了 'use client' 的部分才水合。SSR 渲染的是「整棵树」,RSC 把树「劈成两半」,只把交互那一半送到浏览器。可以理解为:SSR 解决「首屏有没有内容」,RSC 解决「发多少 JS」。
-
Server 组件怎么响应点击?
它不响应。点击属于浏览器事件,只有水合后的 Client 组件能接。方案:把按钮拆成 Client 子组件;或者点击触发的是「服务端变更」(如表单提交),用 Server Action(第 7 课)。
-
两个 Server 组件之间能传 onClick 吗?
不能。同理也不能在 Server 组件里把任何函数传给 Client 组件。需要在客户端间共享行为时,该行为定义在 Client 文件里。
-
'use client' 是不是写在页面顶部就整页变客户端了?
是「从该文件向下游传染」:该文件及其导入的模块全部进客户端包,父级不受影响。所以工程惯例是边界尽量推到叶子:页面保持 Server,只把最小的交互单元声明为 Client。
-
Client 组件也能渲染 SEO 内容吗?
能。Client 组件同样会参与服务端首渲染(SSR),其初始 HTML 也带内容;只是它的 JS 必须下发并水合。所以「SEO 要求」不构成用 Client 的理由,「交互需求」才是。
错法对照
| 错法 | 问题 | 正解 |
|---|
在 Server 组件里 useEffect 取数据 | 直接报错,且丧失服务端取数优势 | 组件改 async Server Component,直接 await |
const [s, setS] = useState(0) 写在 page.tsx | App Router 默认 Server,报错 | 拆出 'use client' 子组件 |
给 Server 组件传 onClick={fn} | 函数不可序列化,报错 | 把按钮整体挪进 Client 组件 |
| 为了「安全」把所有组件都标 'use client' | 密钥代码照样能被打包工具触及,且包体积暴涨 | 按需最小化 Client 边界 |
Client 组件里读 process.env.DATABASE_URL | 客户端拿不到该值,运行时 undefined | 服务端读取后以 props 传值 |
卡点急救
- 报错
You're importing a component that needs useState...:当前文件是 Server 却引入了带状态的模块,或自己在 Server 文件里写了 Hook。解决:该文件顶部加 "use client",或把状态逻辑拆出去。
- 报错
Functions cannot be passed directly to Client Components:检查是否从 Server 往 Client 传了回调函数。要么把「带函数的那棵树」整体变成 Client,要么改传可序列化数据 + 用 Server Action。
- 页面按钮点了没反应、控制台无报错:多半组件在服务端渲染了但 JS 没水合成功——查浏览器 Console 是否有 hydration mismatch(常见原因:服务端与客户端渲染不一致,如随机数、
Date.now()、typeof window 判断),或 JS 包被网络拦截。
面试常问
-
Server Component 和 Client Component 的区别与选型?
Server 组件仅在服务器执行一次并序列化输出,不能使用状态与浏览器 API,但可 async 取数、访问后端资源、零客户端包体积;Client 组件在浏览器持久存活,可用状态、事件与浏览器 API,JS 会下发并水合。选型原则:默认 Server,只在需要交互/状态/浏览器 API 的最小单元声明 'use client',数据在服务端取好后经 props 流入。
-
RSC 与 SSR 的区别?
SSR 是渲染策略——整棵组件树在服务器渲染成 HTML,浏览器端全量水合。RSC 是组件模型——组件树在构建时就被划分为 Server/Client 两部分,Server 部分永不进入客户端包,Client 部分才水合。RSC 叠加在 SSR 之上,本质优化点是「减少下发 JS + 允许组件直接取后端数据」。
-
为什么 Server 组件不能有状态?Client 组件的 props 为什么不能传函数?
Server 组件执行完即销毁,服务器不为它保存运行时实例,而状态本质是「跨时间存活的内存」,二者矛盾。函数不可跨网络边界传递,因为 Server → Client 的 props 要经过 JSON 序列化传输,函数没有可靠序列化形式(Server Action 例外,它走独立协议)。
自测三问
- 一段代码需要同时「读数据库」和「监听 input 输入」,你会怎么拆组件?
'use client' 写在中间层组件,对它的父组件和子组件分别有什么影响?
- 不写任何代码,说出 RSC 和 SSR 的两点本质区别。
小结一句话
App Router 默认一切皆 Server 组件,交互的最小单元加 'use client';数据在服务端取、状态在客户端管、组件用 children 穿越边界,是这套架构的全部心法。
延伸