[Vue源码]从源码角度详细分析Vue的生命周期

414 阅读8分钟

前言

在面试中,关于Vue的知识点环节里一般都会问到生命周期,整体的生命周期不复杂,如下所示:

一般靠使用Vue的经验和背网上的答案都可以基本答出个大概,但如果被细问其中就可能答不出来,这里就从源码的角度开始分析Vue的生命周期。

关于callHook

这里首先要介绍一下callHook函数,以方便一下阅读源码,在Vue的源码中,当要执行到生命周期钩子的函数时,都会通过该函数去调用执行用户注册在该钩子下的代码。例如,当执行到created时,就会调用callHook(vm, 'created')

在介绍callHook函数的源码之前,先说一下Vue在初始化的时候对用户注册在钩子函数下的代码是怎么处理的:

初始化时通过mergeOption合并Vue构造函数中的options以及用户传入的options,然后放到vm.$options

// src\core\instance\init.js
// 合并options,这里的options指new Vue(option)中的option
// _isComponent=true代表该Vue实例是组件
    // 我们在这里把Vue实例分两种:
    // 1. 通过new Vue()生成的普通Vue,
    // 2. 写在单文件里,会被外部引用注册到component里的组件Vue
    if (options && options._isComponent) {
      // 针对是组件的Vue实例,用initInternalComponent以优化内部组件的实例化,
      // 因为动态选项合并非常缓慢,而且没有一个内部组件选项需要特殊处理。
      initInternalComponent(vm, options)
    } else {
      // 普通Vue实例则用mergeOptions,
      // 把vm和Vue构造函数自身的option
      vm.$options = mergeOptions(
        // resolveConstructorOptions在普通的new Vue中返回的是Vue.options
        // 如果要初始化的类是通过Vue.extend生成的构造函数,则返回mergeOption(Vue.option,该构造函数.extendOptions)
        // 该构造函数.extendOptions为传入Vue.extend(extendOptions)中的形参,即组件
        resolveConstructorOptions(vm.constructor),
        options || {},
        vm
      )
    }

mergeOptions函数执行过程中,针对options中每一个生命周期钩子的合并处理会通过mergeHook实现,我们看一下mergeHook的源码:

// src\core\util\options.js
function mergeHook (
  parentVal: ?Array<Function>,
  childVal: ?Function | ?Array<Function>
): ?Array<Function> {
  const res = childVal
    ? parentVal
      ? parentVal.concat(childVal)
      : Array.isArray(childVal)
        ? childVal
        : [childVal]
    : parentVal
  return res
    ? dedupeHooks(res)
    : res
}

里面有好几个嵌套的三目运算符,这里我拆开分析一下:

  1. 如果childVal为真值,判断parentVal

    1. 如果parentVal为真值,则通过parentVal.concat(childVal)合并数组

    2. 如果parentVal为假值,则判断childVal是否为数组:

      1. 如果childVal是数组,则返回childVal
      2. 如果childVal不是数组,则返回[childVal]
  2. 如果childVal为假值,返回parentVal

可见,钩子函数最后会以数组的形式记录在vm.$options中,例如:

当我们在Vue中的开发代码如下:

new Vue({
 created: function () {
   console.log('hello world')
 }
})

Vue初始化时,vm.$options为:

vm.$options = {
 // ...其他属性
 created: [
   function created() {
     console.log('hello world')
   }
 ],
}

以数组的形式保存是为了存放Vue.mixin中混入的生命周期钩子函数。现在再看一下callHook的源码:

// src\core\instance\lifecycle.js
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()
}

// 执行传入的handler钩子函数且对错误进行处理
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
}

callHooks就是根据传入的hook(生命周期钩子名称)获取对应的钩子函数列表,然后遍历传入invokeWithErrorHandling函数中执行。

这一节主要介绍callHook函数,其作用是用来执行用户注册的钩子函数。

从new Vue() 分析生命周期

入口分析

