Nuxt 4 教程:从第一个页面到静态部署

这门课的写法参照本站(一个真实的 Nuxt 4 静态站点):每一章先给「最短能跑的一段代码」,再给对照表,最后列出这一章会踩的坑。学完的判定标准很朴素:你能从零起一个 Nuxt 项目,写出 5 个页面、2 个服务端接口,nuxt generate 出一份能丢进 Nginx 的静态产物。

版本基线:Nuxt 4.5 / Nitro 2.x / Vue 3.5 / Vite 7。文中关键配置(app.baseURL、nitro.prerender、components 前缀、routeRules 等)都对照本项目的 nuxt.config.ts 与产物核过;需要区分旧行为时会注明「Nuxt 3 时代」。前置知识是本教程的姊妹篇《Vue 3 教程》——Nuxt 不发明新语法,它只是把 Vue 应用装进了一个有服务端的外壳里。

一、Nuxt 是什么:Vue 应用外面那层服务端

一句话:Nuxt = Vue + 路由 + 服务端渲染 + 构建配置 + 一整套目录约定。

如果你已经按《Vue 3 教程》用 Vite 建过项目,你会遇到这三件事,Nuxt 就是把它们全包了:

你自己搭 Vite + Vue 时的活Nuxt 里的对应
装 vue-router、写路由表、注册 <RouterView>在 app/pages/ 放文件,路由自动生成
建 components/、每个组件手写 import放进 app/components/,模板里直接用
写 useCounter.js 组合式函数并手动 import放进 app/composables/,自动可用
配 vite.config.js、build.base、dev 代理nuxt.config.ts 一个文件
需要 SEO / 首屏快 → 自己搭 Node SSR 服务内置 SSR,且能一个开关切到纯静态
需要几个后端接口给前端用server/api/*.ts 文件即接口(Nitro)

为什么「首屏」这件事值得多一层服务端

浏览器拿到的是 HTML。SPA(纯客户端渲染)的 HTML 里只有一个空 <div id="app"></div>,正文要等 JS 下载、执行、请求数据之后才出现;SSR 的 HTML 里正文已经在里面了。

差别体现在三件具体的事上:

  1. 用户:首屏不白屏,弱网/低端机上观感差距明显。
  2. 爬虫:搜索引擎抓取的是 HTML 源码。正文在源码里 = 能被索引;空壳 = 你得赌它的渲染引擎会执行 JS。社交平台的分享卡片(Open Graph)同理。
  3. 成本:SSR 需要服务器跑 Node 进程;但 Nuxt 还能「构建期就把 HTML 全生成好」(nuxt generate),产物是纯静态文件,一台 Nginx 甚至对象存储就够——这正好是很多内容站真正需要的形态。
示例 01:CSR / SSR / SSG 的加载时序与水合现场在新标签页打开

示例 01 把两种模式的瀑布一步步放给你看,最值得注意的是 SSR 那条:看得见 ≠ 点得动。服务端渲染出的 HTML 只是「长得一样」,事件要等客户端 JS 执行完「水合」才绑上——这是理解后面所有坑的前提。

Nuxt 与 Vue 的关系:不是什么

  • Nuxt 不是另一个框架,语法还是 Vue 那一套(<script setup>、ref、computed、组件、Pinia、Vue Router 全都在)。
  • Nuxt 不是「必须有一个后端」。它可以输出纯静态文件,服务器上完全不跑 Node。
  • Nuxt 不是 Next/Nuxt 之争的主角。它对标的是「Next.js 之于 React」,但作为 Vue 官方推荐的应用框架,接口设计更贴近 Vue 的直觉。

⚠️ 本章的坑

1. 以为 Nuxt 一定要 Node 服务器。 内容站用 nuxt generate 出静态产物 + Nginx 就够了(第十三章实测链路)。反过来说,需要「按请求动态渲染」时才要 Node(nuxt build + node .output/server/index.mjs)。

2. 把 SSR 当成「一定比 CSR 快」。 服务端要为每个请求跑一遍组件渲染,高并发下 CPU 是成本;对纯内部系统(登录才能进、无 SEO 需求)ssr: false 反而更省事。

3. 忽略了「水合」这一层的代价。 SSR 首屏有内容,但要等 JS 加载完才可交互,这段间隔叫 TTI。页面里塞了大 JS 时,SSR 也救不了交互延迟——所以第九章的渲染模式和第十四章的包体积优化要一起看。

4. 拿 Nuxt 2 / Nuxt 3 的老教程直接抄。 Nuxt 4 把源码目录收进了 app/(pages/ → app/pages/),server/、public/、content/ 仍在根目录。还有一批默认值变化(见第二章对照表)。搜资料时先确认版本。

附:名词对照

名词是什么
Nuxt应用框架(你写代码的地方)
NitroNuxt 的服务端引擎(server/ 目录、构建产物、部署适配都是它管的)
Vite开发服务器与打包器,Nuxt 用它,配置通过 vite: {} 传
h3Nitro 里的 HTTP 框架,defineEventHandler 来自它
SSR / SSG / CSR服务端渲染 / 构建期静态生成 / 纯客户端渲染
水合(hydration)客户端把已有 DOM 接管为响应式组件,并绑上事件
payloadNuxt 把服务端取到的数据序列化进 _payload.json,供客户端复用

二、跑起来第一个项目:三条命令与目录约定

创建与运行

# ① 官方脚手架(会问你是否装 modules / 用 TS / 起 dev 服务)
npx nuxi@latest init my-site

# ② 手动(已有仓库时)
npm create nuxt@latest my-site

cd my-site
npm install
npm run dev          # http://localhost:3000,改文件即时热更新

package.json 里 Nuxt 给了四条命令,语义区别要一次记清:

命令做什么产物用于
nuxt dev带 SSR 的开发服务器无(内存里跑)日常开发
nuxt build生产构建,保留服务端.output/(含 Node server)需要按请求动态渲染/接口
nuxt generate构建 + 把所有页面预渲染成 HTML.output/public/(纯静态)内容站、部署到 Nginx/OSS
nuxt preview本地按产物方式预览—上线前必跑一遍

nuxt prepare(脚手架会挂到 postinstall)用来生成 .nuxt/ 里的类型与自动导入声明——IDE 报「找不到 useFetch」多半是它没跑过。

目录约定(Nuxt 4:源码进 app/)

my-site/
├── nuxt.config.ts        唯一的总配置
├── app/                  ← Nuxt 4 的 srcDir
│   ├── app.vue           根组件(挂 <NuxtLayout><NuxtPage/></NuxtLayout>)
│   ├── error.vue         错误页(放在 app/ 根,专门显示 404/500)
│   ├── pages/            页面 → 自动生成路由(第三章)
│   ├── layouts/          布局骨架(default.vue 默认全站套用)
│   ├── components/       组件,自动导入(本章最后一节)
│   ├── composables/      useXxx 组合式函数,自动导入
│   ├── utils/            纯函数,自动导入(无需 use 前缀)
│   ├── plugins/          插件(第十二节)
│   ├── middleware/       路由中间件(第十二章)
│   └── assets/           需要被打包的 css/图片/字体
├── server/               Nitro 服务端
│   ├── api/              文件即接口 → /api/xxx
│   ├── routes/           不走 /api 前缀的接口
│   ├── middleware/       服务端中间件
│   └── utils/            服务端内部工具,自动导入
├── public/               原样拷贝到产物根(favicon、静态 demo)
├── content/              内容模块的 Markdown(本站教程就放这里)
└── .nuxt/                构建缓存与生成物,别提交 git

public/ 与 app/assets/ 的分界是最常问的:public/ 里的文件路径不变、原样搬过去(public/img/logo.png 直接用 /img/logo.png);assets/ 里的会被打包、加哈希、能参与 CSS url() 解析和体积优化。

示例 02:目录树点击器——每个目录会自动发生什么在新标签页打开

示例 02 把整棵树做成了可点开的形式,顶部还能按关键词(layouts / api / public)过滤,忘了某个目录放什么就点一下。

nuxt.config.ts 的最小可用版

本站的实际配置形状(有删减,注释是我加的):

// nuxt.config.ts
export default defineNuxtConfig({
  compatibilityDate: '2025-09-01',      // 锁定行为版本,避免升级悄悄改默认值
  devtools: { enabled: true },

  ssr: true,                            // 服务端渲染(默认就是 true)

  modules: ['@nuxt/content', '@nuxtjs/sitemap'],

  components: [{ path: '~/components', pathPrefix: false }],  // 关掉「目录名前缀」

  nitro: {
    prerender: {
      crawlLinks: true,                 // 从入口出发顺着链接爬,自动发现要生成的页面
      routes: ['/', '/tools', '/articles', '/tutorials']
    }
  },

  site: { url: 'https://your-domain.com', name: '站名' }   // sitemap / OG 绝对地址用
})

~ 别名指向 srcDir(Nuxt 4 里就是 app/),@@ 指向项目根目录。

自动导入:Nuxt 最省力也最容易懵的一层

下面这些不需要 import,在 app/ 里直接写就能用:

类别例子规则
Vue / Vue Router APIref、computed、watch、useRoute、useRouter自动按需引入(import 手写也行,不会重复)
Nuxt 组合式函数useFetch、useAsyncData、useHead、navigateTo、callOnce、useState内置
编译器宏definePageMeta、defineNuxtRouteMiddleware、defineNuxtPlugin、defineAppConfig别去 import,它们由编译器在构建时替换
app/components/ 的组件AppHeader.vue → <AppHeader>默认带目录前缀:components/ui/BaseButton.vue → <UiBaseButton>;用上面 pathPrefix: false 关掉
app/composables/useDocToc()文件名里必须是 use-xxx / useXxx
app/utils/formatDate()无命名限制
server/utils/useDb()(仅服务端可用)服务端自动导入

不会自动导入的:第三方库(axios、lodash-es、dayjs)、你自己的非约定目录(app/helpers/)、Node 内置模块(node:fs,而且只能在 server/ 里用)。

示例 04:哪些不用 import —— 自动导入边界自测在新标签页打开

⚠️ 本章的坑

1. IDE 报 Cannot find name 'useFetch'。 没跑 nuxt prepare(或 npm install 后没触发 postinstall)。跑一次即可,.nuxt/types/ 里就有声明。TS 项目还要确认 tsconfig.json 是 extends "./.nuxt/tsconfig.json"。

2. tsconfig.json 指向了错误的 workspace。 Nuxt 会生成 .nuxt/tsconfig.server.json 给 server/ 用(两套上下文不同:一个有 window,一个有 process)。把整个项目塞进同一份 tsconfig 里会看到一堆假报错。

3. 组件目录前缀让 <BaseButton> 找不到。 默认规则是「子目录名会拼进组件名」(components/ui/BaseButton.vue → <UiBaseButton>)。两种解法:按规则写名字,或配 components: [{ path: '~/components', pathPrefix: false }]。同一项目里选一种,别混——混用会出现「同名组件被两个来源解析」。

4. 组件名冲突时静默覆盖。 两个不同目录里的同名 Card.vue(关掉前缀后尤其容易),后者赢,且只有一个能用。给基础组件加前缀(Base/App/领域名)是根治办法。

5. 把 public/ 当 assets/ 用。 public/ 里的图片不参与哈希与优化,改文件后浏览器可能拿旧缓存;assets/ 里的被打包且带哈希。反过来把大文件塞进 assets/ 会让构建变慢。经验:要缓存指纹的进 assets,直接按路径引用的(favicon、robots.txt、demo)进 public。

6. 在 app/ 里 import 'node:fs'。 Nuxt 不会拦你,但运行到服务端上下文之外就炸。需要在服务端读文件的逻辑请放 server/(第七章)。

7. .nuxt/ 提交进 git 或手工编辑。 那是生成物,随时会被覆盖。要改配置就改 nuxt.config.ts。

8. compatibilityDate 忘了设。 不设时 Nuxt 会用一个默认日期,未来升级后部分行为默认值改变(例如某些序列化、路由匹配细节)。显式写死它,是「升级不炸」的最低成本保险。

附:Nuxt 3 → Nuxt 4 迁移对照

项目Nuxt 3Nuxt 4
源码目录根目录 pages/、components/…全部进 app/(srcDir: 'app/')
服务端 / public / content根目录不变(仍在根目录)
异步数据默认值useFetch 无 default 时给 null需要通过 default: () => … 显式给(data 初始为 undefined)
useState可在组件外调用必须在 setup 上下文内
路由 meta 里的 key 前缀、hydration 属性data-v-app 等若干 DOM 细节变化(依赖第三方 CSS 选择器时要注意)
升级命令—npx nuxi@latest upgrade --force
升级命令—npx nuxi@latest upgrade --force
官方 codemod(批量搬目录)—npx @nuxt/upgrade-nuxt-4(或 upgrade 向导里的 move files)

三、页面与路由:文件名就是 URL

app/pages/ 下的 .vue 文件会被编译成路由表——没有单独的路由配置文件需要维护(背后仍是 Vue Router,vue-router 是 Nuxt 的依赖)。这也是 Nuxt 相比手搭 Vite 项目省掉的最大一块活。

路径映射规则

app/pages/index.vue                 →  /
app/pages/about.vue                 →  /about
app/pages/blog/index.vue            →  /blog
app/pages/blog/[slug].vue           →  /blog/:slug            动态段
app/pages/users/[id]/orders.vue     →  /users/:id/orders
app/pages/docs/[...path].vue        →  /docs/*                通配(catch-all)
app/pages/(marketing)/pricing.vue   →  /pricing               括号目录(分组)不进 URL
app/pages/p/[[id]].vue              →  /p/:id?                可选参数(双中括号)

分组目录((admin)/、(marketing)/)纯粹用来在文件管理器里归类文件,对 URL 无影响;同名的分组可以跨目录合并到同一个父路由。

嵌套页面:父组件里要写 <NuxtPage />

这是新手最容易卡住的一条:父级页面模板里没有 <NuxtPage />,子路由就渲染不出来(不报错,只是空白)。

<!-- app/pages/dashboard.vue:/dashboard 与 /dashboard/analytics 共用的外壳 -->
<template>
  <div class="dash">
    <aside>侧边栏(子路由切换时它不会重建)</aside>
    <NuxtPage />          <!-- 子页面渲染在这里 -->
  </div>
</template>

<!-- app/pages/dashboard/index.vue → /dashboard -->
<!-- app/pages/dashboard/analytics.vue → /dashboard/analytics -->

只换子页面时父组件实例被复用(和 Vue Router 的行为一致),这正是「切换 tab 但侧边栏不闪」的实现方式。

<template>
  <NuxtLink to="/about">关于</NuxtLink>
  <NuxtLink :to="{ path: '/blog/vue3', query: { utm: 'nav' } }">读一读</NuxtLink>
  <NuxtLink to="/blog" active-class="on" exact-active-class="on-exact">博客</NuxtLink>
  <NuxtLink to="https://example.com" external target="_blank" rel="noopener">站外链接</NuxtLink>
  <NuxtLink :custom="false">…</NuxtLink>
</template>

<script setup>
// 命令式跳转
await navigateTo('/login?redirect=' + route.fullPath)      // 推荐:SSR 期间也能正确发起跳转
// router.push('/x')  仍然可用,但 SSR 阶段请优先 navigateTo
const route = useRoute()
const router = useRouter()
</script>

<NuxtLink> 相比 <RouterLink> 多做了几件事:自动给外链加 target(配合 external)、默认对站内链接开启组件与数据预取(鼠标悬停即预取,点击时数据已在本地)、可写 no-prefetch 关掉。

页面级配置:definePageMeta

写在页面组件的 <script setup> 顶层,编译器宏,不要 import:

<script setup>
definePageMeta({
  name: 'blog-detail',           // 命名路由,便于 { name, params } 跳转
  layout: 'doc',                 // 套用 app/layouts/doc.vue;false 表示不用布局
  middleware: ['auth'],          // 命名中间件(第十二章)
  meta: { public: true },         // 自定义字段,中间件里 to.meta.public 读得到
  keepAlive: true,               // 页面级缓存(切走不销毁)
  keepalive: { max: 5 },         // 更细的配置写法
  scrollToTop: true,             // 进入本页滚到顶部
  validate: (route) => /^\d{4}$/.test(route.params.year)   // 参数不合法直接 404
})
</script>

validate 是很划算的一个字段:一个函数就能把 /blog/2026-abcd 这种脏 URL 变成干净的 404,而不是让页面组件去处理空数据。

示例 03:文件路径 ⇄ URL 双向查询与练习在新标签页打开

示例 03 上半部分是真的规则引擎(输入 blog/[slug].vue 就出 /blog/:slug),下半部分是五道反向练习题。

⚠️ 本章的坑

1. 父页面忘写 <NuxtPage />。 子路由一片空白,控制台干净。排查顺序:父组件模板 → 子文件是否在 pages/ 下(不是 views/)。

2. app/pages/ 下所有 .vue 都会变成路由。 你随手放的 README.vue、误建在 app/pages/components/Card.vue 的组件,都会生成一个可被访问的页面(/components/card)。非页面组件请一律放 app/components/;只想给某个页面用的私有组件,放 app/components/<页面名>/ 下(例如 app/components/article/CommentBox.vue),别留在 pages/ 里。若要依赖「以 - 开头的文件被忽略」这类规则,请先在你的 Nuxt 版本上实测确认再生效。

3. navigateTo 与 router.push 混用。 服务端渲染阶段(比如中间件里)要用 navigateTo()——它会把跳转变成一次真正的 HTTP 重定向;router.push 在 SSR 阶段没有意义。

4. 动态段参数是字符串。 route.params.id 拿到 '42',直接参与 === 比较或算术就有类型问题。要数字就在跳转处传数字、在页面里 Number(route.params.id),或用 props: true(definePageMeta({ props: true }))让 Nuxt 转成组件 props 后声明类型。

5. 通配路由 [...path].vue 会吃掉所有子路径。 它必须能处理「参数为空」的情况(/docs 本身命中时 path 是 []),否则进入父路径就是 500。

6. 只有 query 变化的跳转不会重跑 onMounted。 /list?page=1 → /list?page=2 复用同一组件实例。数据要跟着 query 走,请用 useFetch(url, { watch: [query] }) 或 watch(() => route.query, ...)(第五章展开)。

7. keepAlive: true 后页面数据不再刷新。 与 Vue 教程第十四章同一个坑:onMounted 只跑一次,需要「每次进入都刷新」请写 onActivated,或对该页面关掉 keepAlive。

8. 分组目录名里带斜杠或特殊字符。 (a/b) 是非法的,会被当成两层目录。分组名请只用字母数字和连字符。

附:文件名 → URL 速查

文件URL读参数
pages/index.vue/—
pages/about.vue/about—
pages/blog/index.vue/blog—
pages/blog/[slug].vue/blog/:slugroute.params.slug
pages/blog/[year]/[month].vue/blog/:year/:month两个参数
pages/docs/[...path].vue/docs/*route.params.path(数组)
pages/p/[[id]].vue/p/:id?可能为 undefined
pages/(group)/x.vue/x—
pages/users/[id]/index.vue/users/:id父 users/[id].vue 需 <NuxtPage/>

四、布局、错误页与页面骨架

一个站点的所有页面共享的「页头 + 容器 + 页脚」叫布局。Nuxt 用 app/layouts/ + <NuxtLayout> 承担这件事。

根组件与默认布局

Nuxt 4 里 app/app.vue 是根组件,标准形状就三行:

<!-- app/app.vue -->
<template>
  <NuxtLayout>
    <NuxtPage />
  </NuxtLayout>
</template>

app/layouts/default.vue 会被自动套上(因为文件名是 default):

<!-- app/layouts/default.vue -->
<template>
  <div class="site">
    <SiteHeader />
    <main class="container"><slot /></main>
    <SiteFooter />
  </div>
</template>

<slot /> 就是页面组件插进来的位置。布局里不要直接写 <NuxtPage />,用 <slot />——这是官方明确的规定,写 <NuxtPage /> 会导致嵌套层级错乱(布局切换时页面被重新挂载)。

换布局或不用布局:

<script setup>
definePageMeta({ layout: 'doc' })     // 用 app/layouts/doc.vue
definePageMeta({ layout: false })     // 不要布局(登录页、独立工具页常用)
</script>

运行时切换:nuxtApp.yamlContext.layout.value = 'doc'(useNuxtApp() 拿 nuxtApp),或 <NuxtLayout name="doc"> 手动包住一段内容。

错误页:app/error.vue

<!-- app/error.vue -->
<script setup>
const props = defineProps({ error: { type: Object, required: true } })
const home = () => navigateTo('/')
</script>

<template>
  <div class="error">
    <h1>{{ error.statusCode }}</h1>
    <p>{{ error.statusMessage || (error.statusCode === 404 ? '页面不存在' : '出错了') }}</p>
    <button @click="home">回首页</button>
  </div>
</template>

触发一个错误页(404/500 的标准做法):

// 页面、中间件、服务端接口里都能用
throw createError({ statusCode: 404, statusMessage: '教程不存在', fatal: true })

本站教程详情页就是这么处理的:查不到文档就直接 throw createError({ statusCode: 404, fatal: true }),而不是渲染一个「数据为空」的分支——前者能让爬虫和监控正确识别 404。

error.vue 有几点硬约束:它在布局之外(拿不到 useAppStore 之类),所以要用到的样式请在 app.vue 层或全局 CSS 里给;它不参与 pages/ 路由,别指望用 navigateTo('/error') 跳过去。

路由动画与公告

<NuxtPage :transition="{ name: 'page', mode: 'out-in' }" />
<NuxtRouteAnnouncer />      <!-- 给读屏软件播报「已进入 关于 页」 -->

<NuxtRouteAnnouncer> 是无障碍的正路(SPA 换页不会触发屏幕阅读器的标题播报)。动画需要配 CSS 类:.page-enter-from、.page-leave-to(和 Vue <Transition> 一模一样,别漏 -from)。

加载态:<NuxtLoadingIndicator>

<!-- app/app.vue -->
<template>
  <NuxtLoadingIndicator color="#0969da" />
  <NuxtLayout><NuxtPage /></NuxtLayout>
</template>

放在 app.vue 里就能给「所有导航和 useFetch」共用的顶部进度条。不想让某个请求触发进度条:useFetch(url, { dedupe: 'defer' }) 或 <NuxtLoadingIndicator :throttle="500">。

单页面渲染(不需要 layouts 的小项目)

删掉 app.vue 时 Nuxt 会生成一个默认的 <div id="app"><NuxtLayout><NuxtPage/></NuxtLayout></div>。留着 app.vue 但里面只有 <NuxtPage/> 也是合法配置(没有布局需求时)。

⚠️ 本章的坑

1. 布局里写 <NuxtPage />。 布局切换时页面会被重复挂载,definePageMeta({ key }) 行为诡异。规则:页面用 <NuxtPage/>,布局用 <slot/>。

2. error.vue 里访问 useRoute()。 错误页不在正常路由上下文里,useRoute() 拿到的不是你以为的那个对象,route.meta 是空的。需要的信息从 props.error 里取。

3. 布局名字打错,静默回到 default。 definePageMeta({ layout: 'docs' }) 但只有 doc.vue,Nuxt 会警告后落到默认布局(或空白,取决于版本)。新建布局时先访问一次页面确认。

4. layouts/ 下的子目录文件不会自动生效。 layouts/admin/sidebar.vue 不是布局。要复用片段请放 components/。

5. 布局里放的数据请求会在每次换页时重跑? 不会——布局实例复用。但如果你用了 definePageMeta({ layout: false }) 又 <NuxtLayout> 手动包,就可能出现「同一份数据取两遍」。二选一。

6. <NuxtLayout> 的默认插槽必须只有一个元素。 包多个根元素会收到「Layout should have a single root element」警告(Vue 3 多根组件在这个位置不被允许)。外面套一个 <div>。

7. 静态生成时 404 页需要额外一份。 nuxt generate 会产出 404.html,但服务器要真的把未知路径指到它(Nginx error_page 404 /nuxt/404.html;),否则用户看到 Nginx 的默认 404。第十三章有配置。

附:内置组件清单

组件放哪作用
<NuxtApp> 相关 API(useNuxtApp())任意拿运行时上下文、注册钩子
<NuxtPage>app.vue渲染当前路由组件,可配 transition/keepalive
<NuxtLayout>app.vue 或页面套用布局
<slot />布局内部页面插入点
<NuxtLoadingIndicator>app.vue顶部进度条
<NuxtRouteAnnouncer>app.vue路由变化的无障碍播报
<ClientOnly>任意内部只在客户端渲染(SSR 安全逃生舱)
<DevOnly>任意内部只在开发环境渲染
<NuxtWelcome>临时页面官方欢迎页(初始化时用一次)

五、数据获取:useFetch 与 useAsyncData

这是 Nuxt 的核心章节。数据什么时候在服务端取、什么时候在客户端取、取几次,决定了首屏速度、SEO 和一半的性能坑。

为什么不能用 onMounted + fetch

先想清楚这条链:SSR 阶段服务端跑组件时不会执行 onMounted(那是浏览器里的钩子),于是服务端渲染出的 HTML 里没有正文;到客户端才发请求,用户看着白屏或 loading 等一跳。爬虫抓到的也是空壳。

useFetch / useAsyncData 解决的就是这个:它们是「服务端会等待、结果会被序列化」的异步数据。

<script setup>
// 完整写法:顶层 await → 服务端取完才渲染 HTML
const { data: posts, pending, status, error, refresh } =
  await useFetch('/api/posts', { query: { page: 1 }, pick: ['id', 'title'] })
</script>

<template>
  <p v-if="pending">加载中…</p>
  <ul v-else-if="!error">
    <li v-for="p in posts" :key="p.id">{{ p.title }}</li>
  </ul>
  <p v-else class="err">{{ error.statusMessage || '加载失败' }}</p>
  <button @click="refresh()">重新获取</button>
</template>

返回值里五个字段各有分工:data 数据本体、error 失败时的错误对象、pending(当前这一次请求)与 status(idle | pending | success | error)、refresh(重跑 handler)、execute(手动触发,配 immediate: false)。

useFetch 与 useAsyncData 的分工

useFetch(url, opts)useAsyncData(key, handler, opts)
内部做什么就是 useAsyncData 的包装,handler 是 $fetch(url)你给任意异步函数(可以是三次串行请求、读文件、算东西)
key默认用 URL 生成必须自己给(同 key 共享同一份结果)
自动取消有(abort)无(自己用 AbortController)
请求拦截/自动前缀有(useFetch 内部 $fetch 的 baseURL 逻辑)无
什么时候用取一个 HTTP 接口组合多个来源、非 HTTP 的数据(DB、文件、计算)

本站详情页用的就是 useAsyncData:queryCollection('tutorials').path(route.path).first() 是本地数据库查询,不是 HTTP 请求。

key:Nuxt 数据缓存的开关

// 两个组件同 key → 只请求一次,共享同一份 data
await useAsyncData('user-stats', () => $fetch('/api/stats'))
await useAsyncData('user-stats', () => $fetch('/api/stats'))   // 不会二次请求

useFetch('/api/list') 的默认 key 由 URL + 选项生成。两个组件用同一 URL 就自动共享;URL 参数不同 key 就不同,会各请求一次。要强制共享就显式写 { key: 'list' }。

这也解释了「为什么我在页脚拿不到最新数据」:同 key 的数据在 payload 里已存在,客户端不会自动重新请求。要刷新请调 refresh(),或加 { dedupe: 'always' } 之类的策略。

客户端才想要的数据:lazy、server: false、immediate

// ① 不阻塞首屏:先渲染,数据到达后再更新(HTML 里服务端已经取过,只是不 await)
const { data } = await useFetch('/api/heavy', { lazy: true })

// ② 服务端完全不取(数据只在浏览器拿)—— 注意:正文不会进 SSR HTML,SEO 会空
const { data } = await useFetch('/api/me', { server: false })

// ③ 先不请求,点按钮时才请求
const { data, execute } = await useAsyncData('detail', () => $fetch(`/api/x/${id}`), { immediate: false })

// ④ 参数变化时自动重新请求(默认只 watch 路由)
const { data } = await useFetch('/api/search', { query: { q }, watch: [q] })

server: false 的代价要说清楚:SSR 出的 HTML 里那块是空的(或是 default 给的骨架),爬虫看到的是骨架,用户看到的是「内容闪一下」。只在「确实需要浏览器环境」(依赖 localStorage、需要当前登录 cookie 且接口不可服务端代理)时用。

给数据一个 default,少写一层判空

const { data: list } = await useFetch('/api/list', {
  default: () => []          // Nuxt 4 起 data 初始为 undefined,不写 default 就得判 undefined
})

Nuxt 3 时代 data 默认是 null,Nuxt 4 变成 undefined——从旧教程抄代码时这里最容易出「list.length 报错」。

一次性副作用:callOnce

有些东西只该在服务端跑一次(初始化数据库、预热缓存、写 cookie):

await callOnce('seed', async () => { await $fetch('/api/seed') })

同一个 key 在整次请求生命周期内只执行一次;useHead 之类的全站初始化也能用它兜住。

示例 05:两种写法的请求时序对比与 SSR payload在新标签页打开

示例 05 把「onMounted + fetch」和「await useFetch」各放一遍:计数器显示 SSR 阶段 1 次 + 客户端 0 次 vs 1 次 + 1 次;再切 server: false / lazy 两个开关,看首屏文字和时间轴如何变化。

⚠️ 本章的坑

1. await 写在异步函数里没生效。 <script setup> 支持顶层 await;但如果你把 useFetch 塞进一个普通函数(function load() { await useFetch(...) })再在 onMounted 里调用,SSR 阶段就白跑了。要么在 setup 顶层直接 await,要么改用 onServerPrefetch。

2. 在 useFetch 里读 window/document。 服务端也会执行 handler 所在的上下文,SSR 时报 window is not defined。需要浏览器环境的数据请用 { server: false } 或 <ClientOnly>。

3. key 撞车导致数据互相覆盖。 两个不同页面用了同一个显式 key('list'),从一个页面切回另一个页面时拿到的是对方的数据。给 key 带上业务语义(tutorials-all、article-${id})。

4. pick / transform 忘了用,payload 巨大。 SSR 把取到的数据序列化进 _payload.json,列表接口返回 200 字段而页面只用 3 个字段时,用户多下载了 60 倍数据。pick: ['id','title'] 或 transform: (r) => r.map(slim),本章末尾有对照表。

5. 页面里 refresh() 之后 loading 状态闪了两次。 refresh() 会重跑请求;要「静默刷新」用 refresh() 配合 { dedupe: 'defer' },或直接 refresh({ dedupe: 'defer' })(版本相关,实测为准)。

6. 相对路径在服务端解析的是 Nuxt 服务器,不是你的后端。 useFetch('/api/posts') 在 SSR 阶段由 Nitro 内部处理(命中你自己的 server/api 时最省事,不发起真实网络请求);但请求外部域名时必须写绝对 URL 或配 baseURL——这是「本地能跑、服务器上 fetch 超时」的头号原因(第六章解决)。

7. 认证 cookie 在服务端取不到。 SSR 阶段 $fetch 不会自动带上浏览器的 cookie。要么用 useRequestHeaders(['cookie']) 把请求头透传给内部接口,要么把这类「只有浏览器才知道结果」的数据放客户端取。

8. data 是可响应式的,别在回调里整体替换它然后期望触发更新。 data.value = newData 是对的;data.list.push(x) 若 data 是 ref 就漏了 .value。(Vue 教程第四章的老规矩在这里一样适用。)

附:数据获取选项速查

选项类型作用
keystring缓存键;同 key 只跑一次
serverboolean是否在服务端执行(false = 只客户端)
lazyboolean不阻塞导航/渲染,数据到达后再更新
immediateboolean创建时是否立刻执行(false 配 execute())
pickstring只保留返回值的指定字段进 payload
transformfunction转换/裁剪返回值
defaultfunctiondata 的初始值(Nuxt 4 建议显式给)
watchRef依赖变化时自动重新请求;传 [] 关掉自动触发
dedupe'cancel' | 'defer' | 'allow'同一请求并发时的策略(默认 cancel)
query / paramsobject拼 URL 查询串(对象值会被序列化)
headers / method / body—透传给 $fetch
getCachedDatafunction自定义「是否复用已有数据」(做数据级缓存)
onResponse / onErrorfunction请求回调(埋点、错误提示)
response—await useFetch(...) 返回的 promise 上有 .response(含状态码/头)
signalAbortSignal自己接管取消

六、$fetch:底层请求函数与它的 baseURL

useFetch 内部调的就是 $fetch。当你不需要「进 payload、SSR 等待、缓存」这套语义时(比如提交表单、点按钮加载下一页),直接用 $fetch 更轻。

// 组件里
const form = reactive({ title: '', body: '' })
async function submit() {
  const res = await $fetch('/api/posts', { method: 'POST', body: { ...form } })
  await navigateTo(`/posts/${res.id}`)
}

$fetch 来自 ofetch,Nuxt 给它注入了一层拦截(useNuxtApp().$fetch)。常用的四个选项:

await $fetch('/api/list', {
  baseURL: '/api',              // 前缀(注意:URL 已经是绝对地址时 baseURL 被忽略)
  query: { page: 2, tag: ['a', 'b'] },
  headers: { 'x-trace': id },
  timeout: 5000,
  retry: 1,                     // 失败重试次数(默认:GET 重试、POST 不重试)
  onResponse({ response, options }) { /* 统一处理 */ },
  onRequestError({ request, options, error }) { /* 请求没发出去/被取消 */ },
  onResponseError({ request, response, options }) { /* 4xx/5xx:会 throw */ }
})

