组件生命周期:从 @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
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 已就绪"的操作。
小练习:在 in4vue 里试一遍
- 在
HomePage.vue加onMounted(() => console.log('首页挂载'))和onUnmounted(() => console.log('首页卸载')) - 访问首页、点击卡片进详情页、再回首页,看控制台输出顺序
- 在详情页加
onMounted(() => window.scrollTo(0, 0)),体验"新页面每次都回到顶部"的效果 - 尝试加
setInterval然后不清理,反复来回切换首页/详情页,打开 DevTools 的 Performance 录制一段,观察内存和定时器数量是否在涨(验证"必须清理"的真实代价)