【七】Vue2 源码 — 组件详解

Vue.js 版本为 v2.6.14

Vue.js 另一个核心思想是组件化。所谓组件化,就是把页面拆分成多个组件(component),每个组件依赖的 CSS、JavaScript、模板、图片等资源放在一起维护。组件是资源独立的,组件在系统内部可复用,组件和组件之间可以嵌套。

这一章,将从源码的角度分析 Vue 组件内部是如何工作的,接下来我们分析一下 Vue 组件初始化的过程。

import Vue from 'vue'
import App from './App.vue'

var app = new Vue({
  el: '#app',
  // 这里的 h 是 createElement 方法
  render: (h) => h(App),
})

这段代码跟之前我们写过的一个例子比较相似,也是通过 render 函数去渲染的,不同的是,这次 createElement 传的参数是一个组件而不是一个原生的标签,接下来我们就分析这一过程。

组件创建

createElement

之前我们分析 createElement 实现的时候知道,它最终会调用 _createElement 方法,其中有一段逻辑是对参数 tag 的判断,如果是一个普通的 html 标签,例如一个普通的 div,则会实例化一个普通 VNode 节点,否则通过 createComponent 方法创建一个组件 VNode。

/** src/core/vdom/cteate-element.js */
export function _createElement(
  context: Component,
  tag: any,
  data: any,
  children: any,
  normalizationType: any,
  alwaysNormalize: boolean
) {
  //……
  if (typeof tag === 'string') {
    let Ctor
    ns = (context.$vnode && context.$vnode.ns) || config.getTagNamespace(tag)
    if (config.isReservedTag(tag)) {
      // platform built-in elements
      if (
        process.env.NODE_ENV !== 'production' &&
        isDef(data) &&
        isDef(data.nativeOn)
      ) {
        warn(
          `The .native modifier for v-on is only valid on components but it was used on <${tag}>.`,
          context
        )
      }
      vnode = new VNode(
        config.parsePlatformTagName(tag),
        data,
        children,
        undefined,
        undefined,
        context
      )
    } else if (
      (!data || !data.pre) &&
      isDef((Ctor = resolveAsset(context.$options, 'components', tag)))
    ) {
      // component
      vnode = createComponent(Ctor, data, context, children, tag)
    } else {
      // unknown or unlisted namespaced elements
      // check at runtime because it may get assigned a namespace when its
      // parent normalizes children
      vnode = new VNode(tag, data, children, undefined, undefined, context)
    }
  } else {
    // direct component options / constructor
    vnode = createComponent(tag, data, context, children)
  }
  //……
}

在本章最开始的例子中,我们给 createElement 传入的是一个 APP 对象,它本质上是一个 Component 类型,那么会走到上述代码的 else 逻辑,直接通过 createComponent 方法来创建 vnode

createElement 方法的实现定义在 src/core/vdom/create-component.js 文件中:

可以看到, createComponent 的逻辑会有些复杂,但是分析源码比较推荐的是只分析核心流程,分支流程可以以后针对性的看,所以这里针对组件渲染这个 case 主要就 3 个关键步骤:

  1. 构造子类构造函数

  2. 安装组件钩子函数

  3. 实例化 vnode

构造子类构造函数

我们在编写一个组件的时候,通常都是创建一个普通对象,还是以我们的 APP.vue 为例,代码如下:

上面的例子中 export 的是一个对象,所以 createComponent 里的代码逻辑会执行到 baseCtor.extend(Ctor),在这里 baseCtor 实际上就是 Vue,这个的定义是在最开始初始化 Vue 的阶段,在 src/core/global-api/index.js 中的 initGlobalAPI 函数有这么一段逻辑:

这里是 Vue.options,而 createComponent 取的是 context.$options,实际上在 src/core/instance/init.js 里 Vue 原型上的 _init 函数中有这么一段逻辑:

这样就把 Vue 上的一些 option 扩展到了 vm.$options 上,所以我们也就能通过 vm.$options._base 拿到 Vue 这个构造函数了。

在了解了 baseCtor 指向了 Vue 之后,接着来看一下 Vue.extend 函数的定义,在 src/core/global-api/extend.js