一条必须知道的默认行为:非 2xx 会抛错(不是返回一个 { ok: false })。所以所有 $fetch 都要包 try/catch,或者在 onResponseError 里统一转成提示:

try {
  await $fetch('/api/order', { method: 'POST', body })
} catch (e: any) {
  // e.data 是被解析后的响应体;e.response.status 是状态码
  toast(e?.data?.message || '提交失败,请稍后再试')
}

服务端阶段访问外部 API:baseURL 的真相

这是 Nuxt 新手最大的一个坑,必须单独讲。

SSR 阶段执行 useFetch('/api/x') 时,请求不发往浏览器地址,而是在 Nitro 内部直接调用对应的 handler(省一次网络往返,很好)。但如果你的意图是「让浏览器去请求第三方域名」,写成相对路径就错了:

// ✗ SSR 阶段:Nitro 会把它当成自己的 /api/x 处理 → 404 或打到不存在的 handler
await useFetch('/api/x')

// ✗ SSR 阶段:从服务器发起,指向的是「本机」而不是外部服务
await useFetch('/weather', { baseURL: 'https://api.open-meteo.com' })  // 这种写法是对的(绝对域名)

// ✓ 正确姿势一:让服务端接口做代理,前端只访问自己
//   app/pages/…
await useFetch('/api/weather')          // Nuxt 自己的接口
//   server/api/weather.get.ts
export default defineEventHandler(() => $fetch('https://api.open-meteo.com/v1/forecast?latitude=…'))

