渲染模式快速指南(实践派必读)

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 测试覆盖该页面?

还有疑问?