Vue3源码学习之三根树:组件树、Vnode Tree、Dom树(持续更新..)

1,786 阅读12分钟

大家都忙着解析各种Vue3的源代码,而我连Vue2的源码还没整明白,这就是差距,手捧《人生模式》一书,还需加倍努力呀!!!把以前写好的草稿发出来,以前写的标题是《Vue源码学习之三根树:组件树、Vnode Tree、Dom树》,很明显已经落伍了,Vue3已经出来好久了,果断把标题改为《Vue2源码学习之三根树:组件树、Vnode Tree、Dom树》,但是后来一想Vue3里这三根树应该还在吧?所以就很不负责任地把标题改为《Vue3源码学习之三根树:组件树、Vnode Tree、Dom树》,本文持续更新中...

备注1,本文不是很详细的Vue源码解析的文章,只是我个人学习Vue源码过程的的笔记,粒度比较粗,只抓核心要点,对于阅读过Vue源码或者看过类似文章的人可能有帮助,对新手是无意义的,推荐你看Vue源码学习Vue技术内幕(Vue3),Vue技术揭秘 这三个资料,本文就是在Vue源码学习基础上的抽象和精简

备注2,Vue源码中的一些约定,以$开头的变量为Vue实例的实例变量,这些变量是Vue公开的Api,例如诸如$children,$root,$options等变量,这些变量我们可以在业务代码中安全的引用,而以_开头的变量为Vue引擎的内部变量,例如本文将要学习的_vnode变量,这些变量我们不能在业务代码中使用(不安全,说不定那天就没了)

备注3,因为Vue本身是面向多种平台的,所以源代码中充斥着各种封装和跳转,本文则统统的略过,因此表述上可能不是太精确,请别介意

从一万英尺的高度看Vue源码,我就看到了Vue的实例化,然后就是互相纠缠的3根树

Vue代码的执行就是Vue组件的实例化,实例化之后,在浏览器的内存中会有三根树。

  • 组件的实例化的过程分为init、mount、render、update(patch)几个关键的步骤,最后会在内存中生成组件树、Vnode Tree,Dom树等至少三根树。
  • Vue中的组件树、Vnode树、Dom 树这三根树的节点互相纠缠、互相指向,组件会指向vnode,vnode会指向组件(准确地说,只有特殊的Vnode即组件Vnode,或者称之为占位Vnode才会指向组件),Vnode会指向Dom 节点,三者会呈现一个你中有我、我中有你的比较happy的境界,请看下面的示意图,你是否联想到了什么

先看一下本文要用到的代码,一个非常简单的html文件

<script src="./vue.js"></script>
<script>
Vue.component('child-component', {
  props: [],
  data: function () {
    return {
    }
  },
  template: '<div>我是子组件</div>'
})
</script>
<div id="root-components">
  <div>我是根组件</div>
  <div>子Vnode</div>
  <child-component></child-component>
</div>
<script>
var app = new Vue({
  el: '#root-components',
  data: function () {
    return {
    }
  },
})
</script>

上述代码中的变量app浏览器的示意图如下

子组件

组件树

父组件通过children指向子组件子组件通过$parent指向了父组件

Vnode树

父Vnode 通过children指向子Vnode,普通子Vnodeparent的指向为空,只有一种特殊的Vnodeparent指向才非空,子组件渲染Vnode_vondeparent为非空,指向该子组件占位Vnode $vnode

组件树与Vnode树的互相指向

组件通过 _vnode指向自己的渲染Vnode,如果一个组件是子组件(非根组件)的话,vm.$vnode会指向组件的占位Vnode,二者的关系是 vm._vnode.parent === vm.$vnode

普通的Vnode没有到组件的指向,组件占位Vnode通过componentInstance指向组件

一句话,就是绕!

完毕!

从一千英尺的高度再看组件实例化过程,即这几根树的生成过程

根组件的实例化 VS 普通组件实例化

  • 根组件实例:是指在main.js里显示调用new Vue(options)生成的实例
  • 普通组件实例:是指只定义了组件选项对象,在生成 DOM Tree 的过程中隐式调用new vnode.componentOptions.Ctor(options)生成的组件

组件实例化分为init\mount\render\update几个步骤

组件实例的初始化工作:

略过

mount过程,主要做了以下工作:

  1. 调用beforeMount钩子
  2. 定义渲染 Watcher 的表达式
  3. 创建渲染 Watcher,且 Watcher 实例会首次计算表达式,创建 VNode Tree,进而生成 DOM Tree
  4. (对于根组件)调用mounted钩子
  5. 返回组件实例vm

这里的最重要的是渲染 Watcher 的表达式updateComponent函数。在表达式updateComponent函数里,vm._render()将执行组件的vm.$options.render方法创建并返回组件的 VNode Tree。而vm._update()方法将基于组件的 VNode Tree 生成 DOM Tree。

