Vue 的 VNode 生命周期事件:在模板里监听子组件挂载
Vue 有一些不太常见的写法。比如,我们可以直接在组件标签上监听它的挂载:
<Child @vue:mounted="onChildMounted" />日常写组件时,生命周期通常出现在 mounted()、onMounted() 这些位置。模板里的 @ 又经常用来监听点击、输入或子组件发出的业务事件。把这两件事放到一起,就会遇到一个容易忽略的入口:父组件可以在使用子组件的地方,监听这个子组件对应节点的生命周期。
这个能力由 Vue 自身提供,官方称为 VNode 生命周期事件。Vue 3 使用 vue: 前缀,Vue 2 则有对应的 hook: 机制。它们和组件自身的生命周期钩子有关,但监听对象、实现方式和支持范围需要分别理解。
为什么会需要从模板监听生命周期
组件内部通常知道自己什么时候挂载。搜索框可以在自己的 onMounted() 里聚焦输入框,图表组件可以在自己的挂载钩子里创建图表实例。
调用方有时也需要知道这个时机。比如搜索框只有点击按钮后才通过 v-if 创建,父组件需要在它出现后调用公开方法;或者一个第三方组件已经提供了实例方法,却没有额外发出 mounted 事件。此时,父组件需要观察的是某个子组件的挂载,而不是父组件自己的挂载。
把逻辑放进父组件的 onMounted(),只能覆盖父组件首次挂载的那个时刻。后来才创建的子组件,不会让父组件重新执行一次 onMounted()。模板上的生命周期监听正好可以把回调绑定到这个具体目标上。
VNode 是 Vue 用来描述界面节点的 JavaScript 对象。组件标签和普通 HTML 元素都会生成相应的 VNode,渲染器再根据这些对象创建、更新和移除界面。VNode 钩子观察的是渲染器处理目标节点的阶段。
回调写在父组件里,监听的对象可以不同
下面两个回调都定义在父组件中,但触发来源不同:
<script setup>
import { onMounted } from 'vue'
onMounted(() => {
console.log('父组件已挂载')
})
function onChildMounted() {
console.log('Child 已挂载')
}
</script>
<template>
<Child @vue:mounted="onChildMounted" />
</template>这里仅展示监听部分,Child 的导入和实现省略。onMounted() 关联的是当前正在执行 setup 的组件实例;@vue:mounted 关联的是模板里这个 Child 节点。回调函数写在哪个文件里,并不能单独决定它观察谁。

