0%

Vue3.0: vnode到真实DOM转换

在 Vue.js 中,组件是一个非常重要的概念,整个应用的页面都是通过组件渲染来实现的

首先,组件是一个抽象的概念,它是对一棵 DOM 树的抽象,我们在页面中写一个组件节点:

1
<hello-world></hello-world>

这段代码并不会在页面上渲染一个<hello-world>标签,而他具体渲染成什么,取决于你怎么编写HelloWorld组件的模版。

所以,从表现上来看,组件的模板决定了组件生成的 DOM 标签,而在 Vue.js 内部,一个组件想要真正的渲染生成 DOM,还需要经历“创建 vnode - 渲染 vnode - 生成 DOM” 这几个步骤

应用程序初始化

一个组件可以通过“模板加对象描述”的方式创建,组件创建好以后是如何被调用并初始化的呢?因为整个组件树是由根组件开始渲染的,为了找到根组件的渲染入口,我们需要从应用程序的初始化过程开始分析。

1
2
3
4
5
6
7
// 在 Vue.js 2.x 中,初始化一个应用的方式如下
import Vue from 'vue'
import App from './App'
const app = new Vue({
render: h => h(App)
})
app.$mount('#app')
1
2
3
4
5
// 在 Vue.js 3.0 中,初始化一个应用的方式如下
import { createApp } from 'vue'
import App from './app'
const app = createApp(App)
app.mount('#app')

可以看到,Vue.js 3.0 初始化应用的方式和 Vue.js 2.x 差别并不大,本质上都是把 App 组件挂载到 id 为 app 的 DOM 节点上。

但是,在 Vue.js 3.0 中还导入了一个 createApp,其实这是个入口函数,它是 Vue.js 对外暴露的一个函数

1
2
3
4
5
6
7
8
9
10
const createApp = ((...args) => {
// 创建 app 对象
const app = ensureRenderer().createApp(...args)
const { mount } = app
// 重写 mount 方法
app.mount = (containerOrSelector) => {
// ...
}
return app
})

从代码中可以看出 createApp 主要做了两件事情:创建 app 对象和重写 app.mount 方法。

1.创建app对象

首先,我们使用 ensureRenderer().createApp() 来创建 app 对象 :

1
const app = ensureRenderer().createApp(...args)

其中 ensureRenderer() 用来创建一个渲染器对象,它的内部代码是这样的:

1
const app = ensureRenderer().createApp(...args)

其中ensureRenderer()用来创建一个渲染器对象,它的内部代码是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// 渲染相关的一些配置,比如更新属性的方法,操作 DOM 的方法
const rendererOptions = {
patchProp,
...nodeOps
}

// 延时创建渲染器,当用户只依赖响应式包的时候,可以通过 tree-shaking 移除核心渲染逻辑相关的代码
function ensureRenderer() {
return renderer || (renderer = createRenderer(rendererOptions))
}

function createRenderer(options) {
return baseCreateRenderer(options)
}
function baseCreateRenderer(options) {
function render(vnode, container) {
// 组件渲染的核心逻辑
}

return {
render,
createApp: createAppAPI(render)
}
}
function createAppAPI(render) {
// createApp createApp 方法接受的两个参数:根组件的对象和 prop
return function createApp(rootComponent, rootProps = null) {
const app = {
_component: rootComponent,
_props: rootProps,
mount(rootContainer) {
// 创建根组件的 vnode
const vnode = createVNode(rootComponent, rootProps)
// 利用渲染器渲染 vnode
render(vnode, rootContainer)
app._container = rootContainer
return vnode.component.proxy
}
}
return app
}
}

可以看到,这里先用ensureRenderer()来延时创建渲染器,这样做的好处是当用户只依赖响应式包的时候,就不会创建渲染器,因此可以通过tree-shaking的方式移除核心渲染逻辑相关的代码

在 Vue.js 3.0 内部通过 createRenderer 创建一个渲染器,这个渲染器内部会有一个 createApp 方法,它是执行 createAppAPI 方法返回的函数,接受了 rootComponent 和 rootProps 两个参数,我们在应用层面执行 createApp(App) 方法时,会把 App 组件对象作为根组件传递给 rootComponent。这样,createApp 内部就创建了一个 app 对象,它会提供 mount 方法,这个方法是用来挂载组件的。

核心渲染流程:创建 vnode 和渲染 vnode

1.创建vnode

首先,是创建vnode的过程

vnode本质上是用来描述DOM的Javascript对象,它在Vue.js 中可以描述不同类型的节点,比如普通元素节点、组件节点等。

什么是普通元素节点呢?

1
<button class="btn" style="width:100px;height:50px">click me</button>

我们可以用vnode这样表示button标签

1
2
3
4
5
6
7
8
9
10
11
const vnode = {
type: 'button',
props: {
'class': 'btn',
style: {
width: '100px',
height: '50px'
}
},
children: 'click me'
}

其中,type 属性表示 DOM 的标签类型,props 属性表示 DOM 的一些附加信息,比如 style 、class 等,children 属性表示 DOM 的子节点,它也可以是一个 vnode 数组,只不过 vnode 可以用字符串表示简单的文本 。

