组件生命周期:从 @PostConstruct 到 onMounted
为什么要有生命周期钩子
一个组件从"被创建"到"从页面移除",中间会经过好几个阶段。有些事必须在特定时机做:
- 发 API 请求拉数据 → 组件挂载后再发,不然还没渲染就请求,拿到也没地方放
- 绑定全局事件(如
window.scroll) → 挂载后绑,卸载前解绑,否则内存泄漏 - 访问 DOM 节点(如自动 focus 输入框) → 必须等 DOM 渲染完
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 已经渲染
最常用的钩子。适用场景:
- 发起第一次 API 请求
- 访问 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 里的好处:
- 意图明确:别人一看就知道"这里是初始化"
- SSR 友好:服务端渲染时
onMounted不会执行(服务端没 DOM),避免重复请求
项目里的约定:
- 不依赖 DOM 的简单初始化 → 直接写顶层
- 发 API 请求、接入 SSR 兼容 → 放
onMounted - 操作 DOM → 必须
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();
}
典型的"必须清理"清单
setInterval/setTimeout→clearInterval/clearTimeoutwindow.addEventListener→removeEventListener- WebSocket / EventSource →
close() - 第三方库的实例(ECharts、Monaco Editor) →
dispose() - 订阅 Pinia 外部 store 的 watch → unwatch 函数
Vue 提供的惯用写法:watch 和 watchEffect 返回一个"取消函数"。在 setup 里调用它们会自动在组件卸载时取消,不用手写 onUnmounted。这一点比 Java 贴心——框架帮你兜底。
5. onUpdated:DOM 更新后
比较少用。数据一变组件就重渲染,重渲染完 DOM 更新完会触发 onUpdated。
<script setup>
import { onUpdated } from 'vue'
onUpdated(() => {
console.log('DOM 更新完毕')
})
</script>
典型场景:
- 需要读取更新后的 DOM 尺寸(比如自动调高度)
- 操作第三方库让它跟上新数据
注意事项:
onUpdated在每次更新后都会触发,一个组件一分钟更新 20 次就触发 20 次,容易做昂贵的事- 需要"首次挂载+后续更新都处理"的场景,改用
watch+{ immediate: true }通常更合适
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)
}
}
关键推论:
currentInstance是个全局指针,只在 setup 跑的瞬间有值- 所以
onMounted必须在 setup 同步代码里调——一旦 setup 跑完(或 await 让出执行权),currentInstance 就被清掉 - 多次调
onMounted没问题——都被 push 到同一个数组,按注册顺序触发 - 不在 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 比父组件早:
- 父执行
patch(null, subTree)时,subTree 里有子组件 vnode - patch 递归遇到子组件 vnode → 走 mountComponent(子)
- 子的 setup → render → patch 递归 → 走完后 queuePostFlushCb(子的 onMounted)
- 回到父 → queuePostFlushCb(父的 onMounted)
- 等所有都跑完,flushPostFlushCbs 按入队顺序调用 → 子先父后
为什么 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 操作做完之后才统一执行。
为什么这么设计:
- patch 过程中可能有多个组件在挂载、卸载、更新
- 如果每个组件 mount 完立刻调 onMounted,里面如果再触发响应式变化,可能干扰还在进行的 patch
- 统一推迟到 post 队列,保证 onMounted 跑时 DOM 是最终稳定状态
具体调度链路:
组件 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 里试一遍
- 在
HomePage.vue加onMounted(() => console.log('首页挂载'))和onUnmounted(() => console.log('首页卸载')) - 访问首页、点击卡片进详情页、再回首页,看控制台输出顺序
- 在详情页加
onMounted(() => window.scrollTo(0, 0)),体验"新页面每次都回到顶部"的效果 - 尝试加
setInterval然后不清理,反复来回切换首页/详情页,打开 DevTools 的 Performance 录制一段,观察内存和定时器数量是否在涨(验证"必须清理"的真实代价)