3233 字
约 10 分钟
16
S14-X15 · 服务端渲染与静态生成:Next.js 的取舍

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. 自测

  1. 一个页面既要 SEO 又要显示当前登录用户昵称,哪种形态可行、代价在哪?
  2. "use client" 标在父组件与标在子组件,对产物有什么不同?
  3. 服务端渲染的时间戳与客户端不一致,除了 suppressHydrationWarning 还有什么正确做法?
  4. Next 14 与 Next 15 的 fetch 默认缓存行为差别是什么,为什么必须显式声明?
  5. 本来静态的页面突然变成 ƒ Dynamic,最可能在哪引入了动态依赖?
  6. 决定静态导出时,哪三种能力会直接消失?
S14-X15 · 服务端渲染与静态生成:Next.js 的取舍
https://www.clxhxhhr.top/posts/5905/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。