Vue.js 是一个跨平台的 MVVM 框架,它可以跑在 web 上,也可以配合 weex 跑在 native 客户端上。不同运行平台的入口文件放在src\platforms目录下,而与平台无关的核心代码(包括内置组件、全局 API 封装,Vue 实例化、观察者、虚拟 DOM、工具函数)文件都放在src\core目录下,这里我们从web环境下的运行时版本进行分析:

src\platforms\web\runtime\index.js

//这里只显示涉及到分析的代码
import Vue from 'core/index'

// $mount放在这里声明是因为“运行时版本”和“运行加编译器版本”的$mount函数不一样,“运行加编译器版本”的$mount函数会添加处理template属性的逻辑
Vue.prototype.$mount = function (
  el?: string | Element,
  hydrating?: boolean
): Component {
  el = el && inBrowser ? query(el) : undefined
  return mountComponent(this, el, hydrating)
}

// 用于通过diff算法对比VNode且异步更新到页面上,由于不同平台(weex和web)的DOM修改方式不同,因此__patch__需要根据不同的平台初始化
Vue.prototype.__patch__ = inBrowser ? patch : noop

export default Vue

src\core\index.js

// 这里去掉关于`SSR`的代码
import Vue from './instance/index'
import { initGlobalAPI } from './global-api/index'

// 初始化Vue.options以存放全局注册的component、filter、directive
// 定义挂载到Vue类上的静态方法:set,delete,nextTick,observable,use,mixin,extend,component,filter,directive
initGlobalAPI(Vue)

Vue.version = '__VERSION__'

export default Vue

接下来看看定义Vue构造函数的文件代码

src\core\instance\index.js

function Vue (options) {
  this._init(options)
}

// 定义Vue.prototype._init方法,供构造函数的内部调用
initMixin()
// 在Vue.prototype中定义$data(指向实例的_data),$props(指向实例的_props),$set,$delete,$watch
stateMixin(Vue)
// 在Vue.prototype中定义$on,$once,$off,$emit
eventsMixin(Vue)
// 在Vue.prototype中定义_update(实例初次渲染和更新视图时会调用),$forceUpdate,$destroy
lifecycleMixin(Vue)
// 在Vue.prototype中定义$nextTick,_render(实例初次渲染和更新视图时会调用)
renderMixin(Vue)

可以看出很多静态方法方法其实定义在Vue.prototype中。

我们下面从new Vue的执行过程触发,从源码上分析何时执行钩子函数。


beforeCreate && created

new Vue()时,开始调用Vue的构造函数,构造函数中会调用在initMixin中声明的Vue.prototype.init方法,然后我们往下分析:

src\core\instance\init.js

export function initMixin (Vue: Class<Component>) {
  Vue.prototype._init = function (options?: Object) {
    const vm: Component = this
    // 初始化uid
    vm._uid = uid++

    // 被标记_isVue=true的对象在传入obverse方法时不会被处理,即不会被响应式处理
    vm._isVue = true
    // 合并option,_isComponent=true代表该Vue实例是组件
    // 我们在这里把Vue实例分两种:
    // 1. 通过new Vue()生成的普通Vue,
    // 2. 写在单文件里,会被外部引用注册到component里的组件Vue
    if (options && options._isComponent) {
      // 针对是组件的Vue实例,用initInternalComponent以优化内部组件的实例化,
      // 因为动态选项合并非常缓慢,而且没有一个内部组件选项需要特殊处理。
      initInternalComponent(vm, options)
    } else {
      // 普通Vue实例则用mergeOptions,
      // 把vm和Vue构造函数自身的option
      vm.$options = mergeOptions(
        // resolveConstructorOptions在普通的new Vue中返回的是Vue.options
        // 如果要初始化的类是通过Vue.extend生成的构造函数,则返回mergeOption(Vue.option,该构造函数.extendOptions)
        // 该构造函数.extendOptions为传入Vue.extend(extendOptions)中的形参,即组件
        resolveConstructorOptions(vm.constructor),
        options || {},
        vm
      )
    }

    vm._renderProxy = vm

    vm._self = vm
    // 初始化与生命周期相关的属性,如$parent,$children,$refs(初始化为空对象),$root
    initLifecycle(vm)
    // 处理父组件通过v-on绑定在该Vue实例上的方法,通常是组件Vue才会详细执行里面的内容
    initEvents(vm)
    // 初始化与渲染相关的属性,如:$createElement,$slots,$scopedSlots,$attrs,$listeners
    initRender(vm)
    // 此时初始化走到'beforeCreate'生命周期,调用注册在beforeCreate下的钩子函数
    callHook(vm, 'beforeCreate')
    // 初始化Injections
    initInjections(vm)
    // 依次初始化实例上的props,methods,data,computed,watch属性,其中包括把data和props转为响应式
    initState(vm)
    // 初始化provide属性
    initProvide(vm)
    // 此时初始化走到'created'生命周期,调用注册在created下的钩子函数
    callHook(vm, 'created')
	// 调用开头声明的$mount方法
    if (vm.$options.el) {
      vm.$mount(vm.$options.el)
    }
  }
}