// ✓ 正确姿势二:外部地址写成绝对 URL,并接受「这是服务端发起的请求」
await useFetch('https://api.example.com/v1/list', { headers: { authorization: `Bearer ${token}` } })

用服务端代理的三条理由(也是所有「前端不该持有密钥」场景的通用答案):

  1. 密钥不下发:第三方 token 留在 server/,浏览器看不到(第八章会说到 runtimeConfig)。
  2. 绕开 CORS:服务端之间没有同源限制。
  3. 可控:能加缓存(swr)、限流、降级、日志。
// 只在服务端执行:把浏览器带来的 cookie 转交给内部接口
const headers = useRequestHeaders(['cookie'])
const { data } = await useFetch('/api/me', { headers })

useRequestHeaders() 在客户端返回空对象——这不是 bug,而是「浏览器本来就会自动带 cookie」。判断标准:SSR 阶段的请求需要你手工带上下文(cookie / authorization / user-agent),客户端阶段的请求由浏览器自动带。

useFetch 与 $fetch 的取舍

需求用什么
页面渲染需要的数据,要进 SSR HTML 和 payloadawait useFetch / useAsyncData
用户点按钮才触发的加载(下一页、加载更多)$fetch + 本地 ref
提交表单、删除、点赞$fetch(写操作不该被缓存)
服务端接口内部再调外部 API$fetch(server/ 里,没有 payload 概念)
图片/文件下载useFetch 的 responseType: 'arrayBuffer' 或直接浏览器行为

