Vue 生命周期钩子函数

293 阅读5分钟

Vue 框架有一套完整的生命周期,在合适的时机调用对应的钩子函数,执行用户自己实现的逻辑。本文将从源码的角度来分析生命周期钩子函数在其内部是如何被调用的?

lifecycle.png

Vue 框架生命周期钩子函数如下:

  • beforeCreate
  • created
  • beforeMount
  • mounted
  • beforeUpdate
  • updated
  • beforeDestroy
  • destroyed
  • activated
  • deactivated

下面逐一分析它们。

beforeCreate & created

这两个钩子函数在初始化 Vue 实例时被调用,位于 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')
  
    ...
}

从代码实现中可以得知,在 created 钩子函数里可以获取 dataprops 等属性,而在 beforeCreate 钩子函数不可以,因为诸如 datacomputedwatchprops 等初始化在 beforeCreate 之后,created 之前。

那么来看下 Vue 框架是如何触发钩子函数的?也就是 callHook 函数的实现:

export function callHook (vm: Component, hook: string) {
  // #7573 disable dep collection when invoking lifecycle hooks
  pushTarget()
  const handlers = vm.$options[hook]
  const info = `${hook} hook`
  if (handlers) {
    for (let i = 0, j = handlers.length; i < j; i++) {
      invokeWithErrorHandling(handlers[i], vm, null, vm, info)
    }
  }
  if (vm._hasHookEvent) {
    vm.$emit('hook:' + hook)
  }
  popTarget()
}

函数接收两个参数:

  • vm:Vue 实例
  • hook:钩子函数名称

在《合并配置(options)》一文中,讲解了 Vue 框架是如何将 Vue 构造函数 options 和用户传参进来的 options 进行合并,而生命周期钩子函数也在这过程中被处理、合并。因此,handlers 数据类型是数组,包含对应的生命周期钩子函数。

遍历 handlers 数组,调用 invokeWithErrorHandling 函数进行处理,实现如下:

