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 可快取頁 SSR 期寫的 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 · 登入態流轉
修法:
- 把這類副作用(追蹤、個人化 cookie)移到 client(
onMounted) - 或改非 ISR 模式
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, 就能額外吃到兩道開發期機檢(比只有 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 深處的間接呼叫 —— 那由下面的 執行期層兜底。
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。