总结一下,VuebeforeCreate之前,初始化了以下属性:

  1. 与生命周期相关的属性,如$parent,$children,$refs(初始化为空对象),$root
  2. 父组件通过v-on绑定在该Vue实例上的方法
  3. 与渲染相关的属性,如:$createElement,$slots,$scopedSlots,$attrs,$listeners

VuebeforeCreatecreated做了以下操作:

  1. 初始化Injections属性
  2. 初始化实例上的props,methods,data,watch,computed属性
  3. 初始化provide属性

beforeMount && mounted

从开头的Vue.prototype.$mount函数中看出他其实调用了mountComponent函数去执行挂载逻辑,接下来看看mountComponent函数的源码:

export const createEmptyVNode = (text: string = '') => {
  const node = new VNode()
  node.text = text
  node.isComment = true
  return node
}

export function mountComponent (
  vm: Component,
  el: ?Element,
  hydrating?: boolean
): Component {
  vm.$el = el
  // 如果渲染函数不存在,则初始化为空的VNode
  if (!vm.$options.render) {
    vm.$options.render = createEmptyVNode
  }
  // 此时初始化走到'beforeMount'生命周期,调用注册在beforeMount下的钩子函数
  callHook(vm, 'beforeMount')
 
  // 初始化updateComponent用于更新DOM,其先调用 vm._render 生成VNode,
  // 然后调用 vm._update 对比新旧VNode节点且更新到DOM视图上。
  let updateComponent
  updateComponent = () => {
    vm._update(vm._render(), hydrating)
  }

  // 实例化一个渲染Watcher,
  // 作为参数路径的形参 updateComponent 函数会在Watcher实例化过程中立即被执行
  // 因此,当渲染Watcher实例化完成时,视图已被更新且挂在到页面上
  new Watcher(vm, updateComponent, noop, {
    before () {
      if (vm._isMounted && !vm._isDestroyed) {
        callHook(vm, 'beforeUpdate')
      }
    }
  }, true /* isRenderWatcher */)
  hydrating = false

  // vm.$node == null 代表此实例是普通Vue,并非组件Vue
  if (vm.$vnode == null) {
    vm._isMounted = true
    callHook(vm, 'mounted')
  }
  return vm
}

