← 返回博客
架构2026-08-21 23:42:41 0

给 Next.js 16 上 ISR,我踩了三个坑:connection、mock 污染与构建失败

给 Next.js 16 内容页上 ISR 时踩到的三个坑:connection 让缓存静默失效、构建期 mock 污染产物、数据层抛错导致 next build 直接失败。含最终正确姿势与验证。

给 Next.js 16 上 ISR,我踩了三个坑:connection、mock 污染与构建失败

作者:陈宁(万象部落) 关键词:Next.js、ISR、SSG、缓存、revalidate、connection 分类:架构

背景

在上篇《从 TP5.1 到 Next.js BFF:7 个让首页一直显示 Mock 的坑》里,我把存量 ThinkPHP 博客接到了 Next.js 16 的 BFF 架构上。当时的做法是:内容页全部 export const dynamic = "force-dynamic",每次请求都实时去 BFF(Next.js 自身)→ web1(ThinkPHP)→ MySQL 取数据。

这样能保证数据最新,但代价也明显:

  • 每次访问都穿透到 MySQL,文章这种低频变化的内容完全没必要实时;
  • 服务端渲染延迟随后端响应波动;
  • 高并发下后端压力大。

所以很自然地想上 ISR(Incremental Static Regeneration):首次生成后缓存 HTML,后台按 revalidate 周期刷新。听起来很简单,但我在 Next.js 16 上踩了三个坑,每个都花了不少时间。这篇文章完整记录,希望能帮你少走弯路。

架构回顾

浏览器 → slb Nginx:443 → node:3000(Next.js BFF) → web1:80(TP5.1,后续 Gin)

内容页数据链路:

坑 1:connection() 让 ISR 静默失效

现象

我给首页加上了:

export const revalidate = 600; // 10 分钟

并在页面组件里调用 connection() 来「构建期跳过预渲染」,理由是防止构建期在无后端的 CI 里把 mock 烤进产物

import { connection } from "next/server";

export default async function Home() {
  await connection(); // 我原以为这只是让预渲染停下
  const data = await getHome(600);
  // ...
}

结果部署后 curl -I 一看:

cache-control: private, no-cache, no-store, max-age=0, must-revalidate

完全没有 x-nextjs-cache 头,也没有 s-maxage ISR 根本没生效。

根因

查了 Next.js 16 官方文档,关键在 fetchCache 的默认行为:

By default, Next.js will cache any fetch() requests that are reachable before any Request-time APIs are used and will not cache fetch requests that are discovered after Request-time APIs are used.

connection() 是一个 Request-time API。它之后的 fetch 请求都不会被缓存,整条路由退化成纯动态渲染。我之前还纠结「为什么文档里 connection 用于跳过预渲染却不影响 revalidate」——实际上在 cacheComponents 关闭的默认模型下,connection() 之后的 fetch 直接不缓存,ISR 就废了。

修复

去掉 connection() 那「构建期防 mock」怎么办?这正是坑 2 和坑 3 要解决的问题。

Checklist

  • connection() / cookies() / headers() 等 Request-time API 之后的 fetch 不会被缓存
  • 用了这些 API,revalidate 就形同虚设,页面对外是 no-store
  • 想让 ISR 生效,页面里不要出现 Request-time API

坑 2:构建期 mock 污染——老问题换个马甲

现象

去掉 connection() 后,revalidate 确实生效了,但出现了一个更隐蔽的问题:构建产物里是 mock 数据

原因很简单:CI 构建环境里没有 BFF/web1,fetchApihttp://localhost:3000 必然失败,而 lib/blog.ts 里每个取数函数都有 try/catch 回退到本地 mock。于是:

  • 构建期预渲染时 fetch 失败 → 回退 mock → mock 被烤进 .next 静态产物
  • 线上首次访问直接命中这个带 mock 的产物

这不就是上篇文章里「坑 4:构建期静态预渲染把 mock 烤进产物」吗?只是这次换成了 ISR 的形态。

修复思路

其实我在坑 1 里就隐约想要一个「构建期不取数」的方案,但 connection() 会连带把缓存也关掉。后来意识到真正的解法是另一个维度:构建期让 fetch 走到一个真实可达的地址

CI(GitHub Actions)是有公网访问的。于是:

const isBuildPhase = process.env.NEXT_PHASE === "phase-production-build";

const API_BASE = isBuildPhase
  ? process.env.NEXT_PUBLIC_API_BASE ?? "https://www.wxbuluo.com" // CI 走公网,真实数据
  : isServer
    ? process.env.API_BASE_URL ?? "http://localhost:3000"          // 运行时走内网 BFF
    : process.env.NEXT_PUBLIC_API_BASE ?? "";

关键点:

  • 构建期NEXT_PHASE === "phase-production-build"):用公网 https://www.wxbuluo.com,CI 能访问,拉到的是真实数据;
  • 运行时(node 容器):用内网 localhost:3000(BFF),不依赖公网回环。

这样构建产物里是真实文章而不是 mock。

Checklist

  • ISR 页面在构建期会真实执行取数,取不到就回退(mock/空),必须让构建期能取到真数据
  • CI 环境通常有公网,可用公网 URL 做构建期取数
  • 运行时无外网回环时,走内网 BFF 地址,两者用 NEXT_PHASE 区分

