Next.js - 04 - 深入理解next.js渲染原理

Next.js SSR / RSC / Hydration 笔记------以主题切换为例

本文目的 :借助主题切换这个真实场景,理解 Next.js 的 Server / Client Component 渲染模式,分清 SSR、RSC、Hydration 三者的关系,以及 hydration 不一致为何会导致报错和闪屏。

0. 基础概念:SSR、RSC 与 Hydration(注水)

读这篇笔记前,先分清三个词:SSRRSCHydration 。很多人把「切路由」理解成「没有服务端渲染」,其实更准确的说法是:切路由不发生「整页 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. 小结

  1. F5 / 首次访问 走整页 HTML SSR;切路由走段级 RSC 渲染,交付 RSC Payload 而非新 HTML 文档------不是没有服务端渲染,而是渲染方式和交付物不同。
  2. 无论哪种入口,Client Component 首次挂载 时都要 Hydration,且服务端首屏必须等于客户端首屏
  3. localStorage 这类浏览器 API 只能等 Hydration 之后再读;页面级样式可用 <head> 内联脚本提前处理。
  4. layout 里已挂载的 Client Component 切路由时保持状态;新页面里首次出现 的 Client Component 仍要走 RSC 预渲染 + Hydration,不是跳过比对、纯客户端渲染(ssr: false 除外)。
  5. CC 的 JS 与首屏显示并行下载,下载完成后在多个时机执行:组件函数体在 render 时跑,事件在交互时跑,effect 在 render 之后跑;Hydration 完成前按钮能看但不能点。
相关推荐
Patrick_Wilson1 小时前
从一次 Safari 白屏,聊聊 CDN 外置 React 的坑与前端监控盲区
前端·react.js·cdn
lichenyang4532 小时前
从 Demo 到本地 HAR 包:把 HarmonyOS JSBridge 运行时做成可复用库
前端
不简说2 小时前
拖拽DIY表格插件来了!sv-print 七月更新:这次真的能自己拼表格了
前端·javascript·前端框架
ssshooter2 小时前
小程序分包页面报 "has not been registered yet" 的隐蔽根因:一个动态 import 拖垮整个分包
前端·javascript·微信小程序
鹏多多2 小时前
Web浏览器后台离线通知方案实现以及兼容指南
前端·javascript·vue.js
yangmu32032 小时前
Vue3 响应式系统深度解析:从 Proxy 到依赖追踪
前端·javascript·vue.js
还有多久拿退休金3 小时前
AI 写了代码,谁替你"试毒"?
前端·架构·ai编程
大E帝国子民13 小时前
OpenCloudOS Server 9 安装 Nginx 完整指南
前端·网络·nginx
少年白马醉春风丶3 小时前
【AI Agent面试题】Agent 的自主性分级,怎么理解?
前端