⚠️ 本章的坑

1. 忘记 try/catch。 4xx/5xx 会 throw,未被捕获时 SSR 阶段直接把页面炸成错误页(客户端则表现为「点了没反应」+ 一条 unhandled rejection)。

2. 相对路径当成「浏览器地址」。 上面整节讲的就是它。症状很迷惑:nuxt dev 里正常,部署到别的域名/子路径后接口全 404。

3. baseURL 与绝对 URL 同时给。 一旦请求字符串里有协议+域名,baseURL 被忽略,请求发往你以为没在用的地址。二选一。

4. POST 被自动重试造成重复下单。 retry 默认对幂等请求生效;把写操作设计成非幂等时显式写 retry: 0。

5. query 里塞 undefined。 ofetch 会跳过 undefined,但序列化 null/空串——「接口收到的 query 和预期不一致」时先打印实际 URL(onRequest 里 console.log(options.url + options.query))。

6. 数组查询参数的编码格式。 query: { ids: [1, 2] } 编成 ids=1&ids=2;不少后端要的是 ids[]=1 或 ids=1,2。用 query: { ids: ids.join(',') } 明确格式,别赌框架默认。

7. 在 onRequestError 里 throw。 拦截器里的异常会覆盖原始错误,排查时会看到「错误来源不明」。回调里只做记录,改写结果请返回新值。

8. 服务端拿不到 useRequestHeaders 之外的浏览器状态。 localStorage、navigator 一律为空。要这类信息就放到客户端再取({ server: false } 或 <ClientOnly>)。

附:$fetch 错误对象形状

字段内容
error.message文本描述(404 Not Found: /api/x 这类)
error.response原生 Response 对象(.status、.headers)
error.data解析后的响应体(后端返回的 { code, message } 在这里)
error.options本次请求的配置(URL、method、headers)
判定位置4xx/5xx → onResponseError;网络/DNS/取消 → onRequestError

七、服务端引擎 Nitro:server/ 里的文件就是接口

nuxt generate 出静态站时不需要它;但只要你需要「有秘密参与」或「按请求计算」的功能(登录、上传、支付回调、爬虫代理、读文件),server/ 就是最短路径——不用另起一个后端项目。

一个接口三步

// server/api/notes/index.get.ts
export default defineEventHandler(async (event) => {
  const { q = '', page = '1' } = getQuery(event)
  const list = await useDb(event).note.search({ q, page: Number(page) })
  return { total: list.length, items: list.map(slim) }     // 返回值 = JSON 响应体
})
文件写法生成的 URL方法
server/api/notes.get.ts/api/notesGET
server/api/notes.post.ts/api/notesPOST
server/api/notes/index.get.ts/api/notesGET(与上面等价,用于同目录多方法)
server/api/notes/[id].delete.ts/api/notes/:idDELETE
server/api/search.ts/api/search任意(不限制方法)
server/routes/health.get.ts/healthGET(routes/ 不带 /api 前缀)
server/middleware/log.ts—每个请求前跑
server/utils/db.ts—不是接口,被自动导入成 useDb()

后缀决定方法(.get / .post / .put / .patch / .delete / .head / .options)。一个文件一个方法是约定,同一资源的不同方法就分文件写(示例 06 演示了 [id].get.ts 与 [id].patch.ts 各自命中)。

handler 里常用的工具

export default defineEventHandler(async (event) => {
  const id = getRouterParam(event, 'id')                 // 路径参数
  const query = getQuery(event)                          // ?a=1
  const body = await readBody(event)                     // JSON 请求体
  const raw = await readRawBody(event)                   // 原始文本
  const file = await readMultipartFormData(event)        // 上传(数组,每项有 filename/data)
  const cookie = getCookie(event, 'sid')

  setHeader(event, 'cache-control', 's-maxage=60, stale-while-revalidate')
  setResponseStatus(event, 201)
  setCookie(event, 'sid', 'abc', { httpOnly: true, sameSite: 'lax', maxAge: 60 * 60 * 24 })

  if (!id) throw createError({ statusCode: 400, statusMessage: '缺少 id' })
  return { ok: true, id }                                // ← 返回即响应;后面别再手写 res
})

规则一句话:async handler 里,返回值就是响应体。 不要用 event.node.res.end()(除非你确定要接管原始响应,比如流式/重定向),否则会出现「已经返回了但连接没关」或「双重写入」的诡异行为。

前端怎么调它

<script setup>
// SSR 阶段:这个请求不经过网络,Nitro 内部直接调用 handler(快、无 CORS、无 baseURL 问题)
const { data: notes } = await useFetch('/api/notes', { query: { page: 1 } })
</script>

<NuxtLink to="/api/notes"> 之类直接访问也能工作——部署时它就是普通的 HTTP 接口。

环境变量与密钥的位置

server/ 里读环境变量用 useRuntimeConfig()(第八章),或 Nitro 的 process.env(只在服务端可见)。密钥永远不要出现在 app/ 下的代码里——打包产物是给人下载的文件。

示例 06:server 目录文件 → URL/方法 三栏联动在新标签页打开

⚠️ 本章的坑

1. server/api 与前端路由撞名。 有人把接口放 app/pages/api/…(当页面渲染了)或把页面放 server/api/…(当成接口了)。分界很清楚:server/ 是接口,app/pages/ 是页面。

2. 忘写方法后缀。 server/api/notes.ts 会接受所有方法——一个本该只读的接口就这样成了 POST 入口。显式 .get.ts。

3. 在 handler 里 console.log 当调试手段,客户端看不到。 服务端日志打印在跑 nuxt dev 的那个终端里,不是浏览器控制台。「接口没反应」先去终端找。

4. readBody 对 GET 请求没意义。 有些客户端发 GET 带 body,Nitro 默认不解析;参数请用 getQuery / getRouterParam。

5. 上传文件没配 bodySizeLimit。 大文件被截断或 413。事件里用 readMultipartFormData(event, { limit }),并同步调 Nginx 的 client_max_body_size。

6. 直接 return 了 Buffer/stream 却忘了设头。 返回二进制请 setHeader(event, 'content-type', 'application/pdf') 再送(或用 event.node.res 明确接管)。

7. 在接口里做重计算,没加缓存。 每次请求都全量算 → CPU 打满。用 routeRules 的 swr/isr(第九章)或 defineCachedEventHandler:

export default defineCachedEventHandler(async () => heavy(), { maxAge: 60, swr: true, name: 'report' })