Vue.extend 的作用就是构造一个 Vue 的子类,它使用一种非常经典的原型继承的方式把一个纯对象转换成一个继承 Vue 构造函数的 Sub 并返回,然后对 Sub 这个对象本身扩展了一些属性,如:扩展 options、添加全局 API 等;并且对配置中的 propscomputed 做了初始化工作;最后对这个 Sub 构造函数做了缓存,避免多次执行 Vue.extend 的时候对同一个子组件重复构造。

这样当我们去实例化 Sub 的时候,执行 this._init 逻辑就会再次走到 Vue 实例的初始化逻辑。

安装组件钩子函数

我们之前提到过,Vue.js 使用的 Virtual DOM 参考的是开源库 snabbdomarrow-up-right,它的一个特点是在 VNode 的 patch 流程中对外暴露了各种时机的钩子函数,方便我们做一些额外的事情, Vue.js 也是充分利用这一点,在初始化一个 Component 类型的 VNode 的过程中实现了几个钩子函数:

整个 installComponentHooks 的过程就是把 componentVNodeHooks 的钩子函数合并到 data.hook 中,在 VNode 执行 patch 的过程中执行相关的钩子函数,稍后在介绍 patch 过程时会详细介绍具体的执行。这里要注意的是合并策略,在合并过程中,如果某个时机的钩子已经存在 data.hook 中,那么通过执行 mergeHook 函数做合并,这个逻辑很简单,就是在最终执行的时候,依次执行这两个钩子函数即可。

实例化 VNode

最后一步非常简单,通过 new VNode 实例化一个 vnode 并返回。需要注意的是,这里和普通元素节点的 vnode 不同,组件的 vnode 是没有 children 的,这点很关键,在之后的 patch 过程中会再提及。

以上是 createComponent 的实现分析,在渲染一个组件的时候有 3 个关键逻辑:构造子类构造函数、安装组件钩子函数、实例化 vnode

patch

createComponent 创建了组件 VNode,接下来会走到 vm._update 方法,进而执行 vm.__patch__ 函数去把 VNode 转换成真正的 DOM 节点。

patch 过程会调用 createElm 创建元素节点,createElm 定义在 src/core/vdom/patch.js 文件中:

这里会判断 createComponent(vnode, insertedVnodeQueue, parentElm, refElm) 的返回值,如果为 true 则直接结束。createComponent 方法也是定义在 src/core/vdom/patch.js 文件中:

createComponent 函数中,首先对 vnode.data 做了一些判断:

如果 vnode 是一个组件 VNode,那么条件会满足,并且得到 i 就是 init 钩子函数,回顾上面我们在创建组件 VNode 的时候合并钩子函数中就包含 init 钩子函数,定义在 src/core/vdom/create-component.js 中:

init 钩子函数执行也很简单(我们先不考虑 keepAlive 的情况),它是通过 createComponentInstanceForVnode 创建一个 Vue 的实例,然后调用 $mount 方法挂载子组件。createComponentInstanceForVnode 的实现也在 src/core/vdom/create-component.js 文件中:

createComponentInstanceForVnode 函数构造了一个内部组件的参数,然后执行 new vnode.componentOptions.Ctor(options)。这里的 vnode.componentOptions.Ctor 对应的就是子组件的构造函数,上面我们分析了它实际上是继承于 Vue 的一个构造函数 Sub,所以这里相当于是 new Sub(options) 。这里有几个关键参数要注意: _isComponenttrue 表示它是一个组件,parent 表示当前激活的组件实例。

所以子组件的实例化实际上就是在这个时机执行的,并且它会执行实例的 _init 方法,这个过程有一些和之前不同的地方需要挑出来说, _init 代码在 src/core/instance/init.js 文件中:

这里首先是合并 options 的过程有变化, _isComponenttrue,所以走到了 initInternalComponent 过程。initInternalComponent 代码也在 src/core/instance/init.js 文件中:

这个过程重点记住以下几个点即可:opts.parent = options.parentopts._parentVnode = parentVnode,它们是把我们通过 createComponentInstanceForVnode 函数传入的几个参数合并到内部的选项 $options 里了。

再来看一下 _init 函数最后执行的代码:

由于组件初始化的时候是不传 el 的,因此组件是自己接管了 $mount 的过程,回到组件 init 的过程, componentVNodeHooksinit 钩子函数,在完成实例化的 _init 后,接着会执行 child.$mount(hydrating ? vnode.elm : undefined, hydrating)。这里 hydratingtrue 一般是服务端渲染的情况,我们现在只考虑客户端的渲染,所以这里 $mount 相当于执行 child.$mount(undefined, false) ,它最终会调用 mountComponent 方法,进而执行 vm._render 方法,vm._render 定义在 src/core/instance/render.js 文件中:

上面的代码只保留了关键部分,这里的 _parentVnode 就是当前组件的父 VNode,而 render 函数生成的 vnode 就是当前组件的渲染 vnodevnodeparent 指向了 _parentVnode,也就是 vm.$vnode 它们是一种父子关系。

我们知道在执行完 vm._render,生成 VNode 后,接下来就要执行 vm._update 去渲染 VNode 了。组件渲染的过程也有一些需要注意的, vm._update 定义在 src/core/instance/lifecycle.js 文件中:

_update 过程中有几个关键的代码:首先 vm._vnode = vnode 的逻辑,这个 vnode 是通过 vm._render() 返回的组件渲染 VNode,vm._vnodevm.$vnode 的关系就是一种父子关系,用代码表达就是 vm._vnode.parent === vm.$vnode。还有一段比较有意思的代码:

这里的 activeInstance 作用就是保持当前上下文的 Vue 实例,它是在 src/core/instance/lifecycle.js 文件中定义的全局变量 export let activeInstance: any = null,并且在之前我们调用 createComponentInstanceForVnode 方法的时候,就是从 src/core/instance/lifecycle.js 中引入的 activeInstance 并作为参数传入到 createComponentInstanceForVnode 中的。因为实际上 JavaScript 引擎是单线程的,Vue 整个初始化是一个深度遍历的过程,在实例化子组件的过程中,它需要知道当前上下文的 Vue 实例是什么,并把它作为子组件的父 Vue 实例。之前我们提到过对子组件的实例化过程会先调用 initInternalComponent(vm, options) 合并 options,把 parent存储在 vm.$options 中,在 $mount 之前会调用 initLifecycle(vm) 方法:

可以看到 vm.$parent 就是用来保存当前 vm 的父实例,并且通过 parent.$children.push(vm),把当前的 vm 存储到父实例的 $children 中。

vm._update 的过程中,把当前的 vm 赋值给 activeInstance,同时通过 const prevActiveInstance = activeInstanceprevActiveInstance 保留上一次的 activeInstance。实际上, prevActiveInstance 和当前的 vm 是一个父子关系,当一个 vm 实例完成它的所有子树的 patch 或者 update 过程后, activeInstance 会回到它的父实例,这样就完美地保证了 createComponentInstanceForVnode 整个深度遍历过程中,在实例化子组件的时候能传入当前子组件的父 Vue 实例,并在 _init 的过程中,通过 vm.$parent 把这个父子关系保留。

那么回到 _update,最后就是调用 __patch__ 渲染 VNode 了。

这里又回到了之前开始的过程,之前分析过负责渲染成 DOM 的函数是 createElmcreateElm 也是定义在 src/core/vdom/patch.js 文件中,不过要注意这里我们只传了 2 个参数,所以对应的 parentElmundefined

注意,这里我们传入的 vnode 是组件渲染的 vnode,也就是我们之前说的 vm._vnode,如果组件的根元素是个普通元素,那么 vm._vnode 也是普通的 vnode,这里 createComponent(vnode, insertedVnodeQueue, parentElm, refElm) 的返回值是 false。那么接下来的过程就跟之前说过的一样,先创建一个父节点占位符,然后再遍历所有子 VNode 递归调用 createElm,在遍历的过程中,如果遇到子 VNode 是一个组件的 VNode,则重复本节开始的过程,这样通过一个递归的方式就可以完整地构建了整个组件树。

由于我们这个时候传入的 parentElm 是空,所以对组件的插入,在 createComponent 有这么一段逻辑:

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

合并配置