beforeMountmounted过程中,做了以下步骤:

  1. 实例化渲染Watcher,且执行传入的更新视图的函数(updateComponent
  2. updateComponent执行过程中,所调用到的data里的数据里会把渲染Watcher放在Dep实例(订阅器)上,形成观察者模式的监听触发逻辑,在之后数据再次发生变化时,会通知Dep实例执行记录在Dep中的Wacther实例,从而完成视图更新

以下内容参考:Vue.js 技术揭秘

拓展: 当上述vm.$vnode !== null时代表改实例为组件Vue组件Vue是在调用vm._update,执行其中的Vue.prototype.__patch__函数(即patch函数)中过程,在视图被更新之后,执行invokeInsertHook函数:

function invokeInsertHook (vnode, queue, initial) {
  if (isTrue(initial) && isDef(vnode.parent)) {
    vnode.parent.data.pendingInsert = queue
  } else {
  	// 这里的queue为insertedVnodeQueue,用于
    for (let i = 0; i < queue.length; ++i) {
      queue[i].data.hook.insert(queue[i])
    }
  }
}

其中,insert函数的定义为:

const componentVNodeHooks = {
  // ...
  insert (vnode: MountedComponentVNode) {
    const { context, componentInstance } = vnode
    if (!componentInstance._isMounted) {
      componentInstance._isMounted = true
      callHook(componentInstance, 'mounted')
    }
    // ...
  },
}

在完成组件的整个 patch 过程后,最后执行 insert(parentElm, vnode.elm, refElm) 完成组件的 DOM 插入,如果组件 patch 过程中又创建了子组件,那么DOM 的插入顺序是先子后父。因此保证了父子组件的执行顺序是: 父beforeCreate > 父created > 父beforeMount > 子beforeCreate > 子created > 子beforeMount > 子mounted > 父mounted

beforeUpdate && updated

当数据更新时,会调用该数据关联的订阅器实例depnotify方法,notify方法源码如下所示:

src\core\observer\dep.js

export default class Dep {
	// ...
    notify () {
	  // subs中存放与该数据关联的watcher
      const subs = this.subs.slice()
      // 遍历调用watcher中的update方法
      for (let i = 0, l = subs.length; i < l; i++) {
        subs[i].update()
      }
    }
}

src\core\observer\watcher.js

let uid = 0

export default class Watcher {
	// .....
	update () {
      // 这里先以渲染watcher为例做分析,渲染watcher的lazy和sync为false
      if (this.lazy) {
        this.dirty = true
      } else if (this.sync) {
        this.run()
      } else {
        queueWatcher(this)
      }
    }
}

src\core\observer\scheduler.js

export function queueWatcher (watcher: Watcher) {
  const id = watcher.id
  // 查询has中是否已记录该watcher.id从而达到去重的目的,防止更新队列queue出现重复的watcher
  if (has[id] == null) {
    has[id] = true
    if (!flushing) {
      queue.push(watcher)
    } else {
      // if already flushing, splice the watcher based on its id
      // if already past its id, it will be run next immediately.
      let i = queue.length - 1
      // index代表当前处理到queue更新队列的第几个元素
      // 这里说一下watcher.id,每一个Watcher类被实例化时,都会执行this.id = uid++,
      // uid是在watcher类所在的文件开头就被定义的,并非在class Watcher{}里面,
      // 故每一个watcher的uid都不一样且随着初始化顺序而递增
      // 这里的while里面的语句比较巧妙:
      //	1. 如果更新队列正在执行,则在会放在大于queue[index]后面的watcher.id的位置上,此举为了保证:
      //		(1) 父组件的watcher先于子组件的watcher执行,因此父组件的beforeUpdate钩子函数先于子组件的执行
      //		(2) 保证了同一组件中watcher的执行顺序:计算watcher(即定义在computed里)>用户watcher(定义在watch里)>渲染watcher
      //	2. 如果目前更新队列执行的watcher的id比要插入的watcher的id大,则直接插入到目前执行的watcher的下一个位置,即queue[index+1],此举可以保证立即执行
      while (i > index && queue[i].id > watcher.id) {
        i--
      }
      queue.splice(i + 1, 0, watcher)
    }
    
    // 判断waiting是否执行,保证nextTick(flushSchedulerQueue)在执行期间不会被调用
    // flushSchedulerQueue用于遍历执行记录在queue的watcher中的run方法
    // flushSchedulerQueue执行完成后会调用resetSchedulerState去清空has以及把flushing和waiting设置为false
    if (!waiting) {
      waiting = true
      nextTick(flushSchedulerQueue)
    }
  }
}

这里不展示nextTick方法的源码,他的用法和vm.$nextTick一样,就是把传入函数放到异步队列中。接下来直接看flushSchedulerQueue的源码:

src\core\observer\scheduler.js

function flushSchedulerQueue () {
  currentFlushTimestamp = getNow()
  // 表示当前在用该方法处理queue队列
  flushing = true
  let watcher, id

  // 根据watcher中的id以正序进行排列,此举为了保证:
  //	1. 父组件的watcher先于子组件的watcher执行
  //	2. 保证了同一组件中watcher的执行顺序:计算watcher(即定义在computed里)>用户watcher(定义在watch里)>渲染watcher。因为计算watcher先于用户watcher创建,用户watcher先于渲染watcher创建
  //	3. 如果一个组件在父组件的 watcher 执行期间被销毁,那么它对应的 watcher 执行都可以被跳过,所以父组件的 watcher 应该先执行。当子组件销毁的时候,子组件的渲染watcher会执行teardown方法把渲染watcher自身的active置为false。watcher.run()方法里会先根据this.active是否为true从而判断是否执行
  queue.sort((a, b) => a.id - b.id)

  // 遍历取出queue更新队列中的watcher然后执行其before和run方法
  for (index = 0; index < queue.length; index++) {
    watcher = queue[index]
    /**
     * 这里再放以下渲染watcher的初始化语句:
     * new Watcher(vm, updateComponent, noop, {
     *    before () {
     *      if (vm._isMounted && !vm._isDestroyed) {
     *         callHook(vm, 'beforeUpdate')
     *       }
     *     }
     *   }, true)
     *  此处的watcher.before会在渲染watcher初始化时
     *  设置为传入构造函数的第四个参数里的before,
     *  此时执行watcher.before相当于执行beforeUpdate钩子函数
     *  注意: watcher.before仅在渲染watcher才存在
     */
    if (watcher.before) {
      watcher.before()
    }
    id = watcher.id
    has[id] = null
    // watcher.run是记录更新逻辑的函数
    watcher.run()
  }
  // 保留queue的副本,因为resetSchedulerState会清空queue
  const updatedQueue = queue.slice()
  // 把waiting和flushing置为false,清空queue和has
  resetSchedulerState()
  // 执行updated钩子函数
  callUpdatedHooks(updatedQueue)
}


function callUpdatedHooks (queue) {
  let i = queue.length
  // 倒序遍历更新队列,保证子组件的updated钩子函数先于父组件的执行
  while (i--) {
    const watcher = queue[i]
    const vm = watcher.vm
    // vm._watcher用于记录vm的渲染watcher,因此这里逻辑是,如果当前遍历到的watcher是渲染watcher,
    // 且本次不是首次渲染(vm._isMounted为真),且vm没被销毁
    // 则对该watcher对应的vm执行'updated'钩子函数
    if (vm._watcher === watcher && vm._isMounted && !vm._isDestroyed) {
      callHook(vm, 'updated')
    }
  }
}

以上的逻辑比较多,总结就是:

beforeUpdate执行之前,变更数据对应订阅器已被通知,然后所有需要更新的wathcer已放在更新队列queue,更新列表通过nextTick放到异步队列然后开始遍历执行。

updated执行之前,对应的更新函数已执行完毕。

主要需要注意的是,父子组件的执行顺序是:父beforeUpdate > 子beforeUpdate > 子beforeUpdate > 父beforeUpdate

beforeDestroy && destroyed

当Vue实例将要被销毁时,会调用Vue.prototype.$destroy方法,其源码如下:

src\core\instance\lifecycle.js

Vue.prototype.$destroy = function () {
  const vm: Component = this
  if (vm._isBeingDestroyed) {
    return
  }
  // 调用该实例的beforeDestroy钩子函数
  callHook(vm, 'beforeDestroy')
  vm._isBeingDestroyed = true
  // 把自身从vm.$parent.$children中移除
  const parent = vm.$parent
  if (parent && !parent._isBeingDestroyed && !vm.$options.abstract) {
    remove(parent.$children, vm)
  }
  // 销毁实例的渲染watcher
  if (vm._watcher) {
    vm._watcher.teardown()
  }
  // 遍历销毁实例的所有watcher,包括计算watcher,用户watcher,渲染watcher
  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--
  }
  vm._isDestroyed = true
  // 更新页面,把该组件的UI从页面中移除
  vm.__patch__(vm._vnode, null)
  // 调用该实例的destroyed钩子函数
  callHook(vm, 'destroyed')
  // 移除所有注册在该实例上的事件
  vm.$off()
  // remove __vue__ reference
  if (vm.$el) {
    vm.$el.__vue__ = null
  }
  // release circular reference (#6759)
  if (vm.$vnode) {
    vm.$vnode.parent = null
  }
}

