生命周期:各个生命周期的执行时机和应用场景是怎样的?

Vue.js 组件的生命周期包括创建、更新、销毁等过程。在这些过程中也会运行叫生命周期钩子的函数,这给了用户在不同阶段添加自己的代码的机会。

在 Vue.js 2.x 中,我们通常会在组件对象中定义一些生命周期钩子函数,到了 Vue.js 3.0,依然兼容 Vue.js 2.x 生命周期的语法,但是 Composition API 提供了一些生命周期函数的 API,让我们可以主动注册不同的生命周期。

// 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.js 3.0 中,setup 函数已经替代了 Vue.js 2.x 的 beforeCreate 和 created 钩子函数,我们可以在 setup 函数做一些初始化工作,比如发送一个异步 Ajax 请求获取数据。

我们用 onMounted API 替代了 Vue.js 2.x 的 mounted 钩子函数,用 onBeforeUnmount API 替代了 Vue.js 2.x 的 beforeDestroy 钩子函数。

其实,Vue.js 3.0 针对 Vue.js 2.x 的生命周期钩子函数做了全面替换,映射关系如下:

beforeCreate -> 使用 setup()
created -> 使用 use setup()
beforeMount -> onBeforeMount
mounted -> onMounted
beforeUpdate -> onBeforeUpdate
updated -> onUpdated
beforeDestroy-> onBeforeUnmount
destroyed -> onUnmounted
activated -> onActivated
deactivated -> onDeactivated
errorCaptured -> onErrorCaptured

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

那么,这些生命周期钩子函数内部是如何实现的?它们又分别在组件生命周期的哪些阶段执行的?分别适用于哪些开发场景?

带着这些疑问,我们来深入学习生命周期钩子函数背后的实现原理。

注册钩子函数

首先,我们来看这些钩子函数是如何注册的,先来看一下它们的实现:

const onBeforeMount = createHook('bm' /* BEFORE_MOUNT */)
const onMounted = createHook('m' /* MOUNTED */)
const onBeforeUpdate = createHook('bu' /* BEFORE_UPDATE */)
const onUpdated = createHook('u' /* UPDATED */)
const onBeforeUnmount = createHook('bum' /* BEFORE_UNMOUNT */)
const onUnmounted = createHook('um' /* UNMOUNTED */)
const onRenderTriggered = createHook('rtg' /* RENDER_TRIGGERED */)
const onRenderTracked = createHook('rtc' /* RENDER_TRACKED */)
const onErrorCaptured = (hook, target = currentInstance) => {
  injectHook('ec' /* ERROR_CAPTURED */, hook, target)
}

我们发现除了 onErrorCaptured,其他钩子函数都是通过 createHook 函数创建的,通过传入不同的字符串来表示不同的钩子函数。

那么,我们就来分析一下 createHook 钩子函数的实现原理:

const createHook = function(lifecycle)  {
  return function (hook, target = currentInstance) {
    injectHook(lifecycle, hook, target)
  }
}

createHook 会返回一个函数,它的内部通过 injectHook 注册钩子函数。你可能会问,这里为什么要用 createHook 做一层封装而不直接使用 injectHook API 呢?比如:

const onBeforeMount = function(hook,target = currentInstance) {
  injectHook('bm', hook, target)
}
const onMounted = function(hook,target = currentInstance) {
  injectHook('m', hook, target)
}

这样实现当然也是可以的,不过,我们可以发现,这些钩子函数内部执行逻辑很类似,都是执行 injectHook,唯一的区别是第一个参数字符串不同,所以这样的代码是可以进一步封装的,即用 createHook 封装,这就是一个典型的函数柯里化技巧。

在调用 createHook 返回的函数时,也就不需要传入 lifecycle 字符串,因为它在执行 createHook 函数时就已经实现了该参数的保留。

所以,当我们通过 onMounted(hook) 注册一个钩子函数时,内部就是通过 injectHook('m', hook) 去注册的,接下来我们来进一步看 injectHook 函数的实现原理:

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 做了一层封装,然后添加到一个数组中,把数组保存在当前组件实例的 target 上,这里,key 是用来区分钩子函数的字符串。比如, onMounted 注册的钩子函数在组件实例上就是通过 instance.m 来保存。

这样的设计其实非常好理解,因为生命周期的钩子函数,是在组件生命周期的各个阶段执行,所以钩子函数必须要保存在当前的组件实例上,这样后面就可以在组件实例上通过不同的字符串 key 找到对应的钩子函数数组并执行。

对于相同的钩子函数,会把封装的 wrappedHook 钩子函数缓存到 hook.__weh 中,这样后续通过 scheduler 方式执行的钩子函数就会被去重。

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

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

接下来就是通过 callWithAsyncErrorHandling 方法去执行我们注册的 hook 钩子函数,函数执行完毕则设置当前运行组件实例为 null,并恢复依赖收集。

