Nuxt 渲染模式选用指南(理论篇)

💡 实践派开发者先看这里:渲染模式快速指南 —— 5 分钟选型 + 决策矩阵 + 反模式速查。

本文是理论篇 / 深度风险讲解,适合做架构决策、深入理解 ISR 机制、排查边界 case 时阅读。

ISR(Incremental Static Regeneration)是 Swift Web 主推的渲染策略 —— 性能数倍优于完整 SSR,命中率高时甚至接近静态站。但 ISR 的"缓存指纹错配 + 缓存内容污染 + 上下文敏感参数 + Nitro 默认值"四类坑非常多,一不小心就会引发严重问题:用户隐私泄漏、缓存爆炸、SEO duplicate content。

本指南面向 Swift Web 全体开发人员,配套 framework/CLAUDE.md 第八章 SSR 兼容规范 与 rules 渲染策略硬规则。开发流程中遇到 defineRouteRules 怎么写、ISR 安不安全、为什么列表页要 CSR 等问题,第一站来这里查。

引言

为什么我们要把渲染策略做透:

  • 原框架完成了从 0 到 1 的关键阶段,奠定了团队对 Nuxt SSR 的工程化经验;新框架在此基础上演进,把 ISR 等缓存策略作为主力(/CLAUDE.md 设计初衷 1),仅必要时走完整 SSR,以应对流量增长带来的性能挑战
  • ISR 写错了不只是慢,可能直接广播式发送某个用户的数据给所有人(A3 上下文敏感参数 + 用户态泄漏)
  • ISR 与 SEO 高度耦合:URL 设计错了,缓存爆炸 + duplicate content 同时发生,SEO 收益还会降低

权威源:Nuxt routeRules 文档 / Nitro Cache 文档 / Vercel ISR 文档 / nuxt#33728(query 规范化)。


⚠️ 前置要求:在 nuxt.config.ts 开启 experimental.inlineRouteRules

defineRouteRules 依赖 Nuxt 的 experimental.inlineRouteRules。applier 必须在 nuxt.config.ts 显式开启,否则所有 defineRouteRules 静默失效、每页退化为完整 SSR:

export default defineNuxtConfig({
  experimental: { inlineRouteRules: true },
})

sw create 生成的项目已自带此配置。未开启时 framework 在模块 setup 阶段构建期直接报错(硬校验,不会静默失效)。背景见 framework#71。

一、四种渲染模式速览

Nuxt 在 defineRouteRules() 里有 4 种渲染策略可选。下面这张表是日常选型时最常查的速查表。

关于 swr 字段:本指南统一用 isr 作为渲染策略。defineRouteRules 和 cache 配置里出现的小写 swr 字段(见 §9.1 / §11.1)是 Nitro cache 的 stale-while-revalidate 行为开关,不是独立的渲染模式——开发者写代码时会遇到这个字段,但选型时不需要把它当成单独一档。

1.1 行为差异表

模式触发方式SSR 期客户端缓存位置SEO适用
prerenderprerender: true构建时一次hydrate静态文件(CDN)✅ 最优内容极少变动(about / terms)
isrisr: <秒>(60–3600 的整数)首次访问 + 周期失效hydrateNitro cache(默认进程内存,有界 LRU)✅ 优秀列表 / 详情 / 内容主页
ssrssr: true(默认)每次请求都跑hydrate不缓存✅ 优秀实时性强(订单 / 实时数据)
csrssr: false输出空壳全部跑不缓存❌ 失去 SEO用户态页 / 后台

1.2 关键术语

  • maxAge:缓存"新鲜窗口",过期后下一次请求触发重生成。Nitro 默认 maxAge: 1(1 秒)—— 大多数业务必须显式设。
  • staleMaxAge:缓存"过期但仍可用"窗口。-1 表示永远先返回旧值再后台刷新。
  • on-demand revalidation:业务侧(如后台发布新文章)显式调 webhook 失效特定 URL 的缓存,不等到 maxAge 自然过期。
  • prerender vs ISR 边界:prerender 在构建时一次性生成所有路由的 HTML(适合静态量小的站点);ISR 是首次访问时按需生成、然后缓存。动态参数页面([id].vue)用 prerender 会全量爆炸,必须用 ISR。

1.3 为什么 ISR 是 Swift Web 主推

  • 性能:CPU 占用降低 10x+(命中率高时几乎为 0)
  • SEO:HTML 完整、爬虫友好,与 SSR 等效
  • 业务时效:通过 maxAge / on-demand revalidate 灵活控制新鲜度
  • 渐进部署:单页面就能切换策略,不影响其他路由

代价是:写错了风险大。这正是本指南存在的原因。

1.4 自托管下 isr 是怎么真正生效的(你不用改写法,但值得知道)

结论先说:你继续写 isr,什么都不用改。 下面解释背后发生了什么,以及为什么别自己改写成 swr / cache。

Nitro 的 isr 是平台级规则:它的 core 完全不消费 isr,只有 Vercel / Netlify / Zeabur 等平台 preset 认它(用于把响应放进平台 CDN)。而我们的部署方式是 node-server preset + pm2——如果不做处理,defineRouteRules({ isr: 3600 }) 在自托管下不会产生任何缓存,每个请求都会跑完整 SSR。

所以 framework 在构建期(nitro:build:before)自动把它翻译成等价的 cache 规则,两个键并存:

// 你写的
defineRouteRules({ isr: 3600 })

// framework 构建期补成(你看不到,也不用管)
{ isr: 3600, cache: { swr: true, maxAge: 3600 } }
//  ↑ 平台 preset 读它走 CDN      ↑ node-server 读它走服务端缓存

这样两边都对:将来部署到 Vercel / Netlify 时 CDN 语义仍然成立,现在自托管则真正拿到服务端缓存。isr 只接受 60–3600 的整数秒,isr: true 已移除、构建期直接报错(fw#414)。

别自己写 swr 或 cache。 它们是 nitro 合法键、也确实生效,但直写会绕开这套统一约定——本站的决策树、反模式清单、以及四条 ESLint 规则全部是围绕 isr 建立的。swift-web/no-ineffective-route-rules 会对 swr / cache / static / 空对象直接报错并指出修法。

调 TTL 不要改写法:用构建期环境变量 NUXT_ISR_TTL=<秒> 统一覆盖所有 ISR 页(设 1 ≈ 关闭缓存,方便验证数据更新;生产不设)。它在 pnpm dev 与 pnpm build 都生效。

上线前怎么自查缓存真的生效:

pnpm build && node .output/server/index.mjs &
curl -sD- -o /dev/null 'http://127.0.0.1:3000/<你的ISR页>/' | grep -i cache-control
# 期望:cache-control: s-maxage=<TTL>, stale-while-revalidate
# 用户态页(ssr:false)应当**没有**这个头

⚠️ 路径要带尾斜杠——页面级 defineRouteRules 会被展开成 /path/,不带斜杠匹配不到规则,看起来就像"缓存没生效"。


二、决策树:3 个问题选定模式

不要一上来就选模式。先回答这三个问题。

2.1 业务特征矩阵

业务特征推荐策略性能理由SEO 理由
0-2 参数稳定(分类 / 分页 / 标签)RESTful URL 路径化 + ISR(如 /products/electronics/page/2)缓存 key 规范化、命中率高URL 语义友好、canonical 单一、crawl budget 高效
3+ 筛选维度(搜索 / 高级筛选)CSR (ssr: false)缓存爆炸、命中率塌陷duplicate content 风险、高维筛选页非着陆目标、SEO 收益低
详情页(内容稳定)ISR(长 maxAge 或 on-demand revalidate)内容稳定,命中率高着陆页核心,SEO 必须
内容首页 / 列表首页ISR(短 maxAge)平衡新鲜度与命中率入口页面,SEO 必须
用户态页(个人中心 / 订单 / 后台)CSR (ssr: false)—不需要被索引(noindex)
错误页 / 404SSR + useSeo({ robots: 'noindex,nofollow' })避免烙进缓存防错误页被搜索引擎收录
极少变动(about / terms / 协议)isr: 3600页头页脚的运营配置也烤在 HTML 里,一小时内要能更新;不建议 prerender(见下)与 prerender 差别可忽略

2.2 决策流程

按顺序问自己 3 个问题:

Q1:这个页面包含用户态数据吗?(登录态 / 个人信息 / 会员价 / 收藏状态)

  • 是 → CSR (ssr: false)(必);如果只有少量私有信息,考虑强制拆分模式
  • 否 → 继续 Q2

Q2:参数维度组合数大概多少?

  • 0-2 个稳定参数(分类、分页) → ISR + RESTful URL 路径化
  • 3+ 个独立筛选维度 → CSR(ISR 会缓存爆炸 + duplicate content 风险)
  • 单纯详情页(按 id 索引) → ISR(每个 id 独立缓存,命中率高)

Q3:内容更新频率?

  • 几乎不变(静态内容) → prerender
  • 数小时到数天 → ISR + 长 maxAge(含 on-demand revalidate)
  • 几分钟级 → ISR + 短 maxAge
  • 实时(订单、库存) → SSR;如有用户态,CSR

2.3 反模式速查

下面这些是 MR Review 一上来就 block 的:

  • 详情页用 SSR(应改 ISR)
  • 个人中心用 ISR(必泄漏,改 CSR)
  • 高维筛选用 ISR(改 CSR)
  • 错误页缺 noindex(防被烙缓存 + 防 SEO 收录)
  • POST 用作翻页 / 筛选(改 GET / RESTful)
  • 动态参数页面用 prerender(构建时全量爆炸)

三、用户态泄漏(最严重风险)⚠️

ISR 缓存的本质是"把首次访问者看到的 HTML 烙进缓存供所有人复用"。如果首次访问者是登录用户,他的私人数据就被广播给后续所有访问者 —— 这是安全/隐私事故,比缓存爆炸或数据陈旧严重得多。

按 ISR 三类风险的严重性排序:用户态泄漏 > 缓存指纹错配(性能)> 缓存内容污染(功能)。本章先讲最严重的。

3.1 ISR 为什么会泄漏用户态

ISR 工作流程:

  1. 用户 A 首次访问 /products/list(带登录 cookie)
  2. SSR 阶段渲染 HTML,期间可能:
    • useFetch 自动带 cookie 给后端
    • useRequestHeaders(['cookie']) 读到 cookie
    • 服务端 API 根据 cookie 返回个性化内容(用户名、会员价、收藏状态)
  3. 渲染结果被烙进 ISR 缓存
  4. 用户 B、C、D... 访问 /products/list,全部命中同一份缓存 = 看到用户 A 的视角

只要 SSR 阶段读了 cookie / 个人 header,且响应被 ISR 缓存,就一定泄漏。没有部分泄漏,是全员泄漏。

3.2 哪些数据算"用户态"

凡是"按用户身份不同而不同"的数据:

类别示例后果
身份 / 会话登录态、用户 ID、用户名、头像用户身份冒认
权限 / 订阅会员等级、付费订阅、企业账户身份越权访问
个性化数据已收藏列表、阅读历史、订单、消息数隐私泄漏
个性化定价会员价、新人价、优惠券折扣价格混乱
进度状态阅读进度、视频进度、游戏存档状态错乱

反过来记:HTML 输出对所有未登录用户应该完全相同 = 公开数据;内容因用户而变 = 用户态数据。

3.3 触发泄漏的代码模式(高危信号)

为什么静态页也用 isr: 3600 而不是 prerender

prerender 在构建期生成,那时没有真实请求、拿不到 Host ⇒ origin 只能来自 NUXT_PUBLIC_SITE_URL(缺它 framework 直接构建报错)。而配了那个变量,canonical / og:url / sitemap 会一并收敛到该域名。

对多域名 / 防封域名站这是有害的:其余域名的页面全部 canonical 到主域名,等于自己声明「我是副本」——主域名被封后镜像也搜不到,恰好抵消防封机制。

⇒ 只要站点有一个 prerender 页,就被迫配置、被迫收敛。 而 isr: 3600(一小时后台重验一次)在运行时开销上与 prerender 差别可忽略,却不需要那个变量,页头页脚里的运营配置还能在一小时内更新。

什么时候仍可用 prerender:确认站点只有一个域名、且明确要「运行时零 Node 开销」时。starter 模板已一律改用 isr: 3600。

对于文档站等采用 SSG 的项目,动态页的路由规则只描述匹配范围。框架不会把 /components/:slug 等参数占位模式当作实际预渲染地址;真实页面仍由链接爬取或 nitro.prerender.routes 提供,真实页面的生成错误仍会阻断构建。

// ❌ 危险:useFetch 默认 credentials: 'include',cookie 会自动转发给后端
defineRouteRules({ isr: 3600 })
const { data } = await useFetch('/api/products/list')
// 如果 /api/products/list 后端读 cookie 返回个性化数据 → 全员看到首访者视角

模式 B:调用了含登录态的 API 端点

// ❌ 直接调用私有数据接口
defineRouteRules({ isr: 3600 })
const { data } = await useFetch('/api/user/orders')   // 用户订单
// 或
const { data } = await useFetch('/api/manga/detail', {
  query: { id, withFavorite: true },                  // 接口返回 is_favorited
})
// ❌ SSR 阶段读 header 里的用户态
defineRouteRules({ isr: 3600 })
const headers = useRequestHeaders(['cookie', 'authorization'])
// 任何依赖这些 header 的逻辑 → 结果烙缓存

模式 D:server middleware 注入用户态

// server/middleware/inject-user.ts ❌
export default defineEventHandler(async (event) => {
  event.context.user = await getUserFromCookie(event)
})

// ISR 页面读:
const event = useRequestEvent()
const user = event.context.user        // 用户对象进入 SSR 渲染

模式 E:私有页面忘标 ssr: false

// app/pages/mine/index.vue ❌
// 缺 defineRouteRules({ ssr: false })
// 默认走 SSR + 可能被 ISR 缓存

3.4 四道防线(按推荐优先级)

防线 1:用户态页面用 CSR (ssr: false) —— 最简单最稳

个人中心、订单页、后台、消息中心、设置页 —— 所有"内容因用户而变"的页面:

defineRouteRules({ ssr: false })
useSeo({ robots: 'private', silenceLengthWarnings: true })

代价:失去 SSR 首屏。但用户态页本来就不需要 SEO,无影响。

防线 2:Public / Private API 分离 —— 核心治理

混合数据页面(如商品详情主体 + 是否已收藏)不要整页降级 CSR,强制拆分:主体走 public API + ISR;附加私有信息单独在 onMounted 后 CSR 拉取回填。判据是后端标注而非前端目录,详见 §10。

详细设计与代码示例见 §十、Public / Private API 分离。

防线 3:Nitro cache.varies —— 限定场景

如果你确实想保留某些参数在 cookie / header 上(如多语言 cookie、多租户 host)—— 把这些 header 加入 cache key:

// nuxt.config.ts
routeRules: {
  '/products/**': {
    isr: 3600,
    cache: { varies: ['accept-language', 'host'] },
  },
}

代价:缓存条目数 = 路径数 × header 组合数。绝对不要把 cookie 全量加 varies —— 会按每个 cookie 值分桶,等同于无缓存。

防线 4:把变量提到 URL 路径

i18n / 多租户等"准用户态"参数,最稳的做法是放进 URL:

  • 多语言 → /zh/products / /en/products(用 @nuxtjs/i18n 的 prefix 策略)
  • 多租户 → 子域名 tenant1.app.com 或路径前缀 /t/<tenant>/...

URL 改动后 cache key 自然不同,ISR 安全。

3.5 自检清单

PR 提交前问自己:

  • 这个页面的 HTML 输出对所有未登录用户是否完全相同?
  • useFetch / $fetch 调用的 API 端点对所有用户返回是否相同?
  • SSR 执行路径上是否调了 private API(后端标注 x-sw-visibility: private;onMounted / 事件处理器里调是合法的)?
  • 是否在 SSR 阶段读了 cookie / authorization header?
  • 是否依赖 event.context.user 等服务端注入态?

任一答案是"是"或"不确定",默认 ssr: false。损失一点 SEO 远好于泄漏用户数据。

3.6 与 §四 缓存指纹错配的关系

§四(A 类)讲的是"非身份的上下文敏感参数"(i18n 语言、UA、多租户 host、geolocation)—— 这些参数也会因 ISR 缓存导致输出错位,但不直接泄漏个人隐私。修法和 §三 防线 3/4 类似(cache.varies 或 URL 路径化)。

把"用户态"和"非身份上下文"分开两章是为了让选择更清晰:

  • 含用户态 → §三(本章,最优先)
  • 仅含非身份上下文 → §四 A3

四、ISR 缓存指纹错配(A 类)

A 类风险的本质是:缓存 key 不能反映真实变量。Nitro 默认 cache key = path + searchParams,URL 没体现的差异会被 ISR 一视同仁地共享同一份缓存。

4.1 A1 POST body 不参与 cache key

ISR 页面里 $fetch / useFetch / useAsyncData 用了 method: 'POST' + body 来传翻页 / 筛选参数?

// ❌ 翻页用 POST + body
defineRouteRules({ isr: 3600 })
const { data } = await useFetch('/api/products/list', {
  method: 'POST',
  body: { page: 2, category: 'electronics' },
})

后果:

  • Nitro cache key = /api/products/list(没有 page/category 信息)
  • 第一个访问者拿到 page=1 的结果被缓存
  • 后续访问者请求 page=2 时,缓存命中返回的是 page=1 的数据
  • 所有翻页失效

修法:

// ✅ GET + URL query
const route = useRoute()
const { data } = await useFetch('/api/products/list', {
  query: { page: route.query.page, category: route.query.category },
})

// ✅ 更优:RESTful 路径
// pages/products/[category]/page/[page].vue
defineRouteRules({ isr: 3600 })
const { data } = await useFetch(
  `/api/products/${route.params.category}/page/${route.params.page}`
)

延伸:A2 可缓存页禁读 query、决策树

4.2 A2 可缓存页的 SSR 路径禁读 query

isr / prerender / swr / cache 页的 SSR 执行路径,不得读 route.query。 兜底:swift-web/no-query-in-cacheable-ssr(error,@swift-web/framework/eslint recommended)

4.2.1 为什么是硬规则

HTML 缓存的 key 在反代层是 path-only(nginx proxy_cache_key "$scheme$host$uri",忽略全部 query)。这样:

  • ?utm_source=x、?channel_code=abc、?evil=random123 全部命中同一份 path 缓存 ⇒ 营销流量也享受缓存,随机参数攻击连一条新缓存条目都造不出(这是缓存爆炸的根治,不是止血)

但这行配置的正确性完全依赖本规则:一旦某个可缓存页在 SSR 期读了 query,?nav=a 与 ?nav=b 就会共享同一份缓存 ⇒ 内容错配(用户看到别的参数下的内容)。这种故障静默、难查,比缓存爆炸严重。

prerender 也在管辖内:它在构建期生成静态文件,那时根本不存在 query ⇒ prerender 页读 query 永远拿不到值,本身就是 bug。ssr: true / ssr: false 不受约束(不进缓存,读 query 无害)。

4.2.2 三条修法

// ❌ 违规:可缓存页在 SSR 路径读 query(computed 会在 SSR 期被求值)
defineRouteRules({ isr: 3600 })
const order = computed(() => String(useRoute().query.order ?? ''))
const { data } = await useAsyncData('list', () => api.movie.list({ order: order.value }))

修法 1(首选):参数移到 path 段

// 路由 pages/movie/list/[id]/index.vue + [order].vue → /movie/list/1 与 /movie/list/1/hot
defineRouteRules({ isr: 3600 })
const route = useRoute()
const order = computed(() => String(route.params.order ?? ''))   // params 不受本规则约束
const { data: meta } = await useAsyncData(`meta-${route.params.id}`, () => api.movie.blockDetail(...))

// 🔴 path 段必须校验边界 —— 不校验就是把 query 爆炸原地搬到 path 上(见 4.2.3)
const allowed = new Set((meta.value?.orders ?? []).map((o) => o.code))
if (order.value && !allowed.has(order.value)) {
  throw createError({ statusCode: 404, statusMessage: '排序方式不存在' })
}

修法 2:onMounted 读 query + CSR 回填(边界不可判定、或参数多到不宜进 path 时)

defineRouteRules({ isr: 3600 })
const { data } = await useAsyncData('base', () => api.movie.list({}))   // SSR 只取默认视图
const extra = ref(null)
onMounted(async () => {
  extra.value = await api.movie.list({ order: useRoute().query.order }) // client 按 query 回填
})

修法 3:整页改 ssr: false(参数多、SEO 也不需要时)

为什么修法 1 是首选(不只是 SEO 考虑)—— 直达 /list/1/hot 这类 URL 时:

修法 1(path)修法 2(CSR 回填)
请求次数缓存命中直出,0 次缓存 HTML + client 再请求一次
用户看到的一次到位默认视图 → 骨架 → 目标视图(一次内容切换)
payload只传目标数据SSR 传了默认数据、client 又拉一份,传两遍用一遍

站内点 tab 切换两者相同(都走 client 一次);差异集中在直达带参 URL(分享链接 / 爬虫)。

修法 2 的副作用:爬虫拿到的是缓存 HTML,query 驱动的内容不会被索引。对筛选 / 排序通常可接受(canonical 应指向默认视图);性能敏感或有 SEO 价值的维度走修法 1。

4.2.3 path 段必须有可判定的边界 🔴

[order] 会匹配 /list/1/任意字符串。不校验就是把 query 爆炸搬到 path 上 —— 而且比 query 那次更糟:path-only cache key 对它零免疫(那行配置正是靠"忽略 query"防爆炸的)。

边界来源按可得性三选,至少命中一条才允许 path 化:

边界来源适用维度判定成本
前端枚举(值域前端已知)排序 / 导航 / 分类纯静态,不回源
响应体(last_page / total)页码等依赖后端数据的维度回源一次;挡缓存但不挡后端
硬上限(MAX_PAGE = 500)上面两条都不可得时的兜底纯静态,作纵深防御
❌ 无任何边界搜索关键词、外链 URL不许 path 化 —— 整页不缓存(ssr: true / ssr: false)

页码的正确写法(边界来自响应体):

const { data } = await useAsyncData(...)
// 第 1 页空 = 暂无内容(合法);第 2 页往后空 = 页码越界。`page > 1 &&` 不能省,否则真·空数据首页被误判 404
if (page > 1 && page > (data.value?.last_page ?? 1)) {
  throw createError({ statusCode: 404, statusMessage: '页码超出范围' })
}
if (page > 500) throw createError({ statusCode: 404 })   // 纯静态兜底,挡掉绝大多数垃圾请求(不回源)

⚠️ 响应体边界只挡缓存、不挡后端:越界必须先回源问一次才知道 ⇒ nginx proxy_cache_valid 200 保证 404 不入缓存(缓存不会爆炸),但后端仍会被打 —— 那是限流 / WAF 的职责,且不做 path 化时 ?page=999999 同样回源,path 化没让这件事变坏。

💡 分页优先考虑「加载更多」追加式:第 1 页 SSR/ISR 直出、后续页 client 追加、页码不进 URL ⇒ 页码根本不进缓存 key,上面这套校验都不需要。

4.2.4 path 化的三个结构性前置条件(踩过才知道)

参数提到 path 段后,父路径与子路径会同时成为可缓存页(/list 与 /list/hot)。这引出三条必须先满足的条件,任一不满足 ⇒ 整页 500 或构建 abort:

① ISR 缓存驱动不能是 fs

/scandals      → 缓存落成文件  …/payload/scandals.json      ← 这是个文件
/scandals/97   → 缓存要落      …/payload/scandals/97.json   ← 这要求它是目录
⇒ ENOTDIR,子路径整页 500

换 URL 结构绕不开(/scandals/cat/97 里 scandals 那一级照旧冲突)。

  • framework ≥0.92.0 内置有界 LRU 内存驱动(??=,零配置即安全)
  • < 0.92.0 且显式配了 fs 的项目必须改;未配置的落 unstorage 根 memory driver、不会撞,但 memory 无上限,仍建议显式挂有界 LRU
  • framework ≥0.93.0 会在构建期拦下这个组合(不必等运行时才发现)

② 禁止页面路径以可选参数结尾(framework#407)

此限制适用于所有页面,与 ISR / SSR / CSR 无关;包括 [[param]].vue、[[param]]/index.vue,以及 definePageMeta 中等价的 path / alias。中间可选参数(如 /a/:x?/end)不在此禁令内。

原因:Nuxt 4.5.2 的页面规则转换器把末尾可选参数折叠成 *,而 Nitro 2 的 radix3 缓存处理器不接管省略该段的路径。例如 /list/:id/:order? 生成 /list/*/*,访问 /list/10 时可能没有缓存;部分形状的 ssr:false 也会漏匹配。页面正常显示或规则合并结果含 ISR,都不能证明缓存真正生效。

❌ pages/list/[id]/[[order]].vue
❌ definePageMeta({ path: '/list/:id/:order?' })
❌ 在 nuxt.config.ts 额外补 '/list/**' 维持上述写法

✅ pages/list/[id]/index.vue          → /list/10
✅ pages/list/[id]/[order].vue        → /list/10/hot

两个入口分别声明 defineRouteRules,共享业务组件,保留现有 URL、默认参数与边界校验。需要跨路由保留组件实例 / KeepAlive 时,可在普通 <script lang="ts"> 中导入并 export default PageView,两个入口复用同一个组件对象;不要复制业务代码,也不要额外套一层会改变组件身份的容器。页面元信息(key、layout、validate 等)留在入口;SEO 由共享视图维护。

复用组件对象的薄入口只使用一个普通 <script lang="ts">:defineRouteRules、definePageMeta、导入与 export default PageView 都放在其中,不要再加 <script setup>,否则 Vue 编译器生成的 setup 会覆盖共享视图的 setup。

末尾 :slug(.*)* / :slug* 与 [...slug].vue 也允许零段,同样禁止;** 缓存规则不会覆盖省略段的根路径。单层文档使用 index.vue + [slug].vue;确需多层内容时明确各层路径。至少一段的 + 不属于可选参数,但自定义正则仍需满足 Nuxt 规则转换限制。

自定义正则若触发 Nuxt NUXT_B4016,改为普通必填参数并在 definePageMeta.validate 中校验(如 [pagination].vue 接收 p2,校验格式和上限);不要再补配置兜底。Nuxt 4.5.2 已支持多个普通动态段,旧版「两个动态段必定丢规则」不再适用。

机检(framework ≥0.157.0):0.156.x 及以下不提供此机检,升级到对应版本后生效。ESLint swift-web/no-trailing-optional-page-route 为 error,检查文件路由及字面量 path / alias;框架在 pages:resolved 检查最终路由,覆盖运行时生成的配置。构建护栏名为 no-trailing-optional-page-route,替代旧 optional-segment-parent-covered。报错给出拆分建议,补 routeRules 不会解除禁令。

③ 动态子页与分类段不能共用同一层

[[cat]].vue(/scandals/:cat?)与 [id].vue(/scandals/:id)路由完全歧义。按「详情页多嵌一层」把详情下移到 detail/[id].vue;这顺带治根了「通配串匹配父路径」那条坑(见 §9.6 F4)。

4.2.5 逃生舱必须成对开

确需在可缓存页读 query 时,eslint-disable 与 nginx cache key 两处必须同时改、注释互指:

// eslint-disable-next-line swift-web/no-query-in-cacheable-ssr -- nav 已纳入 nginx cache key(vps_nginx: zzz-anibay)
const navId = computed(() => String(useRoute().query.nav ?? ''))
location = /movie/index {
  proxy_cache_key "$scheme$host$uri|nav=$arg_nav";   # 配对 movie/index.vue 的 eslint-disable
}

只开一半(只 disable、没改 nginx)= 立刻内容错配,所以注释互指是为了让它在 review 时一眼可见。

历史背景:社区至今没有官方的 query 参数白名单能力(nuxt#33728 仍开)。本生态的解法是把 cache key 治理放到反代层做 path-only,再用本规则保证它的正确性 —— 位置选对了,应用层就不必跟 query 组合数搏斗。

4.2.6 子路径的「不缓存」声明会被可缓存通配吞掉

'/scandals/*':      { isr: 3600 },     // 分类列表页,要缓存
'/scandals/search': { isr: false },    // 搜索页,不要缓存 ← 这行完全没用

产物里 /scandals/search 照样带 s-maxage=3600。lint / typecheck / build 全绿、页面显示也正常,只有 curl 响应头看得出来。

根因:nitro 是否给某路径套 cachedEventHandler,在 app 初始化时按「已注册 handler 的路由模式」一次性决定;每请求合并出的 routeRules 只用于 headers / redirect / proxy,不参与缓存决策。存在一条可缓存通配 ⇒ 它覆盖的每个路径都被缓存。

改成 cache: false 同样无效(已实测)。别按「合并时被压过」去理解 —— 那个键其实保住了(产物里确实是 {"ssr":true,"isr":false,"cache":false}),是缓存包装压根不读它。按错的根因想修法,会往「让子路径规则更强」使劲,那条路整条是死的。

两条有效修法:

  1. 收窄通配 / 改路由结构,让通配匹配不到它(推荐,结构上根治)—— 把该页挪出通配那一层,或给通配加一段更精确的前缀。
  2. 若该页本就可以被缓存,删掉那条 false 声明,让它跟随通配(承认现状,别留骗人的声明)。

ssr: false 是认可的例外:那种页返回的是构建期生成的 SPA 空壳,按定义与请求无关 ⇒ 缓存一份人人相同的壳无内容错配风险(实测:同一页两个不同 query 的 HTML 字节完全相同)。所以 CSR 页被通配缓存不必处理。

ssr: true 那档为什么是硬错误:它会同时骗过 lint —— no-query-in-cacheable-ssr 的判定前提是「文件声明了 isr/swr/cache/prerender」,而这个页面声明的是 isr: false ⇒ 它可以合法地在 SSR 期读 route.query,实际却被缓存 ⇒ ?a=1 与 ?a=2 共享同一份缓存(反代层 cache key 是 path-only)⇒ 内容错配。

framework ≥0.98.0 有构建期护栏拦这一档;ssr: false 放过,故零误报。

4.3 A3 上下文敏感参数不在 URL 上

很多"决定页面输出"的参数不在 URL 上 —— 它们躲在 cookie / 请求头 / 服务端状态里。一旦 ISR 缓存,第一个访问者的视角会被烙进缓存广播给所有人。

常见的"上下文敏感参数":

参数来源风险
i18n 语言cookie / Accept-Language 头首访者语言烙缓存
设备类型User-Agentmobile 看到 desktop 模板(反之亦然)
多租户host / x-forwarded-host租户数据互串
Geolocationcf-ipcountry 等地区限定内容错位

关于登录态 / 用户身份:这类参数也是"上下文敏感参数不在 URL 上",但因为后果是安全级别的用户数据交叉泄漏(不是输出错位),单独抽出在 §三、用户态泄漏 详讲。本节不重复。

::: tip theme 是反例 —— 在 swift-web framework 不算 A3 风险 swift-web framework 的 useTheme 实现是 纯客户端切换 + CSS prefers-color-scheme 兜底:SSR 阶段输出 auto 态(不设 data-theme),由 CSS 媒体查询决定首屏;客户端 hydrate 后再读 localStorage 切换。HTML 不烙主题,ISR 缓存安全。代价是用户明确选了 light/dark 时首屏会有 FOUC。

但若未来为消除 FOUC 改成 cookie + SSR 渲染主题,会重新落入 A3 风险,必须用 cache.varies: ['cookie'] 处理。 :::

修法(两条路):

路径 1:把变量放进 URL

  • i18n 用路径前缀策略(/zh/products、/en/products),别用 cookie 选语言
  • 多语言专用页(如 /disclaimer-cn.vue vs /disclaimer-en.vue)

路径 2:用 Nitro cache.varies

// nuxt.config.ts
export default defineNuxtConfig({
  routeRules: {
    '/products/**': {
      isr: 3600,
      cache: { varies: ['accept-language', 'cookie'] },
    },
  },
})

varies 列出的请求头会进入 cache key(按 hash),缓存按 header 组合分桶。代价:缓存条目数 = 路径数 × header 组合数,量大时仍要小心。

4.4 A4 Nitro 默认 strip 不在 varies 里的请求头

容易被忽略的默认陷阱:

Nitro 在缓存解析时默认会从请求中删除所有不在 varies 列表里的 header,确保缓存命中一致。

后果:

  • ISR 路径里读 useRequestHeaders(['cookie']) 时拿到空 → 拿不到登录态信息
  • 读 Accept-Language 时拿不到 → 走默认 locale

修法:

  • 如果你 SSR 路径需要某个 header,必须在 routeRules 里 cache.varies 显式列出
  • 如果 header 包含敏感信息(cookie),意味着这条路径不适合 ISR,应该改 CSR 或 SSR

4.5 A5 Vercel 部署 ISR 自动加 url=<path> query 副作用

部署到 Vercel 时,Nuxt ISR 会在请求转发到 Lambda 时自动加 url=<path> query 参数(Vercel community 实证)。

后果:

  • 你的 SSR 处理函数里 useRoute().query 会意外多一个 url 字段
  • 如果你用 JSON.stringify(query) 作为 cache key 或 fingerprint,会因这个意外字段而漂移

修法:

  • 在 SSR 路径里 destructure query 时显式枚举字段(const { page, category } = useRoute().query)
  • 不要用 JSON.stringify(全部 query) 作为依赖

五、ISR 缓存内容污染(B 类)

B 类风险的本质是:缓存的内容不是预期的内容(或者意图缓存的副作用根本没生效)。

5.1 B1 错误响应被烙进缓存

ISR 页面里 useFetch / $fetch 第一次拿到错误响应(500 / 403 / 404),如果没显式抛出,错误页会被烙进 ISR 缓存供后续所有人查看。

// ❌ 错误响应不抛出,被 ISR 缓存
defineRouteRules({ isr: 3600 })
const { data, error } = await useFetch('/api/manga/detail', { query: { id } })
// data 为空,但页面正常渲染(空状态),ISR 把这个空状态当成"正常输出"缓存了

修法:

// ✅ 错误时显式 throw,让 Nitro 不缓存
defineRouteRules({ isr: 3600 })
const { data, error } = await useFetch('/api/manga/detail', { query: { id } })
if (error.value) {
  throw createError({
    statusCode: error.value.statusCode || 500,
    statusMessage: error.value.message,
  })
}

::: tip Nitro 的 cache 仅缓存 2xx 响应 默认情况下 Nitro cachedEventHandler 仅缓存 2xx 响应。但 Nuxt route rules 走的是 page 渲染缓存,逻辑层面的"我没数据所以渲染了空状态" 仍是 2xx,会被缓存。所以必须主动 throw 让上层感知失败。 :::

5.2 B2 数据时效漂移

isr: 3600(区间上限)但内容明显高频更新(评论数 / 在线人数 / 价格)→ 用户长时间看到旧数据。

判断 maxAge 的两个维度:

  1. 内容真实变化频率:业务方告诉你"详情页一般几小时更一次"
  2. 能容忍的最大滞后:用户看到 N 小时前的数据是否会引发投诉?

推荐组合:

内容类型maxAge配合
长尾内容(已发布的文章)isr: 3600(区间上限)一小时后台重验一次;isr: true 已移除(fw#414)
列表页isr: 1800 到 isr: 3600swr 默认开启
短时效(榜单 / 热搜)isr: 60 到 isr: 300接受 1-5 分钟滞后
实时(订单 / 库存)不要 ISR,用 SSR + CSR 局部刷—

::: tip 叠 CDN 后窗口被放大 如果 ISR 输出再被 CDN 缓存(如 Cloudflare),实际时效是 ISR maxAge × CDN 缓存时长。多层缓存设计要做"窗口预算"。 :::

::: tip 测试 / 预发布环境可用 NUXT_ISR_TTL 全局覆盖 TTL framework 支持环境变量 NUXT_ISR_TTL=<秒数> 在构建期覆盖所有页面的 ISR TTL——页面里写的 isr: 3600 只是生产期望值。在测试环境观察到"TTL 与声明不符"时先查这个 env,不是 bug。

构建期会把它打出来(该变量只在 build 期真正影响缓存,故日志必须出现在这里,framework#358):

[swift-web] ISR → cache 归一:12 条路由已获得服务端缓存(maxAge 60s…)
[swift-web] ⚠️ NUXT_ISR_TTL=60 已覆盖全部 12 条 ISR 路由的 TTL
  该变量供测试 / 预发布环境临时压低 TTL 验证数据更新,**生产构建不应设置**。

⚠️ 覆盖只会把一个数值改成另一个数值(isr 只接受 60–3600 的整数秒,fw#414),构建日志会打印覆盖告警。 :::

⚠️ 这里的直觉是反的:nitro 的缓存包装把完整响应头(含 Set-Cookie)存进缓存条目、HIT 时逐条回放——不是「设了等于没设」,而是第一个访客的 cookie 被发给了后面每一个命中缓存的人。

// ❌ 可缓存页(isr / swr / prerender)的 SSR 路径上写 Cookie
defineRouteRules({ isr: 3600 })
const event = useRequestEvent()
setCookie(event!, 'visited', '1', { maxAge: 3600 }) // 会被烤进缓存副本,回放给所有命中者

两层后果:

  1. 串号:SSR 期写的 Cookie / 个性化 header 进了共享副本,命中缓存的所有访客都会收到同一份。
  2. 整页不再被缓存:未配 proxy_ignore_headers Set-Cookie 的 nginx / CDN 见到带 Set-Cookie 的响应会拒绝缓存整页——反代层静默空转,现象是上游 s-maxage 一切正常却恒 MISS。

而且可缓存页的渲染根本读不到请求 Cookie(varies 白名单只有 host / x-forwarded-proto),所以你在那里写出的值只能是常量、纯副作用,本就没有个性化的可能。

framework 的兜底(fw#374):可缓存页渲染期的 Set-Cookie 会被从响应里剥离,dev 期 console 告警点名被剥掉的 cookie(strip-cacheable-set-cookie)。

⚠️ 所以你实际看到的症状是「这个 Cookie 永远写不进去」,不是泄漏 —— 上面那两层后果是没有这层剥离时会发生的事。别因为「没看到串号」就以为代码是对的:剥离只保证不出事故,写在那里的逻辑仍然是错的,照样要按下面的修法搬走。

两种最常见的误写形态(都不是显式 setCookie,所以容易漏):

  • useCookie(key, { default: () => ... }) 在 SSR 期求值 —— default 会触发写入
  • i18n 的 detectBrowserLanguage 标记 cookie —— 该检测在可缓存页本就结构性失效,见 i18n 多语言

修法:

  • 把按用户写 Cookie 的逻辑挪到客户端(onMounted)
  • 或把该路由改成不可缓存(ssr: true + isr: false,注意子路径声明会被可缓存通配吞掉,见 §5.2)

延伸:用户信息 Store · 登录态流转

5.4 B4 上游数据源自带缓存

ISR 触发 revalidate 后会回源拉数据,但如果上游 API 自身有缓存(Sanity CDN / 第三方接口的边缘缓存),revalidate 拿到的还是旧数据。

举例:用 Sanity CMS 拉文章,默认 useCdn: true → 即使你 ISR revalidate 了,从 Sanity CDN 拿到的还是旧版本。

修法:

// 在 ISR 路径上禁用上游 CDN
const data = await sanity.fetch(query, params, { useCdn: false })

六、选型反模式(C 类)

C 类是"模式选错了",不是"模式用错了"。

6.1 C1 高维筛选用 ISR

这是 §4.2 A2 的业务化表达。3+ 维度筛选页用 ISR 同时存在三个问题:

  1. 缓存爆炸:维度组合数 = m × n × p × ...,几千几万条缓存不夸张
  2. 命中率塌陷:用户翻页 / 调筛选条件,每次都 cache miss
  3. duplicate content 风险:搜索引擎对参数差异极小但内容相似的多 URL 会折叠或降权(Google duplicate content 文档)

后果叠加:付出 ISR 的所有成本(缓存 / 复杂度 / 风险),却拿不到 ISR 的收益(高命中率 / SEO 价值)。完全不划算。

修法:直接 ssr: false(CSR)。SEO 损失可控(高维筛选页本来就不是搜索目标着陆页)。

6.2 C2 详情页用 SSR — 浪费 CPU

详情页内容稳定,每次请求重新 SSR 是性能浪费。100 万次请求 vs 1 万次 SSR + 99 万次 cache hit,CPU 差 100 倍。

修法:isr: 3600(区间上限,一小时后台重验一次)。

6.3 C3 个人中心 / 后台用 ISR — 必泄漏

这是安全级别事故,详见 §三、用户态泄漏。本节仅作选型层面的"反模式登记" —— 含用户态数据的页面(个人中心 / 订单 / 后台 / 消息中心 / 设置)一律 ssr: false(CSR)。

6.4 C4 频繁更新内容 + 长 maxAge

§5.2 B2 数据时效漂移的反模式版本。错配 maxAge 的代价是用户看不到本应已经更新的内容。

修法:业务方明确"能容忍多久滞后" → 反推 maxAge。

6.5 C5 千页静态 + 短 revalidate — origin 被刷爆

Next.js 社区典型踩坑:

  • 站点有 10000 个详情页
  • 每个详情页 isr: 60(1 分钟过期)
  • 高峰期每秒 100 个不同的页面访问 → 每秒 100 个 origin 回源请求 → API 被刷爆

修法:

  • 长 maxAge + on-demand revalidate(业务侧主动触发,不靠时间)
  • 用 stale-while-revalidate(cache.swr: true,Nitro 默认开启)+ 设 staleMaxAge: -1:过期后先返回旧值再后台刷新

七、SSR 执行环境陷阱(D 类)

D 类不是 ISR 独有,而是 SSR 路径的通病 —— 但ISR 会把这些问题放大:因为 ISR 缓存让 SSR 错误的输出"长期存活"。

7.1 D1 runtime 模块顶层访问 window / document

<script setup> 顶层(或 framework runtime 模块顶层)访问浏览器 API → SSR 期 ReferenceError → HTTP 500。如果不幸刚好被 ISR 缓存,500 错误页就会被烙进缓存。

// ❌ 顶层访问
<script setup lang="ts">
const localValue = localStorage.getItem('foo')  // SSR 500
</script>

// ✅ onMounted 守卫
<script setup lang="ts">
const localValue = ref<string | null>(null)
onMounted(() => {
  localValue.value = localStorage.getItem('foo')
})
</script>

// ✅ import.meta.client 守卫(不要在 framework runtime 模块顶层)
if (import.meta.client) {
  doSomethingClientOnly()
}

参考 framework/CLAUDE.md 第八章 SSR 兼容规范。

7.2 D2 ref 跨请求共享态被 ISR 固化

// ❌ 模块顶层 ref,跨请求共享
const userRef = ref<User | null>(null)

export function useCurrentUser() {
  return userRef
}

后果:

  1. SSR 阶段不同请求共享同一个 userRef → 串数据
  2. ISR 缓存把第一个用户的状态烙进 HTML

修法:用 useState(per-request 隔离):

// ✅ useState 每请求独立
export function useCurrentUser() {
  return useState<User | null>('current-user', () => null)
}

7.3 theme 反例教材

如 §4.3 A3 提示所说:swift-web framework 的 useTheme 是纯客户端切换实现,不烙进 SSR 输出,ISR 安全。

但容易被误判为 A3 风险。判断方法:看 SSR 阶段的 HTML 是否含主题相关 class / data 属性。如果不含(如 swift-web 实现),ISR 安全;如果含(如某些"消除 FOUC"的实现),落入 A3。


八、边缘页面(E 类)

E 类是容易被忽略的"角落"页面。

8.1 E1 error.vue / [...slug].vue 漏渲染策略

app/error.vue 是 Nuxt 全局错误边界。它默认走 SSR,但因为没显式声明 routeRules,可能被某些设置错误地缓存。

强制要求:

  • error.vue 必须 useSeo({ robots: 'noindex,nofollow', silenceLengthWarnings: true })
  • 不需要显式 defineRouteRules(Nuxt 不会对错误页做 ISR),但加上 defineRouteRules({ ssr: true }) 更明确

[...slug].vue 全捕获页面:

  • 如果用作"动态加载内容路由",按内容类型选 ISR
  • 如果用作 404 兜底,加 noindex

8.2 E2 sitemap.xml / robots.txt 等动态文件

如果用 nuxt-sitemap / 自定义 server route 生成,它们也走 routeRules 体系:

  • sitemap.xml:建议 isr: 3600(1 小时刷新够用,搜索引擎不会频繁拉)
  • robots.txt:prerender: true(构建时一次)

8.3 E3 on-demand revalidation webhook 安全

后端发布新文章时调 /api/_revalidate?path=/articles/123 触发 ISR 失效。如果这个 endpoint 公开无防护:

  • 恶意流量刷请求 → 后端被刷爆
  • 竞争对手刷你的 origin → 让你的服务器跑慢

防护方案(建议组合用):

  1. HMAC + timestamp:请求带签名,旧请求拒绝
  2. IP 白名单:只接受内网 IP 调用
  3. Bearer token:签发短期 token
// server/api/_revalidate.post.ts 示例
export default defineEventHandler(async (event) => {
  const sig = getRequestHeader(event, 'x-signature')
  const ts = getRequestHeader(event, 'x-timestamp')
  if (!sig || !ts || Math.abs(Date.now() - Number(ts)) > 60_000) {
    throw createError({ statusCode: 401 })
  }
  const expected = createHmac('sha256', process.env.WEBHOOK_SECRET!)
    .update(`${ts}|${getQuery(event).path}`)
    .digest('hex')
  if (sig !== expected) {
    throw createError({ statusCode: 401 })
  }
  // ... 触发 revalidate
})

九、Nitro 默认值陷阱(F 类)

Nitro 的 cache 默认值容易被忽略,但每个都会影响 ISR 行为。

9.1 F1 swr: true 默认开启

Nitro cachedEventHandler 的 swr 选项默认 true:

  • 命中缓存 → 立即返回旧值
  • 后台异步触发重生成
  • 下一个请求拿到新值

含义:缓存"过期"了不会让用户等,所有用户看到的体验都是"快"的,新鲜度要靠后台刷新追赶。

如果业务希望"过期就让请求等新值"(比如错误恢复后立即看到正确数据),需要显式 swr: false。

9.2 F2 maxAge: 1(1 秒)默认

大多数业务必须显式设。1 秒缓存窗口对 ISR 几乎没用(ISR 价值在长 maxAge 摊销)。

9.3 F3 cache key = path + searchParams,cookie/header 进 key 必须显式 varies

详见 §4.3 A3 与 §4.4 A4。

9.4 自定义 cache 配置

defineRouteRules 支持完整 cache 配置:

defineRouteRules({
  cache: {
    maxAge: 3600,
    staleMaxAge: -1,           // 永远先返回旧值(背景刷)
    swr: true,
    varies: ['accept-language', 'cookie'],
    base: 'redis',             // 用 Redis 作为 storage
    name: 'product-cache',     // cache group 命名
  },
})

9.5 payload extraction 已由 framework 关闭(fw#209)

Nuxt 默认对 isr / cache 路由启用 payload extraction:hydration 数据被剥出 HTML、改从 <pathname>/_payload.json 加载,而该 URL 丢弃 query(上游 nuxt#34496)——query 驱动的 ISR 页(?page= 翻页 / ?order= 排序)hydration 数据会是「无 query 视角」(数据错页); 且 payload 与 HTML 是两个独立 ISR 缓存条目、SWR 各自刷新,存在版本错位致水合错乱。 framework 已统一设 experimental.payloadExtraction: false:hydration 数据恒内联在同一次 渲染的 HTML 里、与 DOM 同源一致,applier 无需配置也不要自行开回。同时 framework 在 dev 期把 nitro route-rule 缓存换为 memory driver——dev 重启即清,不再出现「改了代码仍 被旧缓存响应误导」的排查陷阱。

9.6 F4 通配规则会把渲染语义补进父路径

症状:pages/P/index.vue(列表页,isr: 3600)与 pages/P/[id].vue(详情页, ssr: false)同级时,列表页在生产变成 SPA 空壳 —— HTML 只剩骨架(约 7.8KB)、 <title> 为空、SEO 归零。而本地 dev 一切正常,所以极易一路带到线上。

机制三环(逐环源码验证):

  1. Nuxt 把动态页展开成 /** 通配 —— globRouteRulesFromPages() → pathToNitroGlob() 把 /P/:id 转成 /P/**;
  2. 匹配器认为 /P/** 也匹配零段的父路径 /P —— nitro 的 routeRules 匹配器是 radix3 (nitropack/dist/runtime/internal/route-rules.mjs)。注意 radix3 与 rou3 的 /* 语义相反:
    匹配器/x/** 匹配 /x/x/* 匹配 /x
    radix3(nitro 实际使用)✅❌(只匹配恰好一段)
    rou3✅✅
  3. nitro 用 defu 深合并命中的规则 —— defu({}, ...matchAll(path).reverse())。 列表页那条 isr 规则自身不含 ssr 字段,于是 ssr: false 被从通配规则补了进来。

所以要点是:不是通配"覆盖"了父路径,而是父路径"没声明"的字段被通配填上了。 这也解释了为什么显式声明就能修好。

实测矩阵:

配置/P 解析结果/P/1
/P(isr) + /P/**(ssr:false){ ssr:false, cache } 💥{ ssr:false }
把通配收窄成 /P/*{ cache } ✅{ ssr:false }
给 /P 补 ssr: true{ ssr:true, cache } ✅{ ssr:false }

两种修法:

// ❌ pages/P/index.vue —— 只声明 isr,没声明 ssr
defineRouteRules({ isr: 3600 })

// ✅ 修法 1(推荐):显式声明 ssr,堵住被补的口子;不影响任何子路径
defineRouteRules({ isr: 3600, ssr: true })
# ✅ 修法 2:详情页多嵌一层 —— 通配变成 /P/detail/**,撞不到 /P
pages/P/index.vue          # 列表
pages/P/detail/[id].vue    # 详情

修法 2 正是 starter 一直在用的目录形态(movie/detail/[id].vue)。生态里多数项目 碰巧因此躲过了这个坑 —— 但它此前只是巧合、并未写成约定,所以照 Nuxt 最常见的 posts/index.vue + posts/[id].vue 写就会踩到。

不限于 ssr:prerender / cache 等渲染语义键同理;也不限于 [id].vue,任何会被 展开成 /P/** 的动态子页都算。

兜底:framework 已内置构建期硬校验(nitro:build:before 检测「通配有、父路径没有」的 渲染语义键,命中即抛错并逐条给出修法)。误报面很窄 —— 若你故意要父路径也 CSR, 在父页显式写 ssr: false 即可放过。校验运行在 applier 自己的 nuxt.config hooks 之后,所以想自行改写 routeRules 规避也完全可行。


十、Public / Private API 分离

这是 Swift Web 治理 A3 上下文敏感参数风险的根本架构方案。把"动态判断"(这个调用安不安全)转成"静态契约"(看 import 路径就知道)。

10.1 判据是后端标注,不是前端目录

回到 A3 的核心问题:ISR 页面调了带用户态的 API,整个响应被烙进缓存。需要一种能被机器识别的 契约,而不是靠 reviewer 一行行去看 useCookie / useUserStore 的调用。

早期设想(已废弃):app/api/{public,private}/ 物理分目录 + useFooPrivateApi 命名前缀, 让「看 import 路径就知道安不安全」。codegen 落地后该设想未被任何仓采用 —— app/api/ 的结构 由 generate-api 生成器决定(index.ts / modules/ / types/,禁止手改),消费形态是 auto-import 的全局 api 对象(api.movie.detail()),页面里根本没有 import 语句可扫。

真正的事实源是后端标注:

后端源标注方式前端产物
OpenAPI(自研 server)端点带 x-sw-visibility: public | privategenerate-api 产出 app/api/visibility.json(<module>.<fn> → public | private),生成函数 JSDoc 带 @swVisibility
YAPI(公司旧后端)暂无标注位无 manifest —— 静态机检失效,改由 §10.4 的运行时护栏 L5 兜底

visibility.json 只有 OpenAPI 源的项目才有。公司旧后端走 YAPI、不支持该标注 ⇒ 下面 10.3 的两道静态机检在那类项目上静默 no-op。 但那类项目并非裸奔 —— §10.4 的运行时护栏 L5 不依赖任何后端标注,是它们的主力防线。

想自研后端的项目:只要 API 文档走 OpenAPI 并给端点标 x-sw-visibility, 就能额外吃到 L3 + L4 两道开发期机检(比只有 L5 早得多就能发现问题)。 接入方式见 Codegen 模块 · generate-api 双源。

10.2 语义红线

ISR / prerender 页面的 SSR 执行路径上,不得调用 private API。

private = 响应因用户而异(登录态 / 用户身份 / 会员价 / 收藏状态 / 订单等)。

「SSR 执行路径」指 <script setup> 同步顶层,以及 useAsyncData / useFetch 等 SSR 包装器的 直接回调。事件处理器 / onMounted / 普通函数体内的调用是 client 触发,不受此限 —— 这正是 §10.5「混合数据强制拆分」的合法形态。

10.3 ESLint 强制(三条互补,均归 @swift-web/framework/eslint 的 recommended、默认 error)

  • swift-web/isr-no-private-api —— §10.2 语义红线的直接机检。数据源 = app/api/visibility.json(路径可用 manifest 选项覆盖)。同文件内声明了 isr / prerender 且 private API 调用出现在 SSR 执行路径时报错;client 触发路径不报。 ⚠️ manifest 不存在时静默 no-op(YAPI 源项目零影响)。
  • swift-web/no-isr-on-paid-paths:付费内容路径段(read / play / chapter / video / watch / audio / episode / player / viewer,可用 segments 覆盖)禁 isr / swr / prerender。
  • swift-web/no-isr-on-user-state-paths:用户态路径段(mine / orders / settings / chat / live / profile / account / wallet / vip / subscription / dashboard / message,可用 segments 覆盖)必须 ssr: false。

三者互补:第一条按调用的 API判(精确,但需 manifest);后两条按路由路径判 (粗粒度兜底,任何源都生效)。静态分析追不到 composable 深处的间接调用 —— 那由下面的 L4 / L5 运行时层兜底。

10.4 运行时护栏 L5:可缓存页的 SSR 路径不得携带用户态

这是 YAPI 源项目唯一生效的一层,也是所有项目的最后防线。 它不依赖任何后端标注。

它守的事故形态:ISR 页的首个访问者恰好已登录 → 他的 token 随 SSR 请求发到后端 → 后端返个性化数据(is_favorited / 会员价 / 进度)→ 烙进共享缓存广播给所有访客。 本地 dev 完全发现不了(dev 不走缓存)。

判据不是「调的是不是 private 接口」,而是「这次渲染会不会因用户而变」 —— 同一个 /movie/detail,带不带 token 后端就可能返不同内容。而「带没带身份」是个精确、通用、 零后端依赖的信号,这正是它能覆盖 YAPI 存量项目的原因。

SSR 期发请求
  ├─ 没有 token?                 → 整段跳过(未登录访客,零开销)
  ├─ 本页可缓存吗?               → 查 routeRules,isr / prerender / cache / swr 四键任一为真即算
  │                                 (取不到路由上下文时按「不判」处理,避免误伤 nitro 任务)
  └─ 是 ⇒ 分档处理
       ├─ dev + 真实登录用户(有 user_id) → 直接 throw,当场拦住
       ├─ dev + 仅设备匿名(无 user_id)   → console.warn,不打挂页面
       └─ dev 与 prod 都执行:剥离 token 后重发
            └─ 后端返 errorCode 2002("要登录")→ **显式 throw**,不走静默重登重试

为什么剥离是安全的:实测旧后端 7 个公开端点 token-less 全部返 status=y ⇒ 它是 按接口鉴权、不是网关强制,剥 token 不会把正常 ISR 页打挂,只有真正依赖身份的接口才会抱怨。

为什么 2002 必须自己 throw:旧后端私有端点无 token 时返 HTTP 200 + errorCode 2002, 不是 4xx。而 nitro 的「状态码 ≥400 不入缓存」保护只看 HTTP 状态码 —— 200 照缓存不误。 另外,剥离过身份的这次不能走重登重试(重试会把身份加回去,等于绕过护栏),故走单独分支直接抛。

已知边界:L5 只守 framework 的 request() 通道。applier 直接用 raw useFetch / $fetch 调后端时,Nuxt 默认会转发 cookie,L5 完全看不见。可缓存页的取数一律走 request() (或 generate-api 产出的 api.*),别绕开。

10.5 混合数据强制拆分模式

很多详情页主体是 public(商品信息),但有少量私有附加(is_favorited、会员价、个人推荐)。强制拆分而不是"整页降级到 SSR / CSR"。

反例:合并

// ❌ 主体 + 附加信息混在同一 fetch
defineRouteRules({ isr: 3600 })
const { data } = await useFetch('/api/manga/detail', {
  query: {
    id: route.params.id,
    userId: useUser().id,  // 让接口返回 is_favorited
  },
})
// is_favorited 跟着主体一起缓存 → 首访者收藏状态被广播

正例:拆分

// ✅ ISR 只缓存 public 主体
defineRouteRules({ isr: 3600 })
const route = useRoute()
const { data: manga } = await useAsyncData(() => api.manga.detail({ id: route.params.id }))

// 附加私有信息单独 CSR fetch hydrate
const isFavorited = ref(false)
const memberPrice = ref<number | null>(null)
onMounted(async () => {
  const [favRes, priceRes] = await Promise.all([
    api.manga.hasFavorite({ id: route.params.id }),
    api.manga.memberPrice({ id: route.params.id }),
  ])
  isFavorited.value = favRes
  memberPrice.value = priceRes
})

收益:

  • 主体 ISR 命中率高(所有用户共享同一份)
  • 私有信息按需拉取,无缓存安全风险
  • 强迫架构上关注点分离,长期可维护性强

10.6 generate-api / YAPI 对接

generate-api 工作流当前不参与 public / private 分流,纯靠目录约定 + mr-review review 把关。


十一、defineRouteRules 配置速查

11.1 常用配置组合

// 详情页(永久缓存 + on-demand revalidate)
defineRouteRules({ isr: 3600 })

// 列表页(小时级刷新)
defineRouteRules({ isr: 3600 })

// 列表页(精细 cache 配置:60 秒新鲜,1 小时 stale,stale-while-revalidate 开启)
defineRouteRules({
  cache: { maxAge: 60, staleMaxAge: 3600, swr: true },
})

// 多语言列表(按 locale 分桶缓存)
defineRouteRules({
  isr: 3600,
  cache: { varies: ['accept-language'] },
})

// 静态内容
defineRouteRules({ prerender: true })

// 用户态页 / 后台
defineRouteRules({ ssr: false })

// 实时数据(订单详情)
defineRouteRules({ ssr: true })

// 错误页(默认 SSR + noindex 走 useSeo)
defineRouteRules({ ssr: true })  // useSeo({ robots: 'noindex,nofollow' })

11.2 全局 routeRules vs 页面级 defineRouteRules:同 key 冲突是构建期报错

两个地方都能配,但不是「页面级覆盖全局」那么简单。fw#375 起 framework 加了构建期护栏,规则如下:

情形结果
同一路径、不同 key✅ 合并(如全局给 headers、页面给 isr)
同一路径、同 key 同值(镜像声明)✅ 合法,不报错
同一路径、同 key 不同值🔴 构建期直接报错,不是「谁覆盖谁」
// nuxt.config.ts
routeRules: { '/article/**': { isr: 3600 } }

// app/pages/article/[id].vue
defineRouteRules({ isr: 600 })   // 🔴 同 key 不同值 ⇒ 构建失败

为什么设计成报错而不是定优先级:这类冲突过去是静默的——两处写了不同值,产物里只剩一个,另一个悄悄消失,而 lint / typecheck / build 全绿,只有 curl -D- 看响应头才发现。与其定一个「谁赢」的规则让人去记,不如让它当场停下。

适用场景:

  • 全局:批量给某类路径设默认(如 /admin/** 全 CSR)
  • 页面级:单页面精细控制
  • 两者都想用:让它们管不同的 key,别在同一个 key 上较劲

逃生舱:确认是护栏误报、需要临时解锁时,在 nuxt.config.ts 的 swiftConfig.guards 里写 'page-config-route-rule-conflict': 'warn'(构建继续、只告警)。报错文案末尾也会附上这句写法。 降级是临时解锁不是修复 —— 请把误报连同复现配置报给 framework。

配置键里的动态段写 /*,不写 /:param(nuxt ≥4.5.2,framework#393):Nuxt 把页面级 defineRouteRules 生成的键做成「每个动态段换 *」(/*、/*/*),而 nitro 用 radix3 匹配时同一层一进 * 子树就不再看 :param 子树——nuxt.config 里手写的 '/:menucode/lib': { ssr: false } 会整条静默失效(yc118 片库页因此被 SSR、直达 500,dev / build / lint / typecheck 全绿)。两种写法在 nitro 里语义相同,一律写 '/*/lib'。护栏 route-rules-param-keys 在规则表里已出现 * 段键、而配置里仍有 :param 键时报错并给改写;误报可在 swiftConfig.guards 临时降级。

11.3 编译期宏限制

defineRouteRules 是编译期宏,参数必须是字面量:

// ❌ 不行
const SECONDS = 3600
defineRouteRules({ isr: SECONDS })

// ❌ 不行
defineRouteRules({ isr: process.env.NODE_ENV === 'production' ? 3600 : true })

// ✅ 字面量
defineRouteRules({ isr: 3600 })

如果需要环境差异化,用全局 routeRules + 环境变量在 nuxt.config.ts 里编排。


十二、与 SEO / robots / sitemap 协同

12.1 ISR + canonical

ISR 页面如果有多种 URL 都映射到同一内容(含 query 参数差异),用 canonical 告诉搜索引擎"主版本是哪个":

useSeo({
  title: ...,
  description: ...,
  canonicalPath: `/products/${category}`,  // 不带 query(不含 origin 的路径)
})

12.2 私有页 noindex + ssr:false

私有页(个人中心 / 后台)双重保护:

defineRouteRules({ ssr: false })
useSeo({ robots: 'private', silenceLengthWarnings: true })

robots: 'private' 是 framework useSeo 的便捷别名,等价于 noindex, nofollow。

12.3 错误页 noindex

错误页一旦被搜索引擎收录,会污染品牌词搜索结果。强制 noindex:

// app/error.vue
useSeo({
  title: '出错了',
  description: '页面加载失败,请稍后重试',
  robots: 'noindex,nofollow',
  silenceLengthWarnings: true,
})

详见 SEO 最佳实践。


附录 A:MR Review 渲染策略自检清单

PR 提交前自查(与 rules 02 渲染策略 严格对齐):

必改项

  • 页面有 defineRouteRules({ ... }) 显式声明
  • 私有 / 登录态页用 ssr: false
  • 详情页不用 ssr: true
  • 错误页有 useSeo({ robots: 'noindex,nofollow' })
  • 翻页 / 筛选用 GET(或 RESTful 路径),不用 POST
  • 高维筛选(≥3 维)页面用 CSR
  • runtime 没顶层访问 window / document / localStorage
  • ISR 页面的 SSR 执行路径没调 private API
  • 列表页与详情页同级(P/index.vue + P/[id].vue)时,列表页显式声明了 ssr(§9.6)

重要项

  • ISR maxAge 与业务时效匹配
  • 多语言 / 多租户参数在 URL 上 或 用 cache.varies
  • useFetch 错误时显式 throw createError(...)
  • ISR 页面里没设响应级 cookie / 个性化 header
  • on-demand revalidation webhook 有签名校验
  • 跨组件状态用 useState 而非 ref

次要项

  • defineRouteRules 注释了 maxAge 业务理由
  • Public/Private API 命名跟随物理目录
  • theme 切换不在 SSR 期写 cookie
  • <NuxtLink> 到 CSR 页时考虑 prefetch="false"

附录 B:权威参考资料

被构建期护栏拦下时怎么办(逃生舱 guards)

framework 有一批构建期护栏,命中即终止 nuxt build。它们拦的都是「不报错但线上出事」那类问题 —— 通配规则把渲染语义补进父路径、server route 被 ISR 吃掉致跨请求串号、fs 缓存驱动 × 父子路径同时可缓存必 ENOTDIR 等。

默认假定报错是对的:文案里通常已给出可粘贴的修法,照做即可。

但误报确实发生过,而且代价不对称 —— 漏报只是没查出问题,误报直接让你构建不了,修复要等 framework 发版。所以 framework ≥0.119.0 提供逃生舱:

// nuxt.config.ts —— 🔴 不是 swift.config.json
swiftConfig: {
  ...swiftConfig.framework,
  guards: {
    // 'error'(默认,命中即阻断)/ 'warn'(只打印,构建继续)/ 'off'(完全跳过)
    'uncacheable-under-cacheable-wildcard': 'warn',
  },
},

🔴 落点别写错:guards 与 auth / systemInfo / addToHomescreen 同类 —— swift.config.json 里没有它,CLI 也不管(sw config set framework.guards 无效)。 写进 swift.config.json 顶层零消费者:文件改了、命令成功、护栏照旧阻断构建, 而你正卡在构建不了的时候。

  • key 就是构建报错里出现的那个护栏名,不必查表。
  • 默认全部 error,不配置时行为与从前完全一致。
  • 降级与关闭都会在构建日志留一行 —— 无声关掉的闸会被永久遗忘。
  • 优先用 warn 而非 off:warn 仍然执行护栏、只是不中断,你还看得见它在报什么。

降级是临时解锁,不是修复。 加一条的同时请:① 给 framework 提 issue(附最小复现的 routeRules / 目录形状);② 在配置里写一行注释指向该 issue。framework 修好后把该条移除、恢复 error。