图中上方来自父组件自身的挂载,下方来自子组件的挂载,两条箭头表示不同的触发来源,不表示执行顺序。如果子组件一开始就存在,父组件的挂载钩子通常也已经能访问同步子组件;模板监听更适合需要跟随某个目标后续创建、更新或移除的场景。onMounted() 文档也明确区分了同步子组件、异步组件和 Suspense 的范围。
它和普通组件事件的区别
<Child @ready="onReady" /> 通常需要子组件主动执行 emit('ready'),由组件作者定义什么叫准备完成。<Child @vue:mounted="onChildMounted" /> 则由 Vue 的渲染器触发,子组件无需声明这个事件,也无需执行 emit('mounted')。
Vue 3 还支持把它写在普通元素上:
<div @vue:mounted="onElementMounted">内容</div>这是 Vue 提供的节点钩子入口,浏览器不会自动派发一个名叫 vue:mounted 的 DOM 事件。组件实例和 DOM 元素都能成为观察目标,也是它被称为 VNode 生命周期事件的原因。
Vue 3 支持哪些生命周期事件
Vue 3.5.40 的 VNodeProps 定义提供了六种回调。模板中可以使用下面这些写法,普通挂载、更新和卸载流程中的对应阶段如下:
@vue:before-mount:目标节点即将挂载,对应onVnodeBeforeMount。@vue:mounted:目标节点已挂载,对应onVnodeMounted。@vue:before-update:目标节点即将更新,对应onVnodeBeforeUpdate。@vue:updated:目标节点已完成这次更新,对应onVnodeUpdated。@vue:before-unmount:目标节点即将卸载,对应onVnodeBeforeUnmount。@vue:unmounted:目标节点已卸载,对应onVnodeUnmounted。
这六种都可以用于组件标签和普通 HTML 元素。before-update 和 updated 关注的是目标 VNode 的更新,不能据此统计任意响应式变量变化了多少次;没有影响到这个节点的变化,不一定让它经历一次更新。
组件实例还有 onActivated()、onDeactivated()、onErrorCaptured()、onRenderTracked() 等 API,但 VNode 钩子列表里没有同名入口。不能把它们直接改写成 @vue:activated、@vue:error-captured 就期待 Vue 自动调用。Vue 3 的 setup() 承担了创建阶段的初始化逻辑,VNode 列表里同样没有 @vue:created。
回调收到的是 VNode
Vue 3 的节点挂载、卸载回调会收到目标 VNode,更新回调还会收到上一次的 VNode。这些参数由 VNodeMountHook 和 VNodeUpdateHook 类型定义。
例如,普通元素挂载后可以通过 VNode 观察对应的 DOM:
function onElementMounted(vnode) {
console.log(vnode.el)
}组件调用方如果需要访问子组件公开的方法,仍然优先使用模板 ref。VNode 的内部组件字段属于更底层的数据结构,访问公开实例的约定由组件的 ref、expose 或 defineExpose 决定。
从模板到回调执行的源码链路
下面对照 Vue 3.5.40 的官方源码,沿着 @vue:mounted 看三个阶段:编译器生成回调属性,渲染器在挂载后调用它,模板引用在这个时机之前完成赋值。
编译器识别 vue: 前缀
模板里的 @ 是 v-on 的缩写。处理静态事件名时,compiler-core/src/transforms/vOn.ts会先拿到 rawName。对于当前例子,它的值就是 vue:mounted。下面节选保留了事件名转换分支,略去了源码中的说明注释:
// core/packages/compiler-core/src/transforms/vOn.ts:46-58
if (rawName.startsWith('vue:')) {
rawName = `vnode-${rawName.slice(4)}`
}
const eventString =
node.tagType !== ElementTypes.ELEMENT ||
rawName.startsWith('vnode') ||
!/[A-Z]/.test(rawName)
? toHandlerKey(camelize(rawName))
: `on:${rawName}`在这段处理中,vue:mounted 先变成 vnode-mounted。camelize() 把它转成驼峰形式 vnodeMounted,toHandlerKey() 再生成 onVnodeMounted。同理,vue:before-mount 会得到 onVnodeBeforeMount。
编译 <Child @vue:mounted="onChildMounted" /> 后,产物中的关键部分如下。这是本机 Vue 3.5.40 编译器的输出节选,省略了模块顶部的辅助函数导入:
export function render(_ctx, _cache) {
const _component_Child = _resolveComponent("Child")
return (_openBlock(), _createBlock(_component_Child, { onVnodeMounted: _ctx.onChildMounted }, null, 8 /* PROPS */, ["onVnodeMounted"]))
}_ctx 是模板的渲染上下文,_resolveComponent() 找到 Child 组件,_createBlock() 创建它的 VNode。这里需要关注的是传入节点的 onVnodeMounted 属性:模板监听已经变成了一个渲染器可以读取的回调。末尾的更新标记用于帮助渲染器识别动态属性。
因此,直接写渲染函数时也可以传这个属性:
h(Child, { onVnodeMounted: onChildMounted })旧文章里的
@vnode-mounted属于需要迁移的模板写法。Vue 3.4 的发布说明明确要求改用@vue:mounted,旧模板语法会成为编译错误。渲染函数里的onVnodeMounted是节点属性名,两者不要混淆。
渲染器把回调放到挂载之后
组件的 DOM 子树完成挂载后,runtime-core/src/renderer.ts先安排组件自身的 mounted 钩子,然后处理节点上的 onVnodeMounted。对应的后一个分支如下:
// core/packages/runtime-core/src/renderer.ts:1424-1433
if (
!isAsyncWrapperVNode &&
(vnodeHook = props && props.onVnodeMounted)
) {
const scopedInitialVNode = initialVNode
queuePostRenderEffect(
() => invokeVNodeHook(vnodeHook!, parent, scopedInitialVNode),
parentSuspense,
)
}props.onVnodeMounted 是前面编译出来的回调。queuePostRenderEffect() 负责把它安排到渲染之后,并处理所在 Suspense 的等待关系;invokeVNodeHook() 则调用这个回调,把目标 VNode 传进去。这里还有对异步组件包装层的判断,不能把包装层和最终渲染的内部组件当成同一个挂载阶段。
这条链路已经连接了模板和渲染器,所以子组件不需要手动发出 mounted 事件。
为什么挂载回调里通常可以访问 ref
模板引用也有自己的赋值时机。rendererTemplateRef.ts处理非空引用时,会把赋值任务放进渲染后的队列,并设置较早的执行优先级:
// core/packages/runtime-core/src/rendererTemplateRef.ts:196-198
job.id = -1
pendingSetRefMap.set(rawRef, job)
queuePostRenderEffect(job, parentSuspense)这里的 job 包含了模板引用的赋值动作。正常的同步组件挂载完成后,这个动作会先于 mounted 回调完成,因此父组件可以在 @vue:mounted 中通过 ref 访问子组件的公开实例。ref 能访问哪些成员,仍取决于子组件暴露了什么。