export function invokeWithErrorHandling (
  handler: Function,
  context: any,
  args: null | any[],
  vm: any,
  info: string
) {
  let res
  try {
    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)`))
      // issue #9511
      // avoid catch triggering multiple times when nested calls
      res._handled = true
    }
  } catch (e) {
    handleError(e, vm, info)
  }
  return res
}

函数接收 5 个参数:

  • handler:生命周期钩子函数
  • context:上下文
  • args:参数
  • vm:Vue 实例
  • info:打印信息

实现逻辑挺简单的,利用函数具有 callapply 调用钩子函数。如果出现异常,则调用函数 handleError 进行处理,实现如下:

export function handleError (err: Error, vm: any, info: string) {
  // Deactivate deps tracking while processing error handler to avoid possible infinite rendering.
  // See: https://github.com/vuejs/vuex/issues/1505
  pushTarget()
  try {
    if (vm) {
      let cur = vm
      while ((cur = cur.$parent)) {
        const hooks = cur.$options.errorCaptured
        if (hooks) {
          for (let i = 0; i < hooks.length; i++) {
            try {
              const capture = hooks[i].call(cur, err, vm, info) === false
              if (capture) return
            } catch (e) {
              globalHandleError(e, cur, 'errorCaptured hook')
            }
          }
        }
      }
    }
    globalHandleError(err, vm, info)
  } finally {
    popTarget()
  }
}

函数 handleError 接收三个参数:

  • err:抛出错误对象
  • vm:Vue 实例
  • info:打印信息

先判断 Vue 实例是否有父元素 $parent,如果有的话,则判断是否有 errorCaptured 钩子函数,有的话则调用该钩子函数,否则跳过。无论哪种情况,最终都会调用函数 globalHandleError,实现如下:

function globalHandleError (err, vm, info) {
  if (config.errorHandler) {
    try {
      return config.errorHandler.call(null, err, vm, info)
    } catch (e) {
      // if the user intentionally throws the original error in the handler,
      // do not log it twice
      if (e !== err) {
        logError(e, null, 'config.errorHandler')
      }
    }
  }
  logError(err, vm, info)
}

最终打印错误日志的是函数 logError, 实现如下:

function logError (err, vm, info) {
  if (process.env.NODE_ENV !== 'production') {
    warn(`Error in ${info}: "${err.toString()}"`, vm)
  }
  /* istanbul ignore else */
  if ((inBrowser || inWeex) && typeof console !== 'undefined') {
    console.error(err)
  } else {
    throw err
  }
}

从代码中可以看出,最终的报错信息是通过调用 console.error 打印出来的。

beforeMount & mounted

beforeMount 调用时机在 mountComponent 函数里,位于 src/core/instance/lifecycle.js,代码如下:

export function mountComponent (
  vm: Component,
  el: ?Element,
  hydrating?: boolean
): Component {
  ...
  
  callHook(vm, 'beforeMount')
  
  ...
  
  // manually mounted instance, call mounted on self
  // mounted is called for render-created child components in its inserted hook
  if (vm.$vnode == null) {
    vm._isMounted = true
    callHook(vm, 'mounted')
  }
  
  return vm
}

在执行 renderupdate 函数之前,钩子函数 beforeMount 就已经被调用了,此时还没有将 template 渲染成真实的 DOM,这也就解释了为什么我们不能在 beforeMount 操作 DOM。

对于钩子函数 mounted 调用时机,它是在渲染成真实 DOM,并且添加到 body 里之后才被调用的。也就是说,此时已经完成 Vue 实例挂载,用户可以在 mounted 自由地操作 DOM。

但是,这里有一个判断条件,即 vm.$vnodenull,则表明不是在初始化组件,而是通过外部 new Vue 而触发的。那么对于组件初始化时,mounted 钩子函数是如何被触发的呢?

在组件 patch 过程中,会调用函数 invokeInsertHook,位于 src/core/vdom/patch.js,代码实现如下:

function invokeInsertHook (vnode, queue, initial) {
  // delay insert hooks for component root nodes, invoke them after the
  // element is really inserted
  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:虚拟 DOM
  • queue:钩子函数队列
  • initial :是否初始化

函数的作用是遍历钩子函数队列,然后触发每个钩子函数,执行其逻辑。

最终会调用钩子函数 insert,那么它是在哪里定义的呢?位于 src/core/vdom/create-component.sj ,实现如下:

// inline hooks to be invoked on component VNodes during patch
const componentVNodeHooks = {
  
  ...
  
  insert (vnode: MountedComponentVNode) {
    const { context, componentInstance } = vnode
    if (!componentInstance._isMounted) {
      componentInstance._isMounted = true
      callHook(componentInstance, 'mounted')
    }
    if (vnode.data.keepAlive) {
      if (context._isMounted) {
        // vue-router#1212
        // During updates, a kept-alive component's child components may
        // change, so directly walking the tree here may call activated hooks
        // on incorrect children. Instead we push them into a queue which will
        // be processed after the whole patch process ended.
        queueActivatedComponent(componentInstance)
      } else {
        activateChildComponent(componentInstance, true /* direct */)
      }
    }
  }
  
  ...
}

从代码的实现可以看出,每个组件的生命周期钩子函数是在钩子函数 insert 里被调用的,并且设置属性 _isMountedtrue

beforeUpdate & update

在首次渲染的时候,这两个钩子函数不会被调用。只有当数据发生变化时,才会在适当的时机被调用。

先来看下 beforeUpdate 是在什么时机被调用的?在 mountComponent 函数有一段代码:

new Watcher(vm, updateComponent, noop, {
  before () {
    if (vm._isMounted && !vm._isDestroyed) {
      callHook(vm, 'beforeUpdate')
    }
  }
}, true /* isRenderWatcher */)

从代码可以看出,钩子函数 beforeUpdate 是在 before 调用的,条件是组件已经完成挂载并且未被销毁。那么 before 又是在什么样的时机触发呢?其实这涉及到数据双向绑定响应式原理。

关于数据双向绑定响应式原理后面再分析,这里简单分析下钩子函数是如何被触发的?代码实现在函数 flushSchedulerQueue,位于 src/core/observer/scheduler.js,如下:

/**
 * Flush both queues and run the watchers.
 */
function flushSchedulerQueue () {
  
  ...
  
  // do not cache length because more watchers might be pushed
  // as we run existing watchers
  for (index = 0; index < queue.length; index++) {
    watcher = queue[index]
    if (watcher.before) {
      watcher.before()
    }
    id = watcher.id
    has[id] = null
    watcher.run()
  }
  
  ...

  // call component updated and activated hooks
  callActivatedHooks(activatedQueue)
  callUpdatedHooks(updatedQueue)

  // devtool hook
  /* istanbul ignore if */
  if (devtools && config.devtools) {
    devtools.emit('flush')
  }
}

queue 是一个数组,保存的是 watcher 对象,这里遍历数组 queue,判断 watcher 是否有属性 before ,有的话则调用函数 before,此时钩子函数 beforeUpdate 被触发。

而对于 update 的调用时机,则是在函数 callUpdatedHooks 的实现里,代码如下:

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、用户 watchercomputed watcher。而对于每个一个组件,在初始化过程中都会实例化一个渲染 watcher,用来监听 vm 实例上数据变化重新渲染。

所以,钩子函数 updated 触发的时机是遍历到的 watcher 是渲染 watcher ,与 watchervm._watcher 是一样的,并且组件已经完成挂载及未被销毁,才会被触发。

beforeDestroy & destroyed

这两个钩子函数最终被调用是在 Vue.prototype.$destroy 函数,但是至于该函数是如何被触发的,暂时还没搞清楚,后续再作补充。那么,来看下函数 $destroyed 是如何实现的?

Vue.prototype.$destroy = function () {
    const vm: Component = this
    if (vm._isBeingDestroyed) {
      return
    }
    callHook(vm, 'beforeDestroy')
    vm._isBeingDestroyed = true
    // remove self from parent
    const parent = vm.$parent
    if (parent && !parent._isBeingDestroyed && !vm.$options.abstract) {
      remove(parent.$children, vm)
    }
    // teardown watchers
    if (vm._watcher) {
      vm._watcher.teardown()
    }
    let i = vm._watchers.length
    while (i--) {
      vm._watchers[i].teardown()
    }
    // remove reference from data ob
    // frozen object may not have observer.
    if (vm._data.__ob__) {
      vm._data.__ob__.vmCount--
    }
    // call the last hook...
    vm._isDestroyed = true
    // invoke destroy hooks on current rendered tree
    vm.__patch__(vm._vnode, null)
    // fire destroyed hook
    callHook(vm, 'destroyed')
    // turn off all instance listeners.
    vm.$off()
    // remove __vue__ reference
    if (vm.$el) {
      vm.$el.__vue__ = null
    }
    // release circular reference (#6759)
    if (vm.$vnode) {
      vm.$vnode.parent = null
    }
  }

beforeDestroy 最先被调用,然后执行一系列销毁操作,包括父组件、子组件,watcher 等。值得注意的是再次调用 vm.__patch__ 来触发子组件的销毁操作,如下:

export function createPatchFunction (backend) {
  ...
  
  return function patch (oldVnode, vnode, hydrating, removeOnly) {
    if (isUndef(vnode)) {
      if (isDef(oldVnode)) invokeDestroyHook(oldVnode)
      return
    }
    
    ....
  }
}

传进来的参数 vnodenull,最终会调用函数 invokeDestroyHook,那么该函数又是如何实现的呢?

function invokeDestroyHook (vnode) {
  let i, j
  const data = vnode.data
  if (isDef(data)) {
    if (isDef(i = data.hook) && isDef(i = i.destroy)) i(vnode)
    for (i = 0; i < cbs.destroy.length; ++i) cbs.destroy[i](vnode)
  }
  if (isDef(i = vnode.children)) {
    for (j = 0; j < vnode.children.length; ++j) {
      invokeDestroyHook(vnode.children[j])
    }
  }
}

最终会调用 hook 中函数 destroy,其实现如下:

// inline hooks to be invoked on component VNodes during patch
const componentVNodeHooks = {
  destroy (vnode: MountedComponentVNode) {
    const { componentInstance } = vnode
    if (!componentInstance._isDestroyed) {
      if (!vnode.data.keepAlive) {
        componentInstance.$destroy()
      } else {
        deactivateChildComponent(componentInstance, true /* direct */)
      }
    }
  }
}

子组件的销毁最终也是调用 Vue 原型上的函数 $destroy,这也说明了子组件 destroyed 调用先于父组件,与 mounted 一样。

至此,生命周期钩子函数调用时机分析完了。

参考链接