8. 中间件里 return 了东西。 server/middleware/*.ts 的返回值会被忽略(它是「路过看一眼」的角色);要拦截请 throw createError(...)。

9. dev 环境改 server/utils 后类型没更新。 跑 nuxt prepare 或重启 dev server,TS 服务才会重新读自动导入声明。

附:h3 工具函数速查

工具作用
defineEventHandler(fn)声明一个 handler(唯一的入口包装)
getQuery(event)解析查询串成对象
getRouterParam(event, 'id') / event.context.params路径参数
readBody(event) / readValidatedBody(event, schema)请求体(后者可配 zod 校验)
readRawBody / readMultipartFormData原始体 / 表单上传
getCookie / setCookie / deleteCookiecookie
getHeader / setHeader / setHeaders请求/响应头
setResponseStatus(event, code, msg)状态码
sendRedirect(event, url, code)3xx
createError({ statusCode, statusMessage })抛出结构化错误(前端 error.data 能拿到)
useRuntimeConfig()运行时配置(第八章)
defineCachedEventHandler(fn, opts)接口级缓存(maxAge/swr/variables)
event.context本次请求的上下文(可挂东西,跨中间件传值)

八、运行时配置与环境变量:runtimeConfig

「同一个包,跑在开发/测试/生产三套环境」——这件事必须由配置层解决,而不是改代码。Nuxt 的答案是 runtimeConfig + .env。

一份配置,两种可见性

// nuxt.config.ts
export default defineNuxtConfig({
  runtimeConfig: {
    // 只有服务端能读到(Nitro handler、SSR 阶段的代码)
    dbUrl: '',
    apiSecret: '',
    // 以 public 开头的会进客户端产物(构建时被替换成实际值)
    public: {
      apiBase: '/api',
      siteName: '码上智能',
      mapKey: ''
    }
  }
})
# .env(本地开发用;生产由部署环境注入,别把 .env 提交进仓库)
NUXT_DB_URL=postgres://user:pass@127.0.0.1:5432/shop
NUXT_PUBLIC_API_BASE=https://api.yoursite.com
NUXT_PUBLIC_MAP_KEY=abc123

命名规则固定:runtimeConfig 里的字段名 → 环境变量名 = NUXT_ + 大写下划线;嵌套就是 NUXT_PUBLIC_XXX。这个映射是自动的,不需要手写对应关系。

读取

// app 侧(组件/组合式函数)
const cfg = useRuntimeConfig()
cfg.public.apiBase          // ✓ 有
cfg.dbUrl                   // ✗ 客户端拿到 undefined(SSR 阶段能读到)

// server 侧
export default defineEventHandler(() => {
  const cfg = useRuntimeConfig()
  return query(cfg.dbUrl)   // ✓ 密钥只在服务端出现
})

关键认知:public.* 的值在构建期就被写死进产物。用户 Ctrl+U 查看 JS 文件就能读到它。所以「公开的配置」不等于「不敏感的配置」,而是「即使被看到也没关系的」——站点名、公开接口地址可以;任何能花钱或被刷的东西不行。

开发期跨域代理

前端调本机另一个后端(比如 Java 服务在 8080)时,用 dev 代理避免 CORS:

export default defineNuxtConfig({
  vite: {
    server: {
      proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } }
    }
  }
})

这条只在 nuxt dev 有效。 生产环境必须由 Nginx/网关把 /api 反代到后端(第十三章),否则线上 404——这是「本地一切正常,上线接口全挂」的经典成因。

⚠️ 本章的坑

1. 把密钥写进 public。 它会被打进前端包。写完检查一遍产物:grep -r "sk-" .output/public/_nuxt/。

2. 在客户端读非 public 字段。 cfg.dbUrl 是 undefined,报错信息常是「undefined is not an object」,很难联想到环境变量。规则:非 public 字段只在 server/ 和 SSR 阶段用。

3. 环境变量名打错没报错。 NUXT_DB_URl 这类拼写错误不会提示,读到空串。给配置加显式校验:

if (!process.env.NUXT_DB_URL) throw new Error('缺少 NUXT_DB_URL')

4. .env 提交进仓库。 至少要在 .gitignore 里排除,并给一份 .env.example 列出需要哪些键(不含值)。

5. 以为改了 .env 就生效。 需要重启 dev server;生产要重启进程。nuxt generate 的 public 值已经烧进产物,改环境变量必须重新 generate。

6. 静态产物里改不了 public.*。 这是「一次构建多处部署」的障碍。解法:把可变配置改成运行时接口(/api/config),或在部署脚本里做字符串替换。

7. import.meta.env 与 useRuntimeConfig() 混用。 前者是 Vite 层的构建期常量(VITE_*,前端可读),后者是 Nuxt 层的运行时配置(服务端可读、可被环境变量覆盖)。优先 runtimeConfig:一套写法同时覆盖服务端和客户端。

8. 在 nuxt.config.ts 里直接读 process.env 拼进 runtimeConfig。 可以,但别忘了构建机上没有生产环境变量,产物里就固化成空串——正确姿势是让 runtimeConfig 字段留空,由运行期环境变量填充(NUXT_XXX)。

附:配置来源优先级

来源时机客户端可见典型用途
runtimeConfig.public.*构建期固化✓公开 API 地址、站点名、开关
runtimeConfig.*(非 public)运行期读环境变量✗数据库、第三方密钥
NUXT_* 环境变量部署平台注入视是否 public分环境差异
import.meta.env.VITE_*构建期常量✓构建模式、非敏感常量
服务端 /api/config运行期请求✓需要「同一份产物改配置」的场景
app.config(defineAppConfig)构建期✓UI 常量(主题色、菜单结构),不可被环境变量覆盖

九、渲染模式与 routeRules:同一站点里不同页面用不同策略

前面反复出现「SSR」和「generate」。这一节把它们讲成可以按路径配置的东西:routeRules。

四个开关

export default defineNuxtConfig({
  ssr: true,                                    // 全站默认:服务端渲染
  nitro: { prerender: { crawlLinks: true, routes: ['/', '/about'] } },   // 构建期出 HTML
  routeRules: {
    '/admin/**':    { ssr: false },                     // 这个子树走纯客户端(SPA)
    '/blog/**':     { prerender: true },                // 构建期生成静态 HTML
    '/products/**': { swr: 60 },                        // 缓存 60s,过期后台刷新
    '/api/search':  { cors: true, headers: { 'cache-control': 'no-store' } },
    '/old-path':    { redirect: '/new-path' },          // 301(写 { to, code } 改状态码)
    '/legacy/**':   { proxy: 'https://old.example.com/**' },
    '/privacy':     { isr: true }                       // 平台按需 ISR(Vercel/Netlify 等)
  }
})
模式配置首屏 HTML 有正文服务器要跑 Node适合
SSR默认(ssr: true)✓要内容随请求变化、需要 SEO
SPAssr: false(全站或路由级)✗可不要(纯发静态文件时)后台系统、登录后的界面
SSGnitro.prerender / { prerender: true }✓不要(纯静态)博客、文档、营销页(本站就是这个模式)
SWR{ swr: 60 }✓要内容变化不敏感、想挡住突发流量
ISR{ isr: true }✓平台决定部署在支持按需再生成的托管平台

匹配优先级:越具体越优先

多条规则命中同一个 URL 时,按路径精确度排序:/blog/hello 精确规则 > /blog/** > /**。所以「给全站设默认,再覆盖某几个路径」是能成立的——但反着写会翻车:

routeRules: {
  '/**': { ssr: false },              // ← 全站关掉 SSR
  '/blog/**': { prerender: true }     // 仍然会预渲染,但 ssr:false 让产物是空壳!
}

这类组合的后果很隐蔽:预渲染出来的是不含正文的 HTML。示例 07 的「URL 判定器」就是让你手动改一下顺序、亲眼看结论怎么翻转。

什么时候真的需要 SSR

三条判断,任何一条成立才值得留服务端:

  1. 未登录用户能看到的内容需要被索引(首屏正文必须在 HTML 里)。
  2. 数据必须按请求计算(价格、库存、权限)。
  3. 首屏时间对业务指标有直接影响,且接口足够快。

都不成立时 ssr: false 更省:部署是纯静态文件、服务器不跑 Node,也不会遇到「SSR 阶段 window is not defined」(第五章第 2 条)。

示例 07:五种渲染模式对照与 routeRules 判定器在新标签页打开

⚠️ 本章的坑

1. ssr: false 之后 SEO 归零。 产物 HTML 是空壳。别以为「反正有水合」——爬虫与分享卡片读的是源码(示例 01 有对照)。

2. prerender: true + ssr: false 同时命中。 生成空壳静态页(上面那段代码)。要么去掉 /**: { ssr: false },要么给需要预渲染的路径显式合并写 { prerender: true, ssr: true }。

3. 预渲染没覆盖所有路由。 crawlLinks 只沿页面里的 <NuxtLink> 爬。没有链接指向的页面(只在 sitemap 里、或列表第二页)不会生成,generate 后访问就是 404。解法:nitro.prerender.routes 显式列,或让列表页给出全部链接。本站的工具页是手工维护一份清单进 routes 的(nuxt.config.ts 里的 TOOL_PRERENDER_ROUTES)。

4. 预渲染阶段接口必须可用。 generate 时页面里每个 useFetch 都会真的执行。若接口依赖登录态或外部服务,构建要么失败、要么把「未登录」的空数据固化进 HTML。这类页面改成 { ssr: false } 或 { prerender: false }。

5. swr/isr 依赖缓存存储。 默认是内存缓存,多实例部署各节点一份、命中率与一致性都差。要一致请配 nitro: { storage: { cache: … } }(Redis 等)。

6. 动态路由的预渲染要自己提供数据源。 原则:能爬到的就不用列,爬不到的才列。

nitro: {
  prerender: {
    crawlLinks: true,
    routes: async () => {
      const { data } = await $fetch('https://api.example.com/sitemap-urls')
      return data.map((u) => u.path)
    }
  }
}

7. routeRules 里写的 headers 与 Nginx 设的头冲突。 最终结果取决于先后。排查顺序:先 curl -I 看框架输出,再看 Nginx add_header。

8. 渲染模式与服务端中间件的期望不一致。 { ssr: false } 的路由上,服务端不会为页面渲染执行(但 /api 仍会)。别指望用服务端中间件给 SPA 页面「注入用户信息」。

9. 以为 routeRules 能改客户端行为。 它管的是服务端/构建阶段的策略;组件里的 onMounted 照旧只在浏览器跑。

附:routeRules 常用字段

字段类型作用
ssrboolean该路径是否服务端渲染
prerenderboolean构建期生成静态 HTML
swrnumber/truestale-while-revalidate 缓存秒数
isrboolean/number增量静态再生成(托管平台)
cache{ maxAge, swr, group, variables }更细粒度的缓存控制
redirectstring 或 { to, code }重定向(默认 301)
proxystring反向代理(老站合并常用)
headersobject加响应头(cache-control、CSP)
corsboolean允许跨域
staticboolean当静态文件处理
noIndexboolean响应加 x-robots-tag: noindex
payloadExtractionbooleanpayload 拆成 _payload.json(关了就内联进 HTML)

十、SEO 与 head:标题、meta、sitemap、结构化数据

内容站的成败往往不在交互,而在「搜索引擎和分享卡片怎么看你」。Nuxt 把 <head> 管理做成了组合式函数,SSR 阶段就会写进 HTML——这正是静态站点也能拿 SEO 的原因。

标题与描述:useSeoMeta

<script setup>
const page = /* … 取到的文档 … */

useSeoMeta({
  title: page.title,                        // 浏览器标签与搜索结果标题
  description: page.description,            // 搜索结果里的摘要
  ogTitle: page.title,                      // 社交平台卡片
  ogDescription: page.description,
  ogImage: '/img/' + page.category + '-cover.svg',
  ogType: 'article',
  twitterCard: 'summary_large_image',
  robots: 'index, follow'
})