通过之前的源码分析我们知道, new Vue 的过程通常由 2 种场景,一种是外部用户主动调用 new Vue(options) 的方式实例化一个 Vue 对象;另一种是我们上面分析的组件创建过程中内部通过 new Vue(options) 实例化子组件。

无论哪种场景,都会执行实例的 _init(options) 方法,它首先会先执行一个 merge options 的逻辑,相关代码在 src/core/instance/init.js 中:

可以看到不同场景对于 options 的合并逻辑是不一样的,并且传入的 options 值也有非常大的不同,接下来分别介绍 2 中场景的 options 合并过程。

为了更直观,我们可以举个简单的示例:

外部调用场景

当执行 new Vue 的时候,执行 this._init(options) 就会执行如下逻辑去合并 options

这里通过调用 mergeOptions 方法来合并,它实际上就是把 resolveConstructorOptions(vm.constructor) 的返回值和 options 做合并,resolveConstructorOptions 我们之前分析过,在我们这个场景下,它还是简单返回 vm.constructor.options,而 vm.constructor 就是 Vue,所以相当于 Vue.optionsVue.options 其实是在 initGlobalAPI(Vue) 的时候定义的,代码在 src/core/global-api/index.js 中:

这里之前分析过,整理了 Vue.options 应该是如下结构:

所以刚开始的示例最后 vm.$options 的值差不多是如下这样:

组件场景

由于组件的构造函数是通过 Vue.extend 继承自 Vue 的,先回顾一下这个过程,代码定义在 src/core/global-api/extend.js 中:

上面的代码只保留了关键逻辑,这里的 extendOptions 对应的就是前面定义的组件对象,它会和 Vue.options 合并到 Sub.options 中。

接下来,我们再回忆一下子组件的初始化过程,代码定义在 src/core/vdom/create-component.js 中:

这里的 vnode.componentOptions.Ctor 就是指向 Vue.extend 的返回值 Sub,所以执行 new vnode.componentOptions.Ctor(options) 接着执行 this._init(options) 因为 options._isComponent 为 true,那么合并 options 的过程就走到了 initInternalComponent(vm, options) 逻辑。initInternalComponent 定义在 src/core/instance/init.js 中:

initInternalComponent 方法首先执行 const opts = (vm.$options = Object.create(vm.constructor.options)),这里的 vm.constructor 就是子组件的构造函数 Sub,相当于 vm.$options = Object.create(Sub.options)

接着又把实例化子组件传入的子组件父 VNode 实例 parentVnode、子组件的父 Vue 实例 parent 保存到 vm.$options 中,另外还保留了 parentVnode 配置中的如 propsData 等其他属性。

这么看来,initInternalComponent 只是做了简单一层对象赋值,并不涉及递归,合并策略等复杂逻辑。

因此,在我们当前这个 case 下,执行完如下合并后:

vm.$options 的值差不多是如下这样:

声明周期

每个 Vue 实例在被创建之前都要经过一系列的初始化过程。例如需要设置数据监听、编译模板、挂载实例到 DOM、在数据变化时更新 DOM 等。同时在这个过程中也会运行一些叫做生命周期钩子的函数,给用户在一些特定的场景下添加他们自己的代码的机会。

在我们实际项目开发过程中,会非常频繁地和 Vue 组件的生命周期打交道,接下来我们就从源码的角度来看一下这些生命周期的钩子函数是如何被执行的。

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

callHook 函数的逻辑很简单,根据传入的字符串 hook,去拿到 vm.$options[hook] 对应的回调函数数组,然后遍历执行,执行的时候把 vm 作为函数执行的上下文。

上一小节,我们详细地介绍了 Vue.js 合并 options 的过程,各个阶段的生命周期的函数也被合并到 vm.$options 里,并且是一个数组。因此 callHook 函数的功能就是调用某个生命周期钩子注册的所有回调函数。

了解了生命周期的执行方式后,接下来我们会具体介绍每一个生命周期钩子函数的调用时机。

beforeCreate & created

beforeCreatecreated 函数都是在实例化 Vue 的阶段,在 _init 方法中执行的,它定义在 src/core/instance/init.js 中:

可以看到 beforeCreatecreated 的钩子调用是在 initState 的前后,initState 的作用是初始化 propsdatamethodswatchcomputed 等属性,那么显然 beforeCreate 的钩子函数中就不能获取到 propsdata 中定义的值,也不能调用 methods 中定义的方法。

在这 2 个钩子函数执行的时候,并没有渲染 DOM ,所以我们也不能够访问 DOM,一般来说,如果组件在加载的时候需要和后端有交互,放在这 2 个钩子函数执行都可以,如果是需要访问 propsdata 等数据的话,就需要使用 created 钩子函数。

我们如果去看 vue-router 和 vuex 的时候就会发现它们都混合了 beforeCreate 钩子函数。

beforeMount & mounted

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

在执行 vm._render() 函数渲染 VNode 之前,执行了 beforeMount 钩子函数,在执行完 vm._update() 把 VNode patch 到真实 DOM 后,执行 mounted 钩子。注意,这里对 mounted 钩子函数执行有一个判断逻辑,vm.$vnode 如果为 null,则表明这不是一次组件的初始化过程,而是我们通过外部 new Vue 初始化过程。

那么对于组件,它的 mounted 时机在哪儿呢?之前我们提到过,组件的 VNode patch 到 DOM 后,会执行 invokeInsertHook 函数,把 insertedVnodeQueue 里保存的钩子函数一次执行一遍,它定义在 src/core/vdom/patch.js 中:

该函数会执行 insert 这个钩子函数,对于组件而言, insert 钩子函数的定义在 src/core/vdom/create-component.js 中的 componentVNodeHooks 中:

我们可以看到,每个子组件都是在这个钩子函数中执行 mounted 钩子函数,并且我们之前分析过, insertedVnodeQueue 的添加顺序是先子后父,所以对于同步渲染的子组件而言,mounted 钩子函数的执行顺序也是先子后父。

beforeUpdate & updated

顾名思义, beforeUpdateupdated 的钩子函数执行时机都应该是在数据更新的时候。

beforeUpdate 的执行时机是在渲染 Watcher 的 before 函数中,我们刚才提到了:

注意这里有个判断,也就是在组件已经 mounted 之后并且没有 destroyed,才会去调用这个钩子函数。

update 的执行时机是在 flushSchedulerQueue 函数调用的时候,它定义在 src/core/observer/scheduler.js 中:

updateQueue 是更新了的 watcher 数组,那么在 callUpdatedHooks 函数中,它对这些数组做遍历,只有满足当前 watchervm._watcher, 并且组件已经 mounted,以及组件没有 destroyed 这三个条件,才会执行 updated 钩子函数。

我们之前提到过,在组件 mount 的过程中,会实例化一个渲染的 Watcher 去监听 vm 上的数据变化重新渲染,这段逻辑发生在 mountComponent 函数执行的时候:

那么在实例化 Watcher 的过程中,在它的构造函数里会判断 isRenderWatcher,接着把当前 watcher 的实例赋值给 vm._watcher, 定义在 src/core/observer/watcher.js 中:

同时,还把当前 watcher 实例 push 到 vm._watchers 中, vm._watcher 是专门用来监听 vm 上数据变化然后重新渲染的 watcher,因此在 callUpdatedHooks 函数中,只有 vm._watcher 的回调执行完毕后,才会执行 updated 钩子函数。

beforeDestroy & destroyed

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

beforeDestroy 钩子函数的执行时机在 $destroy 函数执行最开始的地方,接着执行了一系列的销毁动作,包括从 parent$children 中删掉自身,删除 watcher,当前渲染的 VNode 执行销毁钩子函数等,执行完毕后再调用 destroyed 钩子函数。

$destroy 的执行过程中,它又会执行 vm.__patch__(vm._vnode, null) 触发它的子组件的销毁钩子函数,这样一层层的递归调用,所以 destroyed 钩子函数执行顺序是先子后父,和 mounted 过程一样。

activated & deactivated

activateddeactivated 钩子函数是专门为 keep-alive 组件定制的钩子函数。

组件注册

