知

模块三 · 路由、测试与服务端

第 13 / 18 节20 分钟阅读型

SSR、Next.js 与 React 19:服务端与客户端的分界线

首屏快不快,取决于哪些工作放在了服务端。这一节讲清 CSR/SSR/SSG/ISR 的成本、水合失败的根因、Server 与 Client Component 的边界,以及 React 19 的 Actions。

读完这一节,你能做到

  1. 按"内容是否需要实时 + 是否需要 SEO"选出渲染模式。
  2. 解释水合是什么,并定位 hydration mismatch。
  3. 划清 Server Component 与 Client Component 的边界。
  4. 用 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 ComponentClient Component
声明方式默认文件顶部 "use client"
能否用 hooks不能能
能否用事件不能能
数据获取直接 await需要请求库或 effect
打包体积不进客户端 bundle进 bundle
能访问的服务端资源数据库、密钥不能

边界规则:

  1. "use client" 是模块边界,它导入的所有模块都会进入客户端包。所以不要把整个页面标成 client。
  2. Server Component 可以把"渲染好的元素"作为 children 传给 Client Component,这是把服务端内容嵌入交互组件的标准做法。
  3. Client Component 不能 import Server 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 的区别

维度SSRRSC
产物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 触发失效,而不是依赖"过一会儿就好了"。这与前面讲的请求库失效是同一个思路:写操作后必须让相关读取失效。

七、常见坑

  1. 全站加 "use client",RSC 的收益全部消失。
  2. 在 Server Component 里用 useState,直接报错。
  3. 以为 SSR 就一定快,结果服务端渲染耗时 800ms,TTFB 反而更差。
  4. 水合不一致用 suppressHydrationWarning 掩盖,实际每次交互都在重新渲染。
  5. 在渲染期读 localStorage 或 window,服务端直接崩。
  6. 客户端包引用了服务端专用的库(数据库驱动、密钥),构建失败或泄漏。
  7. ISR 的再生时间设成 0,等于每次请求都渲染,失去静态优势。

八、动手任务:自写 → AI 写 → 对比 → 取舍

四步不跳:① 自己写(限时,关掉补全)→ ② 让 AI 写同一题 → ③ 逐条比对 → ④ 说出保留谁的写法。 完整动作与记录模板见《AI 协作与代码评审》。

① 自己写

  1. 用 Next.js 起一个项目,写一个 Server Component 列表页 + 一个 Client Component 搜索框,观察客户端 bundle 大小。
  2. 故意制造一次水合不一致(服务端与客户端渲染不同时间),记录报错信息并修复。
  3. 用 useActionState 写一个带校验与 pending 的表单。
  4. 给列表页加 revalidatePath,验证写操作后数据是否刷新。

② 让 AI 写同一题

把第 2 题(水合不一致)交给 AI,补一句 不许用 suppressHydrationWarning 绕过。

③ 对比要盯的点

  • 它的“修复”是不是只加了一个 suppressHydrationWarning?那是盖住,不是修好。
  • 它有没有把 Date.now()、Math.random() 或 window 判断写进渲染体?这些必须挪到 effect 或服务端。
  • "use client" 它只加在需要的文件上,还是能用 Server Component 的地方也一起加了?

④ 一句话取舍

什么场景下 suppressHydrationWarning 才算正解?举一个你自己项目里的真实例子。

九、自测

  1. CSR、SSR、SSG、ISR 的成本差异是什么?
  2. 水合失败的四种常见原因是什么?
  3. Server Component 与 SSR 的区别是什么?
  4. "use client" 的作用范围是什么?
  5. React 19 的 Actions 解决了哪些样板代码?

参考答案要点:服务器成本与新鲜度;随机值、环境判断、标签嵌套、时区;RSC 是组件在服务端执行且零水合;模块及其依赖;提交、pending、错误、乐观更新。

附:流式渲染与按需水合

首屏优化到一定程度后,剩下的瓶颈往往是“等所有数据都准备好才输出 HTML”。流式渲染解决的就是这个问题。

方式行为效果
传统 SSR全部数据就绪后一次性输出TTFB 高,但首屏完整
流式 SSR先输出外壳,数据就绪的区块陆续吐出TTFB 低,用户更早看到内容
选择性水合只水合用户即将交互的部分减少主线程阻塞

落到 Next.js 里就是 <Suspense> 边界:边界内的内容慢,外壳先发出去,浏览器先渲染骨架。这与前面讲的 Suspense 是同一套机制,只是执行位置从浏览器换到了服务端。

三条实践:

  1. 把慢请求放到叶子节点。放在页面顶层会拖慢整个流。
  2. 骨架尺寸要固定,否则内容到达时布局跳动,CLS 变差。
  3. 不要为了流式而流式。数据都在 50ms 内返回时,流式带来的复杂度没有收益。

按需水合目前仍依赖框架能力(Next.js 的 dynamic 与 RSC 组合)。判断是否需要:首屏有大量静态内容 + 少量交互区域时收益明显;整页都是表单与图表时,收益有限。

配套题目

★ 越多越该先背;点题目可看完整答案与深入版。

提示:读完可以顺手做几道对应题目,检验是否真的理解