Skip to content

Nuxt 生命周期 ​

理解 Nuxt 的生命周期对于编写正确的代码至关重要,尤其是在处理 SSR 时。这是 Nuxt 和纯 Vue 项目最大的区别之一——在 Vue 中,代码只在浏览器运行;在 Nuxt 中,同样的代码可能在服务端和客户端各执行一次。

为什么需要理解生命周期? ​

不理解生命周期的后果具体表现
Hydration 不匹配服务端和客户端渲染结果不同,页面闪烁或报错
跨请求数据污染用户 A 看到了用户 B 的数据(严重安全漏洞!)
代码在错误的时机执行在服务端使用了 window,直接报错
数据获取时机不对页面闪烁:先显示空内容,再显示数据

核心概念

Nuxt 的代码会在两个地方执行——服务端和客户端。你需要清楚每一行代码在哪个环境执行、什么时候执行。

总览 ​

text
服务端(每个请求)                    客户端(首次加载)
━━━━━━━━━━━━━━━━                    ━━━━━━━━━━━━━━
nuxtServerInit                   ↓ Hydration
├── 插件执行                      ├── 插件执行(仅 client)
├── 路由中间件                    ├── 路由中间件
├── useAsyncData/useFetch        ├── useAsyncData/useFetch(使用 payload,不发请求)
└── SSR 渲染 HTML                 └── 页面交互激活

                                   客户端(后续导航)
                                   ━━━━━━━━━━━━━━━━
                                   路由中间件
                                   useAsyncData/useFetch(客户端发请求)
                                   页面切换过渡

关键理解

  1. 首次访问:浏览器请求 URL → 服务端完整执行上述流程 → 返回 HTML + Payload → 客户端 Hydration
  2. 后续导航:在客户端完成(不再请求服务端渲染),就像普通 SPA 一样
  3. Payload:服务端获取的数据会随 HTML 一起发送到客户端,客户端不需要重复请求

服务端生命周期 ​

1. 请求到达 ​

每个 HTTP 请求到达 Nuxt 服务器。

重要

SSR 模式下,每个用户访问页面都是一个独立的 HTTP 请求。这意味着服务端代码是"每个请求独立执行"的,多个用户同时访问时,各请求互不干扰。

2. nuxtServerInit(Pinia) ​

如果使用 Pinia,可以在 store 中定义 nuxtServerInit:

ts
// 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/ 中的文件按字母顺序执行:

ts
// server/middleware/log.ts
export default defineEventHandler((event) => {
  console.log(`[${event.method}] ${getRequestURL(event)}`)
})

Nitro 中间件的特点

  • 每个请求都会执行(包括 API 请求和页面请求)
  • 按文件名字母顺序执行
  • 不应该返回响应(只用于日志、CORS、鉴权等前置处理)

4. 插件执行 ​

app/plugins/ 中的插件按文件名排序执行:

text
plugins/
├── 01.auth.ts      # 先执行——鉴权相关
├── 02.api.ts       # 后执行——API 配置(可能依赖鉴权)
└── analytics.ts    # 按字母顺序

为什么用数字前缀?

默认按文件名字母排序。如果插件之间有依赖关系(如 API 插件依赖鉴权插件),用数字前缀控制执行顺序。

5. 路由中间件 ​

ts
// app/middleware/auth.ts
export default defineNuxtRouteMiddleware((to, from) => {
  // 在渲染页面前执行
  // 可以阻止导航或重定向
})

路由中间件 vs Nitro 中间件的区别

特性路由中间件Nitro 中间件
触发时机页面路由跳转时所有服务端请求
能访问路由信息✅ to、from❌ 只有 event
能否阻止导航✅ abortNavigation()不能(不处理路由)
典型用途登录检查、权限验证日志、CORS

6. 数据获取 ​

useFetch/useAsyncData 在服务端执行,获取数据用于渲染。

这是 Nuxt 数据获取最关键的一步

  1. 服务端执行 useFetch,获取数据
  2. 数据用于渲染 HTML
  3. 数据随 HTML 一起发送到客户端(Payload)
  4. 客户端收到后直接使用 Payload 中的数据,不再重复请求

这就是为什么 useFetch 是"SSR 友好"的——数据获取和 SSR 无缝配合。

7. SSR 渲染 ​

Vue 组件渲染为 HTML 字符串,连同 payload 一起发送到客户端。

SSR 渲染的本质

把 Vue 组件的 render() 函数在 Node.js 环境中执行,生成 HTML 字符串。这个过程是"纯渲染"——没有 DOM、没有事件、没有交互。

客户端生命周期 ​

1. Hydration(水合) ​

客户端接收服务端发送的 HTML 和 payload,"激活"页面使其可交互。

Hydration 做了什么?

  1. Vue 在浏览器中重新执行组件逻辑
  2. 把事件监听器绑定到已有的 DOM 元素上
  3. 建立响应式数据绑定
  4. 不会重新创建 DOM——而是复用服务端生成的 HTML

如果服务端和客户端的渲染结果不一致,Vue 无法正确匹配 DOM,就会报 Hydration 不匹配警告。

Hydration 注意事项:

  • 服务端和客户端的渲染结果必须一致(否则出现 Hydration 不匹配警告)
  • 不要在 onMounted 之前的代码中使用浏览器 API(window、document 等)
  • 不要在组件顶层使用 Date.now() 或 Math.random()(服务端和客户端结果不同)

2. 客户端插件 ​

标记为 .client.ts 的插件仅在客户端执行。

ts
// app/plugins/analytics.client.ts
// 只在浏览器中执行,不会在服务端执行
export default defineNuxtPlugin(() => {
  // 安全地使用 window、document 等
})

什么时候用 .client.ts?

当插件需要使用浏览器 API(如 window.addEventListener)时。

3. 客户端路由 ​

后续的页面导航在客户端完成,不再请求服务端渲染。

首次加载 vs 后续导航的区别

阶段首次加载后续导航
谁渲染页面服务端(SSR)客户端
useFetch服务端获取数据客户端获取数据
页面切换完整请求局部更新(SPA 体验)
速度取决于服务器响应很快(不需要网络往返)

生命周期钩子 ​

Vue 组合式 API 钩子 ​

vue
<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 钩子 ​

ts
// 插件中
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 不匹配 ​

vue
<!-- ❌ 错误:服务端和客户端结果不同 -->
<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 ​

vue
<!-- ❌ 错误: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> ​

vue
<ClientOnly>
  <MapComponent />  <!-- 仅在客户端渲染 -->
</ClientOnly>

什么时候用 <ClientOnly>?

  • 地图组件(依赖浏览器 API)
  • 图表组件(依赖 Canvas)
  • 任何只在浏览器中才能工作的组件

它的效果是:服务端渲染时跳过这部分,客户端 Hydration 后再渲染。

跨请求数据污染(SSR 最危险的 bug) ​

ts
// ❌ 危险!模块级别的变量在所有请求之间共享
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() 或每次请求创建局部变量。

知识脉络 ​

text
渲染模式 → 你在这里:Nuxt 生命周期
              │
              ├─→ 相关:自动导入(了解哪些 API 可以直接用)
              │
              ├─→ 相关:数据获取(useFetch 在生命周期中的行为)
              │
              └─→ 相关:插件(在生命周期中的执行顺序)

基于 Nuxt 4 官方文档整理编写