什么是组件节点呢?其实, vnode 除了可以像上面那样用于描述一个真实的 DOM,也可以用来描述组件。

我们先在模板中引入一个组件标签

1
<custom-component msg="test"></custom-component>

我们可以用 vnode 这样表示 组件标签:

1
2
3
4
5
6
7
8
9
const CustomComponent = {
// 在这里定义组件对象
}
const vnode = {
type: CustomComponent,
props: {
msg: 'test'
}
}

组件 vnode 其实是对抽象事物的描述,这是因为我们并不会在页面上真正渲染一个 标签,而是渲染组件内部定义的 HTML 标签。

那么 vnode 有什么优势呢?为什么一定要设计 vnode 这样的数据结构呢?

首先是抽象,引入vnode,可以把渲染过程抽象化,从而使得组件的抽象能力也得到提升

其次是跨平台,因为patch vnode的过程不同平台可以有自己的实现,基于vnode再做服务端渲染、weex平台、小程序的平台的渲染都变得容易很多

使用vnode并不意味着不用操作DOM了,很多人会误以为vnode的性能一定比手动操作原生DOM好,这个其实不一定。

因为,首先这种基于vnode实现的MVVM框架,在每次render to vnode的过程中,渲染组件会有一定的javascript耗时,特别是大组件,比如一个1000 * 10的Table组件,render to vnode的过程会遍历1000 * 10次去创建内部cell vnode,整个耗时就会变得特别常,加上patch vnode的过程也会有一定的耗时,当我们去更新组件的时候,用户会感觉到明显的卡顿。虽然diff算法在减少DOM操作方面足够优秀,但最终还是免不了操作DOM,所以说性能并不是vnode的优势

那么,Vue.js 内部是如何创建这些 vnode 的呢?

回顾,app.mount函数的实现,内部是通过createVNode函数创建了根组件的vnode:

1
const vnode = createVNode(rootComponent, rootProps)

其实createVNode做的事情很简单,就是对props做标准化处理、对vnode的类型信息编码、创建vnode对象,标准化子节点children

渲染vnode

回顾app.mount函数的实现,内部通过执行这段代码去渲染创建好的vnode:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
render(vnode, rootContainer)
const render = (vnode, container) => {
if (vnode == null) {
// 销毁组件
if (container._vnode) {
unmount(container._vnode, null, null, true)
}
} else {
// 创建或者更新组件
patch(container._vnode || null, vnode, container)
}
// 缓存 vnode 节点,表示已经渲染
container._vnode = vnode
}

这个渲染函数render的实现很简单,如果它的第一个参数vnode为空,则执行销毁组件的逻辑,否则执行创建或者更新组件的逻辑

patch 本意是打补丁的意思,这个函数有两个功能,一个是根据 vnode 挂载 DOM,一个是根据新旧 vnode 更新 DOM

在创建的过程中,patch 函数接受多个参数

  • 第一个参数 n1 表示旧的 vnode,当 n1 为 null 的时候,表示是一次挂载的过程;
  • 第二个参数 n2 表示新的 vnode 节点,后续会根据这个 vnode 类型执行不同的处理逻辑;
  • 第三个参数 container 表示 DOM 容器,也就是 vnode 渲染生成 DOM 后,会挂载到 container 下面。

对于渲染的节点,我们这里重点关注两种类型节点的渲染逻辑:对组件的处理和对普通 DOM 元素的处理。

先来看对组件的处理。由于初始化渲染的是 App 组件,它是一个组件 vnode,所以我们来看一下组件的处理逻辑是怎样的。首先是用来处理组件的 processComponent 函数的实现:

1
2
3
4
5
6
7
8
9
10
const processComponent = (n1, n2, container, anchor, parentComponent, parentSuspense, isSVG, optimized) => {
if (n1 == null) {
// 挂载组件
mountComponent(n2, container, anchor, parentComponent, parentSuspense, isSVG, optimized)
}
else {
// 更新组件
updateComponent(n1, n2, parentComponent, optimized)
}
}

该函数的逻辑很简单,如果 n1 为 null,则执行挂载组件的逻辑,否则执行更新组件的逻辑。

我们接着来看挂载组件的 mountComponent 函数的实现:

1
2
3
4
5
6
7
8
const mountComponent = (initialVNode, container, anchor, parentComponent, parentSuspense, isSVG, optimized) => {
// 创建组件实例
const instance = (initialVNode.component = createComponentInstance(initialVNode, parentComponent, parentSuspense))
// 设置组件实例
setupComponent(instance)
// 设置并运行带副作用的渲染函数
setupRenderEffect(instance, initialVNode, container, anchor, parentSuspense, isSVG, optimized)
}

可以看到,挂载组件函数 mountComponent 主要做三件事情:创建组件实例、设置组件实例、设置并运行带副作用的渲染函数。

首先是创建组件实例,Vue.js 3.0 虽然不像 Vue.js 2.x 那样通过类的方式去实例化组件,但内部也通过对象的方式去创建了当前渲染的组件实例。