条件渲染中的实际用法
假设页面里有一个搜索框,点击按钮后才通过 v-if 创建。父组件希望在它出现后调用公开的 focus() 方法,监听可以直接写在这个搜索框标签上:
<script setup>
import { ref } from 'vue'
import SearchBox from './SearchBox.vue'
const visible = ref(false)
const searchBox = ref(null)
function focusSearchBox() {
searchBox.value?.focus()
}
</script>
<template>
<button @click="visible = !visible">切换搜索框</button>
<SearchBox
v-if="visible"
ref="searchBox"
@vue:mounted="focusSearchBox"
/>
</template>这个例子要求 SearchBox 已经提供公开的 focus() 方法。如果它使用 <script setup>,需要在子组件内部用 defineExpose({ focus }) 暴露该方法;具体规则见组件上的 ref。
父组件首次挂载时 visible 是 false,搜索框还不存在。点击按钮把它改成 true 后,Vue 创建搜索框、赋值 ref、调用挂载回调,聚焦动作才能落到实际实例上。关闭搜索框后再打开,普通 v-if 会创建新实例,回调也会再次执行。
如果改用 v-show,组件一开始就会挂载,后续切换只改变显示状态。再次显示不会重新执行 mounted 回调;需要每次显示都聚焦时,应监听显示状态,并在相应 DOM 更新后调用方法。
另一种常见写法是在父组件修改 visible 后 await nextTick(),再读取 ref。对于由当前更新周期创建的同步子组件,这种方式也能工作。模板监听把动作绑定到目标节点自身的挂载上,适合创建入口分散、需要持续跟随该节点生命周期的情况;nextTick() 也不会自动等待组件内部的网络请求或任意异步初始化。
如果搜索框的设计就是每次挂载都自动聚焦,可以把行为直接放进 SearchBox 自己的 onMounted()。模板监听更适合由调用方决定的动作,不需要把所有组件内部初始化都搬到父组件。
Composition API 和 Vue 2.7 的关系
Composition API 决定的是组件内部怎样组织和注册逻辑;hook:、vue: 则由框架版本的事件和渲染机制决定。组件改成 setup() 或 <script setup>,不会自动改变模板生命周期事件的前缀。
| 使用环境 | 组件内部注册挂载钩子 | 父组件模板监听 |
|---|---|---|
| Vue 2 的普通组件 | mounted() |
@hook:mounted |
Vue 2.6 + @vue/composition-api |
从插件导入 onMounted() |
@hook:mounted |
| Vue 2.7 | 从 vue 导入 onMounted() |
@hook:mounted |
| Vue 3 | 从 vue 导入 onMounted() |
@vue:mounted |

