渲染模式快速指南(實踐派必讀)

5 分鐘決定一個頁面該用 SSR / ISR / SSG / CSR。深入理論 + 風險背景請看 Nuxt 渲染模式選用指南。

本指南配套 framework 當前 auth 設計(device/auth 完全 client-only,詳見 framework#44)。

一句話定位

ISR 主推 + SSR 守付費 + CSR 給使用者態 + 混合頁 hydrate

framework 不預設預設渲染策略(頁面必須顯式 defineRouteRules),但推薦心智按下表落地。

決策矩陣(按業務頁面查表)

頁面類型渲染策略建議 TTL關鍵注意
首頁 / 分類 / 列表 / 排行榜 / 行銷聚合ISR(主推)首頁 600 · 分類 / 列表 / 榜單 1800公開骨架走 ISR;少量使用者態片段(VIP 卡 / 已購標識)用 <ClientOnly> + onMounted 拉私有 API
簡單詳情頁(介紹 / 評分 / 章節列表,無使用者態)ISR3600純公開資料走 ISR,效能最好
詳情頁(公開 + 少量使用者態,最常見)混合策略 1:ISR 空殼 + CSR 使用者態 hydrate3600見下方決策樹 + 範例程式碼
詳情頁(公開 + 大量使用者態主導,個性化推薦佔主體)混合策略 2:SSR + public/private API 分離不快取見 理論指南 §十 Public/Private API 分離
閱讀頁 / 播放頁 / 章節內容 / 影片流(付費)SSR + token 鑑權 ⚠️不快取嚴禁 ISR / SWR / prerender(lint 強制);防 DOM 篡改繞過付費
靜態頁(about / terms / 協議)ISR3600頁首頁尾的營運設定烤在 HTML 裡,一小時內要能更新;不用 prerender(多網域站會被迫收斂 canonical)
個人中心 / 訂單 / VIP 訂閱 / 錢包CSR (ssr: false)不快取全使用者態,無 SEO 價值
聊天 / 打賞 / 直播 / 即時互動CSR (ssr: false)不快取WebSocket / 即時資料
行銷落地頁(帶 ?channel_code= 渠道碼)SSR(保留 channel)或 ISR-with-varies不快取 / 600channel_code initCode 保留 server 端
登入 / 註冊CSR (ssr: false)不快取私有 / 登入態頁,禁 ISR / prerender(硬約束,與個人中心同檔)

isr 只接受 60–3600 的整數秒(isr: true 已移除,建置期報錯,fw#414)。TTL 是「多久內必須看到更新」的上界,不是 CPU 旋鈕:nitro 到期按 SWR 先回舊副本再背景重渲,長短不影響使用者等待。測試環境要壓低 TTL 用 NUXT_ISR_TTL,不改頁面宣告。

詳情頁決策樹

你的頁面要 SEO 嗎?
├─ 不要 → CSR (ssr: false)(使用者態頁 / 即時互動頁)
└─ 要 → 是付費內容主體(章節 / 影片流)?
        ├─ 是 → SSR(帶 token 鑑權,防 DOM 篡改繞過)
        └─ 否 → 使用者態片段佔視覺多少?
                 ├─ 無 → 純 ISR
                 ├─ 少(一兩個 chip / row)→ ISR 空殼 + CSR hydrate(混合策略 1,詳情頁常見模式)
                 └─ 多(個性化推薦佔主體)→ SSR + public/private API 分離(混合策略 2)

詳情頁混合策略 1 範例(最常用,背下來)

<script setup>
defineRouteRules({ isr: 3600 })

// SSR / ISR 期:拉公開主體(快取安全)
const { data: detail } = await useAsyncData(() =>
  api.manga.detail({ id: route.params.id }),
)

// 使用者態片段不在 SSR 期渲染
const isFavorited = ref(false)
const purchasedChapters = ref<string[]>([])

onMounted(async () => {
  // 此時 framework auth.client 已建立 token,可以打私有介面
  const [fav, purchased] = await Promise.all([
    $fetch('/api/user/favorite/check', { query: { id: route.params.id } }),
    $fetch('/api/user/purchased-chapters', { query: { id: route.params.id } }),
  ])
  isFavorited.value = fav.result
  purchasedChapters.value = purchased
})
</script>

<template>
  <article>
    <SectionPublic :detail="detail" />
    <ClientOnly>
      <SectionUserBadges
        :favorited="isFavorited"
        :purchased="purchasedChapters"
      />
    </ClientOnly>
    <SectionChapterList :chapters="detail.chapters" />
  </article>
</template>

效能與體驗:

  • ISR 快取命中 → TTFB 接近靜態站速度
  • 使用者態片段 client hydrate 後才出現(輕微閃爍,但只在小區域,不搶首屏注意力)
  • 主體走 public API(不因使用者而變)→ framework 在可快取頁的 SSR 期還會主動剝離身分(執行期護欄 L5)→ 快取安全

每種策略的「能做 / 不能做」checklist

ISR / SWR / prerender 頁面

✅ 能做:

  • useFetch 公開 API(不需要鑑權的介面)
  • 渲染對所有使用者都相同的 HTML
  • SEO meta 取預設值(站點名、預設 og:image)
  • 用 <ClientOnly> 包使用者態片段

❌ 不能做:

  • 呼叫私有 API(帶 token 鑑權)
  • <script setup> 同步路徑存取 userStore.token / isLoggedIn / nickname(→ Pinia 烤進 payload 廣播給所有人)
  • useRequestHeaders(['cookie' | 'authorization'])(cookie 已被 Nitro 剝光,且使用本身就有洩漏風險)
  • setCookie / setResponseHeader(回應頭不進 ISR 快取,等於沒設)
  • 任何「按使用者身分不同而不同」的渲染
  • 在 SSR 路徑讀 route.query 🔴 —— HTML 快取在反向代理層是 path-only key(忽略全部 query),讀了會讓 ?order=hot 命中 ?order=new 的快取、內容錯配。三條修法(參數移到 path 段 / onMounted CSR 回填 / 整頁 ssr: false)與 path 段的邊界要求見 渲染模式選用指南 §4.2

    ⚠️ computed 也算 SSR 路徑:const order = computed(() => route.query.order) 被 SSR 資料獲取消費時就在 SSR 期求值,別以為「惰性所以不算」。eslint swift-web/no-query-in-cacheable-ssr 會當場報錯;onMounted / 事件處理器裡讀則完全合法。

SSR (ssr: true 預設)

✅ 能做:

  • 所有事情(帶 token 鑑權付費內容、讀 cookie 判斷登入態、個性化首屏)
  • useFetch 私有 API(cookie 自然透傳 → useCookie 讀 token → request util 自動注入)

❌ 不能做:

  • 依賴回應被複用(每請求獨立渲染,不會快取)
  • 期待 server 端自動呼叫 loginByDevice 建身分(server 期不做 auto-login,已登入使用者靠 cookie 自動識別;未登入使用者被 middleware 攔截到 login)

CSR (ssr: false)

✅ 能做:

  • 所有使用者態、私有資料互動
  • WebSocket / 長輪詢 / 即時資料
  • 複雜表單 / 互動密集頁面

❌ 不能做:

  • SEO 關鍵內容(搜尋引擎拿到的是空殼)
  • 首屏要在 server 期就出現

「程式碼放哪裡」決策樹

這段程式碼要在 SSR 期跑嗎?
├─ 是 → 寫在 <script setup> 同步路徑
│      但絕不能:touch userStore mutation(login / setInfo / getDeviceId / setAccount)
│              touch useRequestHeaders('cookie' | 'authorization')
│              touch setCookie / setResponseHeader
└─ 否 → 寫在 onMounted / watch / event handler
        這裡可以:隨便存取 userStore;呼叫私有 API;寫 cookie;任何使用者態操作

反模式速查(5 個最常見錯誤)

❌ 反模式 1:ISR 頁 <script setup> 同步讀 userStore

<script setup>
defineRouteRules({ isr: 3600 })
const userStore = useUserStore()           // ❌ Pinia state 烤進 payload 廣播給所有人
const nickname = userStore.info?.nickname  // ❌ 跨使用者洩漏
</script>

✅ 正確:把 userStore 呼叫挪進 onMounted + 用 <ClientOnly> 包使用者態 UI。

❌ 反模式 2:付費內容用 ISR

<script setup>
defineRouteRules({ isr: 3600 })            // ❌ lint 會報錯
const { data: chapter } = await useFetch('/api/novel/chapter', {
  query: { id, withFavorite: true }        // ❌ 付費章節內容烤進快取 = 免費洩露
})
</script>

✅ 正確:付費內容必須 defineRouteRules({ ssr: true }) 或 ssr: false。

<script setup>
defineRouteRules({ isr: 3600 })
const event = useRequestEvent()
setCookie(event!, 'visited', '1', { maxAge: 3600 })  // ❌ 會被烤進快取副本、回放給所有訪客(framework 已剝離 + dev 告警)
</script>

✅ 正確:把這類副作用(追蹤 cookie / 個性化 cookie)挪到 client(onMounted)。

❌ 反模式 4:ISR 頁呼叫帶 token 的私有 API

<script setup>
defineRouteRules({ isr: 3600 })
const { data } = await useFetch('/api/user/orders')   // ❌ 使用者訂單烤進 ISR 快取
</script>

✅ 正確:

  • 公開資料走 SSR/ISR
  • 私有資料用 onMounted + $fetch(auth.client 已建立 token)
  • 詳情頁混合用 public/private 拆分

❌ 反模式 5:忘了寫 defineRouteRules

<script setup>
// ❌ 缺 defineRouteRules → 跑在 Nuxt 預設 SSR 模式 → 效能浪費 + lint 報錯
</script>

✅ 正確:每個頁面都必須顯式宣告 defineRouteRules({ ... })。lint 會強制。

跟「理論指南」的關係

指南何時看
本指南(快速指南)寫新頁面前;選不準用哪種策略時;review checklist 自查
Nuxt 渲染模式選用指南(理論)深入理解 ISR 風險機制;遇到邊界 case 想知道根因;做架構決策

常用 lint 規則速記

路徑模式允許 routeRules禁止
/read/**、/play/**、/chapter/**、/video/**、/watch/**、/audio/**、/episode/**、/player/**、/viewer/**(付費內容主體,預設 9 段)ssr: true / ssr: falseisr / swr / prerender
/mine/**、/orders/**、/settings/**、/chat/**、/live/**、/profile/**、/account/**、/wallet/**、/vip/**、/subscription(s)/**、/dashboard/**、/message(s)/**(使用者態 / 即時互動,預設 14 段)ssr: false其他全部
任意 page必須有 defineRouteRules({ ... }) 顯式宣告缺省

lint 由 @swift-web/framework/eslint 提供(swift-web/no-isr-on-paid-paths / swift-web/no-isr-on-user-state-paths),在 starter eslint.config.ts 已經 extends,無需手動設定。上表為預設段名,applier 可透過規則的 segments options 覆寫。

review 自查清單(寫完 page 走一遍)

  • 頁面宣告了 defineRouteRules?策略選擇符合上面的決策矩陣?
  • 如果是 ISR / SWR / prerender 頁:HTML 輸出對所有使用者完全相同?
  • 如果是 ISR / SWR / prerender 頁:所有 useFetch / request 呼叫都打了公開 API?
  • 如果是 ISR / SWR / prerender 頁:<script setup> 同步路徑沒有 touch userStore / useRequestHeaders('cookie') / setCookie?
  • 使用者態片段走 onMounted + <ClientOnly>?
  • 付費內容主體(章節 / 影片流)路徑滿足 lint 規則(SSR 或 CSR,禁 ISR)?
  • 至少一個多使用者場景的 E2E 測試覆蓋該頁面?

還有疑問?