坑 3:数据层抛错会让 next build 直接失败

现象

在坑 2 的早期版本,我想用「构建期守卫」——数据层在构建期抛错,让 Next 把该路由标记为动态而不是烤 mock:

function isrGuard(revalidate?: number) {
  if (isBuildPhase && revalidate != null) {
    throw new Error("[ISR] build-time fetch unavailable");
  }
}

结果构建直接失败:

Error occurred prerendering page "/". Read more: https://nextjs.org/docs/messages/prerender-error
Error: [ISR] build-time fetch unavailable
    at e (lib/blog.ts:8:11)
Export encountered an error on /page: /, exiting the build.

根因

我对 Next 的容错假设错了。revalidate 的路由在构建期预渲染失败时,并不会「自动降级为动态」然后继续构建——它会直接 exiting the build。Next 16 的取舍是:宁可构建失败,也不能让一个静态化路由带着错误产物上线。

所以「在数据层抛错来阻止预渲染」这条路走不通。

修复

不抛错。用坑 2 的公网取数方案,让构建期成功拿到真实数据,预渲染自然成功。如果公网也失败(罕见),就让 catch 兜底——但那已经是极小概率,且不会阻塞构建。

Checklist

  • revalidate 路由构建期预渲染抛错 → next build 直接失败,不会降级
  • 不要在数据层用「抛错」来阻止 ISR 预渲染
  • 正确姿势是让构建期取数成功(如公网 URL),而不是让预渲染失败

正确的 ISR 姿势(最终版)

经过三个坑,最终方案是:

1. fetchApi 支持 revalidate

export async function fetchApi<T>(path: string, opts?: { revalidate?: number }) {
  const cache: RequestCache | undefined = opts?.revalidate != null ? undefined : "no-store";
  const next = opts?.revalidate != null ? { revalidate: opts.revalidate } : undefined;
  const res = await fetch(url, { headers: { Accept: "application/json" }, cache, next });
  // ...
}
  • 提供 revalidate → 用 next: { revalidate },进入 ISR 缓存;
  • 不提供(搜索等实时请求)→ 保持 no-store

2. 页面声明 revalidate

// 首页:10 分钟
export const revalidate = 600;

// 文章详情:1 小时
export const revalidate = 3600;

3. 构建期用公网、运行时用内网

const API_BASE = isBuildPhase
  ? process.env.NEXT_PUBLIC_API_BASE ?? "https://www.wxbuluo.com"
  : isServer ? process.env.API_BASE_URL ?? "http://localhost:3000"
  : process.env.NEXT_PUBLIC_API_BASE ?? "";

4. 动态路由用真实 generateStaticParams

// app/article/[id]/page.tsx
export async function generateStaticParams() {
  const ids = await getArticleIds(); // 构建期拉真实 70 篇文章 id
  return ids.map((id) => ({ id: String(id) }));
}

构建输出变成:

Route (app)                         Revalidate  Expire
┌ ○ /                                      10m      1y
├   /article/[id]
│ ├ ● /article/139                          1h      1y
│ ├ ● /article/138                          1h      1y
│ ├ ● /article/137                          1h      1y
│ └ ● [+67 more paths]

5. 验证

$ curl -sI https://www.wxbuluo.com/
x-nextjs-cache: HIT
cache-control: s-maxage=600, stale-while-revalidate=31535400

$ curl -sI https://www.wxbuluo.com/article/139
x-nextjs-cache: HIT
cache-control: s-maxage=3600, stale-while-revalidate=31532400
  • x-nextjs-cache: HIT → 命中缓存;
  • s-maxage=600 / 3600 → 首页 10 分钟、文章 1 小时;
  • stale-while-revalidate → 过期后先返回旧缓存,后台刷新,用户体验无感。

为什么不用 connection()?再补一句

有同学会问:「文档里说 connection() 能让预渲染停止,构建期不预渲染,那我不就能避免 mock 了吗?」

是的,connection() 能阻止预渲染,但它同时会让后续 fetch 不缓存,从而让 revalidate 完全失效。所以在「默认缓存模型」(未开启 cacheComponents)下,connection() 和 ISR 是互斥的。

如果你确实需要「某些内容请求时才渲染,但整体要缓存」,Next 16 的推荐路径是 Cache ComponentscacheComponents + use cache),这是另一套体系。我这套存量代码没开 Cache Components,所以用了公网取数的方案,更贴合现有架构。

总结

三个坑一句话概括:

  1. connection() 让 ISR 失效——Request-time API 之后的 fetch 不缓存,删掉它;
  2. 构建期 mock 污染——构建期 fetch 到不存在的地址会回退 mock 烤进产物,让构建期走公网取真实数据;
  3. 数据层抛错会杀构建——revalidate 路由预渲染失败直接 exiting the build,别用抛错阻止预渲染。

最终这套组合拳让首页、文章、分类、标签页全部获得 ISR:构建产物是真实数据、运行时命中缓存、后台按时刷新,后端压力大幅下降,首屏延迟也随之降低。

希望这篇记录能帮你绕开这些坑。

相关推荐