Nuxt 4 渲染模式实战:SSR、预渲染、混合渲染与数据获取

Nuxt 默认通用渲染,routeRules 可按路由混用预渲染、swr、isr 与纯客户端渲染,useFetch 在服务端取数并把结果随 HTML 传给客户端。本文讲透渲染模式、数据获取、shared 目录边界与部署要点。

  • Nuxt
  • 概念
  • 进阶
  • 更新于 2026-10-31

Nuxt 是 Vue 3 的全栈框架,它替你做了一件 Vue CLI 时代要自己拼装的事:页面默认在服务器上渲染成 HTML,再交给浏览器接管交互。这个默认行为决定了首屏速度、搜索引擎能不能读到正文,也决定了你的数据到底在服务端还是浏览器里取。

但「默认 SSR」只是起点。真实项目里,你需要知道 Nuxt 有哪几种渲染时机、怎么按路由混用、为什么服务端取数要用 useFetch 而不是裸 fetch,以及共享代码该放在哪个目录才不会在构建时炸掉。这篇按「原理 → 配置 → 数据获取 → 边界 → 排错」的顺序讲透,配完整可运行的代码。

一、渲染模式全景:三个时机,不是三个开关

Nuxt 支持三种渲染时机,它们描述的是「这个页面的 HTML 在什么时候被生成」:

模式 HTML 生成时机 首屏 SEO 典型场景
通用渲染(默认,SSR) 每次请求时在服务端渲染 有内容,随 HTML 到达 好 列表、看板、搜索结果
预渲染(SSG) 构建时生成静态 HTML 最快,可上 CDN 最好 文档、落地页、帮助中心
纯客户端渲染(CSR) 只在浏览器渲染 先空壳再填充 差 登录后才可见的纯交互面板

通用渲染是默认模式:浏览器请求一个 URL,Nuxt 在服务端执行 Vue 组件代码、取好数据、渲染出完整 HTML 返回;浏览器下载后,Vue 再执行一次同样的代码,把事件监听绑到已有的 DOM 上,这一步叫 hydration(注水)。

这套机制的好处是首屏内容和爬虫拿到的内容一致;代价是服务端要占用算力,且注水前页面不能真正交互。

纯客户端渲染一行配置就能开:

// nuxt.config.ts
export default defineNuxtConfig({
  ssr: false,
});

关掉 SSR 之后,Nuxt 会把页面组件从服务端包里剔除(构建期优化),页面完全在浏览器里跑。用它时应该补一个加载态模板,否则用户会看到一段空白:

<!-- app/spa-loading-template.html -->
<div class="spa-loading">正在加载…</div>

如果部署到纯静态托管,还要额外产出 SPA 兜底文件(index.html、200.html、404.html),否则用户刷新子路由会 404:

// nuxt.config.ts
export default defineNuxtConfig({
  hooks: {
    'prerender:routes'({ routes }) {
      routes.clear(); // 只保留默认那几个文件
    },
  },
});

二、从零搭一个项目,并亲眼确认它是 SSR

空谈模式没有意义,先把一个最小项目跑起来,并用查看网页源代码确认它真的在服务端渲染了。

# 初始化一个 Nuxt 项目,进入目录并装依赖
npx nuxi init my-nuxt-app
cd my-nuxt-app
npm install

# 启动开发服务器,默认 http://localhost:3000
npm run dev

Nuxt 4 的目录结构里,有三处和渲染直接相关:

my-nuxt-app/
├── app/                      # Vue 应用(组件、页面、组合式函数)
│   ├── app.vue
│   ├── pages/
│   │   └── index.vue         # 页面
│   └── components/
├── server/                   # Nitro 服务端(接口路由、中间件、插件)
│   └── api/
│       └── articles.get.ts   # 接口:/api/articles
├── shared/                   # 前后端共用代码(Nuxt 3.14+)
│   └── types/
└── nuxt.config.ts

写一个服务端接口

// server/api/articles.get.ts —— 访问 /api/articles
import { articles } from '../../shared/data/articles';

