Vue源码学习2.4:生命周期

534 阅读5分钟

建议PC端观看,移动端代码高亮错乱

关于生命周期的概念就不罗嗦了,直奔主题

1. callHook

源码中最终执行生命周期的函数都是调用 callHook 方法,它的定义在 src/core/instance/lifecycle 中:

// src/core/instance/lifecycle

export function callHook (vm: Component, hook: string{
  // #7573 disable dep collection when invoking lifecycle hooks
  pushTarget() // 为了避免在某些生命周期钩子中使用 props 数据导致收集冗余的依赖
  const handlers = vm.$options[hook]
  const info = `${hook} hook`
  if (handlers) {
    for (let i = 0, j = handlers.length; i < j; i++) {
      // 1. 核心调用逻辑
      invokeWithErrorHandling(handlers[i], vm, null, vm, info)
    }
  }
  
  // 2. 判断是否存在生命周期钩子的事件侦听器
  if (vm._hasHookEvent) {
    vm.$emit('hook:' + hook)
  }
  popTarget() // 为了避免在某些生命周期钩子中使用 props 数据导致收集冗余的依赖
}
  • 选项合并时会把生命周期钩子选项合并成一个数组,这在上一节介绍过了
  • 遍历对应 hook 的数组,执行 invokeWithErrorHandling
  • 判断是否 vm._hasHookEvent 触发相应的事件侦听器

1.1 invokeWithErrorHandling

这个函数定义在:src/core/util/error.js

// src/core/util/error.js

export function invokeWithErrorHandling (
  handler: Function,
  context: any,
  args: null | any[],
  vm: any,
  info: string
{
  let res
  try {
    // 调用handler
    res = args ? handler.apply(context, args) : handler.call(context)
    if (res && !res._isVue && isPromise(res) && !res._handled) {
      res.catch(e => handleError(e, vm, info + ` (Promise/async)`))
      // 对不同的钩子返回相同的promise时只绑定一次catch函数
      res._handled = true
    }
  } catch (e) {
    handleError(e, vm, info)
  }
  return res
}
  • 调用 handler,同时绑定 this,这样我们在钩子回调就能通过 this 访问到 vm 实例了
  • 钩子如果返回一个 promise,那么给这个 promise 绑定一个 catch 函数
  • 同时 _handled 保证了只绑定一次 catch

1.2 _hasHookEvent

vm._hasHookEvent 是在 initEvents 函数中定义的,它的作用是判断是否存在生命周期钩子的事件侦听器,初始化值为 false 代表没有,当组件检测到存在生命周期钩子的事件侦听器时,会将 vm._hasHookEvent 设置为 true 介绍下生命周期钩子事件帧听器:

<child
  @hook:beforeCreate="handleChildBeforeCreate"
  @hook:created="handleChildCreated"
  @hook:mounted="handleChildMounted"
  @hook:生命周期钩子
 />

到这里就把 callHook 的逻辑给介绍完了,下面来看看 callHook 都在什么时候调用

2. beforeCreate & created

这两个钩子是在 _init 方法中执行的

// src/core/instance/init.js

Vue.prototype._init = function (options?: Object{
  // ...
  initLifecycle(vm)
  initEvents(vm)
  initRender(vm)
  callHook(vm, 'beforeCreate')
  initInjections(vm) // resolve injections before data/props
  initState(vm)
  initProvide(vm) // resolve provide after data/props
  callHook(vm, 'created')
  // ...
}
  • beforeCreatecreated 的钩子调用是在 initState 的前后执行的
  • initState 的作用是初始化 propsdatamethodswatchcomputed 等属性。
  • 所以beforeCreate 的钩子函数中就不能获取到 propsdata 中定义的值,也不能调用 methods 中定义的函数。
  • 在这俩个钩子函数执行的时候,并没有渲染 DOM,所以我们也不能够访问 DOM
  • 之后我们会介绍 vue-routervuex 的时候会发现它们都混合了 beforeCreate 钩子函数。

3. beforeMount & mounted

beforeMount 钩子函数发生在 mount,也就是 DOM 挂载之前,它的调用时机是在 mountComponent 函数中,定义在 src/core/instance/lifecycle.js 中:

// src/core/instance/lifecycle.js

export function mountComponent (
  vm: Component,
  el: ?Element,
  hydrating?: boolean
): Component 
{
  vm.$el = el
  // ...
  callHook(vm, 'beforeMount')

  let updateComponent
  if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
    // ...
  } else {
    updateComponent = () => {
      vm._update(vm._render(), hydrating)
    }
  }

  new Watcher(vm, updateComponent, noop, {
    before () {
      if (vm._isMounted) {
        callHook(vm, 'beforeUpdate')
      }
    }
  }, true /* isRenderWatcher */)
  hydrating = false

  // 手动调用根实例的 mounted 钩子
  // 子组件的 mounted 钩子在 占位符vnode的insert 钩子中调用
  if (vm.$vnode == null) {
    vm._isMounted = true
    callHook(vm, 'mounted')
  }
  return vm
}
  • beforeMount 调用时机:在执行 vm._render() 函数渲染 VNode 之前
  • mounted 调用时机:在执行完 vm._update()VNode patch 到真实 DOM 后。

注意,这里对 mounted 钩子函数执行有一个判断逻辑,vm.$vnode 如果为 null,则表明这不是一次组件的初始化过程,而是我们通过外部 new Vue 初始化过程。那么对于组件,它的 mounted 时机在哪儿呢?

3.1 组件的mounted分析

稍后会结合例子和流程图分析这个过程,现在我们先来看看一些关键函数的定义:

先看看 patch 函数的一些关键逻辑:

// src/core/vdom/patch.js

export function createPatchFunction (backend{
  return function patch (oldVnode, vnode, hydrating, removeOnly{
    // ...

    let isInitialPatch = false // 区分是否组件patch
    const insertedVnodeQueue = [] // 存放占位符vnode,调用子组件的mounted

    // oldVnode为空表示这是一个组件的patch
    if (isUndef(oldVnode)) {
      isInitialPatch = true
      // ...
    } else {
      // ...
    }

    // 传入 insertedVnodeQueue,isInitialPatch 
    invokeInsertHook(vnode, insertedVnodeQueue, isInitialPatch)
    return vnode.elm
  }
}
  • isInitialPatch 表示这是组件的 patch 上下文还是根实例的 patch 上下文
  • 调用 invokeInsertHook,传入渲染 vnodeinsertedVnodeQueueisInitialPatch 三个参数

看看 invokeInsertHook 这个关键函数:

// src/core/vdom/patch.js

function invokeInsertHook (vnode, queue, initial{
  if (isTrue(initial) && isDef(vnode.parent)) {
    // 组件调用的逻辑...
    vnode.parent.data.pendingInsert = queue
  } else {
    // 根实例调用的逻辑...
    for (let i = 0; i < queue.length; ++i) {
      queue[i].data.hook.insert(queue[i])
    }
  }
}
  • 当是组件调用此方法时,会往当前渲染 vnode 的占位符 vnode 上的 data 对象挂载 pendingInsert 属性,用来存放队列,至于为什么这么做我们稍后结合例子分析
  • 当是根实例调用此方法时,会遍历队列中的占位符 vnode,并执行 insert 钩子,这在之前就有介绍

下面看看 insert 钩子是怎么调用子组件的 mounted 钩子

// src/core/vdom/create-component.js

const componentVNodeHooks = {
  insert (vnode: MountedComponentVNode) {
    const { context, componentInstance } = vnode
    if (!componentInstance._isMounted) {
      componentInstance._isMounted = true
      callHook(componentInstance, 'mounted'// 调用 mounted 钩子
    }
    // keepAlive相关...
  }
}

3.2 结合例子和流程图分析子组件的mounted

组件 mounted 有点绕,我们结合例子和流程图一步步分析:

假设现在我们有以下例子:

const App = {
  name: 'app',
  render(h) {
    return h('div', {}, 'hi vue')
  },
}

var root = new Vue({
  el: '#app',
  render(h) {
    return h(App)
  },
})

结合这个例子,画了下面的流程图,红色的标号表示步骤

步骤1-3就不重复罗嗦了,这个是在之前的章节就应该掌握的知识,直接从步骤4开始分析:

步骤4:invokeInsertHook

  • 此时的 vnodeApp 组件的渲染 vnode
  • 因为 App 组件已经是最深的那个组件了,所以此时的 queue 是一个空数组
  • 通过 vnode.parent 拿到 App 组件的占位符 vnode
  • queue 临时保存到占位符 vnode

步骤5:initComponent

上下文回到了根实例,执行 initComponent 方法

// src/core/vdom/patch.js

function initComponent (vnode, insertedVnodeQueue{
  if (isDef(vnode.data.pendingInsert)) {
    insertedVnodeQueue.push.apply(insertedVnodeQueue, vnode.data.pendingInsert)
    vnode.data.pendingInsert = null
  }
  vnode.elm = vnode.componentInstance.$el
  if (isPatchable(vnode)) {
    invokeCreateHooks(vnode, insertedVnodeQueue)
    // ...
  } else {
    // ...
  }
}
  • 此时的 vnode 就是 App 组件的占位符 vnode
  • 将占位符 vnode 上的临时数组 push 到队列中
  • isPatchable 返回 true,执行 invokeCreateHooks 方法

步骤6:invokeCreateHooks

// src/core/vdom/patch.js

function invokeCreateHooks (vnode, insertedVnodeQueue{
  // ...
  let i = vnode.data.hook
  if (isDef(i)) {
    // ...
    if (isDef(i.insert)) insertedVnodeQueue.push(vnode)
  }
}
  • 判断 vnode 是否定义了 hook,如果是的话则表明这是一个占位符 vnode
  • 将占位符 vnode 推进队列中 此时的 insertedVnodeQueue 状态:

步骤7:invokeInsertHook

  • 由于此时的上下文已经是根实例了,所以走的是 else 逻辑
  • 遍历 insertedVnodeQueue 队列,执行 insert 钩子,在这个钩子又会执行 mounted 钩子

到这里我们组件的 mounted 也已经结合具体的例子和流程图介绍完了,总结两点:

  • beforeMount 先父后子
  • mounted 先子后父

4. beforeUpdate & updated

顾名思义,beforeUpdateupdated 的钩子函数执行时机都应该是在数据更新的时候,到目前为止,我们还没有分析 Vue 的数据双向绑定、更新相关,下一章会详细介绍这个过程,现在大致了解一下:

当在执行 mountComponent 时,会实例化渲染 watcher

// src/core/instance/lifecycle.js

export function mountComponent (
  vm: Component,
  el: ?Element,
  hydrating?: boolean
): Component 
{
  // ...
  
  updateComponent = () => {
    vm._update(vm._render(), hydrating)
  }

  new Watcher(vm, updateComponent, noop, {
    before () {
      if (vm._isMounted && !vm._isDestroyed) {
        callHook(vm, 'beforeUpdate')
      }
    }
  }, true /* isRenderWatcher */)
  
  // ...
}
  • Watcher 的参数中有一个对象,对象中有一个 before 函数
  • 这个函数判断如果组件已经 mounted 并且还没有 destroyed ,就调用 beforeUpdate 钩子。

那么什么时候调用这个 before 函数呢?

执行时机是在 flushSchedulerQueue 函数调用的时候,此函数我们之后会详细介绍,可以先大概了解一下

// src/core/observer/scheduler.js

function flushSchedulerQueue ({
  // ...
  
  // 只需要知道 queue 存放的是一个个 watcher
  for (index = 0; index < queue.length; index++) {
    watcher = queue[index]
    if (watcher.before) {
      watcher.before() // 执行 before函数,会调用 beforeUpdate 钩子
    }
    
    // ...
  }

  // updatedQueue 是更新了的 wathcer 数组
  callUpdatedHooks(updatedQueue)

}
  • 遍历 queue,执行了 before 函数,从而执行了 beforeUpdate 函数
  • 调用 callUpdatedHooks 函数,参数 updatedQueue 是更新了的 wathcer 数组

看看 callUpdatedHooks 做了啥:

// src/core/observer/scheduler.js

function callUpdatedHooks (queue{
  let i = queue.length
  while (i--) {
    const watcher = queue[i]
    const vm = watcher.vm
    if (vm._watcher === watcher && vm._isMounted && !vm._isDestroyed) {
      callHook(vm, 'updated')
    }
  }
}

对这些数组做遍历,只有满足当前 watcher 为渲染 watcher 以及组件已经 mounted 这两个条件,才会执行 updated 钩子函数。

总结一下:

  • beforeUpdate 先父后子
  • updated 先子后父

5. beforeDestroy & destroyed

顾名思义,beforeDestroydestroyed 钩子函数的执行时机在组件销毁的阶段,组件的销毁过程之后会详细介绍,最终会调用 $destroy 方法,它的定义在 src/core/instance/lifecycle.js 中:

// src/core/observer/scheduler.js

Vue.prototype.$destroy = function ({
  // ...
  
  callHook(vm, 'beforeDestroy')
  
  // 递归销毁逻辑
  
  callHook(vm, 'destroyed')
  
  // ...
}
  • beforeDestroy 钩子函数的执行时机是在 $destroy 函数执行最开始的地方
  • 接着执行了一系列的销毁动作
    • 包括从 parent$children 中删掉自身
    • 删除 watcher
    • 当前的 VNode 执行销毁钩子函数等
  • 执行 vm.__patch__(vm._vnode, null) 触发它子组件的销毁钩子函数,这样一层层的递归调用
  • 执行完毕后再调用 destroy 钩子函数。

总结一下:

  • beforeDestroy 先父后子
  • destroyed 先子后父

6. activated & deactivated

activateddeactivated 钩子函数是专门为 keep-alive 组件定制的钩子,我们会在介绍 keep-alive 组件的时候详细介绍,这里先留个悬念。

总结

这一节主要介绍了 Vue 生命周期中各个钩子函数的执行时机以及顺序,通过分析,我们知道了如

  • created 钩子函数中可以访问到数据
  • mounted 钩子函数中可以访问到 DOM
  • destroy 钩子函数中可以做一些定时器销毁工作

了解它们有利于我们在合适的生命周期去做不同的事情。