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 完成前按钮能看但不能点。
相关推荐
weixin_382395239 小时前
为小工厂量身打造:本地部署的物料管理系统带缺料计算
前端·制造
__zRainy__9 小时前
解决pnpm v10+不自动构建
前端·pnpm·工程化
猫猫不是喵喵.10 小时前
Vue3 Props 属性
前端·javascript·vue.js
醉城夜风~11 小时前
CSS元素显示模式(display)
前端·css
AI大模型-小华12 小时前
Codex 三方充值快速入门指南
java·前端·数据库·chatgpt·ai编程·codex·chatgpt pro
做前端的娜娜子14 小时前
同一链接实现 PC Web 与移动 H5 自适应
前端·掘金·金石计划
小帅不太帅14 小时前
架构没变、规模没变,DeepSeek V4 Flash 正式版凭什么暴涨 47 分?
前端·aigc·deepseek
jarvisuni14 小时前
DeepSeekFlash前端依旧拉垮,而且变慢了很多!
前端·javascript·算法
卷福同学16 小时前
AI编程出海第二步:验证关键词能否做站
前端·人工智能·后端