到这里,我们就了解了生命周期钩子函数是如何注册以及如何执行的,接下来,我们来依次分析各个钩子函数的执行时机和应用场景。

首先,我们来看通过 onBeforeMount 和 onMounted 注册的钩子函数。

onBeforeMount 和 onMounted

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)
  }
}

其实整个组件销毁的逻辑很简单,主要就是清理组件实例上绑定的 effects 副作用函数和注册的副作用渲染函数 update,以及调用 unmount 销毁子树。

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

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

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

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

虽然组件在销毁阶段会清理一些定义的 effects 函数,删除组件内部的 DOM 元素,但是有一些需要清理的对象,组件并不能自动完成它们的清理,比如你在组件内部创建一个定时器,就应该在 beforeUnmount 或者 unmounted 钩子函数中清除,举个例子:

<template>
  <div>
    <div>
      <p>{{count}}</p>
    </div>
  </div>
</template>
<script>
  import { ref, onBeforeUnmount } from 'vue'
  export default {
    setup () {
      const count = ref(0)
      const timer = setInterval(() => {
        console.log(count.value++)
      }, 1000)
      onBeforeUnmount(() => {
        clearInterval(timer)
      })
      return {
        count
      }
    }
  }
</script>

可以看到,这里我们在 setup 函数内部定义了一个 timer 计时器, count 每秒会加 1 并在控制台中输出。如果这个组件被销毁,就会触发 onBeforeUnmount 注册的 beforeUnmount 钩子函数,然后清除定时器。如果你不清除,就会发现组件销毁后,虽然 DOM 被移除了,计时器仍然存在,并且会一直计时并在控制台输出,这就造成了不必要的内存泄漏。

接下来,我们来看通过 onErrorCaptured 注册的钩子函数。

onErrorCaptured

在前面的课时中,我们多次接触过一个方法 callWithErrorHandling,它就是执行一段函数并通过 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)
}

handleError 的实现其实很简单,它会从当前报错的组件的父组件实例开始,尝试去查找注册的 errorCaptured 钩子函数,如果有则遍历执行并且判断 errorCaptured 钩子函数的返回值是否为 true,如果是则说明这个错误已经得到了正确的处理,就会直接结束。

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

如果整个链路上都没有正确处理错误的 errorCaptured 钩子函数,则通过 logError 往控制台输出未处理的错误。所以 errorCaptured 本质上是捕获一个来自子孙组件的错误,它返回 true 就可以阻止错误继续向上传播

errorCaptured 在平时工作中可能用的不多,但它的确是一个很实用的功能,比如你可以在根组件注册一个 errorCaptured 钩子函数,去捕获所有子孙组件的错误,并且可以根据错误的类型和信息统计和上报错误。

接下来,我们来看通过 onRenderTracked 和 onRenderTriggered 注册的钩子函数。

onRenderTracked 和 onRenderTriggered

onRenderTracked 和 onRenderTriggered 是 Vue.js 3.0 新增的生命周期 API,它们是在开发阶段渲染调试用的。这里再次回顾一下我们创建的副作用渲染函数的第二个参数(这里你可以去 06 课时“ 响应式:响应式内部的实现原理是怎样的? ”中复习一下),在开发环境下它的代码是这样的:

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
  }
}

通过上述代码我们发现,onRenderTracked 和 onRenderTriggered 注册的钩子函数,原来是在副作用渲染函数的 onTrack 和 onTrigger 对应的函数中执行的。

我们当时介绍 effect 副作用函数的配置时并没有介绍这两个属性,那么它们是做什么用的呢?

这就要先来看 onTrack 函数的执行时机。我们知道当访问一个响应式对象时,会执行 track 函数做依赖收集,我们来回顾一下它的实现:

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 钩子函数

接下来,我们再来回顾一下 trigger 函数的实现:

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 钩子函数

了解完 renderTracked 和 renderTriggered 钩子函数的执行时机后,我们来看一下实际场景的应用:

<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>

像这样在开发阶段,我们可以通过注册这两个钩子函数,来追踪组件渲染的依赖来源以及触发组件重新渲染的数据更新来源。

总结

好的,到这里我们这一节的学习就结束啦,通过学习,你应该掌握 Vue.js 中生命周期注册的 API,了解各个生命周期的执行时机和应用场景。

最后,我们通过一张图再来直观地感受一下组件的各个生命周期:

image 2024 11 13 12 28 35 061

Vue.js 3.0 还有 2 个生命周期 API,分别是 onActivated 和 onDeactivated,我们将会在介绍 KeepAlive 组件时详细分析。

最后,给你留一道思考题目,如果你想在路由组件切换的时候,取消组件正在发送的异步 Ajax 请求,那你应该在哪个生命周期写这个逻辑呢?欢迎你在留言区与我分享。