4.3 Virtual DOM的创建
先简单回顾一下挂载的流程,挂载的过程是调用Vue
实例上$mount
方法,而$mount
的核心是mountComponent
函数。如果我们传递的是template
模板,模板会先经过编译器的解析,并最终根据不同平台生成对应代码,此时对应的就是将with
语句封装好的render
函数;如果传递的是render
函数,则跳过模板编译过程,直接进入下一个阶段。下一阶段是拿到render
函数,调用vm._render()
方法将render
函数转化为Virtual DOM
,并最终通过vm._update()
方法将Virtual DOM
渲染为真实的DOM
节点。
Vue.prototype.$mount = function(el, hydrating) {
···
return mountComponent(this, el)
}
function mountComponent() {
···
updateComponent = function () {
vm._update(vm._render(), hydrating);
};
}
我们先看看vm._render()
方法是如何将render函数转化为Virtual DOM的。
回顾一下第一章节内容,文章介绍了Vue
在代码引入时会定义很多属性和方法,其中有一个renderMixin
过程,我们之前只提到了它会定义跟渲染有关的函数,实际上它只定义了两个重要的方法,_render
函数就是其中一个。
// 引入Vue时,执行renderMixin方法,该方法定义了Vue原型上的几个方法,其中一个便是 _render函数
renderMixin();//
function renderMixin() {
Vue.prototype._render = function() {
var ref = vm.$options;
var render = ref.render;
···
try {
vnode = render.call(vm._renderProxy, vm.$createElement);
} catch (e) {
···
}
···
return vnode
}
}
抛开其他代码,_render函数的核心是render.call(vm._renderProxy, vm.$createElement)
部分,vm._renderProxy
在数据代理分析过,本质上是为了做数据过滤检测,它也绑定了render
函数执行时的this
指向。vm.$createElement
方法会作为render
函数的参数传入。回忆一下,在手写render
函数时,我们会利用render
函数的第一个参数createElement
进行渲染函数的编写,这里的createElement
参数就是定义好的$createElement
方法。
new Vue({
el: '#app',
render: function(createElement) {
return createElement('div', {}, this.message)
},
data() {
return {
message: 'dom'
}
}
})
初始化_init
时,有一个initRender
函数,它就是用来定义渲染函数方法的,其中就有vm.$createElement
方法的定义,除了$createElement
,_c
方法的定义也类似。其中 vm._c
是template
内部编译成render
函数时调用的方法,vm.$createElement
是手写render
函数时调用的方法。两者的唯一区别仅仅是最后一个参数的不同。通过模板生成的render
方法可以保证子节点都是Vnode
,而手写的render
需要一些检验和转换。
function initRender(vm) {
vm._c = function(a, b, c, d) { return createElement(vm, a, b, c, d, false); }
vm.$createElement = function (a, b, c, d) { return createElement(vm, a, b, c, d, true); };
}
createElement
方法实际上是对 _createElement
方法的封装,在调用_createElement
前,它会先对传入的参数进行处理,毕竟手写的render
函数参数规格不统一。举一个简单的例子。
// 没有data
new Vue({
el: '#app',
render: function(createElement) {
return createElement('div', this.message)
},
data() {
return {
message: 'dom'
}
}
})
// 有data
new Vue({
el: '#app',
render: function(createElement) {
return createElement('div', {}, this.message)
},
data() {
return {
message: 'dom'
}
}
})
这里如果第二个参数是变量或者数组,则默认是没有传递data
,因为data
一般是对象形式存在。
function createElement (
context, // vm 实例
tag, // 标签
data, // 节点相关数据,属性
children, // 子节点
normalizationType,
alwaysNormalize // 区分内部编译生成的render还是手写render
) {
// 对传入参数做处理,如果没有data,则将第三个参数作为第四个参数使用,往上类推。
if (Array.isArray(data) || isPrimitive(data)) {
normalizationType = children;
children = data;
data = undefined;
}
// 根据是alwaysNormalize 区分是内部编译使用的,还是用户手写render使用的
if (isTrue(alwaysNormalize)) {
normalizationType = ALWAYS_NORMALIZE;
}
return _createElement(context, tag, data, children, normalizationType) // 真正生成Vnode的方法
}
4.3.1 数据规范检测
Vue
既然暴露给用户用render
函数去手写渲染模板,就需要考虑用户操作带来的不确定性,因此_createElement
在创建Vnode
前会先数据的规范性进行检测,将不合法的数据类型错误提前暴露给用户。接下来将列举几个在实际场景中容易犯的错误,也方便我们理解源码中对这类错误的处理。
- 用响应式对象做
data
属性new Vue({
el: '#app',
render: function (createElement, context) {
return createElement('div', this.observeData, this.show)
},
data() {
return {
show: 'dom',
observeData: {
attr: {
id: 'test'
}
}
}
}
})
- 当特殊属性key的值为非字符串,非数字类型时
这些规范都会在创建new Vue({
el: '#app',
render: function(createElement) {
return createElement('div', { key: this.lists }, this.lists.map(l => {
return createElement('span', l.name)
}))
},
data() {
return {
lists: [{
name: '111'
},
{
name: '222'
}
],
}
}
})
Vnode
节点之前发现并报错,源代码如下:
这些规范性检测保证了后续function _createElement (context,tag,data,children,normalizationType) {
// 1. 数据对象不能是定义在Vue data属性中的响应式数据。
if (isDef(data) && isDef((data).__ob__)) {
warn(
"Avoid using observed data object as vnode data: " + (JSON.stringify(data)) + "\n" +
'Always create fresh vnode data objects in each render!',
context
);
return createEmptyVNode() // 返回注释节点
}
if (isDef(data) && isDef(data.is)) {
tag = data.is;
}
if (!tag) {
// 防止动态组件 :is 属性设置为false时,需要做特殊处理
return createEmptyVNode()
}
// 2. key值只能为string,number这些原始数据类型
if (isDef(data) && isDef(data.key) && !isPrimitive(data.key)
) {
{
warn(
'Avoid using non-primitive value as key, ' +
'use string/number value instead.',
context
);
}
}
···
}
Virtual DOM tree
的完整生成。
4.3.2 子节点children规范化
Virtual DOM tree
是由每个Vnode
以树状形式拼成的虚拟DOM
树,我们在转换真实节点时需要的就是这样一个完整的Virtual DOM tree
,因此我们需要保证每一个子节点都是Vnode
类型,这里分两种场景分析。
- 模板编译
render
函数,理论上template
模板通过编译生成的render
函数都是Vnode
类型,但是有一个例外,函数式组件返回的是一个数组(这个特殊例子,可以看函数式组件的文章分析),这个时候Vue
的处理是将整个children
拍平成一维数组。 - 用户定义
render
函数,这个时候又分为两种情况,一个是当chidren
为文本节点时,这时候通过前面介绍的createTextVNode
创建一个文本节点的VNode
; 另一种相对复杂,当children
中有v-for
的时候会出现嵌套数组,这时候的处理逻辑是,遍历children
,对每个节点进行判断,如果依旧是数组,则继续递归调用,直到类型为基础类型时,调用createTextVnode
方法转化为Vnode
。这样经过递归,children
也变成了一个类型为Vnode
的数组。
function _createElement() {
···
if (normalizationType === ALWAYS_NORMALIZE) {
// 用户定义render函数
children = normalizeChildren(children);
} else if (normalizationType === SIMPLE_NORMALIZE) {
// 模板编译生成的的render函数
children = simpleNormalizeChildren(children);
}
}
// 处理编译生成的render 函数
function simpleNormalizeChildren (children) {
for (var i = 0; i < children.length; i++) {
// 子节点为数组时,进行开平操作,压成一维数组。
if (Array.isArray(children[i])) {
return Array.prototype.concat.apply([], children)
}
}
return children
}
// 处理用户定义的render函数
function normalizeChildren (children) {
// 递归调用,直到子节点是基础类型,则调用创建文本节点Vnode
return isPrimitive(children)
? [createTextVNode(children)]
: Array.isArray(children)
? normalizeArrayChildren(children)
: undefined
}
// 判断是否基础类型
function isPrimitive (value) {
return (
typeof value === 'string' ||
typeof value === 'number' ||
typeof value === 'symbol' ||
typeof value === 'boolean'
)
}
4.3.4 实际场景
在数据检测和组件规范化后,接下来通过new VNode()
便可以生成一棵完整的VNode
树,注意在_render
过程中会遇到子组件,这个时候会优先去做子组件的初始化,这部分放到组件环节专门分析。我们用一个实际的例子,结束render
函数到Virtual DOM
的分析。
template
模板形式var vm = new Vue({
el: '#app',
template: '<div><span>virtual dom</span></div>'
})
- 模板编译生成
render
函数(function() {
with(this){
return _c('div',[_c('span',[_v("virual dom")])])
}
})
Virtual DOM tree
的结果(省略版){
tag: 'div',
children: [{
tag: 'span',
children: [{
tag: undefined,
text: 'virtual dom'
}]
}]
}