总结一下,在beforeDestroydestroyed中,把自身从vm.$parent.$children中移除,遍历销毁实例的所有watcher。移除其他私有属性的引用等。但注册绑定的事件是在destroyed之后才移除的。

拓展:vm.__patch__(vm._vnode, null)执行时,相当于执行patch方法,而patch开头的源码如下:

vue2-study\src\core\vdom\patch.js

return function patch (oldVnode, vnode, hydrating, removeOnly) {
	// 此时传入的oldVnode有定义且vnode为null
    if (isUndef(vnode)) {
      if (isDef(oldVnode)) invokeDestroyHook(oldVnode)
      return
    }
    // ...
}

function invokeDestroyHook (vnode) {
  let i, j
  const data = vnode.data
  if (isDef(data)) {
    if (isDef(i = data.hook) && isDef(i = i.destroy)) i(vnode) //此处i为data.hook.destroy
    for (i = 0; i < cbs.destroy.length; ++i) cbs.destroy[i](vnode)
  }
  // 如果vnode有子节点,则遍历子节点递归调用invokeDestroyHook
  if (isDef(i = vnode.children)) {
    for (j = 0; j < vnode.children.length; ++j) {
      invokeDestroyHook(vnode.children[j])
    }
  }
}

上面的vnode.data.hook.destroycomponentVNodeHooks中被定义,其源码如下:

vue2-study\src\core\vdom\create-component.js

destroy (vnode: MountedComponentVNode) {
  const { componentInstance } = vnode
  // 此时componentInstance._isDestroyed已被设置为true,故以下逻辑不执行
  // 如果是父节点被销毁从而递归销毁到子节点,此时其_isDestroyed为false,进而调用子节点对应的子组件的$destroy方法,
  // 从而实现从父节点到子节点的销毁,而且保证了此过程的执行顺序是:
  // 	父beforeDestroy>子beforeDestroy>子destroyed>父destroyed
  if (!componentInstance._isDestroyed) {
    if (!vnode.data.keepAlive) {
      componentInstance.$destroy()
    } else {
      deactivateChildComponent(componentInstance, true /* direct */)
    }
  }
}

总结

总结一下:

Vue在初始阶段,会通过mergeOption合并option配置,此函数会把实例中的钩子函数转化为数组。当调用callHook时,会遍历执行对应的钩子函数。


new Vue

  1. 初始化与生命周期相关的属性,如$parent,$children,$refs(初始化为空对象),$root
  2. 处理父组件通过v-on绑定在该Vue实例上的方法,通常是组件Vue才会详细执行里面的内容
  3. 初始化与渲染相关的属性,如:$createElement,$slots,$scopedSlots,$attrs,$listeners beforeCreate
  4. 初始化Injections
  5. 初始化实例上的props,methods,data,watch,computed属性,其中包括把data和props转为响应式
  6. 初始化provide属性 created 1.初始化渲染函数,如果不存在渲染函数则解析template beforeMount
  7. 生成渲染watcher并执行传入其中的updateComponent函数,调用render函数生成VNode后更新挂载到视图上
  8. updateComponent执行过程中,所调用到的data里的数据里会把渲染Watcher放在Dep实例(订阅器)上 mounted

数据变化时

  1. 触发记录绑定该数据的订阅器,从而触发异步队列执行更新队列任务 beforeUpdate
  2. 实例的页面已被更新 updated

销毁时

beforeDestroy

  1. 销毁属性,包括datacomputedwatcher
  2. 销毁实例对应的挂载元素的内容 destroy
  3. 移除所有注册在该实例上的事件

后记

之后会找个时间写一下Vue的响应式原理,因为里面叶有一堆知识点需要学习。