组件生命周期:从 @PostConstruct 到 onMounted

为什么要有生命周期钩子

一个组件从"被创建"到"从页面移除",中间会经过好几个阶段。有些事必须在特定时机做

Java 对照

@Component
public class MyBean {
    @PostConstruct      // 构造之后、可用之前
    public void init() { /* 初始化 */ }

    @PreDestroy         // 销毁之前
    public void cleanup() { /* 清理 */ }
}

Vue 的生命周期钩子就是这套"在特定时机执行代码"的机制,只不过切分得更细。


1. 生命周期全景图

                  ┌────────────────────┐
   setup()  ────> │  beforeCreate      │ (选项式 API 才有,setup 里用不到)
                  │  created           │
                  └──────────┬─────────┘
                             │
                             ▼
                  ┌────────────────────┐
                  │  onBeforeMount     │ 挂载前(DOM 还没生成)
                  │  onMounted  ★      │ 挂载后(DOM 可用)
                  └──────────┬─────────┘
                             │
                    (数据变化时循环触发)
                             │
                             ▼
                  ┌────────────────────┐
                  │  onBeforeUpdate    │ 数据变了,DOM 还没改
                  │  onUpdated         │ DOM 已经改完
                  └──────────┬─────────┘
                             │
                             ▼
                  ┌────────────────────┐
                  │  onBeforeUnmount   │ 卸载前(还能访问 DOM)
                  │  onUnmounted ★     │ 卸载后(DOM 没了)
                  └────────────────────┘

打星号的两个(onMounted / onUnmounted)是日常 90% 的场景。其他的钩子偶尔用。


2. setup 本身就是"创建时"

<script setup> 里,顶层代码就相当于 created 钩子——组件创建时执行一次。

<script setup lang="ts">
import { ref } from 'vue'

// 这些代码在组件"被创建"时执行
const count = ref(0)
console.log('setup 执行了一次')
</script>

Java 对照<script setup> 顶层 = 构造函数 + @PostConstruct 的合体

这一点很关键:不需要单独写 created 钩子,直接把代码写在 setup 里就行。这也是 Composition API 比 Options API 清爽的主要原因——少了一半仪式感。


3. onMounted:DOM 已经渲染

最常用的钩子。适用场景:

<script setup lang="ts">
import { ref, onMounted } from 'vue'

const users = ref([])
const inputRef = ref<HTMLInputElement | null>(null)

onMounted(async () => {
  // 1) 发请求拉数据
  users.value = await fetch('/api/users').then(r => r.json())

  // 2) 操作 DOM
  inputRef.value?.focus()

  console.log('组件已挂载,DOM 可用')
})
</script>

<template>
  <input ref="inputRef" />
  <ul>
    <li v-for="u in users" :key="u.id">{{ u.name }}</li>
  </ul>
</template>

Java 对照

@PostConstruct
public void init() {
    // 发请求、加载配置、绑定监听器
    this.users = userService.fetchAll();
}

为什么不在 setup 里直接发请求?

其实也可以,只要不依赖 DOM:

<script setup>
// 顶层就能发请求,setup 阶段已经能用响应式
const users = ref([])
fetch('/api/users').then(r => r.json()).then(list => users.value = list)
</script>

但把"数据加载"放 onMounted 里的好处:

项目里的约定

现场观察:父子挂载 / 卸载顺序

勾选复选框卸载子组件,观察日志顺序 —— 子 mounted 先于父 mounted,卸载反过来

<script setup>
import { ref, onBeforeMount, onMounted, onBeforeUnmount, onUnmounted } from 'vue'

const logs = ref([])
const show = ref(true)

function log(msg) {
  const t = new Date().toLocaleTimeString('zh-CN', { hour12: false })
  logs.value.push(`${t} ${msg}`)
}

onBeforeMount(() => log('父 onBeforeMount'))
onMounted(() => log('父 onMounted'))
onBeforeUnmount(() => log('父 onBeforeUnmount'))
onUnmounted(() => log('父 onUnmounted'))
</script>

<template>
  <div class="box">
    <label><input type="checkbox" v-model="show" /> 挂载子组件</label>
    <Child v-if="show" :log="logs" />
    <pre>{{ logs.join('\n') }}</pre>
  </div>
</template>

