大家都忙着解析各种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,普通子Vnode的parent的指向为空,只有一种特殊的Vnode的parent指向才非空,子组件的渲染Vnode即_vonde的parent为非空,指向该子组件的占位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过程,主要做了以下工作:
- 调用
beforeMount钩子 - 定义渲染 Watcher 的表达式
- 创建渲染 Watcher,且 Watcher 实例会首次计算表达式,创建 VNode Tree,进而生成 DOM Tree
- (对于根组件)调用
mounted钩子 - 返回组件实例
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对象上安装init、prepatch、insert、destroy等管理组件的钩子方法,方便在调用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函数将返回undefined,createElm函数将继续往下执行,为非组件占位 VNode 创建对应的 DOM 节点。
createComponent的主要流程为:
- 若 VNode 存在
vnode.data.hook.init方法,说明是组件占位 VNode,则创建组件实例,挂在vnode.componentInstance上 - 若
vnode.componentInstance存在- 初始化组件实例,设置
vnode.elm - 将组件的 DOM Tree 插入到父元素上
- 返回 true
- 初始化组件实例,设置
- 针对非组件占位 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: vnode:vnode是当前子组件实例的占位 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。