export default defineEventHandler(() => {
  return articles.map((a) => ({ id: a.id, title: a.title, createdAt: a.createdAt }));
});

写一个用服务端取数的页面

<!-- app/pages/index.vue -->
<script setup lang="ts">
// useFetch 会自动推导 server/api/articles.get.ts 的返回类型,不用手写泛型
const { data: articles } = await useFetch('/api/articles');
</script>

<template>
  <ul>
    <li v-for="a in articles" :key="a.id">{{ a.title }}</li>
  </ul>
</template>

确认它真的在服务端渲染

# 抓服务端返回的 HTML,正文应该直接在 HTML 里
Invoke-WebRequest -Uri 'http://localhost:3000/' -UseBasicParsing |
  Select-String -Pattern '<li>' -Quiet

返回 True 说明列表项已经渲染进 HTML——爬虫和用户拿到的是同一份内容。再用浏览器右键「查看网页源代码」对照一次,效果更直观。

反过来,如果这里搜不到 <li>,只会在浏览器里看到内容,说明取数跑在了客户端,SSR 根本没生效。这一条是判断渲染方式最可靠的证据,比看配置可靠。

三、混合渲染:按路由声明 routeRules

大多数真实站点不需要全局切换渲染模式,而是按路由的职责分别声明。Nuxt 用 routeRules 做这件事:

// nuxt.config.ts
export default defineNuxtConfig({
  routeRules: {
    // 首页:构建时预渲染成静态文件
    '/': { prerender: true },

    // 博客详情:首次请求生成后缓存,3600 秒过期,过期时先返回旧内容再后台重建
    '/blog/**': { isr: 3600 },

    // 文档页:后台重建,缓存不过期
    '/docs/**': { swr: true },

    // 后台区域:只在浏览器渲染
    '/admin/**': { ssr: false },

    // 接口:自动加 CORS 响应头
    '/api/**': { cors: true },

    // 旧地址跳转
    '/old-page': { redirect: '/new-page' },

    // 反向代理到外部服务
    '/proxy/**': { proxy: 'https://api.example.com/**' },
  },
});

routeRules 全表

键 取值 作用
prerender boolean 构建期生成路由,产物是纯静态文件
swr number | boolean 加缓存响应头;TTL 到期先返回旧内容、后台重建;true 表示缓存不过期
isr number | boolean 与 swr 行为一致,但结果还能交给平台 CDN(Netlify / Vercel)
ssr boolean 关掉该路由的服务端渲染
cors boolean 自动添加 CORS 响应头
headers object 给该路由加响应头
redirect string 服务端重定向
proxy string | object 把请求代理到外部地址
appMiddleware string | string[] 只在 Vue 应用部分生效的中间件
noScripts boolean 该路由不输出 Nuxt 脚本与资源提示

swr 与 isr 到底差在哪

两者都遵循同一套流程:请求到达 → 有新鲜缓存就直接返回 → 缓存过期时先把旧内容返回给用户,同时在后台异步重建 → 下次请求拿到新内容。用户永远不等待重建。

区别在于结果能不能进 CDN:Nitro 的缓存层在服务器侧,swr 缓存的是服务器上的响应;isr 会额外把响应标记为可由平台 CDN 缓存。站点规模大、需要 CDN 分发时,isr 更合适。

另外注意:走 swr / isr 的路由会额外生成 _payload.json,客户端导航时直接加载这份缓存的 payload,不再重新请求数据。

四、数据获取:为什么必须用 useFetch

这是 Nuxt 里最容易写错、后果也最隐蔽的一节。

Nuxt 提供了三个层次的数据获取方式:

方式 用途 服务端执行 结果进 payload 类型推导
$fetch 裸 HTTP 请求 取决于调用位置 否 需自己声明
useAsyncData 任意异步逻辑(不限于 URL) 是 是 需自己声明
useFetch 请求某个 URL 的语法糖 是 是 能从服务端路由自动推导