Vue 2 的 hook: 来自实例事件
Vue 3 迁移指南介绍了前缀从 hook: 到 vue: 的变化。对照 Vue 2.7.16 的 core/instance/lifecycle.ts,组件运行某个生命周期时,callHook() 会先执行对应的组件选项,然后根据标记发出生命周期事件:
// vue/src/core/instance/lifecycle.ts:412-414
if (vm._hasHookEvent) {
vm.$emit('hook:' + hook)
}hook 是当前生命周期的名字,例如 mounted 或 destroyed。events.ts在注册 hook: 监听器时会设置 _hasHookEvent,因此父组件写下 @hook:mounted 后,就能收到这次实例事件。Vue 2 文档中的程序化事件侦听器也展示了 $once('hook:beforeDestroy', ...) 的用法。
这个实现和 Vue 3 的 VNode 回调属性有明显差别。Vue 2 的普通 HTML 元素没有组件实例,不会因为写了 @hook:mounted 就自动产生生命周期事件。Vue 3 才把对应能力扩展到了普通元素。
Vue 2 常见的组件阶段沿用它们原本的名字:
- 创建阶段:
@hook:beforeCreate、@hook:created。 - 挂载阶段:
@hook:beforeMount、@hook:mounted。 - 更新阶段:
@hook:beforeUpdate、@hook:updated。 - 销毁阶段:
@hook:beforeDestroy、@hook:destroyed。 - 缓存激活与停用:
@hook:activated、@hook:deactivated。
这些名字不等于 Vue 3 的六个 VNode 钩子,不能跨版本机械替换整个列表。
Vue 2.7 内置 Composition API,事件名仍沿用 Vue 2
Vue 2.7 的迁移说明列出了回移的 Composition API 和 <script setup> 等能力。这些能力让组件代码可以采用更接近 Vue 3 的写法,底层运行时仍然属于 Vue 2。
Vue 2.7.16 的 v3/apiLifecycle.ts尤其能说明这个区别:
// vue/src/v3/apiLifecycle.ts:40-44,节选
export const onMounted = createLifeCycle('mounted')
// ...省略更新阶段的导出
export const onBeforeUnmount = createLifeCycle('beforeDestroy')
export const onUnmounted = createLifeCycle('destroyed')createLifeCycle() 把传入的函数合并到组件相应的生命周期选项里。对外 API 可以叫 onUnmounted(),内部注册的阶段仍然是 destroyed,所以父组件要监听 @hook:destroyed,并不会因此出现 @hook:unmounted 或 @vue:unmounted。
对 Vue 2.7 的组件,挂载与销毁监听仍然可以写成:
<Child
@hook:mounted="onChildMounted"
@hook:destroyed="onChildDestroyed"
/>Vue 2.6 的 @vue/composition-api 插件也是同样的分工
如果 composition-api 指的是 @vue/composition-api,它给 Vue 2.6 等旧版本提供组合式 API。插件文档明确说明 Vue 2.7 已内置相关能力,无需继续安装这个插件。
插件 1.7.2 的 apis/lifecycle.ts通过 Vue 原有的生命周期选项合并策略,注册组合式钩子:
// composition-api/src/apis/lifecycle.ts:27-30
const options = instance.proxy.$options as Record<string, unknown>
const mergeFn = Vue.config.optionMergeStrategies[hook]
const wrappedHook = wrapHookCall(instance, val)
options[hook] = mergeFn(options[hook], wrappedHook)插件让 onMounted() 能参与 Vue 2 的 mounted 阶段,没有把 Vue 2 的模板编译器和渲染器换成 Vue 3。因此,即使子组件用插件的 setup() 和 onMounted() 编写,父组件仍然使用 @hook:mounted。
使用时还要区分哪些边界
挂载、更新、停用、卸载和业务初始化并不是同一个完成点。VNode 回调适合观察节点处理阶段,组件实例的生命周期与业务事件仍然各有用途。
KeepAlive 的停用不等于实例销毁
普通 v-if 销毁后重新创建组件时,新实例会再次挂载。KeepAlive 则保存已有实例,让它在停用后重新激活。观察这个缓存生命周期,应使用 onActivated() 和 onDeactivated()。
Vue 3.5.40 的 KeepAlive.ts在激活缓存节点时,也会调用 VNode 的 mounted 回调:
// core/packages/runtime-core/src/components/KeepAlive.ts:157-160
const vnodeHook = vnode.props && vnode.props.onVnodeMounted
if (vnodeHook) {
invokeVNodeHook(vnodeHook, instance.parent, vnode)
}同一个文件的停用分支还会调用 onVnodeUnmounted。因此,在缓存组件上收到 @vue:unmounted,不能据此断言组件实例已经销毁;再次收到 @vue:mounted,也不能据此断言创建了新实例。
在 Vue 3.5.40 中,让同一个缓存组件显示、停用、再显示一次,组件自身的 onMounted() 执行一次、onUnmounted() 执行零次;节点的 mounted 回调执行两次、unmounted 回调执行一次。这与上述激活、停用分支一致。
卸载回调里不要假定 ref 仍然存在
挂载后的可访问性不能直接推到卸载前。Vue 3.5.40 的卸载流程会先清理模板引用,再调用 VNode 的 onVnodeBeforeUnmount。因此,到了 @vue:before-unmount,父组件中的对应 ref 就可能已经是 null。
子组件自己的资源清理通常放在自身的 onBeforeUnmount() 或 onUnmounted() 中,让资源创建与释放保持在同一个组件里。需要由父组件持有并释放的资源,则由父组件保存相应句柄,不要等到卸载节点回调时再临时从 ref 查找。
已挂载和业务已就绪
挂载完成可以作为访问组件公开实例、处理已创建 DOM 的时机,但不会替组件等待接口请求、图片加载或第三方编辑器初始化。等待这些结果时,应由组件提供表达业务含义的 ready 事件或异步方法。
同样,普通 mounted 与 updated 回调属于客户端的界面生命周期。服务端渲染阶段没有实际挂载到浏览器 DOM 的过程,不能依赖它们完成服务端的数据准备。
对照版本验证
这组用法分别在 Vue 3.5.40、Vue 2.7.16,以及 Vue 2.6.14 搭配 @vue/composition-api 1.7.2 的独立浏览器页面中验证过:
- Vue 3 的组件和普通元素都按顺序触发六个 VNode 钩子;更新回调收到新旧 VNode。
- 普通
v-if重新创建组件会再次触发挂载回调,v-show切换不会产生新的挂载。 - Vue 3 的普通挂载回调中可以读取模板
ref;卸载前的节点回调中,引用已经被清空。 - Vue 3 的
KeepAlive重新激活和停用会触发对应的 VNode mounted、unmounted 回调,实例自身的挂载和销毁次数与它们不同。 - Vue 2.7 和 Vue 2.6 插件场景中,组合式钩子与
hook:监听都能执行;onUnmounted()对应的实例事件仍是hook:destroyed。 - Vue 2.7 的普通元素不会自动触发
hook:mounted,vue:前缀也不会因为启用了 Composition API 而生效。
具体选用哪种入口,取决于动作的归属。组件自己的初始化和资源释放放在自身钩子里;调用方需要观察某个目标节点时,使用模板上的生命周期监听;业务准备完成,则由业务事件或异步接口明确表达。