Next.js 的渲染模式不是全局开关,而是按路由段逐个决定的坐标:这段数据能不能在构建期拿到、这个页面是不是每人一份、这次部署有没有 Node 进程。同一份 JSX 放在 App Router 的默认服务端组件里是一次 SSR,放进 output: 'export' 就只能是构建期快照,加一个 revalidate 又变成低频再生。选错不会立刻报错,只在流量峰值、内容更新或部署形态变更时暴露。
按五个判据拆:形态 → 组件边界 → 水合 → 缓存语义 → 部署形态。
1. 四种渲染形态:解决什么,代价是什么
| 形态 | 渲染位置与时机 | 解决的问题 | 付出的代价 |
|---|---|---|---|
| CSR | 浏览器,请求之后 | 交互重、无须 SEO 的后台 | 首屏空窗;SEO 依赖爬虫执行 JS |
| SSR | 服务端,每次请求 | 个性化 + 首屏有内容 + 可被直接抓取 | 每请求占 CPU;TTFB 被数据源拖住;需常驻进程 |
| SSG | 构建期,一次 | 最好的首屏与最强的可缓存性 | 内容更新必须重建重部署;构建时间随页数增长 |
| ISR | 构建期 + 后台再生 | 静态的速度 + 有限的新鲜度 | 有"旧版本仍在上线"的窗口;再生失败多为静默 |
判据不是"哪个更快",而是三问:数据是不是每人一份、更新是否要即时可见、有没有 Node 进程。真正的 SSR 需求来自响应依赖请求上下文——登录态、Cookie、地理位置、实时价格;文档、博客这类构建期确定的页面用 SSG,首屏与 SEO 都不差,还省掉每请求的服务端成本。
2. App Router 与 Pages Router 的差别
| 维度 | Pages Router | App Router(Next 13+,14 为稳定主线) |
|---|---|---|
| 组件默认环境 | 全部客户端组件 | 默认服务端组件,"use client" 才进客户端 |
| 数据获取 | getServerSideProps / getStaticProps |
组件内 async 直接 await fetch |
| 路由段配置 | 导出函数返回值 | 导出 dynamic / revalidate / runtime 常量 |
| 布局与加载态 | _app.tsx 单一布局,手写 isLoading |
layout.tsx 嵌套,loading.tsx + Suspense |
| 元数据 | next/head |
metadata 导出或 generateMetadata |
Pages Router 是页面级钩子,一页一个入口;App Router 是组件级 await,同一棵树上多个数据源各自流式下发。代价是缓存语义下沉到请求级,必须逐个 fetch 交代清楚。
// app/products/[id]/page.tsx
export const revalidate = 300;
export default async function Page({ params }: { params: { id: string } }) {
const res = await fetch(`https://api.internal/products/${params.id}`, {
next: { revalidate: 300, tags: [`product:${params.id}`] },
});
if (!res.ok) notFound();
return <ProductView data={await res.json()} />; // 留在服务端
}
3. 服务端组件与客户端组件的划分判据
划分是硬约束,不是风格:用了 hooks、事件、浏览器 API 的必须在客户端;纯展示、只接 props 的留在服务端,才真正减少下发 JS。
| 组件里出现的东西 | 必须 "use client" |
说明 |
|---|---|---|
useState / useEffect |
是 | 服务端组件没有状态与生命周期 |
onClick 等事件处理器 |
是 | 事件绑定只存在于浏览器 |
window / document / localStorage |
是 | 服务端渲染时不存在 |
依赖 useContext 的库 |
是 | Context 消费在客户端 |
async 组件直接 await |
否 | 客户端组件不能是 async |
| 纯 props 渲染的展示组件 | 否 | 留在服务端,零客户端 JS |
"use client" 标的是边界而非单文件:某文件标了,它 import 的所有模块都进客户端图。最典型的浪费是"为了一个 useState 把整页标成客户端",连带把日期库、Markdown 解析器打进 bundle。正确切法是把交互下沉到叶子:
// app/dashboard/page.tsx —— 服务端取数与拼装
export default async function Dashboard() {
const stats = await getStats(); // 查库,凭据不出服务端
return <section><h1>{stats.title}</h1><Counter initial={stats.count} /></section>;
}
// app/dashboard/counter.tsx —— 交互叶子
'use client';
export function Counter({ initial }: { initial: number }) {
const [n, setN] = useState(initial);
return <button onClick={() => setN(n + 1)}>{n}</button>;
}
反过来,父组件标了 "use client",children 即使没标也按客户端处理。
4. 水合:机制与不一致报错的成因
服务端产出的 HTML 到达浏览器后,React 要重放一次同样的组件树,挂事件、建状态容器,这叫水合(hydration)。它不重建 DOM,而是逐节点比对:服务端输出与客户端首次渲染不一致,就报 hydration mismatch。
| 成因 | 典型代码 | 处理 |
|---|---|---|
| 时间相关 | new Date().toLocaleString() |
服务端给固定时间戳,挂载后再格式化 |
| 随机值 | Math.random() |
用 useId();随机值只在 effect 里取 |
| 浏览器存储 | 初始 state 读 localStorage |
首屏用默认值,useEffect 里读 |
| 环境分支 | 渲染里写 typeof window !== 'undefined' ? A : B |
挂载后切换,或 ssr: false |
// 错误:服务端与客户端的时间必然不同
<span>{new Date().toLocaleTimeString()}</span>
// 正确:首屏一致,挂载后再更新
'use client';
function Clock() {
const [now, setNow] = useState<string | null>(null);
useEffect(() => setNow(new Date().toLocaleTimeString()), []);
return <span suppressHydrationWarning>{now ?? '--:--:--'}</span>;
}
suppressHydrationWarning 只压制该节点文本的警告,不修复结构差异;浏览器插件改写 DOM 也会造成看似无故的 mismatch。
5. 数据获取的缓存语义
App Router 里 fetch 默认带缓存,但默认值变过:Next 14 默认 force-cache,Next 15 起默认不缓存。必须显式声明,按锁定的版本核对。
| 写法 | 渲染性质 | 数据新鲜度 | 适用 |
|---|---|---|---|
cache: 'force-cache' |
构建期可取则静态 | 与构建产物同寿命 | 内容页、文档 |
cache: 'no-store' |
每次请求动态 | 实时 | 登录态、库存、价格 |
next: { revalidate: N } |
静态 + 后台再生 | 最长 N 秒陈旧 | 列表、商品详情 |
next: { tags: [...] } |
静态 + 可定点失效 | 由 revalidateTag 决定 |
后台改数据后刷新 |
cookies() / headers() / searchParams |
强制动态 | 每次请求 | 个性化 |
import { revalidateTag, revalidatePath } from 'next/cache';
export async function POST(req: Request) {
const { id } = await req.json();
await updateProduct(id);
revalidateTag(`product:${id}`); // 只影响打了该 tag 的 fetch
revalidatePath('/products'); // 影响整段路由产物
return Response.json({ ok: true });
}
路由段开关 export const dynamic = 'force-dynamic' | 'force-static' | 'error' 里的 'error' 最有用:让"本该静态的页面意外变动态"在构建时报错。高频事故是页面里加了一个 cookies() 调用(哪怕只读主题偏好),整段退化为动态、CDN 缓存全失效,只能靠构建输出里的 ○ Static / ƒ Dynamic 标记核对。
6. 流式渲染与 Suspense
SSR 的传统问题是"等最慢的那个数据源"。流式把 HTML 分块下发:外壳先到,慢的部分用占位符顶着,数据好了再追加。
export default function Page() {
return (
<>
<Header /> {/* 立刻下发 */}
<Suspense fallback={<ListSkeleton />}>
<SlowList /> {/* await 完成后追加 */}
</Suspense>
</>
);
}
loading.tsx 是路由段级 Suspense 边界,Suspense 可以细到组件。注意:流式一旦开始发送,HTTP 状态码就定死了,下游抛 notFound() 只能靠客户端兜底;generateMetadata 是阻塞的,别放进深层 Suspense。
7. SSR 特有的一类问题:只能在浏览器用的库
服务端没有 window、document、navigator,任何在模块顶层碰这些对象的库都会在 SSR 阶段抛 ReferenceError: window is not defined。三种处理,侵入性从小到大:
// 1) 动态导入 + 关闭 SSR(只能写在客户端组件里)
'use client';
import dynamic from 'next/dynamic';
const Editor = dynamic(() => import('@/components/editor'), { ssr: false });
// 2) 延迟到挂载后:库只在 effect 内被调用
useEffect(() => { const chart = new Chart(el.current!, cfg); }, []);
// 3) 用库提供的 SSR 安全入口
import { createClient } from 'some-lib/server';
ssr: false 在 Server Component 里被禁止(Next 14 起直接报错),必须放在客户端组件中——"关闭 SSR"本身是个客户端决策。
8. 部署形态反过来限制渲染模式
| 部署形态 | 可用 | 不可用 | 触发点 |
|---|---|---|---|
Node 常驻(next start、容器) |
CSR / SSR / SSG / ISR | — | 默认形态 |
静态导出 output: 'export' |
CSR / SSG | 动态 SSR、ISR、POST 型 Route Handler、默认图片优化 | 构建时无服务端 |
边缘运行时 runtime = 'edge' |
SSR / SSG / 流式 | fs、child_process、原生二进制依赖 |
运行时是 V8 isolate |
| Serverless | SSR / ISR(看平台) | 长连接、本地文件写入 | 实例无状态 |
静态导出的约束最硬,报错多在构建期出现:动态路由缺 generateStaticParams、页面用了 cookies()、Route Handler 声明了 POST。边缘运行时的陷阱是"编译通过、运行才炸":某个依赖 require('fs'),判断方法是看依赖树里有没有 native addon 或 Node 内置模块。
9. 常见坑
| 坑 | 表现 | 根因 | 改法 |
|---|---|---|---|
服务端组件里用 useState |
报 useState only works in Client Components |
默认环境是服务端 | 拆出叶子加 "use client" |
整棵树标 "use client" |
bundle 变大、服务端能力丢失 | 边界标在根节点 | 边界下沉到交互叶子 |
| 缓存没失效 | 后台改了、前台还是旧的 | 静态产物 + 无 revalidateTag |
打 tag 并在写后定点失效 |
| CSS-in-JS 受限 | 样式闪烁或运行时报错 | runtime 方案依赖客户端注入 | 改用 CSS Modules / Tailwind |
window 未定义 |
构建或 SSR 期 ReferenceError |
模块顶层访问浏览器对象 | 动态导入或移进 effect |
| 静态导出后接口 404 | 部署到纯静态托管 | output: 'export' 无服务端 |
接口外置或改 Node 部署 |
CSS-in-JS 单独说:服务端组件不执行客户端运行时,styled-components 这类方案要么限制在客户端边界内,要么配 registry 收集样式;新项目直接用 CSS Modules 或原子化 CSS 能省掉整类问题。
10. 排错表
| 现象 | 优先核对 | 处理 |
|---|---|---|
| 控制台 hydration mismatch | 首屏是否用了时间/随机/存储 | 按第 4 节四类成因逐个排除 |
| 页面意外变成动态 | 构建输出的 ƒ 标记、有无 cookies() |
去掉隐式动态依赖或显式 force-static |
| 构建通过但线上 500 | 边缘运行时依赖了 Node API | 改回 Node 运行时或替换依赖 |
fetch 拿到旧数据 |
cache 与 revalidate 实际值 |
显式声明,写后 revalidateTag |
| 静态导出报动态路由错 | 缺 generateStaticParams |
补全可枚举路径 |
| 首屏白屏后突然出现 | 深层 Suspense 里抛了 notFound() |
状态判定前移到外壳 |
11. 边界:什么时候别用 Next.js 的渲染能力
| 场景 | 更合适的选择 | 理由 |
|---|---|---|
| 纯后台、无 SEO | Vite + React SPA | 省掉服务端进程与 RSC 心智负担 |
| 内容几乎不变 | 纯 SSG(Astro、Next 静态导出) | 不需要每请求渲染 |
| 极致交互、离线优先 | SPA + Service Worker | 服务端渲染对离线无帮助 |
| 页面数十万级 | 按需 ISR + 增量构建 | 全量预构建时间不可接受 |
| 团队不熟 RSC 边界 | Pages Router 或 SPA | 边界划错的成本高于收益 |
渲染模式的选择本质是"把计算放在离数据近的地方,还是离用户近的地方":SSR 放服务端换首屏与权限,SSG 前移到构建期换缓存,CSR 交给浏览器换部署简单。
12. 自测
- 一个页面既要 SEO 又要显示当前登录用户昵称,哪种形态可行、代价在哪?
"use client"标在父组件与标在子组件,对产物有什么不同?- 服务端渲染的时间戳与客户端不一致,除了
suppressHydrationWarning还有什么正确做法? - Next 14 与 Next 15 的
fetch默认缓存行为差别是什么,为什么必须显式声明? - 本来静态的页面突然变成
ƒ Dynamic,最可能在哪引入了动态依赖? - 决定静态导出时,哪三种能力会直接消失?