useFetch 是 useAsyncData + $fetch 的组合,它额外做了三件事:自动生成请求 key、把结果写进 Nuxt payload 让客户端注水时复用、并且能从 server/ 目录里的同名接口自动推导响应类型。

<!-- app/pages/articles/index.vue -->
<script setup lang="ts">
import type { ArticleDto } from '#shared/types/article.dto';

// 服务端执行请求,结果序列化进 payload 传给客户端,注水时不再重复请求
const { data: articles, status, error, refresh } = await useFetch<ArticleDto[]>('/api/articles', {
  query: { tag: 'nextjs' }, // 查询参数
});

// URL 传函数:跟随响应式数据自动重新请求
const route = useRoute();
const { data: article } = await useFetch(() => `/api/articles/${route.params.id}`);
</script>

<template>
  <p v-if="status === 'pending'">加载中…</p>
  <p v-else-if="error">加载失败:{{ error.message }}</p>
  <ul v-else>
    <li v-for="item in articles" :key="item.id">
      <NuxtLink :to="`/articles/${item.id}`">{{ item.title }}</NuxtLink>
    </li>
  </ul>
</template>

await 到底改变了什么

这是官方文档里特别强调、也最容易被忽略的一点:

  • 服务端渲染时,无论你 await 与否,Nuxt 都会等请求完成再返回 HTML——所以返回的 HTML 里永远有数据;
  • await 影响的是脚本里的执行顺序。await 之后,代码会等到 data 就位才继续;客户端导航也会被阻塞,直到数据准备好;
  • 不 await 时,脚本继续往下执行,data 一开始是默认值(可以用 default 选项提供),客户端导航时你要自己根据 status 和 error 渲染加载态与错误态。

效果上接近 lazy 选项,但 lazy 才是明确表达「不阻塞导航」的写法。

常用选项

选项 作用
key 显式指定缓存键;同一个 key 的多次调用共享状态、只发一次请求
query / params 查询参数(两者等价)
body / method / headers 请求体、方法、请求头
default 数据就绪前的默认值工厂函数
transform 对返回结果做一次转换
pick 只取结果里的指定字段,减小 payload
lazy 不阻塞导航
server false 表示不在服务端取数,改由客户端在注水后请求
watch 监听响应式来源自动刷新;false 关闭
dedupe 去重策略,'cancel'(默认)或 'defer'
enabled 布尔屏障,为 false 时所有执行都被拦住
timeout 超时毫秒数
serialize 是否把结果写进 Nuxt payload

返回的 data、status、error、pending 都是 Vue ref,在 <script setup> 里要用 .value 访问;refresh / execute / clear 是普通函数。

同一个接口被两个组件调用时

useFetch 的 key 是按调用位置自动生成的,所以两个不同组件用同样的 URL 调用,会各自发一次请求。想让它们共享数据,显式给同一个 key:

<!-- app/components/ArticleList.vue -->
<script setup lang="ts">
const { data } = await useFetch('/api/articles', { key: 'articles' });
</script>
<!-- app/components/ArticleSidebar.vue -->
<script setup lang="ts">
const { data } = await useFetch('/api/articles', { key: 'articles' });
</script>

这样只有一次网络请求,两个组件共享同一份 data / error / status。

裸 fetch 的代价

<script setup lang="ts">
// 反例:结果不进 payload,客户端注水后还要再请求一次,首屏会闪一下空列表
const articles = await $fetch('/api/articles');
</script>

$fetch 只负责发请求,不做任何缓存与状态管理。凡是要在服务端取、客户端接着用的数据,都应该用 useFetch 或 useAsyncData。

五、payload 与序列化:数据是怎么从服务端传到客户端的

useFetch 有一个容易被忽略但很关键的机制:服务端取到的数据会被写进 Nuxt payload,随 HTML 一起发给浏览器。这就是「注水时不重复请求」的物理原因。

一次请求的完整链路

浏览器请求 /articles
   ↓
服务端执行页面组件:await useFetch('/api/articles')
   ↓