// 需要更底层控制(link、script、html 属性)时用 useHead
useHead({
  link: [{ rel: 'canonical', href: 'https://your-site.com/tutorials/vue3' }],
  htmlAttrs: { lang: 'zh-CN' },
  script: [{ src: 'https://…', defer: true }]
})
</script>

useSeoMeta 与 useHead 的关系:前者是后者的语义化包装(自动把 title/description 写成对应的 <title>、<meta name="description">、og: 与 twitter:)。优先用 useSeoMeta,写不出来的字段再回 useHead。

全站默认值放在 app.vue 或配置里

export default defineNuxtConfig({
  app: {
    head: {
      charset: 'utf-8',
      viewport: 'width=device-width, initial-scale=1',
      link: [{ rel: 'icon', href: '/favicon.ico' }],
      meta: [{ name: 'theme-color', content: '#0969da' }]
    }
  },
  site: {
    url: 'https://your-site.com',    // 绝对地址的基准:sitemap / og:url / JSON-LD 都读它
    name: '站名'
  }
})

site.url 是最容易被遗忘的一项:默认值是 http://localhost:3000,忘记改就会生成一份全是本地地址的 sitemap,且 og:url 也错(本站在 nuxt.config.ts 里给它写了显眼注释)。

sitemap 与 robots

npm install @nuxtjs/sitemap
export default defineNuxtConfig({
  modules: ['@nuxtjs/sitemap'],
  site: { url: 'https://your-site.com' },
  sitemap: {
    autoLastmod: true,
    exclude: ['/admin/**', '/private/**', '/tools/**?*_=*']
  }
})

robots.txt 建议直接放 public/robots.txt(静态、零成本)。要按环境屏蔽预发站,就在 public/robots.txt 里写 Disallow: /,或用中间件在预发域名返回不同内容——别依赖 <meta name="robots"> 拦已泄露的 URL,它要求页面能被访问到才生效。

结构化数据(JSON-LD)

<script setup>
useHead({
  script: [{
    type: 'application/ld+json',
    innerHTML: JSON.stringify({
      '@context': 'https://schema.org',
      '@type': 'TechArticle',
      headline: page.title,
      datePublished: page.date,
      author: { '@type': 'Organization', name: '码上智能' }
    })
  }]
})
</script>

innerHTML 一定要 JSON.stringify(对象直接传会被转成 [object Object])。上线前拿 Google 的「富媒体测试」和「网址检查」各跑一次,比自查有效。

防主题闪烁:一段内联脚本

有暗色模式的站点会遇到 FOUC:预渲染的 HTML 里没有 data-theme,暗色用户先看到一帧亮色。标准解法是在 <head> 里放一段内联同步脚本,早于首次绘制把主题写进 <html>:

<script setup>
useHead({
  script: [{
    tagPosition: 'head',
    innerHTML: `(function(){try{var t=localStorage.getItem('site-theme');` +
      `if(t!=='dark'&&t!=='light'){t=matchMedia&&matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light';}` +
      `document.documentElement.setAttribute('data-theme',t);}catch(e){}})();`
  }]
})
</script>

这段是本站 app/app.vue 里真实在用的代码。要点:必须在 head、必须内联、必须 try/catch(隐私模式读 localStorage 会抛)。

⚠️ 本章的坑

1. useSeoMeta 写在异步分支里。 它必须在 setup 同步执行期调用。放进 if/onMounted 里,SSR 出的 HTML 就没有这个 meta(客户端才补上,爬虫看不到)。

2. 动态标题依赖客户端数据。 正确顺序是:await useFetch() 拿到数据 → 立刻 useSeoMeta({ title: data.title })。数据没在服务端取到,标题就只能是默认值。

3. site.url 忘了改。 生成的是 localhost 的绝对地址(本章「全站默认值」那节的 site 配置就是它)。

4. og:image 用了相对路径。 平台抓卡片时不会拼你的域名,必须给绝对 URL:useSeoMeta({ ogImage: config.siteUrl + cover }) 或让 site.url 帮你补全(新版本会补,但要实测确认,别赌)。

5. 预渲染页面的 meta 被固化。 generate 时每个页面各算各的 head,运行时改不了。若 meta 依赖「当前用户」,这类页面就不该预渲染。

6. 同一 key 的 head 重复注入。 在循环/子组件里调用 useHead 会产生多个 <title>。全站级的写在 app.vue,页面级的写在页面里。

7. 用 <ClientOnly> 包住正文。 屏蔽闪烁的目的若是「不让爬虫看到」,那 SEO 目标就同时落空了。正文要 SSR,只把「依赖浏览器环境的按钮/图表」放进 <ClientOnly>。

8. robots 与 noindex 打架。 网关加了 X-Robots-Tag: noindex,页面 meta 却写 index——头信息优先。排查索引问题先看 curl -I。

附:head 相关 API

API用途
useHead(input)任意 head 标签(link/script/style/htmlAttrs)
useSeoMeta(input)title/description/og/twitter 的语义化写法
useServerSeoMeta同上,但明确只在服务端输出(减小客户端体积)
setHead / head(app.vue 里)全站默认
defineCachedEventHandler + cache让带 meta 的 HTML 也进缓存
useRequestURL()拿到当前请求的绝对 URL(canonical / og:url 用)

十一、状态共享:useState 与 Pinia

《Vue 3 教程》第十五章讲了 Pinia 本身;这里只讲在 Nuxt 里用它的三件额外事,以及一个 Nuxt 特有的轻量替代品。

先试 useState:SSR 安全的极简共享状态

// 任意组件/组合式函数里
const panel = useState('side-panel', () => ({ open: false, tab: 'toc' }))
panel.value.open = true      // 全站共享,且服务端每次请求各自一份(不会串号)

useState 的数据会进 payload:服务端写的值,客户端水合时直接复用,不重复请求。它适合「一次会话内的界面状态」:侧栏开合、当前筛选、向导步骤。不适合需要 getter/action/devtools 的复杂逻辑。

三条限制要清楚:值必须可序列化(不能存函数、DOM 引用、类实例);key 是全局的(撞 key 就撞数据);它不是持久化,刷新就没了(要落盘自己配 watch + localStorage,Vue 教程第十三章的 useLocalStorage 正合适)。

Pinia:装进 Nuxt 的三步

npm install pinia @pinia/nuxt
// nuxt.config.ts
export default defineNuxtConfig({ modules: ['@pinia/nuxt'] })
// app/stores/user.ts   —— 与《Vue 3 教程》第十五章完全同构
import { defineStore } from 'pinia'

export const useUserStore = defineStore('user', () => {
  const profile = ref(null)
  const isLoggedIn = computed(() => !!profile.value)

  async function load() {
    profile.value = await $fetch('/api/me')        // SSR 阶段也会执行
  }
  function logout() { profile.value = null }
  return { profile, isLoggedIn, load, logout }
})
<script setup>
const user = useUserStore()
await user.load()          // 想让它进 SSR HTML 就在 setup 顶层 await
</script>

defineStore 可以留在 app/stores/(Nuxt 不自动导入 store,需要 import);要免 import 就放进 app/composables/ 里包一层。

SSR 场景的三条铁律

  1. 每个请求一个 Pinia 实例。 @pinia/nuxt 已经做了这件事(插件里 createPinia() 每次请求新建)。你自己在模块顶层创建的普通单例(const cache = new Map())会跨请求共享——这是 SSR 最危险的一类 bug:A 用户看到了 B 用户的数据。
  2. store 初始化数据要走 useAsyncData/useState,不要 onMounted。否则服务端渲染时 store 是空的,首屏正文也就是空的。
  3. 持久化只在客户端做。localStorage 在服务端不存在,SSR 阶段访问它会报错。用 import.meta.client 守卫:
if (import.meta.client) {
  watch(() => store.theme, (v) => localStorage.setItem('theme', v), { immediate: true })
}
跨课示例(Vue 3 教程 示例 15):两个组件共享同一个 store在新标签页打开

跨课程提示:上面这个示例属于《Vue 3 教程》。在 Nuxt 里唯一多出来的就是「实例按请求创建」这一层,其余用法不变。

⚠️ 本章的坑

1. 模块顶层的可变单例。 const state = reactive({ list: [] }) 写在 <script> 顶层或某个 utils 文件里,SSR 下所有请求共用 → 数据串号。放进 useState() / defineStore / 或 defineNuxtPlugin 内部创建。

2. useState 存了不可序列化的东西。 存 class 实例、函数、DOM 元素 → payload 里丢失或报错,表现为「服务端有值、客户端 undefined」。只存纯数据。

3. localStorage 在 SSR 阶段裸奔。 window is not defined / localStorage is not defined。三种正解:import.meta.client 判断、{ server: false }、<ClientOnly>。

4. 忘记水合导致「闪一下」。 服务端把 store 初始值写进 payload,若客户端又在 onMounted 里重新请求一遍,界面会出现「旧值 → 空 → 新值」。改成 setup 顶层 await 一次。

5. store 里直接读 useRoute()。 可以,但要在 setup 上下文中;且注意服务端阶段的 route 与客户端阶段可能不同(重定向后)。需要「进入页面时的路由」请存进局部变量。

6. $reset() 在 setup store 里不存在。 与《Vue 3 教程》第十五章第 3 条一样:自己写一个 $reset() 返回。

7. 把 SSR 阶段取的用户数据留在 Pinia 里返回给别的用户。 严格来说这是第 1 条的后果,但值得单列:任何「按请求的状态」都必须由 Pinia/useState 托管,不能由模块变量托管。上线前用两个不同账号并发压一次,看有没有串。

附:共享状态选型

需求用什么
一次访问内的界面状态(侧栏、筛选、步骤)useState
需要 getter/action/调试面板的业务状态Pinia
服务端数据缓存与刷新useFetch / useAsyncData(第五章)+ getCachedData
只有当前组件用ref / reactive
父到子 / 子到父props / emit
主题、语言这类环境值useState 或 app.config(构建期常量)
URL 能表达的状态(分页、tab、关键词)路由 query

十二、插件、中间件与组合式函数:挂到生命周期上

这三样是 Nuxt 里「组织代码」的主要手段,分工很清楚:

手段触发时机典型用途
路由中间件 app/middleware/每次导航(客户端)/ 服务端渲染前鉴权、语言切换、埋点重定向
Nuxt 插件 app/plugins/应用初始化(每个请求各跑一次)注册 $http、注入全局样式逻辑、接第三方 SDK
组合式函数 app/composables/你在组件里调用时复用「状态 + 逻辑」
服务端中间件 server/middleware/每个进入服务端的请求日志、CORS、请求计时

路由中间件

// app/middleware/auth.ts   (文件名即中间件名:definePageMeta({ middleware: 'auth' }))
export default defineNuxtRouteMiddleware((to) => {
  const user = useState('user', () => null)
  if (!user.value && to.meta.requiresAuth !== false) {
    return navigateTo(`/login?redirect=${to.fullPath}`)     // 返回跳转 = 拦截
  }
  // 返回 undefined 放行;return abortNavigation('消息') 直接进错误页
})

