敲下“第68天”这几个字的时候,第五阶段“Vue3核心深度深挖”总算推进到了生命周期。说实话,Vue2时代我对钩子函数的理解停留在“能默写八张图”的程度,真正到了项目里该拿DOM的时候拿不到、该清定时器的时候找不到位置,照样翻车。Vue3改了组合式API之后,很多人干脆只写setup,连生命周期函数都不显式调用了——但这恰恰是隐患最多的地方。
Vue3生命周期,指的是一个组件从配置初始化、实例创建、真实DOM挂载、数据更新到最终卸载销毁的完整过程。在组合式API里,它对应一组以on开头的钩子函数,比如onMounted、onUpdated、onUnmounted。不管你是写业务页面、封装通用组件,还是维护组件库,这套时序都绕不开。想把它们真正用对,建议先把“组件一生”的完整时间线在脑子里跑通。这篇文章我就按“时间线→逐个钩子→父子时序→实战坑位”这个顺序,把实际项目中踩过的坑和验证过的结论一起整理出来,代码都可以直接复制到本地跑一遍。
1. 先给生命周期画一条时间线
1.1 组件从配置到销毁,中间到底经历了什么
抛开所有钩子名称,把“一个组件的一生”压缩成一句话:创建实例 → 初始化响应式状态 → 模板编译与渲染 → 挂载到真实DOM → 数据更新驱动重渲染 → 卸载清理。拆开看,每一句都对应着生命周期里的一个阶段,这也是Vue设计钩子的底层依据。
Vue3里,组件实例是通过createApp或者父组件渲染子组件时逐步建立起来的。实例真正可用之前,需要先处理props、attrs、slots这些用户配置,把它们标准化后传入setup函数。setup执行完,组件已经有了响应式状态,但此时还没有开始渲染,模板对应的渲染函数虽然生成,DOM还完全不存在。接着,渲染函数被调用,产出虚拟节点树,renderer再走mount流程,把虚拟节点变成真实DOM并插入容器。挂载完成之后,组件进入“存活期”,响应式依赖一旦变化,就会触发重新渲染。最后,路由跳转、v-if移除组件,或者createApp应用卸载时,Vue会把组件从DOM上移除,同时把组件实例上挂载的副作用、依赖关系、事件监听全部清理掉。
这几个阶段对应几个钩子:挂载前、挂载后、更新前、更新后、卸载前、卸载后。理解这条线,比死记钩子名字有用十倍。我见过刚转Vue3的同事,把接口请求直接写在setup顶层,没有依赖控制,组件每次创建都发一次请求;又因为没在卸载钩子里清理,组件销毁了请求还挂在网络面板里。这就是脑子里只有“某段代码写在某个文件里”,没有“这个组件当前处于什么阶段”的意识。
1.2 Options API和Composition API怎么对标
Vue3刚发布时,大家问得最多的问题就是:组合式API里的onMounted是不是就是以前的mounted?答案是本质上是同一个阶段,但写法从“选项配置”变成了“函数调用”,并且新增了调试用的钩子。下面这张对标表可以收藏起来,以后写代码时对照着看:
| Options API | Composition API | 在组件一生中的位置 |
|---|---|---|
beforeCreate | setup(同一阶段) | 实例初始化前 |
created | setup(同一阶段) | 实例创建后、首次渲染前 |
beforeMount | onBeforeMount | 真实DOM挂载前 |
mounted | onMounted | 真实DOM挂载完成 |
beforeUpdate | onBeforeUpdate | 响应式数据变化后、重渲染前 |
updated | onUpdated | 重渲染完成 |
beforeUnmount | onBeforeUnmount | 实例卸载前 |
unmounted | onUnmounted | 实例卸载完成 |
errorCaptured | onErrorCaptured | 后代组件抛错时捕获 |
renderTracked | onRenderTracked | 渲染过程中追踪依赖 |
renderTriggered | onRenderTriggered | 依赖变化触发了重渲染 |
activated | onActivated | 被KeepAlive缓存后重新激活 |
deactivated | onDeactivated | 被KeepAlive移入缓存 |
注意,beforeCreate和created在选项式写法里依然存在,只是在setup内部不需要再写这两个钩子,因为setup本身发生在created之前。很多人问“setup里为什么没有created”,答案就是:在组合式API的语境下,你自己就是created。至于为什么Vue3还要保留Options API,主要是为了兼容Vue2迭代习惯和渐进迁移。但从新项目角度,我建议统一用<script setup>,生命周期函数按需导入,这样逻辑聚合、结构清晰,也更容易抽成自定义组合函数。
2. 组合式API生命周期钩子逐个拆解
2.1 setup本身:它到底算什么
严格说,setup不是生命周期钩子,而是组合式API的入口。它执行在组件实例被创建之后、模板首次渲染之前,时间点大体落在Vue2的beforeCreate和created之间。在setup里可以安全地初始化响应式状态、声明函数、读取props,但拿不到组件实例的DOM引用,也拿不到this。也就是说,你不能在setup里写this.xxx = 1来访问数据。
这里有个高频坑:setup只在组件创建阶段执行一次,后续响应式数据变化导致重渲染时,它不会重新执行,重新渲染走的是onBeforeUpdate和onUpdated。所以不要在setup顶层写依赖具体DOM尺寸、依赖“每次刷新都弹一次”的逻辑,也不要假设“组件每次重新渲染都会跑setup”。路由切换后新组件实例会重新走setup,但同一个组件内部的数据更新不会。
还有一个知识点容易被忽略:setup本身不能是async的。一旦写成async setup(),函数会返回一个Promise,组件实例和模板渲染的时序会完全乱掉,生命周期钩子也会失效。异步初始化逻辑应该放到onMounted里,或者拆成独立异步函数再去调用。
2.2 onBeforeMount和onMounted:拿DOM的正确姿势
onBeforeMount在组件对应的真实DOM插入容器前触发。此时模板已经编译完成、虚拟DOM已经生成,但还没有挂载到页面。所以在这个钩子里,你依然拿不到页面上的元素节点。它比较适合做“挂载前最后的准备”,比如设置loading状态、记录开始时间等轻量操作。
onMounted是在所有子组件挂载完成后触发,此时DOM已经稳定出现在页面里。初始化工作基本都在这里做:访问DOM节点、初始化图表、绑定外部事件、发起首屏接口请求。为什么推荐在onMounted里发请求而不是setup?因为setup执行时组件还没渲染,某些与DOM尺寸、DOM位置相关的副作用没法保证就绪。虽然不是所有请求都依赖DOM,但统一放在onMounted里,至少保证了一个稳定可用的上下文,排查问题时心智负担也小。
示例:
<script setup> import { ref, onMounted } from 'vue' const boxRef = ref(null) const list = ref([]) onMounted(() => { // 此时能拿到真实DOM节点 console.log(boxRef.value?.getBoundingClientRect()) // 首屏请求也在这里发 loadList() }) </script>注意,onMounted回调是在所有子组件也挂载完才回调,这保证了父组件在onMounted里可以安全访问子组件实例和子组件渲染出来的DOM。但反过来,如果子组件要在onMounted里操作父组件传入的DOM ref,要等父组件也完成相关挂载流程。
2.3 onBeforeUpdate和onUpdated:别在里面改数据
响应式数据发生变化,组件需要重新渲染时,Vue先触发onBeforeUpdate。这是更新前最后一次可以访问“旧DOM”的时机。接着框架重新执行渲染函数,更新虚拟DOM并同步到真实DOM,完成后触发onUpdated。
这两个钩子最大的忌讳是:在回调内部直接修改会触发更新的响应式数据。一旦改了,就会引发新的一轮渲染,再触发onBeforeUpdate和onUpdated,直接死循环。我见过有人为了“等渲染完成再用新数据计算”,在updated里给ref重新赋值,页面直接卡住。
正确做法是:如果需要基于更新后的DOM做计算,只读取不修改;如果确实要改数据,用nextTick或者watch来做异步更新。onUpdated更适合做第三方库的联动,比如数据变化后重新计算图表尺寸,或者更新后执行一些只读的UI适配逻辑。
2.4 onBeforeUnmount和onUnmounted:清理现场
组件销毁前会触发onBeforeUnmount。此时组件实例还完整存在,DOM也还在,因此很适合做最后的准备工作:解绑事件监听、清除定时器、关闭WebSocket连接、断开观察者。接着onUnmounted触发时,组件实例已经被卸载,DOM已经被移除,再访问组件数据通常拿到的是失效对象或undefined。
我踩过最典型的一个坑:在onMounted里用setInterval做轮询,但忘了在onBeforeUnmount里clearInterval。结果用户切换路由之后,控制台还在疯狂打印接口数据,标签页长时间转圈。这不算Vue的问题,而是没有把“副作用清理”写进肌肉记忆。后来我形成习惯:每写一个setInterval、setTimeout、addEventListener,立刻在onBeforeUnmount里写好对应清理逻辑。先清理,后开发,宁愿业务逻辑暂时不跑,也别让定时器裸奔。
2.5 KeepAlive场景:onActivated和onDeactivated
组件被<KeepAlive>包裹时,卸载和挂载逻辑发生变化。组件切走时不是真正销毁,而是被缓存起来,所以onBeforeUnmount/onUnmounted不会触发,取而代之的是onDeactivated;重新进入页面时,触发onActivated,而不是onMounted。
这个场景最常见的坑:列表页用KeepAlive缓存,开发者把数据刷新逻辑写在onMounted里,结果从详情页返回列表后,onMounted根本不执行,还是旧缓存数据。解决办法是把“每次进入需要刷新”的逻辑放到onActivated里,因为每次被重新激活它都会执行。
反过来,如果你在onDeactivated里清理了图表实例,等组件下一次onActivated时一定要重新初始化,否则页面会白屏。建议把初始化和清理分别封装成两个函数,在onActivated和onDeactivated里成对调用,思路与onMounted/onBeforeUnmount完全相同。要注意的是,onActivated在组件首次挂载完成后也会执行一次,如果请求逻辑放在这里,需要判断是否需要和onMounted里的首屏请求去重。
2.6 错误与调试钩子:onErrorCaptured、onRenderTracked、onRenderTriggered
onErrorCaptured用于捕获后代组件抛出的错误,类似ErrorBoundary,但并非全局兜底,需要手动注册在组件里。捕获到错误后,如果回调里没有返回false,错误会继续向上层传播。它适合做局部降级UI,比如子组件渲染崩溃时,用备用组件替代。但要注意,它捕获不了自身组件抛出的错误,也捕不了异步回调里的错误,这点很多开发者容易误判。
onRenderTracked和onRenderTriggered是Vue3新增的调试专用钩子。前者会在渲染函数收集响应式依赖时触发,后者会在某个依赖变化导致重渲染时触发,回调参数里带key、type等信息。调性能问题时,我经常用onRenderTriggered快速定位到底是哪个字段引发了大面积重渲染。
3. Options API与Composition API混用时要注意的事
3.1 能混用吗?什么时候不建议混用
技术上,同一个组件可以同时使用setup和选项式钩子。比如在setup里定义ref,然后在mounted选项里调用某个方法。但除非项目正处在Vue2向Vue3迁移的过渡期,否则我非常不建议这么写。两个心智模型混在一起,逻辑被拆到两块区域,维护成本直接翻倍。更重要的是,setup里定义的状态如果在methods里访问,你会发现自己绕来绕去都绕不过作用域问题。
实际上在Vue3中,如果你不想用<script setup>,也完全可以只写export default里的setup()函数并return所有状态和方法。这样一来,组件依然是组合式API结构,只是没用到语法糖而已。不要在同一个组件里一半选项式、一半组合式,除非你真的很清楚自己在做什么。
3.2 同名钩子会不会都执行
同一个组件里同时注册选项式的mounted和组合式的onMounted,两者都会执行,不会互相覆盖。执行顺序按注册顺序合并,如果注册多个onMounted,也会按注册顺序依次执行。这个特性很适合做逻辑解耦:把图表初始化、埋点上报、主题适配分别放进不同回调,各自维护各自的生命周期,比压在一个大函数里清晰得多。
不过有一点要注意:如果某个onMounted抛了异常,后面的onMounted回调不会继续执行,因为它们是同步顺序执行的。所以在组织多个初始化逻辑时,尽量让每个回调轻量且独立,避免一个环节报错,把后面的初始化和埋点全卡掉。
3.3 在setup之外调用onMounted为什么报警告
组合式API的生命周期函数只能在setup()执行期间同步调用。如果把它放到setTimeout回调、异步函数或普通事件处理器中,会直接抛出警告,类似“onMounted is called when there is no active component instance”。原因是在非setup时段,Vue找不到“当前活跃组件实例”来关联钩子。
封装自定义hooks时我也踩过这个坑。一个定位hook里想在异步数据返回后动态注册onUnmounted做清理,结果发现根本注册不上。组合式API的绑定是静态的,必须在setup执行阶段确定。如果需要清理某个异步副作用,更适合用watch的onCleanup机制,或者把异步请求设计成可取消的AbortController形式,在真正的onUnmounted里去取消。
4. 父子组件生命周期怎么串起来
4.1 挂载顺序:先父后子,但mounted是子先父后
很多面试题会问父子组件挂载顺序。用一句话概括:父组件的渲染函数先执行,渲染过程中遇到子组件,就进入子组件的初始化流程;子组件完成挂载后,父组件才算挂载完成。完整顺序如下:
- 父组件进入
setup - 父组件开始渲染,遇到子组件
- 子组件进入
setup - 子组件
onBeforeMount - 子组件
onMounted - 父组件
onMounted
写成项目里常看到的日志顺序就是:父beforeMount→ 子beforeMount→ 子mounted→ 父mounted。
这个顺序带来的实战启示:在父组件的onMounted里,可以放心拿到所有子组件的DOM和实例,因为子组件已经全部挂完了。反过来,如果你在父组件的setup阶段通过ref获取子组件实例,此时它还是null,必须等子组件onMounted之后才能拿到真正的实例。做一个跨组件操作时,判断“实例是否可用”的标准就是这条时序线。
4.2 更新顺序:并不是所有子组件都会被动更新
父组件数据变化时,父组件先进入onBeforeUpdate。如果子组件依赖了父组件传入的props,或者自身数据也变化了,子组件会进入自己的更新流程:子beforeUpdate→ 子updated,最后回到父updated。
这里容易混淆的点是:父组件更新不一定导致所有子组件更新。子组件是否更新,取决于它是否真正接收了props新值,以及是否参与了该组件的渲染依赖收集。Vue的响应式系统会做精确追踪,这和生命周期本身无关,但和“为什么updated在某个子组件里没有触发”密切相关。
我做过一个表单页面,父组件有个字段每次滚动都会变化,结果所有子组件都跟着更新。排查后发现,子组件props接收的是父组件整个响应式对象,虽然实际渲染并没有用到变化字段,但因为传入了整个对象,变更被感知,子组件仍然走了更新流程。后来改成只传具体字段,更新范围立刻小了很多。用生命周期时序定位问题,最后往往要落到数据流设计上。
4.3 卸载顺序:父先准备,子先死,父后死
卸载顺序是:父onBeforeUnmount→ 子onBeforeUnmount→ 子onUnmounted→ 父onUnmounted。原因和挂载类似:父组件要卸载前,先通知子组件卸载,子组件清理干净后,父组件再清理自己。
因此,父组件在onBeforeUnmount里访问子组件实例时,子组件可能还处于存活状态;但到onUnmounted阶段,整个子树基本都清空了。做复杂组件库的时候,如果父组件依赖子组件的某个状态去释放自身资源,最好在onBeforeUnmount阶段完成依赖读取,不要拖到onUnmounted。
4.4 一个可运行的父子组件日志示例
<!-- Parent.vue --> <script setup> import { onBeforeMount, onMounted, onBeforeUpdate, onUpdated } from 'vue' import Child from './Child.vue' onBeforeMount(() => console.log('父 beforeMount')) onMounted(() => console.log('父 mounted')) onBeforeUpdate(() => console.log('父 beforeUpdate')) onUpdated(() => console.log('父 updated')) </script> <template> <Child /> </template><!-- Child.vue --> <script setup> import { onBeforeMount, onMounted, onBeforeUpdate, onUpdated } from 'vue' onBeforeMount(() => console.log('子 beforeMount')) onMounted(() => console.log('子 mounted')) onBeforeUpdate(() => console.log('子 beforeUpdate')) onUpdated(() => console.log('子 updated')) </script> <template> <div>child content</div> </template>首次加载控制台输出:
父 beforeMount 子 beforeMount 子 mounted 父 mounted如果父组件里某个响应式变量变化,并且这个变量通过props传给了子组件,输出:
父 beforeUpdate 子 beforeUpdate 子 updated 父 updated把这些输出理解到位,很多生命周期相关的面试题和实际Bug都能迎刃而解。
5. 生命周期实战经验与高频坑
5.1 mounted里拿不到DOM/数据
经常有人问:onMounted明明执行了,为什么ref还是null?原因一般是:ref绑定的元素被v-if/v-for控制,元素还没渲染出来;或者模板里根本没有写对应的ref。排查方式很简单,在onMounted里打印整个模板结构,确认节点确实存在。如果节点本身在子组件里,需要等子组件挂载才能访问。
还有一种情况是,元素渲染本身被异步框架接管了,比如某个指令或插件会在nextTick后才把内容插入DOM。此时onMounted已经执行完,但DOM依然没到位,需要再等一帧。项目里遇到这种场景,我一般直接用nextTick包一层:
<script setup> import { nextTick, ref, onMounted } from 'vue' const contentRef = ref(null) onMounted(async () => { await nextTick() // 此时再看 contentRef.value console.log(contentRef.value) }) </script>nextTick不是万灵药,但能解决大部分“生命周期已经跑了,DOM还没更新完”的时序问题。
5.2 定时器和事件监听忘了解绑
轮询定时器在组件销毁后还在跑,是最常见的内存泄漏点。除了定时器,还有window.addEventListener('resize')、scroll、keydown等原生事件,如果不清理,组件反复挂载/卸载后会堆积大量回调,页面卡顿、事件重复触发。
我的处理模板:
<script setup> import { ref, onMounted, onBeforeUnmount } from 'vue' const timer = ref(null) function onResize() { // resize处理逻辑 } onMounted(() => { timer.value = setInterval(() => { // 轮询逻辑 }, 5000) window.addEventListener('resize', onResize) }) onBeforeUnmount(() => { if (timer.value) { clearInterval(timer.value) timer.value = null } window.removeEventListener('resize', onResize) }) </script>项目大了以后,建议把定时器封装成useInterval之类的自定义组合函数,注册和清理做成对称结构,避免遗漏。事件监听也一样,把addEventListener和返回的清理函数绑在一起,在onBeforeUnmount里统一调用。
5.3 KeepAlive和异步路由导致“生命周期没走”
如果你用了<KeepAlive>,组件被切走时走的不是unmounted而是deactivated,重新回来时走的不是mounted而是activated。很多人把请求发在onMounted里,被缓存后再次进入页面,就一直不发新请求,看起来像“缓存了旧数据”。
解决方案:按业务场景区分。需要每次进入都刷新,就把请求逻辑放在onActivated里,而不是onMounted。但onActivated在组件首次挂载后也会执行一次,所以要注意和首屏加载逻辑去重。我项目的习惯是,首屏数据初始化放在onMounted,每次进入刷新列表或重新上报埋点放在onActivated,两者可以并存,各司其职。
5.4 onErrorCaptured其实不是全局错误处理
onErrorCaptured只捕获后代组件的错误,不包括自身错误,也不包括异步回调里的错误。很多初学者以为它和前端监控一样,能自动捕获所有JS异常。实际它的用途是:在某个组件边界做降级渲染,捕获到子组件渲染错误后,展示兜底UI,并决定错误是否继续向上传播。使用组合式API时,回调返回false会阻止错误继续冒泡,否则错误还会继续传递给更上层的处理函数。
另外,onErrorCaptured捕获的错误范围中,如果某个后代组件也注册了这个钩子,错误会从内向外触发,逐层传递。做复杂组件树时,可以利用这个特性做多级降级,每一层都知道下一层发生了什么。
5.5 怎么快速定位生命周期问题
调试生命周期问题,最直观的办法是给每个钩子加console输出,一看执行顺序,90%的问题都能定位。日志最好加上组件名前缀,比如[Child] mounted,在父子组件嵌套比较深的时候能快速分清是谁在什么时候执行。
Vue Devtools也有组件事件相关信息,但业务代码里的console日志往往比插件更直接。遇到诡异问题,先全量打日志,看两件事:第一,哪个钩子没有触发;第二,哪个钩子触发了不该触发。这两点锁定后,基本就能定位到问题根因,而不是靠猜。定位到以后再决定是改数据流、加清理逻辑,还是调整缓存方案。
6. 高频问题排查与调试技巧
6.1 为什么接口请求发了两次
在Vue3里,接口请求发两次的常见原因包括:组件用了KeepAlive,onMounted只执行一次但onActivated里也放了请求,两者叠加;父组件更新导致子组件被强制重建,请求又走了一遍;或者请求逻辑既在setup顶层又在onMounted里重复触发。排查办法:加日志确认每个钩子触发次数,再把数据请求统一收敛到一个方法里,通过参数区分触发源,不要散落多处。
6.2 子组件更新影响了父组件
有一种诡异现象:子组件的updated触发了,父组件也跟着重新渲染。这通常不是生命周期问题,而是数据流问题——子组件内部修改的响应式数据最终影响到了父组件绑定的状态,或者父子组件共享了同一个响应式对象。解决办法是明确数据流向,尽量单向,避免子组件直接修改父组件传入的props对象。也建议检查是不是子组件事件触发后,父组件更新了另一个与子组件强相关的字段,导致看起来像是“子组件带动了父组件”。
6.3 路由切换时组件没有销毁
如果发现路由切换组件没有走unmounted,先检查是否被<KeepAlive>包裹。其次,可能是组件树处于过渡动画中,Vue会等待动画结束再卸载,unmounted会延后触发。排查思路:打开开发者工具,查看当前路由对应的组件是否还停留在组件树里,如果在,就要检查缓存策略和过渡配置。如果确认没加KeepAlive,组件还在,那就要看是不是某个父级条件分支还在渲染它。
6.4 在组合式API里获取组件实例
Vue3组合式API中,setup里的this并不是组件实例,在<script setup>里更不能直接用this。如果确实需要当前实例,可以用getCurrentInstance(),但大部分业务场景完全不需要直接操作实例。优先用ref、provide/inject、defineExpose来替代原来的$parent/$refs套路。强行获取实例去操作内部状态,往往会让代码耦合度剧增,生命周期时序也会更难控制。
6.5 利用onRenderTriggered做性能定位
<script setup> import { onRenderTriggered } from 'vue' onRenderTriggered((event) => { console.log('触发重渲染的字段:', event.key) console.log('触发类型:', event.type) }) </script>当某次数据变化引发意料之外的重渲染时,event.key能直接告诉你到底是哪个字段被抓取了依赖。我在排查大列表频繁刷新问题时,就靠这个方法快速定位到某个不该做成响应式的字段,然后把它改成普通变量或shallowRef,性能问题立刻缓解。这算是生命周期钩子在性能调试上的一个高级用法。
第68天,写到这里,Vue3生命周期的主体内容算是过了一遍。你把它单独拿出来看,就是几个钩子函数,但真要在项目里用好,必须把钩子放回“组件创建、更新、销毁”的完整上下文里理解。我回顾自己从Vue2到Vue3的转变,最大的进步不是记住了多少新API,而是每次写组件前都会先问自己:这个组件的副作用是什么时候建立的,什么时候需要清理。想明白这两句话,比把钩子名单倒背如流都管用。
如果你正在学Vue3,我建议找个简单的业务页面,把本文提到的钩子全部打上日志,然后在父组件里改数据、切路由、加KeepAlive,自己观察完整执行顺序。这种亲手跑一遍的体验,比看十篇文档都深刻。等你看懂了这套时序,再回头写组件、抽hooks、做性能优化,都会顺手很多。