服务端发起接口请求,拿到数据
   ↓
数据写入 payload(页面 HTML 里的 __NUXT_DATA__ 脚本)
   ↓
服务端把渲染好的 HTML + payload 一起返回
   ↓
浏览器下载 HTML,Vue 接管并注水
   ↓
useFetch 从 payload 里读回数据,不再发第二次请求

对照裸 fetch 的链路就清楚了:

浏览器请求 /articles
   ↓
服务端渲染出空列表(数据还在客户端要用的 promise 里)
   ↓
浏览器注水 → 执行组件 → 才发接口请求
   ↓
数据到达,列表出现(首屏闪一下空列表)

payload 能装什么,不能装什么

payload 用专门的序列化格式传输,只有 JSON 友好的值能安全通过:

值 能否进 payload 说明
普通对象、数组、字符串、数字 ✅
Date ✅ 会被还原成 Date 实例
Map / Set ✅ 同样能还原
BigInt ✅
class 实例 ⚠️ 只能还原成普通对象,原型与实例方法丢失
函数 ❌ 无法序列化,会被丢弃
DOM 节点 ❌ 无法序列化

所以不要把后端的实体对象直接塞进 data。后端返回的数据库实体往往是 class 实例(带 getter、方法),进了 payload 就只剩一堆裸字段。正确做法是在接口层就把数据收敛成纯对象:

// server/api/articles.get.ts —— 返回纯对象,而不是数据库实体
export default defineEventHandler(() => {
  return rows.map((row) => ({
    id: row.id,
    title: row.title,
    createdAt: row.created_at, // 日期转成 ISO 字符串
  }));
});

需要更复杂的值时,用 transform 在数据进入 payload 之前先处理好:

const { data } = await useFetch('/api/articles', {
  transform: (rows) => rows.map((r) => ({ ...r, createdAt: new Date(r.createdAt) })),
});

关掉序列化:什么时候不要进 payload

Nuxt 4.6 起 useFetch 有 serialize 选项,默认 true。设成 false 时,服务端取到的数据不进 payload,客户端注水后会重新请求一次——适合「数据很大、又不需要 SSR 首屏」的场合(比如图表的原始数据集)。代价是会产生一次额外请求,所以一般要配合懒注水一起用,避免注水不匹配。

payload 提取与 _payload.json

experimental.payloadExtraction 在 Nuxt 4 里默认就是开启的,不用额外配置。它决定 payload 数据以什么形式交付,共有三档:

取值 首屏 SSR 的 payload 客户端导航的 payload
true(默认) 提取成独立的 _payload.json 提取成独立的 _payload.json
'client' 内联在 HTML 里 提取成独立的 _payload.json
false 内联在 HTML 里 内联在 HTML 里,不产出 _payload.json

只有把 future.compatibilityVersion 设成 5 时默认值才会变成 'client'。而 ssr: false 时,这个选项会被强制为 false——纯客户端渲染没有服务端可取数据,提取也没有意义。

预渲染页面会额外产出 _payload.json,客户端导航时直接加载这份缓存的 payload,而不是重新请求接口。走 swr / isr 的路由也会附带 _payload.json——因为这些页面的 HTML 是缓存的,payload 必须跟着一起缓存,否则缓存命中时客户端会拿到没有数据的页面。

六、shared 目录:前后端共用一份代码

Nuxt 3.14 起提供 shared/ 目录,放在里面的代码既能被 Vue 应用引用,也能被 Nitro 服务端引用:

// shared/types/article.dto.ts —— 两端都能用的类型
export interface ArticleDto {
  id: number;
  title: string;
  excerpt: string;
  createdAt: string;
}

规则有三条:

  1. 只有 shared/utils/ 与 shared/types/ 里的文件会被自动导入,其它位置的文件要手动引入;
  2. 手动引入用 #shared 别名,这个别名由 Nuxt 自动配置,与文件所在位置无关:
import type { ArticleDto } from '#shared/types/article.dto';
import { formatDate } from '#shared/utils/format-date';
  1. 共享代码不能引用 Vue 或 Nitro 的代码。Nuxt 会打出两个独立的包:Vue 应用(客户端 + 服务端渲染)和 Nitro 服务端(接口路由、服务端中间件)。共享代码同时进入两个包,所以它不能 import 组件、不能 import useRoute() 这类需要 Nuxt 上下文的组合式函数、也不能 import Node.js 专属的 API。
// 反例:共享目录里 import 组件 → Nitro 包构建失败或把服务端逻辑带进浏览器包
import ArticleCard from '~/components/ArticleCard.vue';

只在一端用的类型各有各的位置:app/types/ 只在 Vue 应用里自动导入,server/types/ 只在 Nitro 服务端自动导入,两端都要用的才放 shared/types/。

七、SEO:渲染对了,标签也要对

渲染方式解决的是「爬虫能不能看到正文」,Meta 标签解决的是「爬虫怎么理解这一页」。Nuxt 里用 useSeoMeta 输出标签,它带完整类型提示:

<script setup lang="ts">
const route = useRoute();
const { data: article } = await useFetch(`/api/articles/${route.params.id}`);

useSeoMeta({
  title: () => article.value?.title ?? '文章',
  description: () => article.value?.excerpt ?? '',
  ogTitle: () => article.value?.title ?? '',
  ogDescription: () => article.value?.excerpt ?? '',
  ogType: 'article',
  ogUrl: () => `https://example.com/articles/${route.params.id}`,
});
</script>

注意 useSeoMeta 的值可以是函数或 ref——在 SSR 阶段数据还没就位时,函数形式的取值时机更合适。

八、部署要点

  • Nuxt 的服务端由 Nitro 驱动,构建产物在 .output/,node .output/server/index.mjs 即可启动;
  • nuxt build 生成服务端产物,nuxt generate / nuxt build --prerender 额外预渲染成静态文件;
  • isr 需要部署平台支持(Netlify、Vercel 的原生规则),自建服务器上等价能力由 Nitro 的缓存层提供,语义略有差别;
  • nuxt generate 与混合渲染不兼容——静态产物里没有服务端,routeRules 里的 isr / swr 也就无从谈起。

九、实战:给一个内容站配一套混合渲染

前面是把能力拆开讲,这一段拼成一个完整配置。以「技术博客 + 文档 + 后台」为例:

// nuxt.config.ts
export default defineNuxtConfig({
  // 文档与帮助页:构建时预渲染,纯静态产物
  nitro: {
    prerender: {
      crawlLinks: true, // 从首页顺着链接把可达页面都预渲染
      routes: ['/sitemap.xml'],
    },
  },

  // 按路由声明渲染与缓存策略
  routeRules: {
    // 首页:构建时生成
    '/': { prerender: true },

    // 文章详情:10 分钟后重建,缓存交给平台 CDN
    '/articles/**': { isr: 600 },

    // 文档:内容随版本发布才变,长期缓存
    '/docs/**': { isr: 86400 },

    // 标签列表:组合多、变化慢,1 小时重建
    '/tags/**': { swr: 3600 },

    // 搜索与个人面板:纯客户端
    '/search': { ssr: false },
    '/dashboard/**': { ssr: false },

    // 接口与静态资源
    '/api/**': { cors: true },
    '/images/**': { headers: { 'cache-control': 'public, max-age=31536000, immutable' } },
  },
});

对应的页面写法就简单了——大部分页面什么都不用写,靠 routeRules 决定渲染方式:

<!-- app/pages/articles/[slug].vue -->
<script setup lang="ts">
const route = useRoute();
const { data: article } = await useFetch(() => `/api/articles/${route.params.slug}`);
</script>

<template>
  <article v-if="article">
    <h1>{{ article.title }}</h1>
    <div v-html="article.html" />
  </article>
</template>

只有两种情况需要页面里写东西:

  • 确实要每次请求都现取(比如带登录态的个性化推荐位):