三种注册方式:

definePageMeta({ middleware: ['auth'] })            // 命名中间件(app/middleware/ 里的文件)
definePageMeta({ middleware: [(to) => { /* … */ }] }) // 行内
definePageMeta({ middleware: (to) => { if (to.meta.globalSkip) return } })  // 单个页面加全局逻辑

// 全局中间件(每个路由都跑):文件名加 .global.ts
// app/middleware/logger.global.ts

异步中间件直接 async + await 即可。顺序:全局在前、命名在后、行内最后;多个中间件里只要有一个返回了跳转,后面的就不再执行。

Nuxt 插件:提供全局能力

// app/plugins/http.ts
export default defineNuxtPlugin(() => {
  const config = useRuntimeConfig()
  const http = $fetch.create({ baseURL: config.public.apiBase, retry: 1 })
  http.interceptors.request.use((ctx) => { /* 统一加 token */ })
  return { provide: { http } }          // 组件里:const { $http } = useNuxtApp()
})
// app/plugins/track.ts —— 需要在导航上挂钩子的插件
export default defineNuxtPlugin((nuxtApp) => {
  nuxtApp.hook('page:start', (comp) => { /* 页面开始渲染 */ })
  nuxtApp.hook('page:finish', () => { /* 页面渲染完,做埋点 */ })
  nuxtApp.hook('app:mounted', () => { /* 仅客户端 */ })
})

常用钩子:app:created、app:mounted、page:start、page:finish、vue:setup(这个钩子里才能用 onServerPrefetch)、meta:watch。

插件按请求执行(SSR 下每个请求都跑一遍)。所以:不要在插件外面创建可变单例;注册全局样式/第三方 SDK 时注意别重复挂载;重逻辑放 useState/useAsyncData 里让 Nuxt 管缓存。

组合式函数与自动导入(app/composables/useXxx.ts)的写法和《Vue 3 教程》第十三章完全一致,Nuxt 只多给你一条便利:放进目录就不用 import。

服务端中间件

// server/middleware/timing.ts
export default defineEventHandler((event) => {
  const start = Date.now()
  event.context.requestId = crypto.randomUUID()
  setHeader(event, 'x-request-id', event.context.requestId)
  // 返回值在这里被忽略;要做「响应后」的事用 event.waitUntil 或钩子
})

⚠️ 本章的坑

1. 全局中间件命名忘加 .global。 app/middleware/auth.ts 只在被 definePageMeta 引用时生效。想全站生效请 auth.global.ts。

2. 中间件里 await 了但没 return 跳转。 navigateTo('/login') 不加 return,导航会继续(表现为「跳了又跳回来」)。中间件的返回值就是控制流。

3. 中间件里调 useFetch 报错。 中间件不在组件 setup 上下文里,某些组合式(useState 部分用法、useNuxtApp 的组件级 API)不可用。要取数据请放页面里,或在中间件用 $fetch。

4. 插件里创建可变单例。 「第一个用户设置了主题,第二个用户的主题跟着变」就是它(第十一章第 1 条)。

5. 第三方 SDK 在 SSR 阶段被 import 就炸。 它一加载就读 window。解法:动态 await import() 并只在客户端执行(if (import.meta.client)),或 plugins 里用 apply: 'client'(写法随版本略有差异,实测为准)。

6. 客户端与服务端各跑一遍插件,导致重复初始化。 日志里看到同一个 SDK 初始化两次——一次是 SSR、一次是水合,属正常;真需要只跑一次就用 callOnce。

7. 插件文件名带连字符,注入的 key 变了。 plugins/my-http.ts 提供 $http 时,key 由你 provide 的对象决定,跟文件名无关;但用默认导出名(export default defineNuxtPlugin(...) 里返回 { provide: { mySdk } })时请显式命名,别指望自动推断。

8. 服务端中间件里 return 了响应。 中间件的返回被忽略(与 h3 那套一致),想短路请 throw createError(...)。

附:生命周期顺序(一次页面访问)

阶段服务端客户端
1全局中间件 → 页面中间件全局中间件 → 页面中间件
2app:created(插件按请求执行)插件(水合阶段不重复创建应用,但 setup 钩子会跑)
3组件 setup 顶层 await(useFetch/store.load)组件 setup
4page:start → 渲染 HTML → page:finish水合 → app:mounted → onMounted
5响应带 payload 发出后续导航:组件复用,onMounted 不再跑(除非 key 变)

十三、部署:静态产物、子路径与 Nginx

本站就是这个模式的真实案例:Nuxt 生成的静态产物交给已有的 Docker Nginx 托管,挂在 /nuxt/ 子路径下。下面把整条链路和踩过的坑一次讲完。

路线选择

目标命令产物服务器要什么
纯静态站点(内容站、文档、营销页)nuxt generate.output/public/Nginx / OSS / 任意静态托管
需要按请求渲染或 /api 接口nuxt build.output/(含 server/index.mjs)Node 22+ 进程,或 Docker
托管平台(Vercel / Netlify / Cloudflare)nuxt build平台自适应 preset平台自带
容器化部署nuxt build + 官方镜像一个镜像见本章最后

静态部署完整链路

npm run generate          # = nuxt generate,产物在 .output/public

产物形状(本站实测):

.output/public/
├── index.html
├── tutorials/vue3.html           每个路由一个 HTML 文件
├── tutorials/vue3/_payload.json  ← 该页的 SSR 数据,水合时复用,不再请求
├── articles/index.html
├── _nuxt/                        打包后的 JS/CSS(带哈希)
├── img/…  tutorials/…/demos/…    来自 public/ 的原样文件
└── 404.html                      预渲染的错误页

子路径部署必须配 app.baseURL。 站点访问地址是 https://site.com/nuxt/ 时:

export default defineNuxtConfig({
  app: { baseURL: '/nuxt/' },      // 注意:首尾都要有斜杠
  nitro: { prerender: { crawlLinks: true, routes: ['/'] } }
})

配了这一行,产物里的 <script src>、<link href>、_payload.json 请求路径、<NuxtLink> 生成的 URL 全部会带上 /nuxt/ 前缀(Vite 的 base 由 app.baseURL 自动推导,不需要重复配)。

这就是本站最容易复发的一次事故:把产物拷到 nginx/html/nuxt/ 目录,但 baseURL 仍是默认的 /,结果 index.html 能返回、资源却去根路径请求 → 白屏 + 一堆 404,看起来像「Nuxt 有问题」。判定方法很简单:查看源码,资源前缀必须和访问前缀一致。示例 08 就是这个检查器。

示例 08:baseURL × 部署目录 → 资源路径与 404 判定在新标签页打开

Nginx 配置(history 路由 + 静态产物)

# 场景:产物在 /usr/share/nginx/html/nuxt,站点访问 /nuxt/
location /nuxt/ {
  alias /usr/share/nginx/html/nuxt/;
  # 1) 静态文件优先;2) 未知路径回落到本站 index.html;3) 都没有则 404
  try_files $uri $uri/ $uri.html /nuxt/index.html;
}

# 若还需要 Nuxt 的服务端接口(build 模式)
location /nuxt/api/ {
  proxy_pass http://127.0.0.1:3000/api/;
  proxy_set_header Host $host;
  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
  proxy_set_header X-Forwarded-Proto $scheme;
}

# 缓存策略:带哈希的 JS/CSS 可以长期缓存,HTML 与 payload 不能
location /nuxt/_nuxt/ {
  expires 1y;
  add_header Cache-Control "public, immutable";
}

三条要点:

  • try_files 的回落地址必须带子路径(/nuxt/index.html),否则刷新子页面时被回落到站点根的那个应用。
  • _payload.json 与 HTML 不能长期缓存,否则改了内容用户还是旧数据(payload 与页面绑定,版本不一致会导致数据错位)。
  • alias + try_files 的组合有历史坑:某些 Nginx 版本下 alias 与 try_files $uri/ 会 404。若怎么配都不对,改用 root 方案(把产物放到 <root>/nuxt/)最稳。

服务端模式部署(nuxt build)

node .output/server/index.mjs        # 默认 3000
PORT=8080 NITRO_HOST=0.0.0.0 node .output/server/index.mjs
# 单阶段版(简单);生产建议用多阶段 + nitro 的 docker preset
FROM node:22-alpine
WORKDIR /app
COPY .output ./
ENV NITRO_HOST=0.0.0.0 PORT=3000
EXPOSE 3000
CMD ["node", "./server/index.mjs"]

nuxt build 模式下别忘了:NITRO_...、NUXT_... 环境变量在容器里注入(第八章),别把 .env 拷进镜像。

部署前逐项核对

检查命令/方法
资源前缀正确查看产物 index.html 源码,<script src> 前缀 = 访问前缀
全部路由都生成了`find .output/public -name '*.html'
无未预渲染的 404逐个访问列表页里的链接;sitemap 条数一致
密钥没进产物grep -r "sk-|password" .output/public
sitemap / robots 域名正确curl https://site.com/nuxt/sitemap.xml
history 刷新正常直接访问 /nuxt/tutorials/vue3 回车
meta 是真实内容`curl -s URL

⚠️ 本章的坑

1. baseURL 与访问路径不一致(本站真实事故,上面整段就是为它写的)。

2. 子路径少写一个斜杠。 '/nuxt' 会拼成 /nuxtindex.js 这种东西。首尾都要斜杠:'/nuxt/'。

