Vue 3 生命周期 Hooks 的原理及其与调度器(Scheduler)的关系
原文主要依据 Vue 3.0/3.2 时代源码,代码中出现的
queuePreFlushCb、pendingPreFlushCbs、effectTrackDepth等内容不能直接代表 Vue 3.5。本文保留原文“生命周期由组件实例保存、调度器负责安排异步任务”的主线,并按 Vue Core v3.5.39 的源码重新整理。文中的源码片段分为两类:标为“简化流程”的代码只用于解释概念;标为“源码参考”的链接固定到 Vue Core v3.5.39,避免把
main分支或旧版本字段当成稳定 API。
一、本文要解决的问题
生命周期钩子看起来只是 onMounted()、onUpdated() 等函数,但它们实际连接了三个部分:
setup()中调用生命周期 API 时,Vue 如何知道当前属于哪个组件?- 组件首次渲染、更新和卸载时,Vue 在什么位置调用这些 hook?
mounted、updated、watcher 和组件更新为什么经常不是当前调用栈中立即执行?
第一个问题由组件实例和 hook 注入函数解决;第二个问题由 renderer 的组件更新流程解决;第三个问题主要由 Scheduler 和 Suspense effect 队列解决。
二、Vue 3.5 的生命周期清单
Composition API
onBeforeMount onMounted
onBeforeUpdate onUpdated
onBeforeUnmount onUnmounted
onActivated onDeactivated
onErrorCaptured
onRenderTracked onRenderTriggered
onServerPrefetch
Composition API 没有 onBeforeCreate() 和 onCreated()。这两个创建阶段的逻辑通常直接写在 setup() 或 <script setup> 的同步部分。
Options API
beforeCreate created
beforeMount mounted
beforeUpdate updated
beforeUnmount unmounted
activated deactivated
errorCaptured
renderTracked renderTriggered
serverPrefetch
Vue 3 将 Vue 2 的:
beforeDestroy -> beforeUnmount
destroyed -> unmounted
SSR 和开发调试边界
onMounted、onUpdated、onUnmounted等 DOM 生命周期不会在 SSR 执行。onServerPrefetch()是专门的 SSR hook,SSR renderer 会等待它返回的 Promise。onRenderTracked()和onRenderTriggered()只用于开发环境调试,SSR 不会调用。- KeepAlive 的激活/停用是
activated/deactivated,不等同于普通组件的 mounted/unmounted。
三、生命周期 Hook 是如何注册的?
createHook:为每种阶段生成注册函数
Vue 3.5 的简化形式如下:
const createHook = lifecycle => {
return (hook, target = currentInstance) => {
if (!isInSSRComponentSetup || lifecycle === SERVER_PREFETCH) {
injectHook(lifecycle, hook, target)
}
}
}
export const onMounted = createHook(MOUNTED)
export const onUpdated = createHook(UPDATED)
export const onUnmounted = createHook(UNMOUNTED)
currentInstance 是当前正在执行 setup() 的组件实例。因为生命周期要绑定到当前组件,所以注册必须同步发生:
<script setup>
import { onMounted } from 'vue'
// 正确:setup 同步执行期间注册
onMounted(() => {
console.log('mounted')
})
// 不推荐:定时器回调执行时已经没有当前组件实例
setTimeout(() => {
// onMounted(() => {}) // 会发出警告
}, 100)
</script>
如果使用 async setup(),应在第一个 await 之前注册生命周期;或者使用 <script setup> 编译器提供的上下文恢复机制。
injectHook:保存到组件实例的 hook 数组
一个组件可以注册多个同类 hook,因此实例上保存的是数组:
function injectHook(type, hook, target = currentInstance, prepend = false) {
if (!target) {
warn('Lifecycle injection APIs can only be used during setup().')
return
}
const hooks = target[type] || (target[type] = [])
const wrappedHook = hook.__weh || (hook.__weh = (...args) => {
pauseTracking()
const reset = setCurrentInstance(target)
const result = callWithAsyncErrorHandling(hook, target, type, args)
reset()
resetTracking()
return result
})
if (prepend) hooks.unshift(wrappedHook)
else hooks.push(wrappedHook)
return wrappedHook
}
这里的几个细节很重要:
hook.__weh缓存了错误处理包装函数,便于调度器去重。- 执行生命周期回调时会暂停依赖追踪,避免 hook 内部读取响应式值被错误地加入某个 render effect。
- Vue 会暂时恢复
currentInstance,让 hook 内调用的实例相关 API 仍然指向正确组件。 - 回调中的同步异常和 Promise rejection 会交给 Vue 的错误处理流程。
上述代码是简化流程;具体实现见 v3.5.39 apiLifecycle.ts。
四、生命周期 Hook 在组件更新中何时执行?
组件首次更新 effect 的流程可以简化为:
function componentUpdateFn() {
if (!instance.isMounted) {
const { bm, m } = instance
// beforeMount:在第一次 render/patch 前同步执行
if (bm) invokeArrayFns(bm)
const subTree = renderComponentRoot(instance)
instance.subTree = subTree
patch(null, subTree, container, instance, anchor)
instance.isMounted = true
// mounted:排到 post-render effect
if (m) queuePostRenderEffect(m, parentSuspense)
} else {
const { bu, u } = instance
// beforeUpdate:在本次 render/patch 前同步执行
if (bu) invokeArrayFns(bu)
const nextTree = renderComponentRoot(instance)
const prevTree = instance.subTree
instance.subTree = nextTree
patch(prevTree, nextTree, container, instance, anchor)
// updated:排到 post-render effect
if (u) queuePostRenderEffect(u, parentSuspense)
}
}
beforeMount 和 beforeUpdate 在组件更新 job 内同步执行;mounted 和 updated 会进入 post-render effect。这里的“异步”指调度时机通常在当前同步 patch 之后的微任务 flush 中执行,不是说 hook 本身必须返回 Promise,也不是 Vue 会等待一个普通 async mounted() 完成。
invokeArrayFns
function invokeArrayFns(fns, arg) {
for (let i = 0; i < fns.length; i++) {
fns[i](arg)
}
}
真正的源码还会使用错误处理包装、Suspense 和 renderer 的上下文。queuePostRenderEffect 在没有 pending Suspense 时最终会进入 queuePostFlushCb();有 pending Suspense 时,会先保存到 Suspense boundary,待异步分支 resolve 后再执行。
当前组件 job 的简化关系
Vue 3.5 的组件 render effect 可以抽象为:
const effect = new ReactiveEffect(componentUpdateFn)
const update = (instance.update = effect.run.bind(effect))
const updateJob = (instance.job = effect.runIfDirty.bind(effect))
updateJob.id = instance.uid
updateJob.i = instance
effect.scheduler = () => queueJob(updateJob)
这段是概念化代码,用于说明:
- 响应式依赖触发后,不一定马上执行 render,而是把组件 job 放入队列。
uid让父组件通常拥有更小的 job id,从而先于子组件更新。runIfDirty()会先判断 effect 是否真的需要重新运行。- 当前版本用
SchedulerJobFlags.DISPOSED等 flags 使已经卸载的 job 失效,而不是简单依赖旧版job.active = false。
五、Vue 3.5 Scheduler 的核心
三种 watcher flush 时机
flush: 'sync' 响应式变更时同步执行,不进入队列,也没有批处理
flush: 'pre' 默认;首次可直接执行,后续进入主 job 队列的 PRE 任务
flush: 'post' 进入 post-render/post-flush 队列,在组件 DOM 更新后执行
默认 pre watcher 的公共语义是:如果存在父组件更新,它通常在父组件更新之后、owner 组件自己的 DOM 更新之前执行。因此如果 watcher 要读取 owner 组件更新后的 DOM,应使用 flush: 'post',或使用 watchPostEffect()。
watch(source, callback, { flush: 'post' })
watchEffect(effect, { flush: 'sync' })
当前主队列和 flags
Vue 3.5 Scheduler 使用 job flags 进行去重和失效:
enum SchedulerJobFlags {
QUEUED = 1 << 0,
PRE = 1 << 1,
ALLOW_RECURSE = 1 << 2,
DISPOSED = 1 << 3,
}
简化的 queueJob:
function queueJob(job) {
if (job.flags & QUEUED) return
// 省略二分插入:按 id 排序,同 id 的 PRE job 位于组件更新前
insertById(queue, job)
job.flags |= QUEUED
queueFlush()
}
function queueFlush() {
if (!currentFlushPromise) {
currentFlushPromise = resolvedPromise.then(flushJobs)
}
}
与原文旧代码相比,Vue 3.5 不再把 pre watcher 统一放到 pendingPreFlushCbs 数组中;它是带 PRE flag 的主队列任务。flushPreFlushCbs() 会从主队列中扫描并执行这些 PRE job。
flushJobs 的当前顺序
function flushJobs(seen) {
try {
// 主队列按 id 执行:通常父组件 job 先于子组件
for (flushIndex = 0; flushIndex < queue.length; flushIndex++) {
const job = queue[flushIndex]
if (!(job.flags & DISPOSED)) {
callWithErrorHandling(job)
}
job.flags &= ~QUEUED
}
} finally {
queue.length = 0
// mounted/updated 和 flush:post watcher
flushPostFlushCbs(seen)
currentFlushPromise = null
if (queue.length || pendingPostFlushCbs.length) {
flushJobs(seen)
}
}
}
这是便于理解的伪代码。真实实现还包含递归更新检测、错误处理、嵌套 post flush、ALLOW_RECURSE 和 job flags 的精细清理,详见 v3.5.39 scheduler.ts。
nextTick
nextTick() 使用当前 flush Promise,等待本轮已排队的 DOM 更新完成:
import { nextTick, ref } from 'vue'
const visible = ref(false)
visible.value = true
await nextTick()
// 此时可以读取本轮更新后的 DOM
nextTick() 与 setTimeout() 不是一回事。不要依赖某个具体浏览器宏任务实现;使用它表达“等待 Vue 当前批次更新完成”的意图。
六、父子组件生命周期顺序
普通同步组件树的首次挂载
对于没有异步组件和 Suspense 的普通同步树,常见顺序是:
父 beforeMount
-> 子 beforeMount
-> 子 mounted
-> 父 mounted
父组件要等自己的 DOM 和同步子组件都完成挂载后,才满足 mounted 的公共保证。异步组件、Suspense 中的异步 setup 和异步后代不在这个简化顺序的保证范围内。
父驱动的更新
如果父组件更新了传给子组件的 props,常见顺序是:
父 beforeUpdate
-> 子 beforeUpdate
-> 子 updated
-> 父 updated
但如果父子组件分别因为自己的独立响应式状态更新,不应机械地认为每次都必然触发这四个 hook。Vue 会批量合并同步修改,并只更新真正受影响的组件。
卸载
普通同步树的常见顺序是:
父 beforeUnmount
-> 子 beforeUnmount
-> 子 unmounted
-> 父 unmounted
beforeUnmount 在卸载前同步执行;unmounted 作为 post-render effect 执行,具体时刻还可能受到 Suspense 和其他 post flush 任务影响。
KeepAlive
被 <KeepAlive> 缓存的组件切换时通常触发:
deactivated -> activated
而不是每次都触发 unmounted -> mounted。activated 初次挂载时也会调用,deactivated 在缓存组件最终卸载时也会调用。详见 KeepAlive 生命周期。
七、生命周期与 SSR、Suspense
SSR
SSR 中:
setup()和 Options API 的创建阶段仍可能执行;onBeforeMount、onMounted、onBeforeUpdate、onUpdated、onBeforeUnmount、onUnmounted以及 KeepAlive hook 不执行;onServerPrefetch()可以返回 Promise,SSR renderer 会等待它完成后再生成组件 HTML;- 浏览器 API(
window、document、localStorage)应放在onMounted或客户端条件中。
<script setup>
import { onServerPrefetch, ref } from 'vue'
const data = ref(null)
onServerPrefetch(async () => {
data.value = await fetchData()
})
</script>
Suspense
<Suspense> 可以等待异步 setup 和异步组件。pending 时,默认内容可能先渲染到隐藏容器,fallback 显示给用户;对应分支的 post-render effects 会等待 Suspense resolve 后再释放。因此:
- 父组件的
mounted不代表 Suspense 内的异步后代已经 mounted; onMounted等普通 hook 不等同于“所有异步依赖都完成”;- Suspense 目前仍应视为需要关注版本和边界的高级能力。
八、错误处理和调试 Hooks
生命周期错误如何处理
errorCaptured 从错误来源组件的父链开始,由近到远传播,类似事件冒泡。错误来源组件自身的 errorCaptured 不捕获自己的错误。默认情况下,错误最终仍可能交给 app.config.errorHandler;某个捕获 hook 返回 false 才会停止后续传播。
const parent = defineComponent({
errorCaptured(error, instance, info) {
console.error(error, info)
return false
},
})
错误来源可以是 setup()、render、生命周期、watcher、事件处理器、指令或 transition。不要把一段特定控制台输出当作所有构建模式和错误来源下的固定顺序。
renderTracked 和 renderTriggered
它们只用于开发环境调试组件 render effect:
<script setup>
import {
onRenderTracked,
onRenderTriggered,
} from 'vue'
onRenderTracked(event => {
console.log('render tracked:', event.type, event.key)
})
onRenderTriggered(event => {
console.log('render triggered:', event.type, event.key)
})
</script>
renderTracked:render effect 读取、检查或遍历响应式依赖时触发。renderTriggered:已经被当前 render effect 依赖的响应式操作触发更新时触发。- 它们不是普通
watch,也不是任意数据读取/写入都会触发。
九、Vue 3.0/3.2 源码与 Vue 3.5 的差异
原文使用了以下旧模型:
queuePreFlushCb和pendingPreFlushCbs;instance.update.active = false;invalidateJob(instance.update);SchedulerJob.active、ownerInstance等旧字段;- 直接把 mounted/updated 描述成统一的异步回调。
这些内容可以作为 Vue 3 早期实现的历史资料保留,但不应标注为 Vue 3.5 源码。Vue 3.5 的变更重点是:
- watcher 的 pre 任务使用主队列
PREflag; - 组件 job 使用 id 排序,并通过
DISPOSED等 flags 失效; - Scheduler 的去重和递归检测由 flags 管理;
queuePostRenderEffect与 Suspense effect 队列相互配合;- 生命周期 hook wrapper 通过
pauseTracking、current instance 和错误处理函数执行。
总结
- 生命周期 API 负责把 callback 注册到当前组件实例。
- before 类 hook 通常在对应 render/unmount 流程中同步执行。
- mounted、updated、unmounted 等 post-render hook 通常进入 post-flush 调度,但普通 hook Promise 不会被 Vue 等待。
- Vue 3.5 的 watcher 默认
pre、post、sync三种 flush 时机不同;nextTick用于等待本轮 DOM 更新。 - 父子顺序只应作为普通同步树的常见顺序,异步组件、Suspense、KeepAlive 和独立状态更新都可能改变观察结果。
- SSR 使用
onServerPrefetch获取服务端数据,普通 DOM 生命周期不在 SSR 执行。 - 旧版本源码中的队列和 job 字段不能直接复制为 Vue 3.5 实现。