其次设置组件实例,instance 保留了很多组件相关的数据,维护了组件的上下文,包括对 props、插槽,以及其他实例的属性的初始化处理。

初始渲染主要做两件事情:渲染组件生成 subTree、把 subTree 挂载到 container 中。

首先,是渲染组件生成 subTree,它也是一个 vnode 对象。这里要注意别把 subTree 和 initialVNode 弄混了(其实在 Vue.js 3.0 中,根据命名我们已经能很好地区分它们了,而在 Vue.js 2.x 中它们分别命名为 _vnode 和 $vnode)。我来举个例子说明,在父组件 App 中里引入了 Hello 组件:

1
2
3
4
5
6
<template>
<div class="app">
<p>This is an app.</p>
<hello></hello>
</div>
</template>

在Hello组件中是div标签包裹这一个p标签

1
2
3
4
5
<template>
<div class="hello">
<p>Hello, Vue 3.0!</p>
</div>
</template>

在 App 组件中, 节点渲染生成的 vnode ,对应的就是 Hello 组件的 initialVNode ,为了好记,你也可以把它称作“组件 vnode”。而 Hello 组件内部整个 DOM 节点对应的 vnode 就是执行 renderComponentRoot 渲染生成对应的 subTree,我们可以把它称作“子树 vnode”。

我们知道每个组件都会有对应的 render 函数,即使你写 template,也会编译成 render 函数,而 renderComponentRoot 函数就是去执行 render 函数创建整个组件树内部的 vnode,把这个 vnode 再经过内部一层标准化,就得到了该函数的返回结果:子树 vnode。

渲染生成子树 vnode 后,接下来就是继续调用 patch 函数把子树 vnode 挂载到 container 中了。

那么我们又再次回到了 patch 函数,会继续对这个子树 vnode 类型进行判断,对于上述例子,App 组件的根节点是

标签,那么对应的子树 vnode 也是一个普通元素 vnode,那么我们接下来看对普通 DOM 元素的处理流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const mountElement = (vnode, container, anchor, parentComponent, parentSuspense, isSVG, optimized) => {
let el
const { type, props, shapeFlag } = vnode
// 创建 DOM 元素节点
el = vnode.el = hostCreateElement(vnode.type, isSVG, props && props.is)
if (props) {
// 处理 props,比如 class、style、event 等属性
for (const key in props) {
if (!isReservedProp(key)) {
hostPatchProp(el, key, null, props[key], isSVG)
}
}
}
if (shapeFlag & 8 /* TEXT_CHILDREN */) {
// 处理子节点是纯文本的情况
hostSetElementText(el, vnode.children)
}
else if (shapeFlag & 16 /* ARRAY_CHILDREN */) {
// 处理子节点是数组的情况
mountChildren(vnode.children, el, null, parentComponent, parentSuspense, isSVG && type !== 'foreignObject', optimized || !!vnode.dynamicChildren)
}
// 把创建的 DOM 元素节点挂载到 container 上
hostInsert(el, container, anchor)
}

可以看到,挂载元素函数主要做四件事:创建 DOM 元素节点、处理 props、处理 children、挂载 DOM 元素到 container 上。

首先是创建 DOM 元素节点,通过 hostCreateElement 方法创建,这是一个平台相关的方法,我们来看一下它在 Web 环境下的定义:

1
2
3
4
function createElement(tag, isSVG, is) {
isSVG ? document.createElementNS(svgNS, tag)
: document.createElement(tag, is ? { is } : undefined)
}

它调用了底层的 DOM API document.createElement 创建元素,所以本质上 Vue.js 强调不去操作 DOM ,只是希望用户不直接碰触 DOM,它并没有什么神奇的魔法,底层还是会操作 DOM。

另外,如果是其他平台比如 Weex,hostCreateElement 方法就不再是操作 DOM ,而是平台相关的 API 了,这些平台相关的方法是在创建渲染器阶段作为参数传入的。

创建完 DOM 节点后,接下来要做的是判断如果有 props 的话,给这个 DOM 节点添加相关的 class、style、event 等属性,并做相关的处理,这些逻辑都是在 hostPatchProp 函数内部做的,这里就不展开讲了。

接下来是对子节点的处理,我们知道 DOM 是一棵树,vnode 同样也是一棵树,并且它和 DOM 结构是一一映射的。

如果子节点是纯文本,则执行 hostSetElementText 方法,它在 Web 环境下通过设置 DOM 元素的 textContent 属性设置文本:

1
2
3
function setElementText(el, text) {
el.textContent = text
}

子节点的挂载逻辑同样很简单,遍历 children 获取到每一个 child,然后递归执行 patch 方法挂载每一个 child 。注意,这里有对 child 做预处理的情况(后面编译优化的章节会详细分析)。

可以看到,mountChildren 函数的第二个参数是 container,而我们调用 mountChildren 方法传入的第二个参数是在 mountElement 时创建的 DOM 节点,这就很好地建立了父子关系。

另外,通过递归 patch 这种深度优先遍历树的方式,我们就可以构造完整的 DOM 树,完成组件的渲染。