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;
}
规则有三条:
- 只有
shared/utils/与shared/types/里的文件会被自动导入,其它位置的文件要手动引入; - 手动引入用
#shared别名,这个别名由 Nuxt 自动配置,与文件所在位置无关:
import type { ArticleDto } from '#shared/types/article.dto';
import { formatDate } from '#shared/utils/format-date';
- 共享代码不能引用 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,而不是一段还在传输的骨架。
但这个设计也带来两个必须知道的约束:
- 响应状态码和响应头在第一个字节发出时就定死了。渲染过程中再调用
setResponseStatus()、useResponseHeader()或setCookie()都不会生效——这些改动发生在外壳已经发出去之后。需要改状态码的逻辑要放进插件(插件在渲染开始前就跑完了),或者对这条路由关掉流式。 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 渲染模式(含 routeRules 全表)
- Nuxt useFetch —— 返回值、选项与 payload 机制
- Nuxt 数据获取指南
- Nuxt shared/ 目录 —— 自动导入范围与
#shared别名 - Nuxt useSeoMeta
- Nuxt 自定义 useFetch —— 统一 baseURL 与鉴权头
- Nuxt 与 hydration
- Nuxt defineRouteRules —— 页面级路由规则与路径映射规则
- Nuxt 实验特性 —— payload 提取、流式渲染、共享预渲染数据等开关
- Nuxt 配置项 ——
server.builder、别名与ssr等全局配置 - Nuxt 预渲染与 payload 提取 —— 静态产物、兜底文件与
_payload.json的产出方式 - 服务端渲染与 SEO:为什么源码里必须有正文 —— 渲染方式与收录的关系