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 · 登入態流轉

修法:

  • 把這類副作用(追蹤、個人化 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 同時存在三個問題:

  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, 就能額外吃到兩道開發期機檢(比只有 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:權威參考資料

被建置期護欄擋下時怎麼辦(逃生艙 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。