// app/pages/dashboard/recommendations.vue
const { data } = await useFetch('/api/recommendations', {
  server: false, // 只在浏览器取,不进 payload
});
  • 要控制缓存周期,让单个请求比路由默认更频繁更新:
const { data } = await useFetch('/api/articles', {
  query: { tag },
  key: `articles-${tag}`, // 显式 key,避免与其它调用共享状态
});

验证混合渲染是否符合预期

# 构建产物,看哪些页面被预渲染成了静态文件
Get-ChildItem '.output/public/articles' -Recurse -Filter index.html | Measure-Object

# 抽查一个预渲染页面的 HTML,正文应该直接在里面
Invoke-WebRequest -Uri 'http://localhost:3000/articles/koa-onion' -UseBasicParsing |
  Select-String -Pattern '<h1>' -Quiet

预渲染目录里出现了 index.html,说明这个页面在构建期就生成了;http://localhost:3000 下 nuxt preview 起的是生产构建,用它验证比 dev 模式更接近线上。

十、缓存与新鲜度:数据到底该多新

渲染模式决定了「HTML 由谁生成」,但没决定「数据多久算过期」。这一节讲 Nuxt 4 里跟新鲜度直接相关的几个开关,它们可以互相叠加,也可以互相打架。

experimental.sharedPrerenderData:预渲染页面之间共享数据

默认情况下,每个预渲染页面各自取一次数据。sharedPrerenderData 打开后,Nuxt 会在预渲染阶段把相同 key 的数据在页面之间共享——同一批文章被 10 个页面引用时,只取一次。

// nuxt.config.ts
export default defineNuxtConfig({
  experimental: {
    sharedPrerenderData: true,
  },
});

这个开关有个硬前提:key 必须能唯一标识数据。官方给的反例是动态路由页:

// 反例:key 里没有 slug,Nuxt 不知道这两次取的是不同数据
const route = useRoute();
const { data } = await useAsyncData(async () => {
  return await $fetch(`/api/my-page/${route.params.slug}`);
});

// 正例:key 带上 slug,不同页面对应不同缓存
const { data } = await useAsyncData(route.params.slug, async () => {
  return await $fetch(`/api/my-page/${route.params.slug}`);
});

useFetch 会按调用位置自动生成 key,大多数情况下不用操心;手写 useAsyncData 时才需要自己保证 key 与数据一一对应。

experimental.ssrStreaming:把首屏变成流式输出

流式渲染(experimental.ssrStreaming)让服务端先吐出 HTML 外壳(<head>、样式、preload 提示、入口脚本),再把正文分块推给浏览器,首字节时间因此能明显提前。

export default defineNuxtConfig({
  experimental: {
    ssrStreaming: {
      // 命中这个正则的 UA 不走流式,改用完整缓冲的 HTML
      botRegex: /googlebot|bingbot|my-internal-crawler/i,
    },
  },
});

流式有一个对 SEO 很重要的默认行为:爬虫 UA 会被自动排除在流式之外。搜索引擎拿到的是完整渲染好的 HTML,而不是一段还在传输的骨架。

但这个设计也带来两个必须知道的约束:

  1. 响应状态码和响应头在第一个字节发出时就定死了。渲染过程中再调用 setResponseStatus()、useResponseHeader() 或 setCookie() 都不会生效——这些改动发生在外壳已经发出去之后。需要改状态码的逻辑要放进插件(插件在渲染开始前就跑完了),或者对这条路由关掉流式。
  2. useHead / useSeoMeta 必须同步注册。在 <script setup> 里 await 之前同步调用的会正常进 <head>;await 之后注册的标签赶不上外壳,只能作为内联脚本在客户端补 DOM。canonical、OG 图这类 SEO 关键标签,务必放在同步区。

单条路由要退出流式,用 routeRules:

export default defineNuxtConfig({
  experimental: { ssrStreaming: true },
  routeRules: {
    '/no-stream/**': { streaming: false },
  },
});

