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

639 阅读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 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。
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: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) 
  } 
}
  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,如果有,则通过 queuePostRenderEffect 把updated钩子函数推入 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,如果有则通过 queuePostRenderEffect 把 unmounted 钩子函数推入到 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

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

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

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

因此对应到副作用渲染函数,当它执行的时候,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