模块三 · 路由、测试与服务端
SSR、Next.js 与 React 19:服务端与客户端的分界线
首屏快不快,取决于哪些工作放在了服务端。这一节讲清 CSR/SSR/SSG/ISR 的成本、水合失败的根因、Server 与 Client Component 的边界,以及 React 19 的 Actions。
读完这一节,你能做到
- 按"内容是否需要实时 + 是否需要 SEO"选出渲染模式。
- 解释水合是什么,并定位
hydration mismatch。 - 划清 Server Component 与 Client Component 的边界。
- 用 React 19 的 Actions、
useActionState、useOptimistic处理表单。
一、四种渲染模式
| 模式 | HTML 何时生成 | 首屏 | 数据新鲜度 | 适用 |
|---|---|---|---|---|
| CSR | 浏览器执行 JS 后 | 慢,白屏 | 实时 | 后台系统、强交互 SPA |
| SSR | 每次请求时 | 快 | 实时 | 个性化页面、需 SEO |
| SSG | 构建时 | 最快 | 构建时 | 博客、文档、营销页 |
| ISR | 构建时 + 定期再生 | 快 | 近实时 | 商品列表、内容站 |
成本对比:
| 模式 | 服务器成本 | 缓存难度 | 首屏 TTFB |
|---|---|---|---|
| CSR | 最低(静态托管) | 低 | 取决于 API |
| SSR | 高(每次渲染) | 高 | 取决于服务端渲染耗时 |
| SSG | 低 | 低 | 最低 |
| ISR | 中 | 中 | 低 |
判断顺序:先问内容是否因人而异,再问是否允许过期,最后才问是否要 SEO。后台管理页既不需要 SEO 也不允许过期,CSR 就是正确答案。
二、水合:HTML 只是"照片"
SSR 返回的 HTML 是静态快照,浏览器要执行 JS 让 React 接管,这个过程叫水合(hydration):
服务端渲染 HTML → 浏览器显示(可看不可点)
→ 下载并执行 JS → React 复用现有 DOM 并绑定事件 → 可交互
水合失败(hydration mismatch)的四种典型原因:
| 原因 | 例子 | 修法 |
|---|---|---|
| 服务端与客户端数据不同 | 直接读 Date.now()、Math.random() | 放到 useEffect 或 suppressHydrationWarning |
| 浏览器环境判断 | typeof window !== "undefined" 直接渲染不同分支 | 用 useEffect 切换,首屏保持同一结构 |
| 非法嵌套 HTML | <div> 里放 <p> 里再放 <div> | 修正标签结构 |
| 时区/语言差异 | 服务端 UTC、客户端本地时区格式化 | 统一时区或客户端格式化 |
排查方法:控制台会指出"哪个节点的哪个属性不匹配"。多数情况不是 React 的问题,而是同一份数据在两端算出了不同结果。
三、Server Component 与 Client Component
Next.js App Router 默认所有组件都是 Server Component:
| 维度 | Server Component | Client Component |
|---|---|---|
| 声明方式 | 默认 | 文件顶部 "use client" |
| 能否用 hooks | 不能 | 能 |
| 能否用事件 | 不能 | 能 |
| 数据获取 | 直接 await | 需要请求库或 effect |
| 打包体积 | 不进客户端 bundle | 进 bundle |
| 能访问的服务端资源 | 数据库、密钥 | 不能 |
边界规则:
"use client"是模块边界,它导入的所有模块都会进入客户端包。所以不要把整个页面标成 client。- Server Component 可以把"渲染好的元素"作为
children传给 Client Component,这是把服务端内容嵌入交互组件的标准做法。 - Client Component 不能
importServer Component,但可以通过 props 接收它的输出。
// app/orders/page.tsx(Server)
import { OrderTable } from "./order-table"; // Client
import { getOrders } from "@/lib/db";
export default async function Page() {
const orders = await getOrders(); // 直接查库,不进客户端
return <OrderTable orders={orders} />;
}
// app/orders/order-table.tsx
"use client";
export function OrderTable({ orders }: { orders: Order[] }) {
const [keyword, setKeyword] = useState("");
// 交互逻辑在客户端
}
一个常见误区:以为 Server Component 就是 SSR。SSR 是"在服务端生成 HTML",RSC 是"组件在服务端执行、只把序列化结果发给客户端",两者可以同时存在,也可以分别使用。
四、RSC 与 SSR 的区别
| 维度 | SSR | RSC |
|---|---|---|
| 产物 | HTML 字符串 | 序列化的组件树(RSC Payload) |
| 客户端 JS | 整个组件树都要水合 | Server Component 不参与水合,零 JS |
| 数据访问 | 需要额外请求 | 直接在组件里查库 |
| 更新 | 整页或路由跳转 | 可以只更新一部分(Server Action 后刷新) |
RSC 的真正价值是减少客户端 JS,而不是"更快出 HTML"。一个全是 Server Component 的页面,客户端可能只有几百 KB 的运行时。
五、React 19 的 Actions
React 19 把"表单提交 + 异步 + 状态"收敛成一套 API:
"use client";
import { useActionState } from "react";
async function updateName(prev: string | null, formData: FormData) {
const name = String(formData.get("name"));
if (!name) return "名称不能为空";
await saveName(name);
return null;
}
export function NameForm() {
const [error, formAction, isPending] = useActionState(updateName, null);
return (
<form action={formAction}>
<input name="name" />
{error && <p role="alert">{error}</p>}
<button disabled={isPending}>{isPending ? "保存中" : "保存"}</button>
</form>
);
}
四个收益:
- 不需要手写
onSubmit+preventDefault+setPending。 isPending由 React 管理,不会出现重复提交。- 表单在 JS 加载完成前也能通过原生表单提交(渐进增强)。
- 错误作为返回值,而不是抛异常。
配合 useOptimistic 做乐观更新:
const [optimisticItems, addOptimistic] = useOptimistic(
items,
(state, next: Item) => [...state, next],
);
React 19 其他值得记住的变化:
| 变化 | 说明 |
|---|---|
ref 作为 prop | 函数组件不再需要 forwardRef |
use() | 在组件里读取 Promise 或 Context |
| 文档元数据 | 组件里直接写 <title>、<meta>,自动提升到 head |
| 错误清理 | 未捕获错误统一交给 onUncaughtError 等根选项 |
六、Next.js 的数据获取与缓存
| 场景 | 做法 |
|---|---|
| Server Component 取数 | 直接 await fetch 或查库 |
| 需要实时 | fetch(..., { cache: "no-store" }) |
| 需要缓存 | 默认缓存 + revalidate 定时再生 |
| 客户端交互取数 | TanStack Query |
| 写操作 | Server Action 或 Route Handler |
缓存要显式:用 revalidatePath/revalidateTag 触发失效,而不是依赖"过一会儿就好了"。这与前面讲的请求库失效是同一个思路:写操作后必须让相关读取失效。
七、常见坑
- 全站加
"use client",RSC 的收益全部消失。 - 在 Server Component 里用
useState,直接报错。 - 以为 SSR 就一定快,结果服务端渲染耗时 800ms,TTFB 反而更差。
- 水合不一致用
suppressHydrationWarning掩盖,实际每次交互都在重新渲染。 - 在渲染期读
localStorage或window,服务端直接崩。 - 客户端包引用了服务端专用的库(数据库驱动、密钥),构建失败或泄漏。
- ISR 的再生时间设成 0,等于每次请求都渲染,失去静态优势。
八、动手任务:自写 → AI 写 → 对比 → 取舍
四步不跳:① 自己写(限时,关掉补全)→ ② 让 AI 写同一题 → ③ 逐条比对 → ④ 说出保留谁的写法。 完整动作与记录模板见《AI 协作与代码评审》。
① 自己写
- 用 Next.js 起一个项目,写一个 Server Component 列表页 + 一个 Client Component 搜索框,观察客户端 bundle 大小。
- 故意制造一次水合不一致(服务端与客户端渲染不同时间),记录报错信息并修复。
- 用
useActionState写一个带校验与 pending 的表单。 - 给列表页加
revalidatePath,验证写操作后数据是否刷新。
② 让 AI 写同一题
把第 2 题(水合不一致)交给 AI,补一句 不许用 suppressHydrationWarning 绕过。
③ 对比要盯的点
- 它的“修复”是不是只加了一个
suppressHydrationWarning?那是盖住,不是修好。 - 它有没有把
Date.now()、Math.random()或window判断写进渲染体?这些必须挪到 effect 或服务端。 "use client"它只加在需要的文件上,还是能用 Server Component 的地方也一起加了?
④ 一句话取舍
什么场景下 suppressHydrationWarning 才算正解?举一个你自己项目里的真实例子。
九、自测
- CSR、SSR、SSG、ISR 的成本差异是什么?
- 水合失败的四种常见原因是什么?
- Server Component 与 SSR 的区别是什么?
"use client"的作用范围是什么?- React 19 的 Actions 解决了哪些样板代码?
参考答案要点:服务器成本与新鲜度;随机值、环境判断、标签嵌套、时区;RSC 是组件在服务端执行且零水合;模块及其依赖;提交、pending、错误、乐观更新。
附:流式渲染与按需水合
首屏优化到一定程度后,剩下的瓶颈往往是“等所有数据都准备好才输出 HTML”。流式渲染解决的就是这个问题。
| 方式 | 行为 | 效果 |
|---|---|---|
| 传统 SSR | 全部数据就绪后一次性输出 | TTFB 高,但首屏完整 |
| 流式 SSR | 先输出外壳,数据就绪的区块陆续吐出 | TTFB 低,用户更早看到内容 |
| 选择性水合 | 只水合用户即将交互的部分 | 减少主线程阻塞 |
落到 Next.js 里就是 <Suspense> 边界:边界内的内容慢,外壳先发出去,浏览器先渲染骨架。这与前面讲的 Suspense 是同一套机制,只是执行位置从浏览器换到了服务端。
三条实践:
- 把慢请求放到叶子节点。放在页面顶层会拖慢整个流。
- 骨架尺寸要固定,否则内容到达时布局跳动,CLS 变差。
- 不要为了流式而流式。数据都在 50ms 内返回时,流式带来的复杂度没有收益。
按需水合目前仍依赖框架能力(Next.js 的 dynamic 与 RSC 组合)。判断是否需要:首屏有大量静态内容 + 少量交互区域时收益明显;整页都是表单与图表时,收益有限。
配套题目
★ 越多越该先背;点题目可看完整答案与深入版。
提示:读完可以顺手做几道对应题目,检验是否真的理解