<script>
import { defineComponent, onBeforeMount, onMounted, onBeforeUnmount, onUnmounted } from 'vue'
export default defineComponent({
  components: {
    Child: {
      props: ['log'],
      setup(props) {
        const push = (msg) => {
          const t = new Date().toLocaleTimeString('zh-CN', { hour12: false })
          props.log.push(`${t}   └ ${msg}`)
        }
        onBeforeMount(() => push('子 onBeforeMount'))
        onMounted(() => push('子 onMounted'))
        onBeforeUnmount(() => push('子 onBeforeUnmount'))
        onUnmounted(() => push('子 onUnmounted'))
      },
      template: '<div class="child">我是子组件</div>',
    },
  },
})
</script>

<style scoped>
.box { padding: 20px; font-family: system-ui; }
.child { margin: 10px 0; padding: 8px; background: #ede9fe; border-radius: 4px; }
pre { margin-top: 12px; padding: 10px; background: #0f172a; color: #e2e8f0; border-radius: 4px; font-size: 12px; line-height: 1.7; max-height: 220px; overflow: auto; }
</style>

4. onUnmounted:组件卸载后

第二常用的钩子。主要用来清理副作用

<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue'

function handleScroll() {
  console.log('scroll', window.scrollY)
}

onMounted(() => {
  window.addEventListener('scroll', handleScroll)
})

onUnmounted(() => {
  window.removeEventListener('scroll', handleScroll)
  // 不清理的话:组件消失了但监听器还在,内存泄漏 + bug
})
</script>

Java 对照

@PreDestroy
public void cleanup() {
    eventBus.unregister(this);
    scheduler.shutdown();
}

典型的"必须清理"清单

Vue 提供的惯用写法watchwatchEffect 返回一个"取消函数"。在 setup 里调用它们会自动在组件卸载时取消,不用手写 onUnmounted。这一点比 Java 贴心——框架帮你兜底。


5. onUpdated:DOM 更新后

比较少用。数据一变组件就重渲染,重渲染完 DOM 更新完会触发 onUpdated

<script setup>
import { onUpdated } from 'vue'

onUpdated(() => {
  console.log('DOM 更新完毕')
})
</script>

典型场景

注意事项


6. onBeforeMount / onBeforeUpdate / onBeforeUnmount

Before 前缀的三个钩子,在对应动作之前触发。日常用得少,但有两个典型场景:

场景 1:onBeforeUnmount 做收尾

有时候"卸载前"还要访问 DOM,这时 DOM 还在:

<script setup>
import { onBeforeUnmount } from 'vue'
onBeforeUnmount(() => {
  // 比如把当前滚动位置存进 sessionStorage,下次进来恢复
  sessionStorage.setItem('scroll', String(window.scrollY))
})
</script>

onUnmounted 阶段 DOM 已经被移除,访问不了。

场景 2:onBeforeUpdate 获取更新前的状态

<script setup>
import { onBeforeUpdate } from 'vue'
onBeforeUpdate(() => {
  console.log('要更新了,当前 DOM 还是旧的')
})
</script>

几乎只在调试时用。


7. 其他生命周期钩子(了解即可)

钩子 触发时机 典型场景
onErrorCaptured 子组件抛错时 错误边界(类比 try-catch)
onActivated <KeepAlive> 缓存的组件被激活 页面切换回来时刷新数据
onDeactivated <KeepAlive> 缓存的组件被停用 暂停定时器
onRenderTracked 渲染时追踪某个依赖(仅开发环境) 调试响应式
onRenderTriggered 某个依赖触发了重渲染(仅开发环境) 调试响应式

onActivated / onDeactivated 只在外层包了 <KeepAlive> 时才有意义。Vue 的 <KeepAlive> 类似 Android 的 Fragment 保活——组件被切走但实例留在内存里。


8. 生命周期 + 父子组件的触发顺序

一个父子组件树,挂载顺序是:

父 setup
  └─ 子 setup
       └─ 子 onBeforeMount
            └─ 子 onMounted
  └─ 父 onBeforeMount
       └─ 父 onMounted

规律:子组件比父组件挂载完。理由:父组件渲染到 <ChildComp /> 时要先把子组件渲染出来,子的 DOM 是父的一部分。

卸载顺序相反:父先 beforeUnmount,然后卸载每个子组件,最后父 unmounted

理解这个顺序,在"父组件传 props、子组件 onMounted 里要用这个 prop" 的场景就不会踩坑——子挂载时父的 props 已经传过来了。


9. 项目里的实例

src/pages/HomePage.vue 目前没用到任何生命周期钩子——所有数据都是静态 import 的,不需要异步加载。

改造示例:假设笔记要从远程 API 拉,应该长这样:

<script setup lang="ts">
import { ref, onMounted } from 'vue'

const allNotes = ref<NoteListItem[]>([])
const loading = ref(true)

onMounted(async () => {
  try {
    allNotes.value = await fetch('/api/notes').then(r => r.json())
  } finally {
    loading.value = false
  }
})
</script>

<template>
  <div v-if="loading">加载中...</div>
  <div v-else>
    <!-- 原有模板 -->
  </div>
</template>

这个模式以后接 AI 聊天接口、加载用户设置时都会反复用到。


10. 新手常见误区

误区 1:在 setup 里访问 DOM

<script setup>
import { ref } from 'vue'
const inputRef = ref<HTMLInputElement | null>(null)

// ❌ setup 阶段 DOM 还没生成
inputRef.value?.focus()
</script>

<template>
  <input ref="inputRef" />
</template>

setup 执行完、DOM 才生成,所以 inputRef.value 此时是 null访问 DOM 必须在 onMounted 及之后的钩子里

误区 2:忘了清理

<script setup>
import { onMounted } from 'vue'

onMounted(() => {
  setInterval(() => console.log('tick'), 1000)
  // ❌ 页面切走后这个定时器还在跑
})
</script>

修复

<script setup>
import { onMounted, onUnmounted } from 'vue'

let timer: number
onMounted(() => {
  timer = window.setInterval(() => console.log('tick'), 1000)
})
onUnmounted(() => clearInterval(timer))
</script>

或者用 VueUse 的 useIntervalFn,自动帮你管理生命周期:

import { useIntervalFn } from '@vueuse/core'
useIntervalFn(() => console.log('tick'), 1000)

误区 3:把所有初始化塞进 onMounted

<script setup>
import { ref, onMounted } from 'vue'

const count = ref(0)
onMounted(() => {
  // 这些根本不需要等 DOM
  console.log('hi')
  fetchConfig()
})
</script>

不需要 DOM 的初始化代码,直接写在 setup 顶层更直观。onMounted 留给真正需要"DOM 已就绪"的操作。


11. 原理深挖:钩子怎么"挂"到组件上

onMounted(fn) 看起来像往全局注册函数,实际上每个钩子都挂在当前组件实例上:

// vue 源码简化
let currentInstance = null

export function setCurrentInstance(instance) {
  currentInstance = instance
}

function onMounted(hook) {
  if (!currentInstance) {
    warn('onMounted is called when there is no active component instance.')
    return
  }
  // 把 hook 推进当前实例的 m 数组
  ;(currentInstance.m ||= []).push(hook)
}

// 类似的:
// onBeforeMount → instance.bm
// onUpdated → instance.u
// onBeforeUpdate → instance.bu
// onUnmounted → instance.um
// onBeforeUnmount → instance.bum
// onErrorCaptured → instance.ec

调用 setup 的代码

function setupComponent(instance) {
  setCurrentInstance(instance)
  try {
    instance.setupState = setup(instance.props, setupContext)
  } finally {
    setCurrentInstance(null)
  }
}

关键推论

  1. currentInstance 是个全局指针,只在 setup 跑的瞬间有值
  2. 所以 onMounted 必须在 setup 同步代码里调——一旦 setup 跑完(或 await 让出执行权),currentInstance 就被清掉
  3. 多次调 onMounted 没问题——都被 push 到同一个数组,按注册顺序触发
  4. 不在 setup 里调会静默失败(带 dev 警告)

反例

<script setup>
// ❌ 异步后注册钩子(不会生效)
onMounted(async () => {
  await fetch('/api')
  onMounted(() => console.log('我永远不会跑'))   // currentInstance 已经是 null
})

// ❌ 在工具函数里直接调(除非这个工具函数在 setup 同步路径里)
function setupAutoSave() {
  onMounted(() => { ... })   // 看起来很可疑,但只要调用方在 setup 同步里调它,就 OK
}
</script>

这就是 composable 的本质——useXxx() 必须在 setup 同步路径里调,因为它内部用了 onMounted/inject 等依赖 currentInstance 的 API。


12. 原理深挖:钩子调用链路 + 父子顺序

mount 一个组件的完整流程(patch 算法的组件分支):

mountComponent(vnode, container)
  │
  ├─ 1. 创建实例 instance = createComponentInstance(vnode)
  │
  ├─ 2. setupComponent(instance)
  │     └─ 跑 setup() —— 这一步把所有 onXxx 钩子注册到 instance.m / instance.bm 等
  │
  ├─ 3. setupRenderEffect(instance)
  │     └─ 创建一个 ReactiveEffect 包住下面这段:
  │
  │     function componentUpdateFn() {
  │       if (!instance.isMounted) {
  │         // ── 首次挂载 ──
  │         callHooks(instance.bm)              // ★ onBeforeMount
  │         const subTree = instance.render()    // 跑渲染函数
  │         patch(null, subTree, container)      // ★ 递归 patch 子树
  │                                              //   ↑ 这一步会触发子组件的整个 mount 流程
  │                                              //   所以子组件的 onMounted 在这里被 push 进队列
  │         instance.subTree = subTree
  │         instance.isMounted = true
  │         queuePostFlushCb(() => callHooks(instance.m))   // ★ onMounted(延迟到 DOM 操作完)
  │       } else {
  │         // ── 更新 ──
  │         callHooks(instance.bu)              // onBeforeUpdate
  │         const newSubTree = instance.render()
  │         patch(instance.subTree, newSubTree)
  │         queuePostFlushCb(() => callHooks(instance.u))   // onUpdated
  │       }
  │     }
  │
  └─ 4. effect.run()  → 启动渲染

为什么子组件的 onMounted 比父组件早

为什么 unmount 父先子后

unmount(vnode)
  ├─ callHooks(instance.bum)        // ★ onBeforeUnmount(父先)
  ├─ unmount(instance.subTree)      // 递归卸载子树(子的 bum/um 在这里跑)
  └─ queuePostFlushCb(instance.um)  // onUnmounted(父)

父的 beforeUnmount 比子早调,因为是先调用父再递归子;onUnmounted 因为都用 queuePostFlushCb,遵循入队顺序——父先入队、子在递归里入队,所以父先于子?

不对,再细看:父的 unmount 函数里先调 instance.bum(同步)→ 然后递归 unmount 子树(子的 bum/um 入队)→ 然后父的 um 入队。所以入队顺序:子 um 在前、父 um 在后 → flush 时子先父后触发 onUnmounted。

记忆:mount 时子先父后;unmount 时 beforeUnmount 父先子后,但 onUnmounted 又是子先父后。理由都是同一个递归算法。


13. 原理深挖:为什么 onMounted 在"DOM 渲染完"才触发

注意上面伪码里的关键一行:

queuePostFlushCb(() => callHooks(instance.m))

不是直接调 onMounted,而是进 post flush 队列。这个队列在所有 DOM patch 操作做完之后才统一执行。

为什么这么设计

具体调度链路

组件 A 的 update:
  patch(...)
    └─ 触发组件 B 的 mount
       └─ queuePostFlushCb(B.onMounted)    ← B 的 onMounted 入队
   queuePostFlushCb(A.onUpdated)           ← A 的 onUpdated 入队
完成 patch
   ↓
flushJobs()   ← 主队列,跑完所有渲染相关 job
   ↓
flushPostFlushCbs()  ← post 队列,按入队顺序跑
   ├─ B.onMounted()
   └─ A.onUpdated()

Vue 的整个调度大致分三层

队列 何时跑 内容
pre 微任务,渲染前 默认 watch 回调
main 微任务 组件 render effect、watch flush:'sync' 之外的
post 主队列跑完之后 onMounted、onUpdated、onUnmounted、watch flush:'post'

nextTick(fn) 注册的 fn 在所有 post 队列都跑完之后——所以是最后才能拿到稳定状态的位置。


14. 原理深挖:onErrorCaptured = Vue 的错误边界

<script setup>
import { onErrorCaptured } from 'vue'

onErrorCaptured((err, instance, info) => {
  console.error('子组件出错', err, '触发位置:', info)
  reportToSentry(err)
  return false   // 返回 false 阻止错误继续向上冒泡
})
</script>

何时触发:当前组件任何子孙组件抛错时——包括 setup、render、生命周期钩子、watch 回调、事件处理函数。

冒泡规则

子组件 throw new Error('boom')
  ↓
→ 找最近的 errorCaptured handler,调用
  ↓
→ 如果返回 false,停止冒泡
→ 否则继续向父冒泡
  ↓
→ 一直冒到 app.config.errorHandler(应用级兜底)
  ↓
→ 没有处理 → console.error

典型用法:根布局组件挂一个 errorCaptured 上报错误监控,开发环境再打印细节:

<!-- App.vue -->
<script setup>
onErrorCaptured((err, instance, info) => {
  Sentry.captureException(err, { extra: { vueComponent: instance?.type.name, hook: info } })
  if (import.meta.env.PROD) return false   // 生产环境吃掉,不弹白屏
})
</script>

Java 对照:类似 Spring 的 @ControllerAdvice + @ExceptionHandler——拦截所有控制器抛的异常做统一处理;errorCaptured 是组件树里的"切面异常处理器"。

注意:errorCaptured 抓不到自身组件抛的错——只抓子孙。要抓自己的错只能 try/catch 或全局 errorHandler。


15. 原理深挖:watch / watchEffect 自动清理 = effectScope

第 4 节说"setup 里的 watch 不写 onUnmounted 也自动清理"——背后是 effectScope

// vue 源码简化
class EffectScope {
  effects = []
  cleanups = []
  scopes = []        // 嵌套的 scope

  run(fn) {
    activeEffectScope = this
    try { return fn() }
    finally { activeEffectScope = parentScope }
  }

  stop() {
    for (const e of this.effects) e.stop()
    for (const cleanup of this.cleanups) cleanup()
    for (const s of this.scopes) s.stop()
  }
}

// 创建 effect 时
function effect(fn) {
  const e = new ReactiveEffect(fn)
  if (activeEffectScope) {
    activeEffectScope.effects.push(e)   // ★ 自动登记到当前 scope
  }
  return e
}

组件实例创建时绑一个 scope

function createComponentInstance() {
  return {
    ...
    scope: new EffectScope(true),
    ...
  }
}

// 跑 setup 时
instance.scope.run(() => {
  setup()   // setup 里所有 watch / computed / watchEffect 都被收进 scope
})

// 卸载时
function unmountComponent(instance) {
  instance.scope.stop()   // 一行清空所有 effect
}

这就是为什么 setup 里的 watch 自动清理——它登记到了组件 scope,组件 unmount 时 scope.stop() 把所有 effect 一起停。

手动用 effectScope 的场景——在 Pinia store 里、在某个工具函数里管理一组 effect:

import { effectScope } from 'vue'

const scope = effectScope()
scope.run(() => {
  watch(state, () => { ... })
  watchEffect(() => { ... })
})

// 不需要时一并清理
scope.stop()

Java 对照:类似 Spring 的 ApplicationContext.close() —— 销毁容器时所有 Bean 的 @PreDestroy 一起调;scope 是 Vue 的"小型容器",专门管 effect 的生命周期。


16. 原理深挖:keep-alive 的 onActivated / onDeactivated 怎么实现

第 7 节提到 KeepAlive 包过的组件不走 mount/unmount,走 activated/deactivated。原理:

首次挂载:
  组件正常 mount → onMounted → onActivated(同时触发)

切走(被 KeepAlive 缓存):
  组件 vnode 从 DOM 中"摘下"但实例保留 → onDeactivated(不调 onUnmounted)
  实例的 effect 仍存活,subTree 被移到一个隐藏 container

再次切回:
  实例从缓存取出 → DOM 重新插回 → onActivated(不调 onMounted)

KeepAlive 真正销毁(超出 max、include 移除):
  → onDeactivated → onUnmounted

为什么这么麻烦:纯粹的"留在 DOM 里 + display: none" 解决不了"想节约渲染"——组件还是活着的,watcher 还在跑。KeepAlive 的设计是实例和 DOM 双重缓存——DOM 摘下来不渲染、effect 保留状态。

Java 对照:类似 JSP 的 session 保活——会话不结束时 model 状态留着;KeepAlive 是组件层面的会话保活。


小练习:在 in4vue 里试一遍

  1. HomePage.vueonMounted(() => console.log('首页挂载'))onUnmounted(() => console.log('首页卸载'))
  2. 访问首页、点击卡片进详情页、再回首页,看控制台输出顺序
  3. 在详情页加 onMounted(() => window.scrollTo(0, 0)),体验"新页面每次都回到顶部"的效果
  4. 尝试加 setInterval 然后不清理,反复来回切换首页/详情页,打开 DevTools 的 Performance 录制一段,观察内存和定时器数量是否在涨(验证"必须清理"的真实代价)

延伸阅读