3. generate 后仍然有服务端接口需求。 静态产物里的 /api/* 不存在。需要接口就换 build 模式或把接口挪到别的服务 + Nginx 反代。

4. HTML 被 CDN 缓存。 改了内容线上不变,还「有时对有时错」。给 HTML 明确 Cache-Control: no-cache,带哈希的 _nuxt/* 才用长期缓存。

5. 404.html 没接上。 Nginx 需要 error_page 404 /nuxt/404.html;(配合 internal),否则未知路径回落 index 时返回 200(软 404,对 SEO 有害)。软 404 检查方法:curl -I https://site/nuxt/不存在,状态码必须是 404。

6. 环境变量在构建期固化。 public.* 值改不了「一份产物跑多环境」,只能重新 generate(第八章第 6 条)。

7. 本地 nuxt preview 正常,线上不同步。 preview 的 base 处理与真 Nginx 不完全一样。最终以「产物直接放进目标目录」为准做一次真机验证。

8. Windows 上构建的大小写问题。 本地能 build 过、Linux 容器里失败,八成是 import 路径大小写(《Vue 3 教程》第十八章第 1 条同款)。

附:常用部署配置速查

配置作用
app: { baseURL: '/nuxt/' }站点访问前缀(资源 + 路由 + payload 全靠它)
app: { cdnURL: 'https://cdn.x.com' }把 _nuxt/* 指到 CDN
nitro: { prerender: { crawlLinks, routes, concurrency } }静态生成的范围与并发
nitro: { static: true }明确纯静态(不生成 server 逻辑)
nitro: { output: { dir } }改产物目录(方便 CI 直接拷)
nitro: { preset: 'static' | 'node_server' | 'docker' }目标平台
nitro: { storage: { cache: { driver: 'redis', … } } }多实例共享缓存(swr 需要)
routeRules: { '/x': { prerender: true } }按路径覆盖生成策略

十四、性能与产物体积:先测,再优化

这一章的写法是「先给测量方法,再给手段」——不测量就优化,等于把时间花在猜测上。

先学会看三个数

# 1) 产物体积构成
npx nuxtanalyze            # 生成一份可视化报告(旧名 nuxi analyze)
npx vite-bundle-visualizer # 或者直接看 Vite 产物

# 2) 首屏 HTML 里有没有正文(判断 SSR/预渲染是否真的生效)
curl -s https://site/nuxt/tutorials/vue3 | head -c 2000

# 3) payload 有多大(SSR 数据序列化的代价)
ls -la .output/public/tutorials/vue3/_payload.json

Chrome DevTools 里看三个指标就够:FCP(首屏有内容)、TTI(能交互,水合完成的时刻)、Total JS(下载量)。SSR 优化的是 FCP,代码分割优化的是 TTI 和下载量。

有效的手段(按性价比排)

手段做法收益
路由级懒加载Nuxt 默认对 pages/ 组件做了;确认 import() 没被手写静态引入破坏首屏 JS 大幅下降
别用 barrel 文件app/components/index.ts 里 export * from './A' 会把整个目录拖进依赖图保住 tree-shaking
只取需要的字段useFetch(url, { pick: ['id','title'] }) / transformpayload 与网络双降
图片交给构建用 @nuxt/image(<NuxtImg>)或 assets/ + 导入自动格式/尺寸/懒加载
重组件异步化defineAsyncComponent(() => import('…'))(图表、编辑器、地图)首屏不含重组件
列表裁剪长列表分页/虚拟滚动(《Vue 3 教程》第十一章)渲染耗时
静态内容别 SSR 两遍纯静态子树用 v-memo / <ClientOnly> 慎用;优先 prerenderCPU + 水合成本
依赖预打包检查vite: { optimizeDeps: { include: [...] } } 只对 dev 生效开发体验
第三方脚本延后useHead 里给 defer,或改成用户触发后再注入TBT(阻塞时长)
关 devtools/分析包devtools: { enabled: false } 生产无影响但别忘 sourcemap: false体积与泄露

水合相关的两个具体做法

首屏之外的东西别水合。 大列表的下半部分、页脚的动态内容可以用 callOnce / 纯静态渲染减少水合成本;<ClientOnly> 是逃生舱不是常规手段(它会让 SSR HTML 变空)。

hydration mismatch 的三类源头:

<!-- 1) 服务端与客户端算出不同结果 -->
<p>{{ Date.now() }}</p>                      <!-- ✗ 每次都不同 -->
<p>{{ Math.random() }}</p>                    <!-- ✗ -->
<p>{{ localStorage.getItem('theme') }}</p>    <!-- ✗ 服务端没有 -->

<!-- 2) 服务端 HTML 结构被浏览器改了 -->
<tbody><tr>…</tr></tbody>   <!-- 浏览器会把非法嵌套修一遍,水合就对不上 -->

<!-- 3) 内联脚本提前改了 DOM(本站的主题脚本是刻意这么做的,它只写 <html> 属性,不动正文) -->

修不了就用 <ClientOnly> 包住那一小块,或用 onMounted 之后再赋值(并接受那块没有 SSR 正文)。

测量清单(本站用过的)

# 首屏 HTML 是否含正文
curl -s URL | grep -c 'article__body'

# 有多少个 JS 文件、总大小
find .output/public/_nuxt -name '*.js' | xargs du -ch | tail -1

# 预渲染页数量是否等于路由数
find .output/public -name '*.html' | wc -l

# 单个页面 payload 体积
du -h .output/public/tutorials/vue3/_payload.json
示例 05(回看):数据取几次、payload 里装了什么在新标签页打开

⚠️ 本章的坑

1. 为了「优化」把正文塞进 <ClientOnly>。 首屏 HTML 空了,SEO 与 FCP 双输。SSR 是内容站的基本盘,客户端只留「真的依赖浏览器」的部分。

2. pick 忘用,payload 几 MB。 移动端下载 payload 的时间可能比接口本身还长(第五章第 4 条)。

3. barrel index.ts 让 tree-shaking 失效。 一个 export * from './all-charts' 能把 400KB 拖进首屏。目录里请直接 import 具体文件。

4. 手写 import Chart from 'echarts' 破坏了懒加载。 重组件请 defineAsyncComponent,或在事件回调里 await import()。

5. 生产开了 sourcemap。 体积翻倍且把源码结构泄露出去。sourcemap: false(或用平台托管的隐藏 sourcemap)。

6. 图片放在 assets/ 里指望自动压缩。 Nuxt 默认只做哈希与内联阈值,不改尺寸不转格式。响应式图片要 @nuxt/image。

7. 字体导致布局抖动。 没写 font-display: swap / 没有 fallback 尺寸,文字会「跳一下」(CLS 变差)。字体建议 public/ + <link rel="preload">。

8. 只优化了自己的代码,没管第三方。 一个统计脚本就能吃掉 200KB。上线前用 nuxtanalyze 看第一屏柱子——通常最大那根不是你的代码。

9. 拿 nuxt dev 的速度判断生产。 dev 有未压缩与中间件开销,冷热差异极大。任何性能结论以 nuxt build/generate + preview(或真环境)为准。

附:性能问题定位顺序

症状先看什么常见根因
白屏时间长FCP / HTML 是否含正文没 SSR(ssr:false)、SSR 阶段接口慢
有内容但点不动TTI / 水合耗时JS 体积大、组件树深、水合数据大
图片/字体闪烁资源是否内联、preload关键 CSS/字体未预加载
每次导航都慢payload 请求耗时页面数据太大、lazy 没配
列表长页面卡顿渲染节点数没裁剪、key 不稳定、无虚拟滚动
构建变慢.nuxt 缓存、依赖数barrel 文件、过多模块

十五、收尾:坑汇总、对照表与下一步

从 Vue 到 Nuxt:只多了这几层

Vue 3 里你写的Nuxt 里的等价物多出来的注意点
createApp(App).use(router).mount('#app')不存在了,app/app.vue 就是根不要再手写 main.js
routes 数组 + <RouterView>app/pages/ 文件即路由 + <NuxtPage/>父组件要放 <NuxtPage/>(第三章)
import X from './X.vue'app/components/ 自动导入目录前缀规则(第二章)
useXxx 手动 importapp/composables/ 自动导入命名必须 use*
useRoute/useRouter一样可用跳转优先 navigateTo()
手写 axios 实例useFetch / useAsyncData / $fetch取数时机(第五、六章)
Pinia@pinia/nuxt + useState实例按请求创建(第十一章)
defineProps/defineEmits/defineModel完全一样无变化
vue-tsc 类型检查nuxt typecheck两份 tsconfig(app 与 server)
Vite baseapp.baseURL子路径部署的头号坑(第十三章)
index.html 里的 <title>/metauseSeoMeta / useHeadSSR 阶段必须同步调用(第十章)

按症状找原因

症状最可能的原因详见
首屏白,源码里有 <div id="app"> 但没正文ssr: false 生效 / 数据只在客户端取九、五
本地能跑,部署后资源 404、页面白屏app.baseURL 与访问子路径不一致十三(本站真实事故)
刷新子页面 404Nginx 缺 try_files 回落;或该页没被预渲染十三、九
改了内容线上还是旧的HTML/payload 被缓存;或产物没重新拷贝十三
Cannot find name 'useFetch'没跑 nuxt prepare二
页面空白、控制台没报错父页面漏 <NuxtPage/>三
组件 <BaseButton> 找不到目录前缀(应为 <UiBaseButton>)或路径大小写二
服务端报 window is not definedSSR 阶段访问了浏览器 API五、十一
数据在服务端取到了但界面 undefinedNuxt 4 的 default 缺失 / payload 序列化失败五
同一份数据请求两次两处 key 不同(URL 参数变了)或用了 server: false五
切了筛选,列表数据不变同路由只换 query 未触发重新请求(watch 选项)三、五
两个用户数据串了模块级可变单例 / 插件里创建共享状态十一、十二
页面「闪一下」或 hydration 警告Date.now()/random/localStorage 进了模板十四
error.vue 样式全丢它在布局之外四
sitemap.xml 里全是 localhostsite.url 没改十
卡片分享没图og:image 不是绝对 URL十
构建在 CI 失败、本地成功大小写敏感、Node 版本、compatibilityDate 差异二、十三

上线核对清单

类别检查项不做的后果
配置app.baseURL = 真实访问前缀(含首尾斜杠)白屏 + 资源 404
配置site.url 是真实域名sitemap/og 指向 localhost
配置compatibilityDate 显式设定升级后行为漂移
渲染每个路径的模式与预期一致(routeRules 顺序检查过)空壳被预渲染 / SEO 归零
数据首屏数据用 await useFetch,且配了 default 与 pick正文缺失、payload 巨大
数据需要密钥的请求走 server/api 代理,密钥不在 public密钥泄露
路由无用的 .vue 不在 pages/ 下;404 由 createError 抛出脏路由、软 404
SEO每页 title/description 由 SSR 输出;curl 验证过索引不到、卡片空白
生成nitro.prerender.routes 覆盖所有不可爬到的页面generate 后 404
服务器try_files 回落地址带子路径;HTML no-cache;_nuxt 长缓存刷新 404、内容不更新
服务器error_page 404 指向预渲染的 404,状态码真的是 404软 404 有害 SEO
安全.env 未入库;产物 grep 过密钥;sourcemap 关闭泄露源码与密钥
验证nuxt generate + 真目录部署跑过一遍;nuxtanalyze 看过体积把「能构建」当「能上线」

下一步

  1. 补服务端能力:server/api 之外还有定时任务(nitro: { scheduledTasks })、WebSocket(defineWebSocketHandler)、存储抽象(useStorage,本站用它管内容)。关键词:Nitro 文档的 Deployment 与 Storage 两节。
  2. 内容型站点:@nuxt/content v3(集合 + SQLite 索引,本站教程栏目就是它驱动的);配图与响应式图片用 @nuxt/image。
  3. 交互复杂度上来后:把本教程第五章的 getCachedData、dedupe、refreshNuxtData 玩熟,再评估要不要 TanStack Query。
  4. 测试与质量:@nuxt/test-utils(e2e + 渲染测试)、nuxt typecheck、CI 里跑一次 generate 并 curl -I 抽查 404 状态码。
  5. 原理补两页:Nuxt 文档的「Rendering Modes」与「Best Practices」;水合与 payload 的机制看懂后,第九章和第十四章的所有坑都能自己推出来。

Nuxt 带来的全部复杂度,其实只有一句话:同一份组件代码,可能在服务器上跑,也可能在浏览器里跑,还可能两边各跑一次。 写每一行代码时问自己「这段在服务端跑会怎样」,这门课的所有坑就都成了可预测的事。

评论

为保障内容质量,评论需站长审核通过后才会展示;提交后没立刻看到属正常,请勿重复提交。

还没有评论,来抢沙发~