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 | 适用 |
|---|---|---|---|---|---|---|
| prerender | prerender: true | 构建时一次 | hydrate | 静态文件(CDN) | ✅ 最优 | 内容极少变动(about / terms) |
| isr | isr: <秒>(60–3600 的整数) | 首次访问 + 周期失效 | hydrate | Nitro cache(默认进程内存,有界 LRU) | ✅ 优秀 | 列表 / 详情 / 内容主页 |
| ssr | ssr: true(默认) | 每次请求都跑 | hydrate | 不缓存 | ✅ 优秀 | 实时性强(订单 / 实时数据) |
| csr | ssr: 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) |
| 错误页 / 404 | SSR + 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 工作流程:
- 用户 A 首次访问
/products/list(带登录 cookie) - SSR 阶段渲染 HTML,期间可能:
useFetch自动带 cookie 给后端useRequestHeaders(['cookie'])读到 cookie- 服务端 API 根据 cookie 返回个性化内容(用户名、会员价、收藏状态)
- 渲染结果被烙进 ISR 缓存
- 用户 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 提供,真实页面的生成错误仍会阻断构建。
模式 A:useFetch 默认会带 cookie
// ❌ 危险: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
})
模式 C:useRequestHeaders 读 cookie / authorization
// ❌ 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/authorizationheader? - 是否依赖
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}`
)
4.2 A2 可缓存页的 SSR 路径禁读 query
isr/prerender/swr/cache页的 SSR 执行路径,不得读route.query。 兜底:swift-web/no-query-in-cacheable-ssr(error,@swift-web/framework/eslintrecommended)
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}),是缓存包装压根不读它。按错的根因想修法,会往「让子路径规则更强」使劲,那条路整条是死的。
两条有效修法:
- 收窄通配 / 改路由结构,让通配匹配不到它(推荐,结构上根治)—— 把该页挪出通配那一层,或给通配加一段更精确的前缀。
- 若该页本就可以被缓存,删掉那条
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-Agent | mobile 看到 desktop 模板(反之亦然) |
| 多租户 | host / x-forwarded-host | 租户数据互串 |
| Geolocation | cf-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.vuevs/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 的两个维度:
- 内容真实变化频率:业务方告诉你"详情页一般几小时更一次"
- 能容忍的最大滞后:用户看到 N 小时前的数据是否会引发投诉?
推荐组合:
| 内容类型 | maxAge | 配合 |
|---|---|---|
| 长尾内容(已发布的文章) | isr: 3600(区间上限) | 一小时后台重验一次;isr: true 已移除(fw#414) |
| 列表页 | isr: 1800 到 isr: 3600 | swr 默认开启 |
| 短时效(榜单 / 热搜) | 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),构建日志会打印覆盖告警。
:::
5.3 B3 ISR 页面里写 set-cookie 会被缓存回放给所有访客
⚠️ 这里的直觉是反的:nitro 的缓存包装把完整响应头(含 Set-Cookie)存进缓存条目、HIT 时逐条回放——不是「设了等于没设」,而是第一个访客的 cookie 被发给了后面每一个命中缓存的人。
// ❌ 可缓存页(isr / swr / prerender)的 SSR 路径上写 Cookie
defineRouteRules({ isr: 3600 })
const event = useRequestEvent()
setCookie(event!, 'visited', '1', { maxAge: 3600 }) // 会被烤进缓存副本,回放给所有命中者
两层后果:
- 串号:SSR 期写的 Cookie / 个性化 header 进了共享副本,命中缓存的所有访客都会收到同一份。
- 整页不再被缓存:未配
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 同时存在三个问题:
- 缓存爆炸:维度组合数 = m × n × p × ...,几千几万条缓存不夸张
- 命中率塌陷:用户翻页 / 调筛选条件,每次都 cache miss
- 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 被刷爆
- 站点有 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
}
后果:
- SSR 阶段不同请求共享同一个
userRef→ 串数据 - 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 → 让你的服务器跑慢
防护方案(建议组合用):
- HMAC + timestamp:请求带签名,旧请求拒绝
- IP 白名单:只接受内网 IP 调用
- 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
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 一切正常,所以极易一路带到线上。
机制三环(逐环源码验证):
- Nuxt 把动态页展开成
/**通配 ——globRouteRulesFromPages()→pathToNitroGlob()把/P/:id转成/P/**; - 匹配器认为
/P/**也匹配零段的父路径/P—— nitro 的 routeRules 匹配器是 radix3 (nitropack/dist/runtime/internal/route-rules.mjs)。注意 radix3 与 rou3 的/*语义相反:匹配器 /x/**匹配/x/x/*匹配/xradix3(nitro 实际使用) ✅ ❌(只匹配恰好一段) rou3 ✅ ✅ - 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 | private | generate-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:权威参考资料
- Nuxt routeRules 文档
- Nitro Cache 文档 —
varies/swr/maxAge/staleMaxAge完整字段 - Vercel ISR 文档
- Next.js ISR 文档
- Google duplicate content 与 canonical 文档
- 内部资源:
- framework/CLAUDE.md 第八章 SSR 兼容规范
useTheme.ts实现 — A3 反例教材源- SEO 最佳实践 — 与渲染策略并列章节
rules/rendering-strategies.md— 本指南配套硬规则
- 社区议题:
被构建期护栏拦下时怎么办(逃生舱 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。