前言
Vue3探秘系列文章链接:
不止响应式:Vue3探秘系列— 虚拟结点vnode的页面挂载之旅(一)
不止响应式:Vue3探秘系列— diff算法的完整过程(三)
计算属性:Vue3探秘系列— computed的实现原理(六)
Hello~大家好。我是秋天的一阵风
在 Vue 3 中,生命周期钩子函数被重新设计以适应 Composition API
, Composition API
提供了一种更灵活的方式来组织和重用组件逻辑。
Vue 3 的生命周期钩子函数主要分为两大类:选项式 API 生命周期钩子
和 组合式 API
生命周期钩子。
在 Vue 3 中,你可以使用 <script setup>
语法糖或传统的 <script>
标签来定义这些钩子
一、 Vue3 与 Vue2 的生命周期差异
// Vue.js 2.x 定义生命周期钩子函数
export default {
created() {
// 做一些初始化工作
},
mounted() {
// 可以拿到 DOM 节点
},
beforeDestroy() {
// 做一些清理操作
}
}
// Vue.js 3.x 生命周期 API 改写上例
import { onMounted, onBeforeUnmount } from 'vue'
export default {
setup() {
// 做一些初始化工作
onMounted(() => {
// 可以拿到 DOM 节点
})
onBeforeUnmount(()=>{
// 做一些清理操作
})
}
}
Vue 2 生命周期钩子 | Vue 3 生命周期钩子 | 描述 |
---|---|---|
beforeCreate | 无直接对应 | 在 Vue 3 中,beforeCreate 的功能被 setup() 钩子覆盖。在 setup() 中,你可以访问到 props 和 context ,但不能访问到 this 。 |
created | setup() | 在 Vue 3 中,created 的功能被 setup() 钩子覆盖。在 setup() 中,你可以访问到 props 和 context ,但不能访问到 this 。 |
beforeMount | onBeforeMount | 在组件挂载到 DOM 之前调用。 |
mounted | onMounted | 在组件挂载完成后调用。 |
beforeUpdate | onBeforeUpdate | 在组件即将更新之前调用。 |
updated | onUpdated | 在组件更新后调用。 |
beforeDestroy | onBeforeUnmount | 在组件即将卸载之前调用。 |
destroyed | onUnmounted | 在组件卸载后调用。 |
errorCaptured | onErrorCaptured | 当捕获一个来自子孙组件的错误时被调用。 |
注意: 对于 errorCaptured 这个生命周期,大部分开发者都会觉得陌生。
如果你感兴趣,可以在我的这篇文章中浏览了解 :
除此之外,Vue.js 3.0
还新增了两个用于调试的生命周期 API:onRenderTracked
和 onRenderTriggered
。
更多的生命周期API信息你可以在这里阅读:组合式API:生命周期钩子
问题来了,这些钩子函数在组件生命周期的哪些阶段执行的?它们生命周期钩子函数内部又是如何实现的?
接下来,我们就开始进入源码探究。
二、实现原理
1. 注册钩子函数
我们在 /core/packages/runtime-core/src/apiLifecycle.ts
(Github)文件中可以找到生命周期函数钩子的注册逻辑:
export const enum LifecycleHooks {
BEFORE_CREATE = 'bc',
CREATED = 'c',
BEFORE_MOUNT = 'bm',
MOUNTED = 'm',
BEFORE_UPDATE = 'bu',
UPDATED = 'u',
BEFORE_UNMOUNT = 'bum',
UNMOUNTED = 'um',
DEACTIVATED = 'da',
ACTIVATED = 'a',
RENDER_TRIGGERED = 'rtg',
RENDER_TRACKED = 'rtc',
ERROR_CAPTURED = 'ec'
}
export const onBeforeMount = createHook(LifecycleHooks.BEFORE_MOUNT/* 'bm' */)
export const onMounted = createHook(LifecycleHooks.MOUNTED/* 'm' */)
export const onBeforeUpdate = createHook(LifecycleHooks.BEFORE_UPDATE /* 'bu' */)
export const onUpdated = createHook(LifecycleHooks.UPDATED /* 'u' */)
export const onBeforeUnmount = createHook(LifecycleHooks.BEFORE_UNMOUNT /* 'bum' */)
export const onUnmounted = createHook(LifecycleHooks.UNMOUNTED /* 'um' */)
export const onRenderTracked = createHook<DebuggerHook>(
LifecycleHooks.RENDER_TRACKED
)
export const onErrorCaptured = (
hook: ErrorCapturedHook,
target: ComponentInternalInstance | null = currentInstance
) => {
injectHook(LifecycleHooks.ERROR_CAPTURED, hook, target)
}
可以看到,大多数生命周期钩子函数都是通过 createHook
函数创建的,通过传入不同的字符串来表示不同的钩子函数。
那么,我们就来分析一下 createHook
钩子函数的实现原理。
2. createHook
const createHook = function(lifecycle) {
return function (hook, target = currentInstance) {
injectHook(lifecycle, hook, target)
}
}
createHook
接收的是不同钩子函数的字符串枚举值,比如 onMounted
是 'm'
,onUpdated
是'u'
。它会返回一个新函数。
所以生命周期钩子函数的真正本尊其实是:
export const onMounted = function (hook, target = currentInstance) {
injectHook('m', hook, target)
}
hook参数
就是我们在组件中写的钩子函数要执行的逻辑,比如下面的例子, hook实参
就是 ()=>{ console.log("挂载方法1") }
const { onMounted} = Vue
Vue.createApp({
setup() {
onMounted(()=>{
console.log("挂载方法1")
})
return {};
},
}).mount('#demo')
那么问题又来了?为什么要多此一举呢?
直接按照上面的改写在函数中通过 injectHook('m', hook, target)
进行注册不是更简单吗? 这其实是用到了函数柯里化的特性。函数柯里化不是我们本篇文章的重点,你只需要知道它的优点有:参数复用、 延迟执行、代码重用等等。
这里使用了函数柯里化,我们只需要在执行createHook
时,传入第一个有差异的参数lifecycle
即可。
3. injectHook
注册的核心逻辑藏在了 injectHook
方法里面,如果是注册mounted方法,那么此时type
参数的值为'm'
,hook
参数就是我们在组件中写下的逻辑:()=>{ console.log("挂载方法1")}
function injectHook(type, hook, target = currentInstance, prepend = false) {
const hooks = target[type] || (target[type] = [])
// 封装 hook 钩子函数并缓存
const wrappedHook = hook.__weh ||
(hook.__weh = (...args) => {
if (target.isUnmounted) {
return
}
// 停止依赖收集
pauseTracking()
// 设置 target 为当前运行的组件实例
setCurrentInstance(target)
// 执行钩子函数
const res = callWithAsyncErrorHandling(hook, target, type, args)
setCurrentInstance(null)
// 恢复依赖收集
resetTracking()
return res
})
if (prepend) {
hooks.unshift(wrappedHook)
}
else {
hooks.push(wrappedHook)
}
}
-
首先是传入的
hook
函数进行一层封装,赋值给到wrappedHook
变量 -
然后将
wrappedHook
push进hooks数组
里面,这个hooks数组
会存储到当前实例对象的target对象
中。key
是用来区分钩子函数的字符串。比如,onMounted
注册的钩子函数在组件实例上就是通过instance.m
来保存。 -
在
wrappedHook函数
执行的过程中,会先停止依赖收集,因为钩子函数内部访问的响应式对象,通常都已经执行过依赖收集,所以钩子函数执行的时候没有必要再次收集依赖,毕竟这个过程也有一定的性能消耗。 -
接着是设置
target
为当前组件实例。在Vue.js
的内部,会一直维护当前运行的组件实例currentInstance
,在注册钩子函数的过程中,我们可以拿到当前运行组件实例currentInstance
,并用target
保存,然后在钩子函数执行时,为了确保此时的currentInstance
和注册钩子函数时一致,会通过setCurrentInstance(target)
设置target
为当前组件实例。 -
接下来就是通过
callWithAsyncErrorHandling
方法去执行我们注册的hook
钩子函数,函数执行完毕则设置当前运行组件实例为null
,并恢复依赖收集。
问题来了,为什么hooks是一个数组呢? 这是因为vue.js
支持你多次注册生命周期函数,比如下面的例子:
const { onMounted} = Vue
Vue.createApp({
setup() {
onMounted(()=>{
console.log("挂载方法1")
})
onMounted(()=>{
console.log("挂载方法2")
})
return {
}
},
}).mount('#demo')
// 挂载方法1
// 挂载方法2
控制台会打印两次,分别会打印 挂载方法1
和 挂载方法2
Vue.js
会在合适的时机会将hooks
取出来,进行循环调用执行。
那么各个生命周期函数会在什么时候执行呢?我们继续往下探讨。
三、执行时机
1. onBeforeMount 和 onMounted
onBeforeMount 注册的 beforeMount 钩子函数会在组件挂载之前执行,onMounted 注册的 mounted 钩子函数会在组件挂载之后执行。
如果你对组件挂载时的流程不清楚,可以去这篇文章阅读: 不止响应式:Vue3探秘系列— 虚拟结点vnode的页面挂载之旅(一)
我们来回顾一下组件副作用渲染函数关于组件挂载部分的实现:
const setupRenderEffect = (instance, initialVNode, container, anchor, parentSuspense, isSVG, optimized) => {
// 创建响应式的副作用渲染函数
instance.update = effect(function componentEffect() {
if (!instance.isMounted) {
// 获取组件实例上通过 onBeforeMount 钩子函数和 onMounted 注册的钩子函数
const { bm, m } = instance;
// 渲染组件生成子树 vnode
const subTree = (instance.subTree = renderComponentRoot(instance))
// 执行 beforemount 钩子函数
if (bm) {
invokeArrayFns(bm)
}
// 把子树 vnode 挂载到 container 中
patch(null, subTree, container, anchor, instance, parentSuspense, isSVG)
// 保留渲染生成的子树根 DOM 节点
initialVNode.el = subTree.el
// 执行 mounted 钩子函数
if (m) {
queuePostRenderEffect(m, parentSuspense)
}
instance.isMounted = true
}
else {
// 更新组件
}
}, prodEffectOptions)
}
export const invokeArrayFns = (fns: Function[], arg?: any) => {
for (let i = 0; i < fns.length; i++) {
fns[i](arg)
}
}
-
在执行
patch
挂载组件之前,会检测组件实例上是有否有注册的beforeMount
钩子函数bm
,如果有则通过invokeArrayFns
执行它,我们刚刚提到过,Vue.js
是支持你多次注册某个生命周期函数,所以这里instance.bm
是一个数组,通过遍历这个数组来依次执行beforeMount
钩子函数。 -
在执行
patch
挂载组件之后,会检查组件实例上是否有注册的mounted
钩子函数m
,如果有的话则执行queuePostRenderEffect
,把mounted
钩子函数推入postFlushCbs 中
,然后在整个应用render
完毕后,同步执行flushPostFlushCbs
函数调用mounted
钩子函数
注意: 在这里我们不再花时间介绍
queuePostRenderEffect
函数,你可以在上一篇中侦听属性:Vue3探秘系列— watch的实现原理(七) 了解它的具体实现
2. onBeforeUpdate 和 onUpdated
onBeforeUpdate 注册的 beforeUpdate 钩子函数会在组件更新之前执行
onUpdated 注册的 updated 钩子函数会在组件更新之后执行
同样的,对于组件更新时的流程不清楚,可以去这篇文章阅读: # 不止响应式:Vue3探秘系列— 组件更新会发生什么(二)
我们来回顾一下组件副作用渲染函数关于组件更新的实现:
const setupRenderEffect = (instance, initialVNode, container, anchor, parentSuspense, isSVG, optimized) => {
// 创建响应式的副作用渲染函数
instance.update = effect(function componentEffect() {
if (!instance.isMounted) {
// 渲染组件
}
else {
// 更新组件
// 获取组件实例上通过 onBeforeUpdate 钩子函数和 onUpdated 注册的钩子函数
let { next, vnode, bu, u } = instance
// next 表示新的组件 vnode
if (next) {
// 更新组件 vnode 节点信息
updateComponentPreRender(instance, next, optimized)
}
else {
next = vnode
}
// 渲染新的子树 vnode
const nextTree = renderComponentRoot(instance)
// 缓存旧的子树 vnode
const prevTree = instance.subTree
// 更新子树 vnode
instance.subTree = nextTree
// 执行 beforeUpdate 钩子函数
if (bu) {
invokeArrayFns(bu)
}
// 组件更新核心逻辑,根据新旧子树 vnode 做 patch
patch(prevTree, nextTree,
// 如果在 teleport 组件中父节点可能已经改变,所以容器直接找旧树 DOM 元素的父节点
hostParentNode(prevTree.el),
// 缓存更新后的 DOM 节点
getNextHostNode(prevTree),
instance,
parentSuspense,
isSVG)
// 缓存更新后的 DOM 节点
next.el = nextTree.el
// 执行 updated 钩子函数
if (u) {
queuePostRenderEffect(u, parentSuspense)
}
}
}, prodEffectOptions)
}
-
在执行
patch
更新组件之前,会检测组件实例上是有否有注册的beforeUpdate
钩子函数bu
,如果有则通过invokeArrayFns
执行它。 -
在执行
patch
更新组件之后,会检查组件实例上是否有注册的updated
钩子函数u
,如果有,则通过queuePostRenderEffect
把updated
钩子函数推入postFlushCbs
中,因为组件的更新本身就是在nextTick
后进行flushJobs
,因此此时再次执行queuePostRenderEffect
推入到队列的任务,会在同一个Tick
内执行这些postFlushCbs
,也就是执行所有updated
的钩子函数。 -
在 updated 钩子函数执行时,组件 DOM 已经更新,所以你现在可以执行依赖于 DOM 的操作。如果要监听数据的改变并执行某些逻辑,最好不要使用 updated 钩子函数而用计算属性或 watcher 取而代之,因为任何数据的变化导致的组件更新都会执行 updated 钩子函数。
另外注意!!! 不要在 updated 钩子函数中更改数据,因为这样会再次触发组件更新,导致无限递归更新 。
3. onBeforeUnmount 和 onUnmounted
onBeforeUnmount 注册的 beforeUnMount 钩子函数会在组件销毁之前执行
onUnmounted 注册的 unmounted 钩子函数会在组件销毁之后执行 。
我们来看一下组件销毁相关逻辑实现:
const unmountComponent = (instance, parentSuspense, doRemove) => {
const { bum, effects, update, subTree, um } = instance
// 执行 beforeUnmount 钩子函数
if (bum) {
invokeArrayFns(bum)
}
// 清理组件引用的 effects 副作用函数
if (effects) {
for (let i = 0; i < effects.length; i++) {
stop(effects[i])
}
}
// 如果一个异步组件在加载前就销毁了,则不会注册副作用渲染函数
if (update) {
stop(update)
// 调用 unmount 销毁子树
unmount(subTree, instance, parentSuspense, doRemove)
}
// 执行 unmounted 钩子函数
if (um) {
queuePostRenderEffect(um, parentSuspense)
}
}
-
在组件销毁前,会检测组件实例上是有否有注册的
beforeUnmount
钩子函数bum
,如果有则通过invokeArrayFns
执行。 -
循环遍历
effects
数组,清除组件实例身上绑定副作用函数 -
如果有
update函数
,也一起清理。 -
unmount
函数里面主要就是遍历子树,它会通过递归的方式来销毁子节点,遇到组件节点时执行unmountComponent
,遇到普通节点时则删除 DOM 元素。组件的销毁过程和渲染过程类似,都是递归的过程。 -
在组件销毁后,会检测组件实例上是否有注册的
unmounted
钩子函数um
,如果有则通过queuePostRenderEffect
把unmounted
钩子函数推入到postFlushCbs
中,因为组件的销毁就是组件更新的一个分支逻辑,所以在nextTick
后进行flushJobs
,因此此时再次执行queuePostRenderEffect
推入队列的任务,会在同一个Tick
内执行这些postFlushCbs
,也就是执行所有的unmounted
钩子函数。
对于嵌套组件,组件在执行销毁相关的生命周期钩子函数时,先执行父组件的
beforeUnmount
,再执行子组件的beforeUnmount
,然后执行子组件的unmounted
,最后执行父组件的unmounted
。
4. onErrorCaptured
注意: 对于 errorCaptured 这个生命周期,大部分开发者都会觉得陌生。
如果你感兴趣,可以在我的这篇文章中浏览了解 :
errorCaptured 本质上是捕获一个来自子孙组件的错误,它返回 true 就可以阻止错误继续向上传播
在Vue.js
源码中,很多地方在需要执行函数调用的时候都会出现 callWithErrorHandling
这个方法的身影,它本身是执行一段函数,如果catch
到错误就会通过 handleError
处理错误。那么,handleError
具体做了哪些事情呢?
function handleError(err, instance, type) {
const contextVNode = instance ? instance.vnode : null
if (instance) {
let cur = instance.parent
// 为了兼容 2.x 版本,暴露组件实例给钩子函数
const exposedInstance = instance.proxy
// 获取错误信息
const errorInfo = (process.env.NODE_ENV !== 'production') ? ErrorTypeStrings[type] : type
// 尝试向上查找所有父组件,执行 errorCaptured 钩子函数
while (cur) {
const errorCapturedHooks = cur.ec
if (errorCapturedHooks) {
for (let i = 0; i < errorCapturedHooks.length; i++) {
// 如果执行的 errorCaptured 钩子函数并返回 true,则停止向上查找。、
if (errorCapturedHooks[i](err, exposedInstance, errorInfo)) {
return
}
}
}
cur = cur.parent
}
}
// 往控制台输出未处理的错误
logError(err, type, contextVNode)
}
-
从当前报错的组件的父组件实例开始,尝试去查找注册的
errorCaptured
钩子函数,如果有则遍历执行并且判断errorCaptured
钩子函数的返回值是否为true
,如果是则说明这个错误已经得到了正确的处理,就会直接结束。 -
否则会继续遍历,遍历完当前组件实例的
errorCaptured
钩子函数后,如果这个错误还没得到正确处理,则向上查找它的父组件实例,以同样的逻辑去查找是否有正确处理该错误的errorCaptured
钩子函数,直到查找完毕。 -
如果整个链路上都没有正确处理错误的
errorCaptured
钩子函数,则通过logError
往控制台输出未处理的错误。
举个例子,现在有个嵌套组件场景:
<A>
<B>
<C>
<D>
</D>
</C>
</B>
</A>
D的父组件是C组件,C组件的父组件是B组件,B组件的父组件是A组件。
当只有A中里定义了一个errorCaptured函数
,而你又在 D 组件发生错误被catch到的时候就会开始往上查,发现C组件没有errorCaptured
函数,就继续往上查到B,B也是一样的判断,然后再到A组件,A定义了errorCaptured
方法,就会执行。
errorCaptured
在平时工作中可能用的不多,但它的确是一个很实用的功能,比如你可以在根组件注册一个 errorCaptured
钩子函数,去捕获所有子孙组件的错误,并且可以根据错误的类型和信息统计和上报错误。
5. onRenderTracked 和 onRenderTriggered
onRenderTracked
和 onRenderTriggered
是 Vue.js 3.0
新增的生命周期 API,它们是在开发阶段渲染调试用的。
(1) 使用例子
<template>
<div>
<div>
<p>{{count}}</p>
<button @click="increase">Increase</button>
</div>
</div>
</template>
<script>
import { ref, onRenderTracked, onRenderTriggered } from 'vue'
export default {
setup () {
const count = ref(0)
function increase () {
count.value++
}
onRenderTracked((e) => {
console.log(e)
debugger
})
onRenderTriggered((e) => {
console.log(e)
debugger
})
return {
count,
increase
}
}
}
</script>
在开发阶段,我们可以通过注册这两个钩子函数,来追踪组件渲染的依赖来源以及触发组件重新渲染的数据更新来源
(2) 实现原理
-
在
core\packages\runtime-core\src\renderer.ts
中创建setupRenderEffect
函数的时候时调用了内置的effect
函数,onRenderTracked
和onRenderTriggered
是作为effect函数
的第二个参数传入 -
onRenderTracked
和onRenderTriggered
注册的钩子函数,是在副作用渲染函数的onTrack
和onTrigger
对应的函数中执行的
instance.update = effect(function componentEffect() {
// 创建或者更组件
}, createDevEffectOptions(instance))
function createDevEffectOptions(instance) {
return {
scheduler: queueJob,
onTrack: instance.rtc ? e => invokeArrayFns(instance.rtc, e) : void 0,
onTrigger: instance.rtg ? e => invokeArrayFns(instance.rtg, e) : void 0
}
}
我们回忆一下在响应式章节中终于轮到你了:Vue3探秘系列— 响应式设计(五)学习到的 track函数
和 trigger函数
function track(target, type, key) {
// 执行一些依赖收集的操作
if (!dep.has(activeEffect)) {
dep.add(activeEffect)
activeEffect.deps.push(dep)
if ((process.env.NODE_ENV !== ‘production’) && activeEffect.options.onTrack) {
// 执行 onTrack 函数
activeEffect.options.onTrack({
effect: activeEffect,
target,
type,
key
})
}
}
}
可以看到,track
函数先执行依赖收集,然后在非生产环境下检测当前的 activeEffect
的配置有没有定义 onTrack
函数,如果有的则执行该方法。
因此对应到副作用渲染函数,当它执行的时候,activeEffec
t 就是这个副作用渲染函数,这时访问响应式数据就会触发 track
函数,在执行完依赖收集后,会执行 onTrack
函数,也就是遍历执行我们注册的renderTracked
钩子函数。
function trigger (target, type, key, newValue) {
// 添加要运行的 effects 集合
const run = (effect) => {
if ((process.env.NODE_ENV !== 'production') && effect.options.onTrigger) {
// 执行 onTrigger
effect.options.onTrigger({
effect,
target,
key,
type,
newValue,
oldValue,
oldTarget
})
}
if (effect.options.scheduler) {
effect.options.scheduler(effect)
}
else {
effect()
}
}
// 遍历执行 effects
effects.forEach(run)
}
我们知道,trigger
函数首先要创建运行的 effects
集合,然后遍历执行,在执行的过程中,会在非生产环境下检测待执行的 effect
配置中有没有定义 onTrigger
函数,如果有则执行该方法。
因此对应到我们的副作用渲染函数,当它内部依赖的响应式对象值被修改后,就会触发 trigger
函数 ,这个时候副作用渲染函数就会被添加到要运行的 effects
集合中,在遍历执行 effects
的时候会执行 onTrigger
函数,也就是遍历执行我们注册的 renderTriggered
钩子函数。
总结
好了,本篇的生命周期钩子函数探究就结束了,最后再用一张流程图巩固一下: