写生命周期这事儿我琢磨了很久才决定动笔。网上讲 Vue 生命周期的教程不少但大多停留在“给你一张图、背几个钩子函数名”的层面。今天我想换个角度不只告诉你每个钩子什么时候触发更想聊聊每个阶段到底能做什么、不能做什么以及在真实项目中那些文档里不会写、但踩过坑才懂的经验。这篇文章适合刚把 Vue 语法过完、准备上手写真实项目的朋友也适合已经写了段时间但总觉得生命周期理解不够系统的开发者。1. 理解Vue生命周期先搞懂这棵“组件一生”的大树1.1 什么是生命周期从一个组件的“人生阶段”说起如果你把一个Vue组件想象成一个独立的人那它从被创建到被销毁的整个过程就是生命周期。人这一生会经历出生、成长、工作、衰老、离世几个阶段组件也一样从数据初始化、模板编译、挂载DOM、响应式更新到最后从页面上消失每一步都有明确的时间节点。为什么开发者必须理解这些节点因为组件在不同阶段能访问的资源完全不同。举个例子data里的数据在beforeCreate阶段还没初始化你这时候去取this.xxx只会得到undefined但到了created阶段数据已经就绪你可以放心地发起请求、处理逻辑。如果搞不清这些时间点你写的代码就像一个人还没睁眼就被塞了张报纸让他读——不是能力问题是时机不对。1.2 Vue 3比Vue 2多了什么变化从八个钩子到一套体系很多朋友是从Vue 2转过来的这里要先打通新旧观念的差异。Vue 2的八个生命周期钩子——beforeCreate、created、beforeMount、mounted、beforeUpdate、updated、beforeDestroy、destroyed——在Vue 3里大部分保留下来了只是beforeDestroy改名叫beforeUnmountdestroyed改名叫unmounted。表面看只是改了名字但背后是设计思路的转变Vue 3强调的是“组件被卸载”这个动作而不是“销毁”这个结果语义更精确。同时Vue 3引入了组合式API提供了一套onXxx格式的钩子函数让逻辑可以按功能聚合而不是按选项分散。这个变化对于大型项目尤其重要稍后我会专门用一节来讲。还有一个很多人忽略的点Vue 3的生命周期钩子只能在setup()期间同步注册。如果你在异步回调里调用onMountedVue会直接抛警告因为框架没法确定钩子到底属于哪个组件实例。// 错误示范异步注册钩子 setup() { setTimeout(() { onMounted(() { console.log(这不会按预期工作) }) }, 100) }注意在组合式API中生命周期钩子的注册必须是同步的。每个钩子都依赖当前正在初始化的组件实例一旦脱离这个同步上下文框架就无法建立关联。1.3 一张表看懂Vue 3生命周期全家桶为了让你有个全局视角我先把Vue 3里所有相关钩子列出来包括选项式API和组合式API的对应关系以及它们各自的触发时机。选项式API组合式API触发时机核心用途beforeCreate无直接对应组件实例初始化前极少使用created无直接对应实例创建完成后数据请求、非DOM操作beforeMountonBeforeMount挂载开始前最后一次修改数据的机会mountedonMounted挂载完成后DOM操作、第三方库初始化、事件绑定beforeUpdateonBeforeUpdate数据变化、DOM更新前获取更新前的状态updatedonUpdatedDOM更新后依赖新DOM的操作beforeUnmountonBeforeUnmount卸载前清理定时器、解绑事件unmountedonUnmounted卸载后最终清理errorCapturedonErrorCaptured捕获子孙组件错误时全局错误处理renderTrackedonRenderTracked追踪依赖时开发环境调试性能renderTriggeredonRenderTriggered依赖触发重新渲染时开发环境调试性能activatedonActivated被KeepAlive缓存激活时恢复状态deactivatedonDeactivated被KeepAlive缓存隐藏时暂停操作这张表值得你截图保存。实际项目里最常用的就是created/onMounted、beforeUnmount/onBeforeUnmount这几个但全貌要先搭起来。2. 从创建到渲染四个核心钩子的实战观察2.1 beforeCreate和created数据从无到有的临界点先把容易混的一对拎出来说。beforeCreate是组件生命周期最早的一个钩子此时组件的选项对象刚初始化但data、computed、methods、watch都还没挂载到实例上。严格说你在beforeCreate里连this.data都访问不到更别说调用方法了。created则是另一个极端此时Vue已经完成了数据观测、属性注入、方法绑定、计算属性求值等一系列初始化工作this上该有的东西基本都有了。所以created里最常见的用法就是发起异步请求、初始化非DOM相关的数据、设置定时器。实际开发中很多人喜欢把数据请求放在created里但我要提醒一句如果你的请求回调里需要操作DOM比如拿到接口数据后再去动态设置某个元素的样式那就得放到mounted里或者等this.$nextTick再执行。这不是生命周期的问题而是DOM还没渲染的问题。export default { data() { return { userId: 0, userInfo: null } }, async created() { console.log(created阶段能拿到data:, this.userId) // 这里可以发起请求但如果回调里要操作DOM记得用 $nextTick const res await fetchUser(this.userId) this.userInfo res this.$nextTick(() { // 此时DOM才可靠 }) } }2.2 beforeMount和mountedDOM世界的入口beforeMount触发的瞬间Vue已经完成了模板编译生成了渲染函数但还差最后一步把虚拟DOM转成真实DOM并挂载到页面上。此时你可以对组件数据做最后一次修改不会触发额外的渲染。这个钩子实际用得非常少但对理解流程很有帮助——它像一个宣告“马上上场”的信号。mounted才是重头戏。到了这个阶段组件的模板已经编译成真实DOM并插入页面你可以安全地访问this.$el可以用document.querySelector查找组件内部的节点。最常见的使用场景有三个第一初始化需要操作DOM的第三方插件或者做DOM测量第二绑定window、document级别的原生事件监听第三那些必须等DOM就绪才能执行的逻辑。export default { mounted() { // 场景1初始化图表库 this.chart echarts.init(this.$refs.chartContainer) this.chart.setOption({ /* 配置项 */ }) // 场景2绑定全局事件 window.addEventListener(resize, this.handleResize) this.handleResize() }, beforeUnmount() { // 记得解绑否则会内存泄漏 window.removeEventListener(resize, this.handleResize) if (this.chart) { this.chart.dispose() } } }2.3 为什么不要在mounted里做太多事我的踩坑记录有一次我在项目里遇到一个诡异的问题页面加载特别慢网络请求也没超标但交互就是卡顿。后来一查才发现某个组件在mounted里同步做了一堆重活解析大段JSON、循环处理几千条数据、同步生成复杂的DOM结构。浏览器被这些同步任务阻塞主线程一直被占用用户自然觉得卡。mounted虽然安全但不代表“想干什么就干什么”。这里给出三条实操建议第一如果初始化逻辑跟DOM无关放到created里执行让mounted专注于DOM相关内容。第二如果数据量大、处理耗时考虑用异步方式或者拆成小块调度。第三如果需要读取DOM尺寸之类的信息记得用this.$nextTick确保布局已稳定——刚挂载时某些样式还没完全生效的情况确实存在。// 推荐的写法拆分轻重任务 created() { // 轻量数据准备不涉及DOM this.prepareData() }, async mounted() { // 先渲染基础结构再把重型任务交给异步 await this.$nextTick() requestIdleCallback(() { this.initHeavyComponents() }) }3. 更新阶段beforeUpdate和updated里的性能陷阱3.1 触发机制什么会让组件走更新流程组件的更新流程由响应式数据变化驱动。当你修改了data里某个属性Vue会把这个变化通知到依赖它的组件组件重新执行渲染函数生成新的虚拟DOM和旧的做对比找出差异后更新真实DOM。beforeUpdate就在这个流程开始前触发而updated在DOM更新完成后触发。需要注意的是并不是只有直接修改数据才会触发更新。父子组件间的props变化、inject提供的数据变化、通过Vuex或Pinia改变的状态最终都会通过响应式系统传递到对应组件触发更新流程。export default { data() { return { count: 0 } }, beforeUpdate() { console.log(更新前DOM内容:, document.querySelector(#count)?.textContent) // 这里读取到的DOM还是旧值 }, updated() { console.log(更新后DOM内容:, document.querySelector(#count)?.textContent) // 这里读取到的是新值 }, methods: { increment() { this.count } } }3.2 beforeUpdate访问旧DOM的唯一时机beforeUpdate在实际开发中用得不多但有一个场景非常合适你想在数据变化但DOM还没更新时记下当前DOM的某些状态比如滚动位置、焦点位置、用户正在编辑的内容。这时候读取DOM拿到的还是旧渲染结果恰好能满足“记录旧状态”的需求。有一个我实际用过的例子一个聊天窗口当新消息插入时如果用户当前不在底部就保持滚动位置不动如果用户已经在底部就自动滚到最新消息。这个逻辑需要在beforeUpdate里记录滚动高度在updated里判断应该保持还是滚动。data() { return { messages: [], isNearBottom: true } }, beforeUpdate() { const el this.$refs.chatList if (el) { // 记录更新前是否“粘”在底部 this.isNearBottom el.scrollHeight - el.scrollTop - el.clientHeight 50 } }, updated() { const el this.$refs.chatList if (el this.isNearBottom) { el.scrollTop el.scrollHeight } }3.3 updated里的死循环一个经典的面试题也是实战题updated钩子里隐藏着一个经典陷阱在这里修改数据很可能触发下一次更新紧接着再次进入updated如果修改的数据又依赖DOM状态就会形成死循环。很多新手面试时被问“为什么不在updated里修改数据”答案其实就是这个容易导致无限循环。真实项目里我确实遇到过当时是一个列表组件在updated里根据DOM高度动态添加“加载更多”的占位元素结果数据一变、DOM重新渲染、updated又执行、又触发数据变化浏览器直接卡死。后来改成在watch里判断数据、用$nextTick配合处理问题才解决。如果你确实需要在DOM更新后基于新的布局做计算更稳妥的方式是用watch监听数据源在回调里配合this.$nextTick访问最新DOM而不是依赖updated钩子本身。watch: { // 推荐的替代方案watch nextTick list: { handler() { this.$nextTick(() { // 此时数据变了DOM也更新了 this.adjustLayout() }) }, deep: true } }4. 收尾工作beforeUnmount和unmounted的清理艺术4.1 从beforeDestroy到beforeUnmount命名变化背后的语义升级如果你是从Vue 2迁移过来的这里有个必须适应的变化。Vue 2里的beforeDestroy和destroyed在Vue 3里被替换成了beforeUnmount和unmounted。很多教程说“只是改了个名”但我觉得这个变化的语义很重要destroy强调的是组件实例的销毁是一种内部行为而unmount强调的是从DOM树上卸载是外部视角。这个变化还带来一个实际影响在Vue 3里即使调用了unmount()方法手动卸载组件beforeUnmount和unmounted也一定会被触发而在Vue 2里如果你通过其他方式移除了组件对应的DOM节点destroyed钩子却不一定执行。新版的生命周期更可靠地保证了清理时机这对我们写清理代码是有利的。4.2 定时器、事件监听、第三方实例不清理的后果比你想象的严重beforeUnmount是清理资源的黄金时间。常见的资源泄漏有三类定时器、事件监听、第三方库实例图表、地图、播放器。我见过一个印象深刻的线上bug一个数据大屏项目用户频繁切换页面后页面越来越卡最后直接卡死。排查半天发现是某个图表组件在mounted里初始化了定时器做轮询请求却没有在卸载时清理。每切换一次页面就多一个定时器每一次轮询都在发请求、更新图表旧实例还没销毁内存和CPU都被慢慢吃掉了。所以我的习惯是凡是在mounted里创建的东西在beforeUnmount里必须成对清理。可以这样建立检查清单在mounted里做了什么在beforeUnmount里就该做什么添加了window/document事件监听移除对应监听创建了定时器或轮询清除定时器实例化了图表库调用实例销毁方法创建了WebSocket连接关闭连接订阅了外部状态如Vuex subscribe取消订阅export default { data() { return { timer: null, pollId: null } }, mounted() { this.timer setInterval(() { this.fetchLatestData() }, 5000) this.pollId setInterval(() { console.log(心跳检测) }, 10000) }, beforeUnmount() { // 一次性清理所有定时器 clearInterval(this.timer) clearInterval(this.pollId) // 如果有事件监听也要移除 window.removeEventListener(scroll, this.handleScroll) } }4.3 KeepAlive带来的特殊钩子activated和deactivated使用KeepAlive包裹组件后组件实例不会在切换时被卸载而是被缓存起来。这时候就是activated和deactivated登场的时候——activated在组件被重新激活时触发deactivated在组件被缓存隐藏时触发。一个最典型的应用列表页和详情页的切换。用户从列表页进入详情页再返回列表页希望滚动位置还在原来的地方、数据不用重新请求。这时用KeepAlive缓存列表页把“恢复滚动位置”的逻辑写在activated里而不是mounted里。KeepAlive includeListPage router-view / /KeepAlive// ListPage组件内部 activated() { // 恢复滚动位置而不是重新发起请求 this.$refs.list.scrollTop this.scrollPosition }, deactivated() { // 记录离开时的滚动位置 this.scrollPosition this.$refs.list.scrollTop }提示activated和deactivated配合KeepAlive使用时它们的执行顺序是在外层组件的mounted和beforeUnmount之间的。换句话说页面首次加载时onMounted和onActivated都会触发要小心逻辑重复执行。5. 组合式APIonXxx钩子与逻辑复用5.1 setup里的钩子选项式函数到组合式函数的映射组合式API推出后很多人最不适应的一点就是生命周期写法变了。原来写在选项式里的mounted()和beforeUnmount()现在变成了在setup里调用onMounted()和onBeforeUnmount()函数。两者的执行时机是一一对应的唯一需要注意的是注册时机必须在setup的同步执行期间。我之前写过有问题的代码在onMounted之后又用await异步请求数据再在异步回调里注册onBeforeUnmount结果控制台直接报警告清理逻辑也没执行。import { onMounted, onBeforeUnmount, ref } from vue export default { setup() { const count ref(0) let timer null // 正确在setup同步上下文中注册 onMounted(() { timer setInterval(() { count.value }, 1000) }) onBeforeUnmount(() { clearInterval(timer) }) return { count } } }5.2 用组合式函数把生命周期逻辑拆出去组合式API真正的威力在于逻辑复用。过去你想封装一套“在挂载时请求数据、在卸载时清理”的逻辑得写一个混入(mixin)但mixin存在命名冲突和来源不明的问题。现在你可以把这段逻辑直接写成一个组合式函数。我项目里就维护了一个usePolling的组合式函数专门负责轮询请求、错误处理、以及卸载时自动清理组件里只关心数据本身完全不碰生命周期细节。// hooks/usePolling.js import { ref, onMounted, onBeforeUnmount } from vue export function usePolling(requestFn, interval 5000) { const data ref(null) const loading ref(false) const error ref(null) let timer null const fetchData async () { loading.value true try { data.value await requestFn() error.value null } catch (e) { error.value e } finally { loading.value false } } const start () { fetchData() timer setInterval(fetchData, interval) } const stop () { clearInterval(timer) } onMounted(start) onBeforeUnmount(stop) return { data, loading, error, start, stop } }组件里使用这个组合式函数代码会变得非常干净import { usePolling } from /hooks/usePolling import { fetchUserList } from /api/user export default { setup() { // 组件的生命周期已经完全被封装这里只需要关心数据和业务 const { data, loading, error } usePolling(fetchUserList, 10000) return { data, loading, error } } }5.3 开发环境专用的钩子onRenderTracked和onRenderTriggeredVue 3还提供了两个开发环境专用的钩子onRenderTracked和onRenderTriggered。它俩在调试性能问题时的价值被很多人低估了。onRenderTracked在组件渲染过程中追踪每一个被响应式系统收集的依赖时触发你可以看到当前组件到底依赖了哪些状态。onRenderTriggered则更聚焦它只在某个依赖导致组件重新渲染时触发并且会告诉你是什么原因触发了这次更新。有一次我优化一个表格组件总觉得它不该频繁刷新用onRenderTriggered一查发现渲染原因是state.time——一个全局的时间戳。原来某处逻辑改了全局时间戳所有组件都被波及。找到元凶后把全局变量改为局部引用问题立刻解决。import { onRenderTriggered } from vue setup() { onRenderTriggered((event) { console.log(导致重渲染的依赖:, event.key) console.log(变化前的值:, event.oldValue) console.log(变化后的值:, event.newValue) // 检查 event.target、event.type 等字段定位原因 }) }6. 生命周期执行顺序父子组件和异步组件的细节6.1 父子组件的生命周期调用顺序在真实项目中几乎没有独立存在的组件组件树一层套一层父组件的生命周期钩子和子组件的钩子是一套严格的协作流程。用一句话概括父组件的beforeCreate和created先执行然后是父组件的beforeMount挂载阶段从最内层的子组件开始依次mounted最后回到父组件的mounted。更新和卸载阶段也是类似规律——父组件的beforeUpdate先执行然后是子组件的更新流程子组件updated完成后父组件的updated才触发。我把完整的顺序写在这里建议对着自己的项目验证一下父 beforeCreate 父 created 父 beforeMount 子 beforeCreate 子 created 子 beforeMount 子 mounted 父 mounted更新阶段父 beforeUpdate 子 beforeUpdate 子 updated 父 updated卸载阶段父 beforeUnmount 子 beforeUnmount 子 unmounted 父 unmounted6.2 异步组件如何影响生命周期一个容易忽视的差异有些朋友用defineAsyncComponent加载异步组件时会发现父组件的mounted时序变了。异步组件在加载完成前父组件已经完成了自己的挂载流程等异步组件就绪后才开始走它自己的生命周期。这意味着如果你在父组件的mounted里做某些依赖于子组件DOM的操作比如测量高度当时异步子组件可能还没渲染出来。解决办法也很简单异步组件内自己处理依赖自身DOM的逻辑或者用v-if控制渲染确保子组件就绪后再触发父组件里的操作。养成这个习惯后很多“偶发获取不到DOM”的问题就能从根上避免。const AsyncChild defineAsyncComponent(() import(./AsyncChild.vue)) export default { components: { AsyncChild }, mounted() { // 异步组件可能还没加载完这里拿不到子组件的DOM // 如果需要等待用 $nextTick 或监听特定标志位 this.$nextTick(() { // 仍然不能保证异步子组件已加载完毕 }) } }6.3 响应式数据发生在哪个阶段几个关于生命周期可靠性的实验结论我在项目里总结过几个值得记下的规律。第一beforeCreate阶段不能访问data和methodscreated之后可以。第二mounted时组件的DOM结构已经存在但不保证所有子组件都完成了挂载尤其是异步或动态组件。第三updated里不能无脑修改绑定的依赖数据否则容易引起循环渲染警告。第四onErrorCaptured可以捕获后代组件抛出的错误但注意谨慎处理错误本身避免形成错误循环。我还做过一个实验在mounted里修改响应式数据发现DOM不会立即更新而是在下一个Tick批量更新。这其实是Vue性能优化的核心机制——把多个变更合并成一次渲染。理解了这个机制你在写那些“修改数据后立刻读取DOM”的代码时就不会踩到“数据变了但DOM没跟上”的坑了。7. 常见问题速查与调试技巧7.1 为什么在created里访问不到DOM因为created阶段只完成了数据层的初始化模板还没编译、DOM还没生成。这是正常的别纠结。如果你确实需要在数据初始化后操作DOM请使用mounted或者$nextTick。7.2 为什么定时器明明设置了页面卸载后还在运行大概率是没在beforeUnmount里清理。注意如果使用了KeepAlive组件被缓存时不会触发beforeUnmount所以定时器不会停。这种情况应该监听deactivated来暂停任务、activated来恢复任务。7.3 为什么查看DOM时内容还是旧值可能是你在beforeUpdate里读的DOM——这个阶段DOM还没更新。另一个可能是你修改数据后立即读取DOM此时Vue还在等下一个Tick批量渲染。解决方案读取操作放在updated里或者在数据变更后使用this.$nextTick(() {})。7.4 组合式API里onMounted不执行最常见的原因是注册时机不对。如果onMounted被放在了async setup()的await之后Vue就找不到应该关联的组件实例了。另一点如果组件本⾝没有挂载成功比如v-if为false或者路由没匹配到生命周期钩子自然不会触发。我把这些规律做成了速查表方便随时对照问题可能原因方向created里访问data为undefined数据未初始化检查是否有同名data属性冲突mounted里操作子组件DOM失败异步组件未加载完改用子组件自身逻辑或$nextTick页面卡死、频繁重渲染updated里修改了依赖项改用watch配合$nextTick定时器重复执行未在beforeUnmount清理检查清理逻辑是否成对KeepAlive缓存后数据过期只写了mounted没写activated改用activated刷新数据setup里onMounted无效异步注册导致实例丢失确保钩子在同步代码中注册控制台警告Maximum recursive updates数据更新形成循环检查是否在更新流程里改数据7.5 我的个人调试流程和工具推荐调试生命周期相关问题时我有一套固定的流程。第一步在涉及的关键钩子里打印日志确认执行顺序和次数找出“多执行了”还是“没执行”的钩子。第二步打开Vue DevTools看组件树中当前组件属于哪种状态——是激活中、已激活还是已停用尤其在排查KeepAlive问题时帮助很大。第三步如果是性能问题用onRenderTriggered精确找出触发重渲染的依赖项。打印日志时有个技巧在组合式API里用console.trace替代console.log可以直接看到调用栈一眼定位是路由切换、父组件重新渲染还是其他原因触发了钩子。onMounted(() { console.trace(当前组件被挂载) })另外日常开发中我习惯在mounted和beforeUnmount里成对加上console.log标签比如[ListPage] mounted和[ListPage] unmounted。这样在排查多个组件互相嵌套的问题时能快速从日志里看出组件的创建和销毁顺序是否正确非常实用。8. 从一个简单的组件到一个真实系统生命周期的宏观思考8.1 组件设计时怎么规划生命周期逻辑写组件时我一般会先在脑子里过一次这个组件的整个生命历程把要做的事按阶段分好工哪些操作必须在数据就绪后立刻做哪些要等DOM渲染完哪些要等到卸载前才清理。这个过程看似多花了时间但能避免后期大量重构。我习惯在组件文件顶部用注释把生命周期规划写出来比如“mounted里初始化图表并在beforeUnmount销毁”“activated里重新请求列表数据”“created里拉取用户偏好”这样接手代码的人一眼就能理解组件的生命周期设计意图。8.2 路由切换、状态更新和节点复用的协同在路由系统中组件的生命周期和路由跳转深度绑定。切换路由时旧页面组件进入卸载流程新页面组件开始创建流程使用KeepAlive时旧组件走deactivated新组件走activated。理解这套协同对优化后台系统的体验至关重要。我做过一个后台管理系统为了报表页切换时保留筛选条件是把整个页面都包进KeepAlive的效果很好。但后来发现其他页面也被缓存了进入时数据不刷新最后改用include属性只缓存需要的页面问题才解决。这说明生命周期的选择要结合具体业务缓存不是越多越好。8.3 生命周期之外理解Vue响应式的边界理解生命周期最终是为了理解Vue整个响应式系统的运行节奏。生命周期钩子是这条运行逻辑上的一个个“检查点”每经过一个检查点组件的能力边界就扩大一点从无数据到有数据从无DOM到有DOM从活跃到卸载。在开发中遇到“为什么这里访问为undefined”“为什么DOM没更新”“为什么定时器还在跑”这类问题时回头思考组件现在走到哪个生命阶段答案往往就在眼前。这也是为什么我一直认为生命周期不是背出来的八个函数名字而是一套理解组件行为的心智模型。我把这篇内容里用到的核心经验再浓缩一下。第一数据请求尽量放到created或onMounted里但涉及DOM操作的务必等挂载完成。第二任何在mounted阶段创建的资源都要在beforeUnmount阶段成对清理。第三使用KeepAlive时用activated/deactivated管理缓存期间的行为别把逻辑全压在mounted里。第四组合式API配合自定义组合式函数可以把生命周期逻辑封装成可复用的能力。第五遇到生命周期相关的bug先用日志确认执行顺序再借助DevTools和onRenderTriggered定位原因。写代码这么多年我最大的体会是框架的API总在变但理解运行时行为的思维方式永远值钱。把生命周期这套心智模型打牢后面不管用Vue、React还是别的框架你都能快速找到问题的切入点。