Vue.prototype._render

渲染函数本质就是创建vnode,即各种递归调用_createElement函数,而createElment函数就是创建Vnode

创建 VNode

创建Vnode分为普通Vnode(就是诸如div、span这类vnode)和组件vnode

创建普通的 VNode

太简单了,略。

创建组件节点的 VNode

export function createComponent (
  Ctor: Class<Component> | Function | Object | void,
  data: ?VNodeData,
  context: Component,
  children: ?Array<VNode>,
  tag?: string
): VNode | Array<VNode> | void {
  if (isUndef(Ctor)) {
    return
  }
  ...省略n行
    // install component management hooks onto the placeholder node
  // 安装组件管理钩子方法
  installComponentHooks(data)

  // return a placeholder vnode
  // 注意:针对所有的组件,返回的 vnode 都是占位 vnode
  const name = Ctor.options.name || tag
  const vnode = new VNode(
    `vue-component-${Ctor.cid}${name ? `-${name}` : ''}`,
    data, undefined, undefined, undefined, context,
    // vnode.componentOptions
    { Ctor, propsData, listeners, tag, children },
    asyncFactory
  )
  ...省略n行

创建组件的 VNode 就要复杂很多,需要处理组件的各种情况和数据等,我们只抓核心,支线部分都略过。核心部分主要是安装组件占位Vnode的钩子函数和创建组件占位Vnode

组件节点的 VNode,我们一般称之为组件占位 VNode,因为该 VNode 在最终创建的 DOM Tree 并不会存在一个 DOM 节点与之一一对应,即它只出现在 VNode Tree 里,但不出现在 DOM Tree 里。

代码分析都这里为止,父组件(在这里是根组件)实例和父组件的vnode树都构建好了,子组件对应的占位vnode也好了,不过,子组件的实例和子组件对应的vnode树好像还没有构建,其实,这些工作都将在在patch的过程中完成。

备注,组件在创建组件占位 VNode 之前,会往组件的data对象上安装initprepatchinsertdestroy等管理组件的钩子方法,方便在调用vm.__patch__期间,为组件占位 VNode 提供额外的功能,比如创建组件实例、等操作。

Vue.prototype._update

Vue.prototype._update是对vm.__patch__方法的封装,真正创建/更新(包括销毁)DOM Tree 是由vm.__patch__方法来完成的,而_update方法做一些调用vm.__patch__前后的处理。

在调用vm.__patch__时,将根据是否存在旧 VNode 节点prevVnode,确定是组件的首次渲染还是再次更新,从而传入不同的参数。

patch

patch的本质就是根据新旧vnode的比较,创建或者更新dom节点/组件实例,如果是首次的话,那就创建dom或者组件实例

createElm

备注,注意区分这里的createElm 和前面提到的createElement, createElm 的结果是DOM或者是子组件的实例,而createElement返回的结果是vnode,说实话,Vue源码中变量和方法的名字取得有点...

组件的首次patch时,肯定要为所有的 VNode 节点创建对应的 DOM 节点,而在组件更新的过程中,也有可能需要为新增的 VNode 节点创建 DOM 节点。

createElm,顾名思义,就是创建 VNode 节点的vnode.elm。不同类型的 VNode,其vnode.elm的和创建过程也不相同。对于组件占位 VNode 来说,会调用createComponent来创建组件占位 VNode 的组件实例;对于非组件占位 VNode 来说,会创建对应的 DOM 节点。

创建组件实例

当调用createElm为 VNode 创建对应的 DOM 节点时,会先调用createComponent,以判断该 VNode 是否是组件占位 VNode。如果是,则进入到创建组件实例的流程,最终createComponent返回true并结束createElm的过程;若该 VNode 不是组件占位 VNode,createComponent返回false,继续为非组件占位 VNode 创建对应的 DOM 元素/文本/注释节点。

详见下文的[创建子组件实例]

创建 DOM 节点

太简单,略掉

创建子组件实例

在[创建组件实例]中我们知道,根组件是用户显式调用new Vue()创建的 Vue 实例。除根组件实例之外的 Vue 实例,我们统称为子组件实例。而子组件,都是在根组件patch的过程中创建的。

PS:一般所说的组件,都是指子组件,当指根组件时,会强调是根组件。

当调用createElm为 VNode 创建对应的 DOM 节点时,会先判断该 VNode 是否是组件占位节点。如果是,则创建组件实例,并结束createElm的过程;否则,继续为非组件占位 VNode 创建对应的 DOM 元素/文本/注释节点。

  function createElm (
    vnode,
    insertedVnodeQueue,
    parentElm,
    refElm,
    nested,
    ownerArray,
    index
  ) {
    // ...
    // 组件占位 VNode:创建组件实例以及创建整个组件的 DOM Tree,(若 parentElm 存在)并插入到父元素上
    if (createComponent(vnode, insertedVnodeQueue, parentElm, refElm)) {
      return
    }
    // ...
  }

createComponent

createComponent主要负责创建组件占位 VNode 的组件实例并做一些事后处理工作,而对于非组件占位 VNode 将不做任何操作并返回undefined

我们在为组件创建组件占位 VNode 时,会在组件占位 VNode 的vnode.data.hook上[安装一系列的组件管理钩子方法],其中就存在init钩子。

若传入的 VNode 是组件占位 VNode,则将存在vnode.data.hook.init()钩子,调用init钩子后,将为组件占位 VNode 创建组件实例vnode.componentInstance。因此针对组件占位 VNode,createComponent函数最终将返回true,以表明该传入的 VNode 是组件占位 VNode,并完成了组件实例的创建工作。

反之,若传入的 VNode 不是组件占位 VNode,则不会存在vnode.data.hook.init()钩子,更加不会创建出组件实例vnode.componentInstance,因此最终createComponent函数将返回undefinedcreateElm函数将继续往下执行,为非组件占位 VNode 创建对应的 DOM 节点。

createComponent的主要流程为:

  1. 若 VNode 存在vnode.data.hook.init方法,说明是组件占位 VNode,则创建组件实例,挂在vnode.componentInstance
  2. vnode.componentInstance存在
    • 初始化组件实例,设置vnode.elm
    • 将组件的 DOM Tree 插入到父元素上
    • 返回 true
  3. 针对非组件占位 VNode,返回undefined
// src/core/vdom/patch.js
export function createPatchFunction (backend) {
  // ...
  /**
   * 创建组件占位 VNode 的组件实例
   * @param {*} vnode 组件占位 VNode
   * @param {*} insertedVnodeQueue
   * @param {*} parentElm DOM 父元素节点
   * @param {*} refElm DOM nextSibling 元素节点,如果存在,组件将插入到 parentElm 之下,refElm 之前
   */
  function createComponent (vnode, insertedVnodeQueue, parentElm, refElm) {
    let i = vnode.data
    if (isDef(i)) {
      // 是否是重新激活的节点(keep-alive 的组件 activated 了)
      const isReactivated = isDef(vnode.componentInstance) && i.keepAlive
      if (isDef(i = i.hook) && isDef(i = i.init)) {
        // 若是 vnode.data.hook.init 存在(该方法是在 create-component.js 里创建组件的 Vnode 时添加的)
        // 说明是组件占位 VNode,则调用 init 方法创建组件实例 vnode.componentInstance
        i(vnode, false /* hydrating */)
      }
      // after calling the init hook, if the vnode is a child component
      // it should've created a child instance and mounted it. the child
      // component also has set the placeholder vnode's elm.
      // in that case we can just return the element and be done.
      // 注释翻译:
      // 若是该 VNode 是子组件(的占位 VNode),调用 init 钩子方法后,该 VNode 将创建子组件实例并挂载了
      // 子组件也设置了占位 VNode 的 vnode.elm。此种情况,我们就能返回 true 表明完成了组件实例的创建。
      if (isDef(vnode.componentInstance)) {
        // 初始化组件实例
        initComponent(vnode, insertedVnodeQueue)
        // 将组件 DOM 根节点插入到父元素下
        insert(parentElm, vnode.elm, refElm)
        if (isTrue(isReactivated)) {
          reactivateComponent(vnode, insertedVnodeQueue, parentElm, refElm)
        }
        return true
      }
    }
  }
  // ...
}

vnode.data.hook.init

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

const componentVNodeHooks = {
  init (vnode: VNodeWithData, hydrating: boolean): ?boolean {
    if (
      vnode.componentInstance &&
      !vnode.componentInstance._isDestroyed &&
      vnode.data.keepAlive
    ) {
      // kept-alive components, treat as a patch
      const mountedNode: any = vnode // work around flow
      componentVNodeHooks.prepatch(mountedNode, mountedNode)
    } else {
      // 创建子组件实例
      const child = vnode.componentInstance = createComponentInstanceForVnode(
        vnode,
        activeInstance
      )
      // 对于正常的子组件初始化,会执行 $mount(undefined)
      // 这样将创建组件的渲染 VNode 并创建其 DOM Tree,但是不会将 DOM Tree 插入到父元素上
      child.$mount(hydrating ? vnode.elm : undefined, hydrating)
    }
  }
}

/**
 * 创建子组件实例
 * @param {*} vnode 组件占位 VNode
 * @param {*} parent 创建该组件时,处于活动状态的父组件,如此形成组件链
 */
export function createComponentInstanceForVnode (
  vnode: any, // we know it's MountedComponentVNode but flow doesn't
  parent: any, // activeInstance in lifecycle state
): Component {
  // 创建子组件实例时,传入的 options 选项
  const options: InternalComponentOptions = {
    // 标明是内部子组件,在调用组件的 _init 初始化时,将采用简单的配置合并策略
    _isComponent: true,
    // 组件的占位 VNode
    _parentVnode: vnode,
    // 当前处于活动状态的父组件
    parent
  }
  // check inline-template render functions
  const inlineTemplate = vnode.data.inlineTemplate
  if (isDef(inlineTemplate)) {
    options.render = inlineTemplate.render
    options.staticRenderFns = inlineTemplate.staticRenderFns
  }
  return new vnode.componentOptions.Ctor(options)
}

init钩子方法里,会先调用createComponentInstanceForVnode创建子组件的实例。

createComponentInstanceForVnode函数里,vnode.componentOptions.Ctor是在为组件创建 VNode 时传入的组件构造函数,该构造函数是基于Vue构造函数继承而来,并混合了组件自身的选项在Ctor.options里。此外,创建实例时,也会往Ctor里传入options选项,但是这个options跟创建根组件传入的options有些许区别。

  • _isComponent: true:用来标明这个组件是内部子组件,在调用组件的_init方法初始化时,将采用简单的配置合并
  • _parentVnode: vnodevnode是当前子组件实例的占位 VNode,用于在后续合并配置时将组件实例跟组件占位 VNode 联系起来
  • parent:创建当前子组件实例时,处于活动状态的父组件

new vnode.componentOptions.Ctor(options)将生成组件实例,并调用vm._init方法对组件实例做初始化工作后返回组件实例。

init钩子里,创建完子组件实例之后,会将子组件实例赋给vnode.componentInstance,这样的话,组件占位 VNode 和组件实例就联系了起来。之后,调用子组件实例的$mount方法,但是传入的第一个参数为undefined,子组件实例将调用vm._render方法生成渲染 VNode,并调用vm._update进而调用vm.__patch__创建组件的 DOM Tree,但是不会将 DOM Tree 插入到父元素上,插入到父元素的操作将在初始化子组件实例时完成。

::: tip 重要提示 此处创建子组件的实例时,会创建子组件的渲染 VNode 并创建子组件的 DOM Tree。若是子组件里有子孙组件,也会递归创建子孙组件的实例、创建子孙组件的渲染 VNode,并创建子孙组件的 DOM Tree。 :::

initComponent

  /**
   * 初始化组件实例
   */
  function initComponent (vnode, insertedVnodeQueue) {
    if (isDef(vnode.data.pendingInsert)) {
      // 将子组件在创建过程中新增的所有节点加入到 insertedVnodeQueue 中
      insertedVnodeQueue.push.apply(insertedVnodeQueue, vnode.data.pendingInsert)
      vnode.data.pendingInsert = null
    }
    // 获取到组件实例的 DOM 根元素节点
    vnode.elm = vnode.componentInstance.$el
    if (isPatchable(vnode)) {
      // 调用 create 钩子
      invokeCreateHooks(vnode, insertedVnodeQueue)
      setScope(vnode)
    } else {
      // empty component root.
      // skip all element-related modules except for ref (#3455)
      registerRef(vnode)
      // make sure to invoke the insert hook
      insertedVnodeQueue.push(vnode)
    }
  }
  /**
   * 判断 vnode 是否是可 patch 的:若组件的根 DOM 元素节点,则返回 true
   */
  function isPatchable (vnode) {
    while (vnode.componentInstance) {
      vnode = vnode.componentInstance._vnode
    }
    // 经过 while 循环后,vnode 是一开始传入的 vnode 的首个非组件节点对应的 vnode
    return isDef(vnode.tag)
  }

初始化组件实例过程中,需要做比较多的工作:

  • 将子组件首次渲染创建 DOM Tree 过程中收集的insertedVnodeQueue(保存在子组件占位 VNode 的vnode.data.pendingInsert里)添加到父组件的insertedVnodeQueue
  • 获取到组件实例的 DOM 根元素节点,赋给vnode.elm
  • 判断组件是否是可patch
    • 组件可patch
      • 调用create钩子
      • 设置scope
    • 组件不可patch
      • 注册组件的ref
      • 将组件占位 VNode 加入到insertedVnodeQueue

组件 DOM Tree 插入到父元素

当组件创建好并初始化好组件实例之后,其 DOM Tree 也已经完全 ready,此时若是存在parentElm,就会将组件的 DOM Tree 插入到parentElm。若是该组件同时作为其他组件渲染 VNode 的根节点,则不会存在parentElm,也不会插入到parentElm