生命周期:Vue3探秘系列— 钩子函数的执行过程(八)

169 阅读16分钟

前言

Vue3探秘系列文章链接:

不止响应式:Vue3探秘系列— 虚拟结点vnode的页面挂载之旅(一)

不止响应式:Vue3探秘系列— 组件更新会发生什么(二)

不止响应式:Vue3探秘系列— diff算法的完整过程(三)

不止响应式:Vue3探秘系列— 组件的初始化过程(四)

终于轮到你了:Vue3探秘系列— 响应式设计(五)

计算属性:Vue3探秘系列— computed的实现原理(六)

侦听属性:Vue3探秘系列— watch的实现原理(七)

生命周期:Vue3探秘系列— 钩子函数的执行过程(八)

依赖注入:Vue3探秘系列— provide 与 inject 的实现原理(九)

Vue3探秘系列— Props:初始化与更新流程(十)

Vue3探秘系列— directive:指令的实现原理(十一)

Hello~大家好。我是秋天的一阵风

在 Vue 3 中,生命周期钩子函数被重新设计以适应 Composition APIComposition 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
createdsetup()在 Vue 3 中,created 的功能被 setup() 钩子覆盖。在 setup() 中,你可以访问到 props 和 context,但不能访问到 this
beforeMountonBeforeMount在组件挂载到 DOM 之前调用。
mountedonMounted在组件挂载完成后调用。
beforeUpdateonBeforeUpdate在组件即将更新之前调用。
updatedonUpdated在组件更新后调用。
beforeDestroyonBeforeUnmount在组件即将卸载之前调用。
destroyedonUnmounted在组件卸载后调用。
errorCapturedonErrorCaptured当捕获一个来自子孙组件的错误时被调用。

注意: 对于 errorCaptured 这个生命周期,大部分开发者都会觉得陌生。

如果你感兴趣,可以在我的这篇文章中浏览了解 :

据说只有1%的Vue开发者才知道的 “errorCaptured” 生命周期,你out了吗?

除此之外,Vue.js 3.0 还新增了两个用于调试的生命周期 API:onRenderTrackedonRenderTriggered

更多的生命周期API信息你可以在这里阅读:组合式API:生命周期钩子

问题来了,这些钩子函数在组件生命周期的哪些阶段执行的?它们生命周期钩子函数内部又是如何实现的?

接下来,我们就开始进入源码探究。

二、实现原理

1. 注册钩子函数

我们在 /core/packages/runtime-core/src/apiLifecycle.tsGithub)文件中可以找到生命周期函数钩子的注册逻辑:

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) 
  } 
}
  1. 首先是传入的hook函数进行一层封装,赋值给到wrappedHook变量

  2. 然后将wrappedHook push进 hooks数组里面,这个hooks数组会存储到当前实例对象的target对象中。key 是用来区分钩子函数的字符串。比如, onMounted 注册的钩子函数在组件实例上就是通过 instance.m 来保存。

  3. wrappedHook函数执行的过程中,会先停止依赖收集,因为钩子函数内部访问的响应式对象,通常都已经执行过依赖收集,所以钩子函数执行的时候没有必要再次收集依赖,毕竟这个过程也有一定的性能消耗。

  4. 接着是设置 target 为当前组件实例。在Vue.js的内部,会一直维护当前运行的组件实例 currentInstance,在注册钩子函数的过程中,我们可以拿到当前运行组件实例 currentInstance,并用 target 保存,然后在钩子函数执行时,为了确保此时的 currentInstance 和注册钩子函数时一致,会通过 setCurrentInstance(target) 设置target为当前组件实例。

  5. 接下来就是通过 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)
  }
}
  1. 在执行 patch 挂载组件之前,会检测组件实例上是有否有注册的 beforeMount 钩子函数 bm,如果有则通过 invokeArrayFns 执行它,我们刚刚提到过,Vue.js是支持你多次注册某个生命周期函数,所以这里 instance.bm 是一个数组,通过遍历这个数组来依次执行 beforeMount 钩子函数。

  2. 在执行 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) 
}


  1. 在执行 patch 更新组件之前,会检测组件实例上是有否有注册的 beforeUpdate 钩子函数 bu,如果有则通过 invokeArrayFns 执行它。

  2. 在执行 patch 更新组件之后,会检查组件实例上是否有注册的 updated 钩子函数 u,如果有,则通过 queuePostRenderEffectupdated钩子函数推入 postFlushCbs 中,因为组件的更新本身就是在 nextTick 后进行 flushJobs,因此此时再次执行 queuePostRenderEffect 推入到队列的任务,会在同一个 Tick 内执行这些 postFlushCbs,也就是执行所有 updated 的钩子函数。

  3. 在 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) 
  } 
}

  1. 在组件销毁前,会检测组件实例上是有否有注册的 beforeUnmount 钩子函数 bum,如果有则通过 invokeArrayFns 执行。

  2. 循环遍历 effects数组,清除组件实例身上绑定副作用函数

  3. 如果有 update函数,也一起清理。

  4. unmount 函数里面主要就是遍历子树,它会通过递归的方式来销毁子节点,遇到组件节点时执行 unmountComponent,遇到普通节点时则删除 DOM 元素。组件的销毁过程和渲染过程类似,都是递归的过程。

  5. 在组件销毁后,会检测组件实例上是否有注册的 unmounted钩子函数 um,如果有则通过 queuePostRenderEffectunmounted 钩子函数推入到 postFlushCbs 中,因为组件的销毁就是组件更新的一个分支逻辑,所以在 nextTick 后进行 flushJobs,因此此时再次执行 queuePostRenderEffect 推入队列的任务,会在同一个 Tick 内执行这些 postFlushCbs,也就是执行所有的 unmounted 钩子函数。

对于嵌套组件,组件在执行销毁相关的生命周期钩子函数时,先执行父组件的 beforeUnmount,再执行子组件的 beforeUnmount,然后执行子组件的 unmounted ,最后执行父组件的 unmounted

4. onErrorCaptured

注意: 对于 errorCaptured 这个生命周期,大部分开发者都会觉得陌生。

如果你感兴趣,可以在我的这篇文章中浏览了解 :

据说只有1%的Vue开发者才知道的 “errorCaptured” 生命周期,你out了吗?

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) 
}
  1. 从当前报错的组件的父组件实例开始,尝试去查找注册的 errorCaptured 钩子函数,如果有则遍历执行并且判断 errorCaptured 钩子函数的返回值是否为 true,如果是则说明这个错误已经得到了正确的处理,就会直接结束。

  2. 否则会继续遍历,遍历完当前组件实例的 errorCaptured 钩子函数后,如果这个错误还没得到正确处理,则向上查找它的父组件实例,以同样的逻辑去查找是否有正确处理该错误的 errorCaptured 钩子函数,直到查找完毕。

  3. 如果整个链路上都没有正确处理错误的 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

onRenderTrackedonRenderTriggered 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) 实现原理

  1. core\packages\runtime-core\src\renderer.ts中创建setupRenderEffect函数的时候时调用了内置的effect函数,onRenderTrackedonRenderTriggered 是作为effect函数的第二个参数传入

  2. onRenderTrackedonRenderTriggered 注册的钩子函数,是在副作用渲染函数的 onTrackonTrigger 对应的函数中执行的

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 函数,如果有的则执行该方法。

因此对应到副作用渲染函数,当它执行的时候,activeEffect 就是这个副作用渲染函数,这时访问响应式数据就会触发 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 钩子函数。

总结

好了,本篇的生命周期钩子函数探究就结束了,最后再用一张流程图巩固一下:

image.png