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 中间件
触发时机页面路由跳转时所有服务端请求
能访问路由信息tofrom❌ 只有 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(windowdocument 等)
  • 不要在组件顶层使用 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 官方文档整理编写