Next.js SSR / RSC / Hydration 笔记------以主题切换为例
本文目的 :借助主题切换这个真实场景,理解 Next.js 的 Server / Client Component 渲染模式,分清 SSR、RSC、Hydration 三者的关系,以及 hydration 不一致为何会导致报错和闪屏。
0. 基础概念:SSR、RSC 与 Hydration(注水)
读这篇笔记前,先分清三个词:SSR 、RSC 、Hydration 。很多人把「切路由」理解成「没有服务端渲染」,其实更准确的说法是:切路由不发生「整页 HTML 式 SSR」,但会发生「段级 RSC 渲染」。
0.1 SSR(Server-Side Rendering,服务端渲染)
在服务端把 React 组件跑一遍,生成 HTML 字符串,随响应发给浏览器。用户可以先看到内容,不必等 JS 下载执行完毕。
典型场景:首次访问、F5、硬刷新。浏览器收到的是一份完整的 HTML 文档。
浏览器请求 /about
↓
服务端跑当前路由链(layout + page)
↓
产出整页 HTML → 浏览器先显示内容
↓
客户端 JS 下载完毕 → 对 Client Component 做 Hydration
0.2 RSC(React Server Components,React 服务端组件)
RSC 是 Next.js App Router 的底层渲染模型,和「整页 HTML SSR」不是同一个层面的概念。
| 整页 HTML SSR | RSC(段级渲染) | |
|---|---|---|
| 触发场景 | 首次访问 / F5 | <Link> / router.push 切路由 |
| 浏览器是否刷新整页 | 是 | 否(软导航) |
| 服务端产出什么 | 完整 HTML 文档 | RSC Payload(也叫 Flight 数据流) |
| 渲染范围 | 当前路由链全部段 | 只重新渲染变化的路由段 |
| Client Component 会跑吗 | 会(产出 HTML 片段) | 会(服务端预渲染首屏 HTML 片段,序列化进 Payload;客户端仍需 Hydration) |
RSC Payload 是什么?
可以把它理解成服务端返回的「组件树更新包」,不是一份新的 HTML 页面。里面包含:
- Server Component 渲染出的内容(HTML 片段或序列化描述)
- Client Component 的占位信息:组件类型、props、服务端预渲染出的首屏 UI
- 子树结构、懒加载引用等元数据
客户端 React 收到 Payload 后,局部更新 DOM 里对应区域,而不是替换整个 <body>。
点击 <Link href="/about">(当前在 /)
↓
浏览器不刷新整页
↓
向服务端发 RSC 请求(不是请求整页 HTML)
↓
服务端只重新跑「变化的路由段」(比如 about/page)
↓
返回 RSC Payload(不是 HTML 文档)
↓
客户端用 Payload 更新 DOM 对应区域
关键结论 :切路由时不是没有服务端渲染,而是渲染方式从「整页 HTML SSR」变成了「段级 RSC 渲染」,交付物从 HTML 变成了 RSC Payload。
Server Component 和 RSC 的关系 :App Router 里默认所有组件都是 Server Component,它们只在服务端执行 ,渲染结果进入 RSC Payload,不会把组件逻辑打进客户端 JS 包。
Client Component 和 RSC 的关系 :带 "use client" 的组件逻辑会下载到浏览器,但在 RSC 流程里,服务端仍会先预渲染一遍它的首屏 UI,把结果写进 Payload(F5 时则写进 HTML)。客户端首次挂载时,仍要与这份服务端输出比对。
0.3 Hydration(注水)
浏览器收到服务端产出的 UI(来自 HTML 或 RSC Payload)后,React 在客户端再跑一遍 Client Component,把事件(如 onClick)、状态(如 useState)「绑定」到已有 DOM 上。
核心约束 :客户端首次 render 的输出,必须和服务端预渲染结果一致,否则报 Hydration mismatch。
服务端预渲染 Client Component → 产出首屏 UI(HTML 或 Payload 里)
↓
客户端首次 render 同一 Client Component
↓
比对是否一致
↓
一致 → Hydration 成功,绑定交互
不一致 → 报错,丢弃服务端 UI,客户端重绘(可能闪屏)
对 Client Component 来说,服务端预渲染和 Hydration 是前后两个阶段,常连续发生,但不是同一件事。无论这份首屏 UI 来自整页 HTML 还是 RSC Payload,比对规则都一样。
0.4 三种场景一张表
重要 :首次访问或 F5 只处理当前 URL 对应的那条路由链 (如 F5 在 /about 只渲染 /about + layout,不会渲染全站其他 page)。
| 场景 | 服务端渲染方式 | 交付给浏览器的是 | Hydration |
|---|---|---|---|
| 首次访问 / F5 / 硬刷新 | ✅ 整页 HTML SSR(底层走 RSC 管线) | 完整 HTML 文档 | ✅ 当前路由链上所有 Client Component |
<Link> / router.push 切路由 |
✅ 段级 RSC 渲染(只渲染变化的路由段) | RSC Payload(非整页 HTML) | layout 里已挂载 的 CC 保持状态、不重新 Hydration;新页面里首次出现的 CC 服务端已预渲染,客户端仍需与其比对后 Hydration |
setState 重渲染 |
❌ 纯客户端 | 无新服务端数据 | ❌ |
| 未访问过的其他路由 | ❌ | ❌ | ❌ |
1. 例子场景:三个文件怎么串起来
RootLayout (Server Component) ← layout.tsx
└── SiteHeader (Server Component) ← siteHeader/index.tsx
├── NavLinks (Client Component) ← navLinks/index.tsx,有 "use client"
└── ThemeToggle (Client Component) ← theme/index.tsx,有 "use client"
| 文件 | 类型 | 职责 |
|---|---|---|
layout.tsx |
Server | 页面骨架;在 <head> 里内联脚本,绘制前 读 localStorage 给 <html> 加 dark class |
siteHeader/index.tsx |
Server | 纯布局,组合导航和主题按钮,本身不碰浏览器 API |
navLinks/index.tsx |
Client | 导航高亮:usePathname() 判断当前路由 |
theme/index.tsx |
Client | 交互按钮:读/写 localStorage,切换图标 🌙/🌞 |
边界原则:Server Component 可以 import Client Component(向下传),Client Component 不能 import Server Component。因为 SC 代码不打进客户端 JS 包,CC 代码会下载到浏览器,反向 import 等于把服务端逻辑拉到浏览器执行。
所以分工是:SiteHeader 负责「放按钮」;ThemeToggle 负责「让按钮能点」。
layout.tsx(Server Component,摘录)
tsx
<html lang="en" suppressHydrationWarning>
<head>
<script
dangerouslySetInnerHTML={{
__html: `(function(){try{var t=localStorage.getItem("${cachedThemeKey}");if(t==="dark")document.documentElement.classList.add("dark")}catch(e){}})();`,
}}
></script>
</head>
<body className={`${geistSans.variable} ${geistMono.variable}`}>
<SiteHeader />
{children}
</body>
</html>
suppressHydrationWarning的作用 :内联脚本在 React 渲染前就修改了<html>的class(如加上dark),这会导致服务端输出的<html>属性与客户端 Hydration 时的 DOM 不一致。suppressHydrationWarning告诉 React 忽略这个元素上的属性差异,不报 mismatch 错误。它只作用于当前元素本身,不影响子树。
siteHeader/index.tsx(Server Component,摘录)
tsx
<div className={styles.siteHeader}>
<NavLinks />
<ThemeToggle />
</div>
theme/index.tsx(Client Component,摘录)
tsx
"use client";
function ThemeToggle() {
const [theme, setTheme] = useState<ThemeMode>("light");
useLayoutEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setTheme(getStoredTheme());
}, []);
useEffect(() => {
document.documentElement.classList.toggle("dark", theme === "dark");
}, [theme]);
return (
<button aria-label="Toggle theme">{theme === "light" ? "🌙" : "🌞"}</button>
);
}
export default ThemeToggle;
2. Client Component 和 Server Component 有何区别?
这是最容易误解的一点:"use client" 的组件仍然会在服务端先预渲染一遍首屏 UI,客户端 Hydration 时必须与这份输出一致(详见第 0.2、0.3 节)。
Server Component vs Client Component 对比
| Server Component | Client Component ("use client") | |
|---|---|---|
| 运行环境 | 只在服务端 | 服务端预渲染 + 客户端 Hydration |
| 代码是否进客户端 JS 包 | ❌ 不进 | ✅ 进 |
| 能用 hooks 吗 | ❌ | ✅ |
| 能用 localStorage 吗 | ❌ | ✅(但只能在浏览器阶段) |
| 能有 onClick 吗 | ❌ | ✅ |
| 会 Hydration 吗 | 不会(不是交互组件) | 会(首次挂载时) |
| 典型用途 | 读数据库、静态内容 | 按钮、表单、主题切换 |
Client Component 的 JS 什么时候执行?
很多人以为:「客户端组件 = 渲染的时候才执行 JS」。只对了一半。 CC 的 JS 分好几层,执行时机并不相同。
总体顺序:JS 下载与首屏显示并行,Hydration 最后发生
服务端生成 HTML(含 <script> / <link rel="preload"> 标签)
↓
浏览器开始接收并解析 HTML
↓
解析到 <script> / <link rel="preload"> → 触发 JS 下载(立即并行开始)
↓(以下两件事并行进行)
┌──────────────────────────────┬────────────────────────────────┐
│ 浏览器用 SSR HTML 渲染首屏 │ JS chunk 在后台下载 │
│ 用户已能看到内容 │(Next.js 已提前注入 preload) │
└──────────────────────────────┴────────────────────────────────┘
↓(JS 下载完成 + React 初始化)
React 在客户端 render → 与服务端 UI 比对 → Hydration(绑定事件)
↓
页面从「能看」变成「能点」
所以:JS 下载不是"UI 显示完之后才开始" ,而是由 HTML 里的
<script>标签触发、与首屏渲染并行进行。Hydration 必须等 JS 加载执行完才能开始,在此之前按钮看得见但点不了。
一次 render 内部的执行顺序:
render(跑组件函数体,算出 JSX)
↓
提交 DOM 更新
↓
useLayoutEffect(在浏览器绘制前)
↓
浏览器绘制
↓
useEffect
3. 为什么会报错?
Hydration 要求:服务端预渲染输出 = 客户端首次 render 输出。这份「服务端输出」在 F5 时来自 HTML,在切路由时来自 RSC Payload,规则相同。
错误写法(首屏直接读 localStorage):
tsx
const [theme, setTheme] = useState(getStoredTheme); // ❌
| 阶段 | 读到什么 | 按钮输出 |
|---|---|---|
| 服务端 SSR | 没有 window,固定 "light" |
🌙 |
| 客户端首次 render | localStorage 是 "dark" |
🌞 |
React 对比 DOM 发现不一致 → 抛出 Hydration mismatch 警告/错误。
4. 为什么会闪屏?
报错之后 React 不会「硬绑」错误的 DOM,而是丢弃服务端的预渲染 UI,在客户端整棵子树重绘。
先看到 SSR 的 🌙(light)
→ hydration 失败
→ 客户端重绘成 🌞(dark)
→ 图标/样式跳变 = 闪屏
页面背景色也可能经历同样过程(先白后黑),因为 dark class 的生效时机和按钮图标不同步。
5. 当前项目的解法
核心思路:服务端首屏和客户端首屏保持一致,localStorage 延后到 Hydration 之后再读。 这对 F5 和切路由同样适用------只要 Client Component 参与服务端预渲染,就要遵守这条规则。
① 按钮图标 --- theme/index.tsx
tsx
const [theme, setTheme] = useState("light"); // 首屏固定 light,和服务端一致
useLayoutEffect(() => {
setTheme(getStoredTheme()); // hydration 后再同步真实主题
}, []);
② 页面背景色 --- layout.tsx 内联脚本
在 JSX 的 <script> 标签里直接写文本内容,React 会出于 XSS 防护将其转义为普通文本而非可执行脚本;dangerouslySetInnerHTML 是明确告知 React「我知道风险,请原样注入这段 HTML」。浏览器收到的是普通 <script>,下面是格式化后的等价形式:
源码写法(layout.tsx):
tsx
<script
dangerouslySetInnerHTML={{
__html: `(function(){try{var t=localStorage.getItem("${cachedThemeKey}");if(t==="dark")document.documentElement.classList.add("dark")}catch(e){}})();`,
}}
></script>
浏览器收到的 HTML(格式化后,便于阅读):
html
<!-- 在 React 渲染前执行,避免背景先白后黑 -->
<script>
(function () {
var t = localStorage.getItem("random-write-theme");
if (t === "dark") document.documentElement.classList.add("dark");
})();
</script>
| 要解决的问题 | 手段 | 为什么有效 |
|---|---|---|
| hydration 报错 | 首屏 useState("light") |
服务端和客户端首次 render 都是 🌙 |
| 图标闪一下 | useLayoutEffect 而非 useEffect |
在浏览器绘制前同步真实主题,减少可见跳变 |
| 背景闪一下 | <head> 内联脚本 |
在 HTML 解析阶段就加上 dark,早于 React |
6. 小结
- F5 / 首次访问 走整页 HTML SSR;切路由走段级 RSC 渲染,交付 RSC Payload 而非新 HTML 文档------不是没有服务端渲染,而是渲染方式和交付物不同。
- 无论哪种入口,Client Component 首次挂载 时都要 Hydration,且服务端首屏必须等于客户端首屏。
- localStorage 这类浏览器 API 只能等 Hydration 之后再读;页面级样式可用
<head>内联脚本提前处理。 - layout 里已挂载的 Client Component 切路由时保持状态;新页面里首次出现 的 Client Component 仍要走 RSC 预渲染 + Hydration,不是跳过比对、纯客户端渲染(
ssr: false除外)。 - CC 的 JS 与首屏显示并行下载,下载完成后在多个时机执行:组件函数体在 render 时跑,事件在交互时跑,effect 在 render 之后跑;Hydration 完成前按钮能看但不能点。