在 Vue.js 中,除了它内置的组件,如 keep-alivetransitiontransition-group 等,其他用户自定义组件在使用前必须注册。在开发过程中可能会遇到如下报错信息:

一般报这个错的原因都是使用了未注册的组件。Vue.js 提供了 2 种组件的注册方式,全局注册和局部注册。接下来从源码的角度来分析这两种注册方式:

全局注册

要注册一个全局组件,可以使用 Vue.component(tagName, options)。例如:

那么, Vue.component 函数是在什么时候定义的呢,它的定义过程发生在最开始初始化 Vue 的全局函数的时候,代码在 src/core/global-api/assets.js 中:

函数首先遍历 ASSET_TYPES ,得到 type 后挂载到 Vue 上。 ASSET_TYPES 定义在 src/shared/constants.js中:

所以在 initAssetRegisters 方法中,实际上 Vue 是初始化了 3 个全局函数,并且如果 typecomponentdefinition 是一个对象的话,通过 this.options._base.extend,相当于 Vue.extend 把这个对象转换成一个继承于 Vue 的构造函数,最后通过 this.options[type + 's'][id] = definition 把它挂载到 Vue.options.components 上。

由于我们每个组件的创建都是通过 Vue.extend 继承而来,我们之前分析过在继承的过程中有那么一段逻辑:

也就是说它会把 Vue.options 合并到 Sub.options,也就是组件的 options 上,然后在组件的实例化阶段,会执行 merge options 逻辑,把 Sub.options.components 合并到 vm.$options.components 上。

然后在创建 vnode 的过程中,会执行 _createElement 方法,我们再来回顾一下这部分的逻辑,它定义在 src/core/vdom/create-element.js 中:

这里有一个判断逻辑 isDef((Ctor = resolveAsset(context.$options, 'components', tag))) 先来看一下 resolveAsset 的定义,在 src/core/utils/options.js 中:

这段逻辑很简单,先通过 const assets = options[type] 拿到 assets,然后再尝试拿 assets[id],这里有个顺序,先直接使用 id 拿,如果不存在,则把 id 变成驼峰的形式再拿,如果仍然不存在,则在驼峰的基础上把首字母再变成大写的形式(大驼峰)再拿,如果仍然拿不到则报错。这样说明了我们在使用 Vue.component(id, definition) 全局注册组件的时候,id 可以是连字符、小驼峰或大驼峰的形式。

那么回到我们的调用 resolveAsset(context.$options, 'components', tag),即拿 vm.$options.components[tag],这样我们就可以在 resolveAsset 的时候拿到这个组件的构造函数,并作为 createComponent 函数的参数。

局部注册

Vue.js 也同样支持局部注册组件,我们可以在一个组件内部使用 components 选项做组件的局部注册,例如:

其实理解了全局注册的过程,局部注册是非常简单的,在组件的 Vue 的实例化阶段有一个合并 options 的逻辑,之前我们也分析过,其中就有把 components 合并到 vm.$options.components 上,这样就可以在 resolveAsset 的时候拿到这个组件的构造函数,并作为 createComponent 函数的参数。

注意:局部注册和全局注册不同的是,只有在组件引用并注册了,才能访问局部注册的子组件,而全局注册是扩展到 Vue.options 下,所以在所有组件的创建过程中,都会从全局的 Vue.options.components 扩展到当前组件的 vm.$options.components 下,这就是全局注册的组件能被任意使用的原因。

异步组件

在我们平时的开发工作中,为了减少首屏代码体积,往往会把一些非首屏的组件设计成异步组件,按需加载。Vue 也原生支持了异步组件的能力,如下:

示例中可以看到,Vue 注册的组件不再是一个对象,而是一个工厂函数,函数有两个参数 resolvereject,函数内部用 setTimeout 模拟了异步,实际使用可能是通过动态请求异步组件的 JS 地址,最终通过执行 resolve 方法,它的参数就是我们的异步组件对象。

在了解了异步组件如何注册后,我们从源码的角度来分析一下它的实现。

上一节,我们分析了组件的注册逻辑,由于异步组件的定义并不是一个普通对象,所以不会执行 Vue.extend 的逻辑把它变成一个组件的构造函数,但是它仍然可以执行到 createComponent 函数,我们再来对这个函数做回顾,它定义在 src/core/vdom/create-component.js 中:

我们省略了不必要的逻辑,只保留关键逻辑,由于我们这个时候传入的 Ctor 是一个函数,那么它也并不会执行 Vue.extend 逻辑,因此它的 cidundefined,进入了异步组件创建的逻辑。这里首先执行了 Ctor = resolveAsyncComponent(asyncFactory, baseCtor) 方法,它定义在 src/core/vdom/helpers/resolve-async-component.js 中:

resolveAsyncComponent 函数的逻辑略复杂,因为它实际上处理了 3 种异步组件的创建方式,除了刚才示例的组件注册方式,还支持 2 种,一种是支持 Promise 创建组件的方式,如下:

另一种是高级异步组件,如下:

那么接下来,就根据这 3 种异步组件的情况,来分别去分析 resolveAsyncComponent 的逻辑。

1、普通函数异步组件

针对普通函数的情况,前面几个 if 判断可以忽略,它们是为高级组件所用,对于 factory.owners 的判断,是考虑到多个地方同时初始化一个异步组件,那么它的实际加载应该只有一次。接着进入实际加载逻辑,定义了 forceRenderresolvereject 函数,注意 resolvereject 函数用 once 函数做了一层包装,once 函数定义在 src/shared/util.js 中:

once 逻辑非常简单,传入一个函数,并返回一个新函数,它非常巧妙地利用闭包和一个标志位保证了它包装的函数只会执行一次,在本小节中,就是确保了 resolvereject 函数只执行一次。

接下来执行 const res = factory(resolve, reject) 逻辑,这块儿就是执行我们异步组件的工厂函数,同时把 resolvereject 函数作为参数传入,组件的工厂函数通常会先发送请求去加载异步组件的 JS 文件,拿到组件定义的对象 res 后,执行 resolve(res) 逻辑,它会先执行 factory.resolved = ensureCtor(res, baseCtor)

这个函数目的是为了保证能找到异步组件 JS 定义的组件对象,并且如果它是一个普通对象,则调用 Vue.extend 把它转换成一个组件的构造函数。

resolve 逻辑最后判断了 sync,显然我们这个场景下 sync 为 false,那么就会执行 forceRender 函数,它会遍历 factory.owners,拿到每一个调用异步组件的实例 vm,执行 vm.$forceUpdate() 方法,它定义在 src/core/instance/lifecycle.js 中:

$forceUpdate 的逻辑非常简单,就是调用渲染 watcherupdate 方法,让渲染 watcher 对应的回调函数执行,也就是触发了组件的重新渲染。之所以这么做是因为 Vue 通常是数据驱动视图重新渲染,但是在整个异步组件加载过程中是没有数据发生变化的,所以通过执行 $forceUpdate 可以强制组件重新渲染一次。

2、 Promise 异步组件

webpack 2+ 支持了异步加载的语法糖: () => import('./my-async-component') ,当执行完 res = factory(resolve, reject),返回的值是 import('./my-async-component') 的返回值,它是一个 Promise 对象,接着进入 if 条件,判断了 isPromise(res) ,条件满足,执行:

当组件异步加载成功后,执行 resolve, 加载失败则执行 reject,这样就非常巧妙地实现了配合 webpack 2+ 的异步加载组件的方式(Promise)加载异步组件。

3、高级异步组件

由于异步加载组件需要动态加载 JS,有一定网络延时,而且有加载失败的情况,所以通常我们在开发异步组件相关逻辑的时候需要设计 loading 组件和 error 组件,并在适当的实际渲染它们。 Vue.js 2.3+支持了一种高级异步组件的方式,他通过一个简单的对象配置,帮你搞定 loading 组件和 error 组件的渲染时机,你完全不用关心细节,非常方便。接下来,我们就从源码的角度来分析高级异步组件是怎么实现的。

高级异步组件的初始化逻辑和普通异步组件一样,也是执行 resolveAsyncComponent,当执行完 res = factory(resolve, reject),返回值就是定义的组件对象,显然满足 else if (isPromise(res.component)) 的逻辑,接着执行 res.component.then(resolve, reject),当异步组件加载成功后,执行 resolve,失败执行 reject

