Vue 生命周期钩子 源码解析
引入
笔者最近想给自己的一个开源库增加生命周期钩子,从而能够让开发者在特定阶段运行自己的代码,增加库的可扩展性。于是我学习了Vue3生命周期钩子的源码,对其产生了一些理解和思考。
生命周期钩子(以下简称钩子函数)的源码主要分为两部分:
- 注入。注入过程不会调用钩子函数的回调,只是把用户传入的回调注册到组件实例上
- 触发。这部分会根据钩子函数的类别,以同步或异步的方式调用钩子函数的回调
本文将主要介绍钩子函数的注入和触发过程,并回答以下几个主要问题:
- 钩子函数是如何注入到组件实例当中的
- 在组件实例的生命周期当中,钩子函数是如何触发的
本文最后写了我的一点思考与总结。
注入
这部分的代码都位于runtime-core/src/apiLifecycle.ts中。
ts复制代码export const createHook =
<T extends Function = () => any>(lifecycle: LifecycleHooks) =>
(hook: T, target: ComponentInternalInstance | null = currentInstance) =>
// post-create lifecycle registrations are noops during SSR (except for serverPrefetch)
(!isInSSRComponentSetup || lifecycle === LifecycleHooks.SERVER_PREFETCH) &&
injectHook(lifecycle, (...args: unknown[]) => hook(...args), target)
export const onBeforeMount = createHook(LifecycleHooks.BEFORE_MOUNT)
export const onMounted = createHook(LifecycleHooks.MOUNTED)
export const onBeforeUpdate = createHook(LifecycleHooks.BEFORE_UPDATE)
export const onUpdated = createHook(LifecycleHooks.UPDATED)
export const onBeforeUnmount = createHook(LifecycleHooks.BEFORE_UNMOUNT)
export const onUnmounted = createHook(LifecycleHooks.UNMOUNTED)
通过查看源码我们可以发现,常用的钩子函数(如onBeforeMount、onMounted等)都是通过createHook函数得到,当我们调用钩子函数时,实际上并没有执行我们传给钩子函数的回调,而是执行了injectHook函数,把回调注册到了组件实例上。
ts复制代码export function injectHook(
type: LifecycleHooks,
hook: Function & { __weh?: Function },
target: ComponentInternalInstance | null = currentInstance,
prepend: boolean = false
): Function | undefined {
if (target) {
const hooks = target[type] || (target[type] = [])
// cache the error handling wrapper for injected hooks so the same hook
// can be properly deduped by the scheduler. "__weh" stands for "with error
// handling".
const wrappedHook =
hook.__weh ||
(hook.__weh = (...args: unknown[]) => {
if (target.isUnmounted) {
return
}
// disable tracking inside all lifecycle hooks
// since they can potentially be called inside effects.
pauseTracking()
// Set currentInstance during hook invocation.
// This assumes the hook does not synchronously trigger other hooks, which
// can only be false when the user does something really funky.
setCurrentInstance(target)
const res = callWithAsyncErrorHandling(hook, target, type, args)
unsetCurrentInstance()
resetTracking()
return res
})
if (prepend) {
hooks.unshift(wrappedHook)
} else {
hooks.push(wrappedHook)
}
return wrappedHook
}
// 省略一些不重要的代码
}
injectHook的代码看上去多,实际上做的事情非常清晰:
- 首先拿到target(也就是组件实例)的对应类型的钩子函数的数组,比如目前注入的是onMounted钩子,对应类型m,那么我们先拿到hooks = target['m'],对应目前已经注册的onMounted钩子
- 然后把hook(也就是我们传给钩子函数的回调)和错误处理的内容包裹到了一起,从而得到wrappedHook,再把wrappedHook放到hooks当中
至此,我们就回答了“钩子函数是如何注入到组件实例当中的”这个问题:实际上就是把钩子函数的回调分类放到了实例的hooks数组中,从而可以在组件的生命周期对回调进行触发。
触发
按生命周期来分,不同的钩子函数回调的触发时机不同,例如:
- onBeforeMount、onMounted:触发时机在mountComponent阶段,具体代码位于renderer.ts的setupRenderEffect函数中
- onBeforeUpdate、onUpdated:触发时机在updateComponent阶段,代码同样位于setupRenderEffect函数中
- onBeforeUnmount、onUnmounted:触发时机在unmountComponent阶段,代码位于unmountComponent函数中
按触发方式来分,可以分为同步和异步两类:
- 同步:onBeforeMount、onBeforeUpdate、onBeforeUnmount
- 异步:onMounted、onUpdated、onUnmounted
由于钩子函数回调的触发具有相似性,以下仅介绍onBeforeMount和onMounted,其他钩子函数的原理是一样的。
ts复制代码// 仅保留了钩子函数相关代码
const componentUpdateFn = () => {
if (!instance.isMounted) {
const { bm, m, parent } = instance
// beforeMount hook
if (bm) {
invokeArrayFns(bm)
}
// mounted hook
if (m) {
queuePostRenderEffect(m, parentSuspense)
}
} else {
// updateComponent
let { next, bu, u, parent, vnode } = instance
// beforeUpdate hook
if (bu) {
invokeArrayFns(bu)
}
// updated hook
if (u) {
queuePostRenderEffect(u, parentSuspense)
}
}
}
从代码可以看到,当实例还没有被挂载的情况下,组件实例上的bm和m会被解构出来,这两个变量就是onBeforeMounted和onMounted的钩子函数回调数组。
对于bm,invokeArrayFns函数以同步的形式对bm中的回调进行调用
ts复制代码export const invokeArrayFns = (fns: Function[], arg?: any) => {
for (let i = 0; i < fns.length; i++) {
fns[i](arg)
}
}
对于m,queuePostRenderEffect函数以异步的形式对m中的回调进行调用。如果想要了解queuePostRenderEffect的具体内容,建议去整体性地看一下scheduler.ts的源码,了解vue3的调度机制,我之前的文章也有介绍过该部分的原理和源码。简单总结的话就是:
- vue中的异步任务在执行时存在顺序关系,从先到后分别是pre > DOM更新 > post > nextTick
- 通过调用queuePostRenderEffect,m会被添加到post这个时机的任务队列当中,从而异步地在post时机被执行
那么,为什么要如此复杂呢?直接同步调用m中的回调不可以吗,为什么要用异步的方式在post时机调用?
之所以这样做,是因为watch和计算属性的更新时机位于pre阶段,如果同步调用m的回调,就会导致在onMounted回调中无法访问到最新的值。并且onMounted本身也意味着DOM挂载后这个时点,因此理应在位于DOM更新时点之后的post节点,onUpdated和onUnmounted也是同理。
至此,我们回答了问题“在组件实例的生命周期当中,钩子函数是如何触发的”:实际上就是在组件实例生命周期的特定阶段,把实例上的钩子函数回调数组拿到,并且以同步/异步的方式进行调用。
思考与总结
1. 注入与调度的设计
生命周期钩子实际上从其自身原理上来说,并不是很复杂。但是这部分代码的逻辑层次很清晰:
- 首先,关注点分离到了不同的文件当中。注入逻辑放在生命周期逻辑相关文件中,调用行为放在组件实例逻辑的文件中,具体的调度逻辑放在调度器的文件当中。
- 其次,这种设计方式利用了已有的底层机制(调度器),只为组件实例添加了一个属性(就是hooks数组),对已有的代码结构完全没有破坏性,就实现了生命周期钩子。
这种使得代码具有可扩展性的代码组织方式和功能模块的设计方式是我值得学习的。
2. 变量名
vue的变量名和函数名的设计非常简洁而准确,很多概念的抽象也有助于理解,值得学习。
3. 闭包变量
vue使用了大量的由其他模块导入的变量,作为闭包变量来保存全局状态使用。
这部分主要使用了currentInstance用来保存当前活跃的实例,这样生命周期钩子就不用传入target来指定组件实例了,直接引用闭包变量即可。这一点之前的源码没注意到,这次学习到了。