cache、isr、swr、redirect、noScripts 这些路由规则,以及预渲染路由,本身就不走流式——缓存和流式在语义上是互斥的。

experimental.inlineRouteRules:把路由规则写进页面

routeRules 写在 nuxt.config.ts 里是全局的,但很多页面的规则其实是「这个页面预渲染、那个页面 10 分钟刷一次」。experimental.inlineRouteRules 打开后,可以在页面组件里直接声明:

<!-- app/pages/articles/index.vue -->
<script setup lang="ts">
defineRouteRules({
  swr: 600,
});
</script>

Nuxt 会把它翻译成 routeRules 里对应路径的一条规则。路径是从页面位置推出来的:~/pages/foo/bar.vue 对应 /foo/bar,~/pages/foo/[id].vue 对应 /foo/*。

有两个坑:

  • 规则只作用于这个页面文件自己,写在 ~/pages/foo/[id].vue 里不会自动覆盖 /foo/bar;
  • 页面路径如果没法翻译成等价的路由规则模式(比如带正则的 /:id(\d+)、片段拼接的 /prefix-:id、可重复参数 /:slug+),规则会被忽略,并在构建时给出警告。这种页面要自己把规则写进 nuxt.config.ts 的 routeRules。

十一、部署目标决定能力边界

Nuxt 的渲染能力最终要落到一个具体的部署目标上,而不同目标能兑现的保证完全不同。选型之前先看清边界,比事后排查划算。

server.builder:谁来打包服务端

export default defineNuxtConfig({
  server: {
    builder: 'nitro', // 默认值
  },
});
  • 'nitro'(默认,实际是 @nuxt/nitro-server)——完整的 Nitro 集成,产出 .output/server/index.mjs,服务端路由、routeRules 缓存、isr 全都能用。
  • 'vite'——实验性的纯 Vite 实现。用 ssr: false 时产出静态 SPA;开 SSR 时产出一个导出 web 标准 { fetch } 的服务端,.output/server/index.mjs 只是把静态产物挡在前面。它不携带任何服务端运行时:服务端路由、除了 prerender 以外的 routeRules、内联样式、服务端组件都不可用。

文档明确写了这个选项「供内部使用,API 尚未定稿」。生产项目除非明确知道自己在做什么,否则留在默认的 'nitro'。

边缘渲染(ESR):把服务端推到离用户更近的地方

ESR 不是一种渲染模式,而是部署目标:请求先到离用户最近的 CDN 边缘节点,页面在那里渲染并返回,而不是回到源站。Nuxt 借助 Nitro 可以跑到这些平台上:

平台 做法
Cloudflare Pages 用 Git 集成,nuxt build,零额外配置
Vercel Edge nuxt build + 环境变量 NITRO_PRESET=vercel-edge
Netlify Edge Functions nuxt build + 环境变量 NITRO_PRESET=netlify-edge

ESR 可以和混合渲染叠加使用——routeRules 照常生效,只是执行位置换到了边缘节点。

纯静态托管:兜底文件与错误页

nuxt generate / nuxt build --prerender 会把静态资源写进 .output/public,其中包含三个兜底文件:

  • 200.html——请求路径匹配不到任何静态文件时,托管平台用它返回,让客户端路由接管;
  • 404.html——路径确实不存在时返回,但仍然保持 404 状态码;
  • index.html——站点根路径。

只有 nuxt generate 或 nuxt build --prerender 会产出这些文件;单纯的 nuxt build 不会。用了混合渲染规则时,可以用 routeRules 单独补上兜底文件,或者跑一次预渲染构建。

默认情况下 404.html 是个空壳,error.vue 的内容要等客户端应用启动后才出现。想让它在构建期就渲染出来,打开 experimental.prerenderErrorPages:

export default defineNuxtConfig({
  experimental: {
    prerenderErrorPages: true,
  },
});

这里有个容易踩的坑:错误页在构建期的路径是 /404.html,不是用户当时访问的那个 URL。所以错误页里凡是依赖当前路径的内容(useRoute()、useRequestURL()、按路径取的数据)在预渲染版本里拿到的都是构建期的值,必须靠注水来纠正。依赖请求的展示内容要放进 <ClientOnly>,依赖请求的数据获取要跳过构建期:

<!-- error.vue -->
<script setup lang="ts">
import type { NuxtError } from '#app';

defineProps<{ error: NuxtError }>();

const route = useRoute();

const { data: suggestions } = await useAsyncData(
  'error-suggestions',
  () => $fetch('/api/suggestions', { query: { path: route.path } }),
  // 构建期路径是 /404.html,改为只在客户端取
  { server: !import.meta.prerender },
);
</script>

<template>
  <h1>{{ error.statusCode }}</h1>
  <ClientOnly>
    <p>没有找到 {{ route.path }}</p>
  </ClientOnly>
  <ul v-if="suggestions">
    <li v-for="s in suggestions" :key="s">{{ s }}</li>
  </ul>
</template>

import.meta.prerender 只在页面被生成的那个构建期为 true,同一份 error.vue 部署成服务端时照常走服务端渲染。

十二、排错清单

现象 原因 修法
首屏闪一下空列表 用了裸 fetch / $fetch,结果没进 payload 换成 useFetch / useAsyncData
注水时报 mismatch 警告 服务端与客户端渲染结果不同(常见于直接读 window、随机值、时间戳) 把这类逻辑放进 onMounted 或客户端事件
注水后接口被请求两次 用了裸 fetch,或 key 不一致导致去重失效 统一用 useFetch;多组件共享时给同一个 key
data 一开始是 undefined 没有 await,或没有设置 default await useFetch(...) 或提供 default 选项
路由被渲染成客户端模式 routeRules 里写了 ssr: false,或全局关了 SSR 检查 nuxt.config.ts 的 routeRules 与 ssr
静态部署刷新子路由 404 缺少 SPA 兜底文件 产出 200.html / 404.html 并在托管平台配置回退
shared 目录构建报错 共享代码引用了 Vue 或 Nitro 的东西 移到 app/ 或 server/ 各自目录,或只保留纯逻辑与类型
isr 在自建服务器上不生效 平台不支持,语义由 Nitro 缓存层近似 确认部署目标,或改用 swr
页面数据拿到了但组件里方法没了 后端返回了 class 实例,序列化后退化成普通对象 接口层返回纯对象,只带需要的字段
客户端导航很慢 每次导航都重新请求接口 打开 payload 提取,导航时加载 _payload.json
同一接口被多个组件请求多次 key 按调用位置自动生成 给这些调用显式同一个 key
useFetch 在非 setup 环境报错 它必须在 setup 函数、插件或路由中间件里调用 把调用移到 setup 或用 useAsyncData
生产环境接口 404 静态产物里没有服务端 静态托管要用 nuxt generate;需要接口就用 nuxt build
预渲染页面没进产物 动态路由没被 crawler 发现 在 nitro.prerender.routes 里显式列出,或补 generateStaticParams 式的路径清单
预渲染时同一份数据被重复取了很多遍 没开 experimental.sharedPrerenderData 打开它,并保证 useAsyncData 的 key 唯一标识数据
爬虫拿到的是没渲染完的骨架 UA 命中了 botRegex,或流式没排除爬虫 把爬虫 UA 写进 ssrStreaming.botRegex,SEO 关键标签放在 await 之前同步注册
渲染里改响应状态码/响应头不生效 流式渲染下外壳已发出,状态码和响应头早已定死 把改动放进插件,或对该路由设 routeRules: { streaming: false }
页面里的 defineRouteRules 没生效 没开 experimental.inlineRouteRules,或路径无法转换成规则模式 打开开关;遇到带正则的参数等无法转换的路径时,改在 nuxt.config.ts 里写 routeRules

参考资料

标签

  • #Nuxt
  • #ssr
  • #Vue
  • #TypeScript
  • #rendering