因为异步组件加载是一个异步过程,它接着又同步执行了如下逻辑:

先判断 res.error 是否定义了 error 组件,如果有的话则赋值给 factory.errorComp。接着判断 res.loading 是否定义了 loading 组件,如果有的话则赋值给 factory.loadingComp,如果设置了 res.delay 且为 0,就立即执行 facotry.loading = true,否则延时 delay 的时间执行。

最后判断 res.timeout 如果配置了该项,则超过 res.timeout 时间,如果组件没有成功加载,执行 reject

resolveAsyncComponent 的最后有一段逻辑:

如果 delay 的配置为 0,则这次直接渲染 loading 组件,否则延时 delay 执行 forceRender,那么又会再一次执行到 resolveAsyncComponent

那么这时候,我们有几种情况,按逻辑的执行顺序,对不同的情况做判断。

异步组件加载失败

当异步组件加载失败,会执行 reject 函数:

这个时候会把 factory.error 设置为 true,同步执行 forceRender() 再次执行到 resolveAsyncComponent

那么这个时候就返回 factory.errorComp,直接渲染 error 组件。

异步组件加载成功

当异步组件加载成功,会执行 resolve 函数:

首先把加载结果缓存到 factory.resolved 中,这个时候因为 sync 已经是 false,则执行 forceRender 再次执行到 resolveAsyncComponent

那么这个时候直接返回 factory.resolved,渲染成功加载的组件。

异步组件加载中

如果异步组件加载中并未返回,这时候会走到这个逻辑:

那么则会返回 factory.loadingComp,渲染 loading 组件。

异步组件加载超时

如果超时,则走到了 reject 逻辑,之后逻辑和加载失败一样,渲染 error 组件。

异步组件 patch

回到 createComponent 的逻辑:

如果是第一次执行 resolveAsyncComponent,除非使用高级异步组件 0 delay 创建了一个 loading 组件,否则返回是 undefined,接着通过 createAsyncPlaceholder 创建一个注释节点作为占位符。它定义在 src/core/vdom/helpers/resolve-async-component.js 中:

实际上就是创建一个占位的注释 VNode,同时把 asyncFactoryasyncMeta 赋值给当前 vnode

当运行 forceRender 的时候,会触发组件的重新渲染,那么会再一次执行 resolveAsyncComponent,这个时候就会根据不同的情况,可能返回 loading、error 或成功加载的异步组件,返回值不为 undefined,因此就走正常的组件 renderpatch 过程,与组件第一次渲染流程不一样,这个时候是存在新旧 vnode 的,会执行组件更新的 patch 过程。

组件更新

组件的更新还是调用了 vm._update 方法,我们再回顾一下这个方法,它定义在 src/core/instance/lifecycle.js 中:

组件更新的过程,会执行 vm.$el = vm.__patch__(prevVnode, vnode),它仍然会调用 patch 函数,在 src/core/vdom/patch.js 中定义:

这里执行 patch 的逻辑和首次渲染是不一样的,因为 oldVnode 不为空,并且它和 vnode 都是 VNode 类型,接下来会通过 sameVnode(oldVnode, vnode) 判断它们是否是相同的 VNode 来决定走不同的更新逻辑:

sameVnode 的逻辑非常简单,如果两个 vnodekey 不相等,则是不同的;否则继续判断,对于同步组件,则判断 isCommentdatainput 类型等是否相同,对于异步组件,则判断 asyncFactory 是否相同。

所以根据新旧 vnode 是否为 sameVnode 会走到不同的更新逻辑,我们先来说一下不同的情况。

新旧节点不同

如果新旧 vnode 不同,那么更新的逻辑非常简单,它本质上是要替换已存在的节点,大致分为 3 步:

  • 创建新节点

以当前旧节点为参考节点,创建新的节点,并插入到 DOM 中。

  • 更新父的占位符节点

我们只关注主要逻辑即可,找到当前 vnode 的父的占位符节点,先执行各个 moduledestroy 的钩子函数,如果当前占位符是一个可挂载的节点,则执行 modulecreate 钩子函数。

  • 删除旧节点

Last updated