Nuxt 生命周期
理解 Nuxt 的生命周期对于编写正确的代码至关重要,尤其是在处理 SSR 时。这是 Nuxt 和纯 Vue 项目最大的区别之一——在 Vue 中,代码只在浏览器运行;在 Nuxt 中,同样的代码可能在服务端和客户端各执行一次。
为什么需要理解生命周期?
| 不理解生命周期的后果 | 具体表现 |
|---|---|
| Hydration 不匹配 | 服务端和客户端渲染结果不同,页面闪烁或报错 |
| 跨请求数据污染 | 用户 A 看到了用户 B 的数据(严重安全漏洞!) |
| 代码在错误的时机执行 | 在服务端使用了 window,直接报错 |
| 数据获取时机不对 | 页面闪烁:先显示空内容,再显示数据 |
核心概念
Nuxt 的代码会在两个地方执行——服务端和客户端。你需要清楚每一行代码在哪个环境执行、什么时候执行。
总览
服务端(每个请求) 客户端(首次加载)
━━━━━━━━━━━━━━━━ ━━━━━━━━━━━━━━
nuxtServerInit ↓ Hydration
├── 插件执行 ├── 插件执行(仅 client)
├── 路由中间件 ├── 路由中间件
├── useAsyncData/useFetch ├── useAsyncData/useFetch(使用 payload,不发请求)
└── SSR 渲染 HTML └── 页面交互激活
客户端(后续导航)
━━━━━━━━━━━━━━━━
路由中间件
useAsyncData/useFetch(客户端发请求)
页面切换过渡关键理解
- 首次访问:浏览器请求 URL → 服务端完整执行上述流程 → 返回 HTML + Payload → 客户端 Hydration
- 后续导航:在客户端完成(不再请求服务端渲染),就像普通 SPA 一样
- Payload:服务端获取的数据会随 HTML 一起发送到客户端,客户端不需要重复请求
服务端生命周期
1. 请求到达
每个 HTTP 请求到达 Nuxt 服务器。
重要
SSR 模式下,每个用户访问页面都是一个独立的 HTTP 请求。这意味着服务端代码是"每个请求独立执行"的,多个用户同时访问时,各请求互不干扰。
2. nuxtServerInit(Pinia)
如果使用 Pinia,可以在 store 中定义 nuxtServerInit:
// stores/auth.ts
export const useAuthStore = defineStore('auth', {
state: () => ({ user: null as User | null }),
actions: {
async nuxtServerInit() {
// 在服务端渲染之前,自动执行
// 典型用途:根据 cookie 中的 token 获取用户信息
const token = useCookie('auth-token')
if (token.value) {
this.user = await $fetch('/api/user', {
headers: { Authorization: `Bearer ${token.value}` },
})
}
}
},
})为什么需要 nuxtServerInit?
考虑这个场景:用户已登录,刷新页面。如果是纯 CSR,页面会先显示"未登录"状态,然后 JS 执行后再变成"已登录"——页面闪烁。使用 nuxtServerInit,服务端渲染时就已经知道用户已登录,直接渲染"已登录"状态。
nuxtServerInit 只在服务端执行
并且只在首次请求(页面刷新/直接访问)时执行。客户端导航不会触发。
3. Nitro 中间件
server/middleware/ 中的文件按字母顺序执行:
// server/middleware/log.ts
export default defineEventHandler((event) => {
console.log(`[${event.method}] ${getRequestURL(event)}`)
})Nitro 中间件的特点
- 每个请求都会执行(包括 API 请求和页面请求)
- 按文件名字母顺序执行
- 不应该返回响应(只用于日志、CORS、鉴权等前置处理)
4. 插件执行
app/plugins/ 中的插件按文件名排序执行:
plugins/
├── 01.auth.ts # 先执行——鉴权相关
├── 02.api.ts # 后执行——API 配置(可能依赖鉴权)
└── analytics.ts # 按字母顺序为什么用数字前缀?
默认按文件名字母排序。如果插件之间有依赖关系(如 API 插件依赖鉴权插件),用数字前缀控制执行顺序。
5. 路由中间件
// app/middleware/auth.ts
export default defineNuxtRouteMiddleware((to, from) => {
// 在渲染页面前执行
// 可以阻止导航或重定向
})路由中间件 vs Nitro 中间件的区别
| 特性 | 路由中间件 | Nitro 中间件 |
|---|---|---|
| 触发时机 | 页面路由跳转时 | 所有服务端请求 |
| 能访问路由信息 | ✅ to、from | ❌ 只有 event |
| 能否阻止导航 | ✅ abortNavigation() | 不能(不处理路由) |
| 典型用途 | 登录检查、权限验证 | 日志、CORS |
6. 数据获取
useFetch/useAsyncData 在服务端执行,获取数据用于渲染。
这是 Nuxt 数据获取最关键的一步
- 服务端执行
useFetch,获取数据 - 数据用于渲染 HTML
- 数据随 HTML 一起发送到客户端(Payload)
- 客户端收到后直接使用 Payload 中的数据,不再重复请求
这就是为什么 useFetch 是"SSR 友好"的——数据获取和 SSR 无缝配合。
7. SSR 渲染
Vue 组件渲染为 HTML 字符串,连同 payload 一起发送到客户端。
SSR 渲染的本质
把 Vue 组件的 render() 函数在 Node.js 环境中执行,生成 HTML 字符串。这个过程是"纯渲染"——没有 DOM、没有事件、没有交互。
客户端生命周期
1. Hydration(水合)
客户端接收服务端发送的 HTML 和 payload,"激活"页面使其可交互。
Hydration 做了什么?
- Vue 在浏览器中重新执行组件逻辑
- 把事件监听器绑定到已有的 DOM 元素上
- 建立响应式数据绑定
- 不会重新创建 DOM——而是复用服务端生成的 HTML
如果服务端和客户端的渲染结果不一致,Vue 无法正确匹配 DOM,就会报 Hydration 不匹配警告。
Hydration 注意事项:
- 服务端和客户端的渲染结果必须一致(否则出现 Hydration 不匹配警告)
- 不要在
onMounted之前的代码中使用浏览器 API(window、document等) - 不要在组件顶层使用
Date.now()或Math.random()(服务端和客户端结果不同)
2. 客户端插件
标记为 .client.ts 的插件仅在客户端执行。
// app/plugins/analytics.client.ts
// 只在浏览器中执行,不会在服务端执行
export default defineNuxtPlugin(() => {
// 安全地使用 window、document 等
})什么时候用 .client.ts?
当插件需要使用浏览器 API(如 window.addEventListener)时。
3. 客户端路由
后续的页面导航在客户端完成,不再请求服务端渲染。
首次加载 vs 后续导航的区别
| 阶段 | 首次加载 | 后续导航 |
|---|---|---|
| 谁渲染页面 | 服务端(SSR) | 客户端 |
| useFetch | 服务端获取数据 | 客户端获取数据 |
| 页面切换 | 完整请求 | 局部更新(SPA 体验) |
| 速度 | 取决于服务器响应 | 很快(不需要网络往返) |
生命周期钩子
Vue 组合式 API 钩子
<script setup>
// ⚠️ <script setup> 中的顶层代码:服务端 + 客户端都执行
// 这是理解 Nuxt 生命周期的关键!
await someAsyncFunction()
// ✅ 仅客户端执行——安全使用浏览器 API
onMounted(() => {
console.log('组件已挂载(仅客户端)')
window.addEventListener('resize', handleResize)
})
// ✅ 组件卸载时(仅客户端)
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
})
// ✅ 路由离开前(客户端导航时)
onBeforeRouteLeave((to, from) => {
// 返回 false 阻止导航
if (hasUnsavedChanges.value) {
return confirm('确定离开?未保存的修改将丢失')
}
})
</script>哪些钩子在服务端执行?哪些只在客户端?
| 钩子 | 服务端 | 客户端 | 典型用途 |
|---|---|---|---|
<script setup> 顶层代码 | ✅ | ✅ | 数据获取、初始化 |
onMounted | ❌ | ✅ | 浏览器 API、DOM 操作 |
onUnmounted | ❌ | ✅ | 清理定时器、事件监听 |
onBeforeRouteLeave | ❌ | ✅ | 离开确认 |
useFetch | ✅ | ✅ | 数据获取(两端都能用) |
Nuxt App 钩子
// 插件中
export default defineNuxtPlugin((nuxtApp) => {
// 页面开始加载
nuxtApp.hook('page:start', () => {
console.log('页面开始加载')
})
// 页面加载完成
nuxtApp.hook('page:finish', () => {
console.log('页面加载完成')
})
// 应用挂载完成(仅客户端)
nuxtApp.hook('app:mounted', () => {
console.log('应用已挂载')
})
})app:mounted vs onMounted
onMounted:每个组件挂载时触发app:mounted:整个应用挂载完成后触发(只触发一次)- 大多数情况用
onMounted就够了
常见陷阱
Hydration 不匹配
<!-- ❌ 错误:服务端和客户端结果不同 -->
<template>
<!-- 服务端渲染时是某个时间,客户端 Hydration 时是另一个时间 -->
<p>当前时间:{{ new Date().toISOString() }}</p>
</template>
<!-- ✅ 正确:仅在客户端渲染时间 -->
<template>
<p v-if="mounted">当前时间:{{ currentTime }}</p>
<p v-else>加载中...</p>
</template>
<script setup>
const mounted = ref(false)
const currentTime = ref('')
onMounted(() => {
mounted.value = true
currentTime.value = new Date().toISOString()
})
</script>为什么会不匹配?
服务端渲染时 new Date() 的结果和客户端 Hydration 时的结果不同(差了几百毫秒),Vue 发现 DOM 内容和期望不一致,报错。
正确使用浏览器 API
<!-- ❌ 错误:SSR 时没有 window -->
<script setup>
const width = window.innerWidth // ReferenceError: window is not defined
</script>
<!-- ✅ 正确:在 onMounted 中使用 -->
<script setup>
const width = ref(0)
onMounted(() => {
width.value = window.innerWidth
})
</script>
<!-- ✅ 另一种方式:用 process.client 判断 -->
<script setup>
if (process.client) {
// 这段代码只在客户端执行
console.log(window.innerWidth)
}
</script>process.client 是什么?
Nuxt 提供的运行时标志,在客户端为 true,在服务端为 false。还有一个 process.server。这是判断运行环境的最简单方式。
使用 <ClientOnly>
<ClientOnly>
<MapComponent /> <!-- 仅在客户端渲染 -->
</ClientOnly>什么时候用 <ClientOnly>?
- 地图组件(依赖浏览器 API)
- 图表组件(依赖 Canvas)
- 任何只在浏览器中才能工作的组件
它的效果是:服务端渲染时跳过这部分,客户端 Hydration 后再渲染。
跨请求数据污染(SSR 最危险的 bug)
// ❌ 危险!模块级别的变量在所有请求之间共享
let currentUser = null
export default defineEventHandler(() => {
currentUser = await getUser() // 用户 A 的数据
// 如果此时用户 B 的请求到来,currentUser 可能已经被覆盖!
return currentUser
})
// ✅ 安全:每次请求创建新的变量
export default defineEventHandler(async (event) => {
const user = await getUser(event) // 每个请求独立
return user
})INFO
️ 这是 SSR 最容易犯的错误 也是最危险的。在服务端,模块级变量在所有请求之间共享。如果你用 ref() 存储用户数据,用户 A 可能会看到用户 B 的数据!
规则
在服务端代码中,永远不要用模块级的 ref() 或普通变量存储请求相关的数据。用 useState() 或每次请求创建局部变量。
知识脉络
渲染模式 → 你在这里:Nuxt 生命周期
│
├─→ 相关:自动导入(了解哪些 API 可以直接用)
│
├─→ 相关:数据获取(useFetch 在生命周期中的行为)
│
└─→ 相关:插件(在生命周期中的执行顺序)