Vue.js源码学习 —— VNode/createElement

如果您是刚开始准备阅读Vue.js的源码,建议先看一下本系列的Vue.js源码学习 —— 起步,相信会对您后面的阅读有很大帮助。

这篇文章要看的代码主要位于文件core/vdom/vnode.js中和文件core/vdom/create-element.js中。

我没有在文章中大量的贴源码,一定要将源码clone到本地哦。

虚拟DOM这块是相对比较独立的模块,core/instance文件中很多地方都要依赖于它,所以先把虚拟DOM弄清楚有助于我们理解其他各部分的实现过程。

今天这篇文章主要看一下Vue是如何封装VNode的,以及createElement函数的实现。

预备知识

为什么不直接使用浏览器提供的真实的DOM呢?我们知道JavaScript和DOM是相互独立的两部分,浏览器通常也把JavaScript和DOM单独实现。比如,Safari中的DOM和渲染是使用WebKit中的WebCore实现,JavaScript部分是由独立的JavaScriptCore引擎来实现。在JavaScript中访问DOM是很昂贵的操作,《高性能JavaScript》第3章中举了一个很形象的例子:

把DOM和JavaScript各自想象为一个岛屿,它们之间用收费桥梁连接。ECMAScript每次访问DOM,都要途径这座桥,并交纳“过桥费”。访问DOM的次数越多,费用也就越高。

所以聪明的前辈们提出了虚拟DOM的概念,它让我们可以用函数的形式描述一个页面元素。在Vue的官方文档中对 虚拟DOM 的描述如下:

Vue 通过建立一个虚拟 DOM 来追踪自己要如何改变真实 DOM。请仔细看这行代码:

return createElement('h1', this.blogTitle)

createElement 到底会返回什么呢?其实不是一个实际的 DOM 元素。它更准确的名字可能是 createNodeDescription,因为它所包含的信息会告诉 Vue 页面上需要渲染什么样的节点,包括及其子节点的描述信息。我们把这样的节点描述为“虚拟节点 (virtual node)”,也常简写它为“VNode”。“虚拟 DOM”是我们对由 Vue 组件树建立起来的整个 VNode 树的称呼。

VNode

VNode的定义位于文件core/vdom/vnode.js中:

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
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
export default class VNode {

// ...

constructor (
tag?: string,
data?: VNodeData,
children?: ?Array<VNode>,
text?: string,
elm?: Node,
context?: Component,
componentOptions?: VNodeComponentOptions,
asyncFactory?: Function
) {
// VNode的标签名称
this.tag = tag
// 与该节点本身相关的attribute,比如{ attrs: { id: '1' }}
this.data = data
// 该节点下的子节点
this.children = children
// 节点的文本,对应真实节点的 textContent
this.text = text
// 真实的DOM节点
this.elm = elm
// 元素的 namespace
this.ns = undefined
// 节点所在的组件
this.context = context
// 下面三个都是用于函数式组件的
this.fnContext = undefined
this.fnOptions = undefined
this.fnScopeId = undefined
// 节点的唯一标识
this.key = data && data.key
// 组件的选项,在创建内部组件时会用到
this.componentOptions = componentOptions
// 组件实例对象
this.componentInstance = undefined
// 当前节点的父节点
this.parent = undefined
// 是否包含 html,只用于 server 端
this.raw = false
// 静态节点只会被渲染一次,使用的 v-once 指令时为 true
this.isStatic = false
// 当前节点是否作为根节点插入的
this.isRootInsert = true
// 当前节点是否为注释节点,创建空节点时会设置为 true
this.isComment = false
// 节点是否是被克隆的
this.isCloned = false
// 节点使用的 v-once 指令时 isOnce 是 true
this.isOnce = false
// 创建异步组件时使用
this.asyncFactory = asyncFactory
this.asyncMeta = undefined
this.isAsyncPlaceholder = false
}

// ...
}

可以看出VNode就是对页面上一个元素节点相关信息的封装,比如下面元素:

1
<div id="app" key="app" v-once> Hello Vue! </div>

对应到VNode中就会是:

1
2
3
4
5
6
7
8
let node = new VNode( 'div', // tag
{ attrs: { id: 'app' } }, // data
undefined, // children
'Hello Vue!' // text
)
node.key = 'app'
node.isStatic = true
node.isOnce = true

参数data的类型是VNodeData,定义在flow/vnode.js中:

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
43
44
45
46
47
48
49
50
51
52
53
54
55
56
declare interface VNodeData {
// 顶层 property
key?: string | number;
// 顶层 property
ref?: string;
// 用于创建动态组件
is?: string;
// 对应 v-pre 指令,跳过这个元素和它的子元素的编译过程。
// 可以用来显示原始 Mustache 标签。
pre?: boolean;
tag?: string;
// 不会改变的class
staticClass?: string;
// 与 `v-bind:class` 的 API 相同,
// 接受一个字符串、对象或字符串和对象组成的数组
class?: any;
// 不会改变的style
staticStyle?: { [key: string]: any };
// 与 `v-bind:style` 的 API 相同,
// 接受一个字符串、对象,或对象组成的数组
style?: string | Array<Object> | Object;
normalizedStyle?: Object;
// 组件 prop
props?: { [key: string]: any };
// 普通的 HTML attribute
attrs?: { [key: string]: string };
// DOM property 示例:{ innerHTML: 'baz' }
domProps?: { [key: string]: any };
hook?: { [key: string]: Function };
// 事件监听器在 `on` 内,
// 但不再支持如 `v-on:keyup.enter` 这样的修饰器。
// 需要在处理函数中手动检查 keyCode。
on?: ?{ [key: string]: Function | Array<Function> };
// 仅用于组件,用于监听原生事件,而不是组件内部使用
// `vm.$emit` 触发的事件。
nativeOn?: { [key: string]: Function | Array<Function> };
transition?: Object;
show?: boolean; // marker for v-show
inlineTemplate?: {
render: Function;
staticRenderFns: Array<Function>;
};
// 自定义指令。注意,你无法对 `binding` 中的 `oldValue`
// 赋值,因为 Vue 已经自动为你进行了同步。
directives?: Array<VNodeDirective>;
keepAlive?: boolean;
// 作用域插槽的格式为
// { name: props => VNode | Array<VNode> }
scopedSlots?: { [key: string]: Function };
// 如果组件是其它组件的子组件,需为插槽指定名称
slot?: string;
model?: {
value: any;
callback: Function;
};
};

VNodeData是一个与模板中 attribute 对应的数据对象。在官方文档#createElement 参数一节中可以找到对其中常用字段的注释。

vnode.js中还提供了3个便捷方法:

  • createEmptyVNode:创建一个空节点,此时isComment设置为true。
  • createTextVNode: 创建一个文本节点,text会设置为传入的值。
  • cloneVNode: 通过浅拷贝一个已有节点得到一个新的节点,可以通过注释看到该方法主要用于静态节点和插槽节点:

    1
    2
    3
    4
    // optimized shallow clone
    // used for static nodes and slot nodes because they may be reused across
    // multiple renders, cloning them avoids errors when DOM manipulations rely
    // on their elm reference.

createElement

createElement函数主要用来创建虚拟DOM中的元素节点的,相当于浏览器DOM中的document.createElement()方法。Vue公开了这个API给开发者使用,可以在官方文档#createElement 参数一节中查看:

1
2
3
const vm = new Vue({})
const h = vm.$createElement
const vnode = h('p', {})

在平常的开发中,createElement通常是在使用render选项时,render函数接收到的一个参数。

1
2
3
render: function (createElement) {
return createElement('h1', this.blogTitle)
}

接下来开始看createElement函数的实现,代码位于文件create-element.js中。

打开文件时可能一下子会有点懵,不知道if/else中都在处理什么情况,为什么要那样判断。好在Vue项目提供了比较全面的测试用例,这时可以在深入看代码之前先看一遍测试用例。createElement的测试用例位于文件test/unit/vdom/create-element.spec.js中。

参数

1
2
3
4
5
6
7
8
9
10
11
12
// wrapper function for providing a more flexible interface
// without getting yelled at by flow
export function createElement (
context: Component,
tag: any,
data: any,
children: any,
normalizationType: any,
alwaysNormalize: boolean
): VNode | Array<VNode> {
// ...
}
  • context:元素所在的Vue实例。在公开给开发者使用的API中不需要传这个参数,因为在将$createElement挂载到Vue实例上时,就已经传了。

    1
    2
    // 代码位于文件 core/instance/render.js 的 initRender 函数中
    vm.$createElement = (tag, data, children, normalizationType) => createElement(vm, tag, data, children, normalizationType, true)
  • tag: 元素节点的标签名称,可以是 string、 Class 、 Function 或 Object,也可以不传。

  • data: 元素节点的数据对象,在上一节中已经介绍过了。

  • children: 该元素节点的子元素节点。

  • normalizationType:用来指定用哪个方式规范化children的传值,可以取下面两个值:

    1
    2
    3
    4
    // 主要用于函数式组件时
    const SIMPLE_NORMALIZE = 1
    // 当 alwaysNormalize 是 true 时,使用这个值
    const ALWAYS_NORMALIZE = 2
  • alwaysNormalize:是要要规范化children的传值,在公开的API中,这个值始终传的true,这也使得normalizationType一直是ALWAYS_NORMALIZE的。

返回

createElement函数的返回类型注解中可以看到,它不仅会返回VNode,还有可能返回一个VNode数组。返回数组的情况是为了兼容创建函数式组件createFunctionalComponent函数会返回数组。我们当前举的例子中返回的都是VNode

允许忽略data参数

1
2
3
4
5
if (Array.isArray(data) || isPrimitive(data)) {
normalizationType = children
children = data
data = undefined
}

这段代码是在兼容忽略掉data参数的情况,所以将传参依次像后移,最后将data置为undefined。看个例子就明白了:

1
2
3
const vm = new Vue({})
const h = vm.$createElement
const vnode = h('p', [h('br'), 'hello world', h('br')])

tag参数后面直接传了children参数。

对传参的预处理

  • 已经被观察的VNode数据对象不能用于创建vnode,此时返回一个空节点。

  • 如果data中有is字段,则tagis的值,主要是处理用is实现动态组件的情况,例如下面这样:

    1
    2
    <!-- 当 `currentView` 改变时,组件也跟着改变 -->
    <component :is="currentView"></component>
  • 如果tag是空的,返回一个空节点。

  • 如果data中有key字段,key的值必须是numberstring,否则在开发环境下给出警告。

  • 如果children是一个数组,并且第一个元素是函数,会将children[0]当做默认的作用域插槽,赋值给了data.scopedSlots,然后children置为空。所以在实际开发中也可以将返回插槽组件的函数放在scopedSlots中,举个例子:

    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
    Vue.component('special-list', {
    props: {
    items: Array,
    },
    render (h) {
    return h('div', this.items.map((item) => {
    return this.$scopedSlots.default(item);
    }));
    },
    });

    new Vue({
    el: '#app',
    data () {
    return {
    pets: [
    { name: 'alma', type: 'cat' },
    { name: 'ring', type: 'cat' },
    { name: 'winston', type: 'dog' },
    ],
    };
    },
    render (h) {
    return h('special-list',
    { props: { items: this.pets } },
    [ function (pet) {
    return h('div', `${ pet.name } is a ${ pet.type }`);
    }])
    }
    })

    上面的render函数也可以这样写:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    render (h) {
    return h('special-list', {
    props: {
    items: this.pets,
    },
    scopedSlots: {
    default (pet) {
    return h('div', `${ pet.name } is a ${ pet.type }`);
    },
    },
    });
    },

规范化children

对应下面这段代码:

1
2
3
4
5
if (normalizationType === ALWAYS_NORMALIZE) {
children = normalizeChildren(children)
} else if (normalizationType === SIMPLE_NORMALIZE) {
children = simpleNormalizeChildren(children)
}

所以咱们主要看normalizeChildren函数和simpleNormalizeChildren函数,位于文件vdom/helpers/normalize-children.js中。这个文件中给了比较详细的注释,翻译一下大致是说:模板编译器通过在编译时期静态分析模板来减少规范化。对于纯HTML标记,完全可以跳过规范化,因为生成的render函数可以保证返回一个VNode数组。但有两种情况还需要规范化children的,一种是在定义函数式组件时,render函数返回的是VNode数组,这会导致children是一个嵌套的数组,需要将它展平。还有一种情况是开发者手动使用了render选项,代替了template选项,这时需要针对children中的每一行做校验。

simpleNormalizeChildren

举个函数式组件中render函数返回数VNode数组的例子,此时就会走进simpleNormalizeChildren函数了。将上面的special-list组件改为函数式组件:

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
// reture more than one root node
Vue.component('special-list', {
functional: true,
props: {
items: Array
},
render (h, context) {
let items = context.props.items
// 这里返回了一个Array<VNode>
return items.map((item) => {
return h('div', `${ item.name } is a ${ item.type }`)
})
}
})

new Vue({
el: '#app',
template: `<div><special-list :items="pets"></special-list></div>`,
data () {
return {
pets: [
{ name: 'alma', type: 'cat' },
{ name: 'ring', type: 'cat' },
{ name: 'winston', type: 'dog' },
],
}
}
})

可以在浏览器中调试看看,此时children是一个二维数组:[[VNode, VNode, VNode]]simpleNormalizeChildren函数做的事情就是将该数组展平,变成一个一维数组返回。

normalizationType是在什么时候被设置成了 1 呢?又为什么children会变成了一个二维数组呢?

通过在浏览器中调试会发现,上面创建的Vue实例的template选项会被编译成如下render函数:

1
2
3
4
(function anonymous(
) {
with(this){return _c('div',[_c('special-list',{attrs:{"items":pets}})],1)}
})

可以看到,_c的最后一个参数是1,第2个参数是数组,如果_c('special-list',{attrs:{"items":pets}})再返回一个数组,就会变成一个二维数组了。

_c就是一个Vue内部使用的createElement函数,所以参数和createElement函数的参数是一样的。它的定义在文件core/instance/render.js中的initRender函数中:

1
2
3
4
5
6
7
8
// bind the createElement fn to this instance
// so that we get proper render context inside it.
// args order: tag, data, children, normalizationType, alwaysNormalize
// internal version is used by render functions compiled from templates
vm._c = (tag, data, children, normalizationType) => createElement(vm, tag, data, children, normalizationType, false)
// normalization is always applied for the public version, used in
// user-written render functions.
vm.$createElement = (tag, data, children, normalizationType) => createElement(vm, tag, data, children, normalizationType, true)

Vue实例被挂载时,会执行Vue.prototype._render,其内部就调用了上面的render函数。

normalizeChildren

normalizeChildren函数通常在开发者显示使用render选项时会被调用。该函数会遍历render中传的children中的每一项,针对不同类型做处理。

  • 元素是undefinednull或布尔类型时直接跳过本次循环,进行下一个元素的处理。

  • 元素是嵌套数组时,会递归调用normalizeChildren函数,得到被嵌套的节点,放入到最终的结果数组中。这里还处理了一种特殊情况,如果结果数组中的最后一个节点是文本节点,然后递归后返回的数组中的第一个也是文本节点,会将该节点与最后一个节点合并。像下面这种情况的:

    1
    2
    3
    4
    5
    6
    7
    new Vue({
    el: '#app',
    render (h) {
    // 递归后得到的第一个节点是`foobar`,`foobar`再与`hello world`合并成一个节点
    return h('p', [ 'hello world', ['foo', 'bar'], h('br')])
    }
    })
  • 元素是基本数据类型时,其实也就是stringnumber时,如果上一个节点是文本节点就与其合并,否则创建一个新的文本节点。

  • 其他情况时,也就是元素是VNode时,如果当前节点和上一个节点都是文本节点就合并,其他情况直接存储结果数组中。

创建vnode

创建什么样的vnode主要是由tag决定的,分以下几种情况处理:

  • tag是字符串类型,这里还分:

    • tag是内置的标签(HTML标签或SVG标签),直接用new VNode(/*...*/)创建一个vnode。例如下面的情况:

      1
      2
      3
      const vm = new Vue()
      const h = vm.$createElement
      const vnode = h('p', {})

      如果数据对象中指定了nativeOn,开发环境下会给出警告,对原生事件的监听只能用在组件上。

      config.parsePlatformTagName(tag)是一个与平台相关的方法,在浏览器环境下会直接将传入的值返回,weex环境下会要过滤掉标签中的weex:字符串。

    • tag是组件名称时,使用createComponent创建一个组件。判断是组件名称需要满足下面两个条件:

      • data没传或者data.prefalse,如果data.pretrue,表示这个元素及其子元素是不需要编译的。

      • 调用resolveAsset(context.$options, 'components', tag)函数获取组件的构造器或组件的选项对象。这个函数定义在文件core/util/options.js中,摘出了其中关键的实现部分:

        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        13
        14
        // type此时为 components,所以 assets 实例对象可以访问到的所有组件
        const assets = options[type]
        // tag 就是组件的名称,
        if (hasOwn(assets, id)) return assets[id]
        // tag 改为驼峰形式,首字母小写
        const camelizedId = camelize(id)
        if (hasOwn(assets, camelizedId)) return assets[camelizedId]
        // tag 改为驼峰形式,首字母大写
        const PascalCaseId = capitalize(camelizedId)
        if (hasOwn(assets, PascalCaseId)) return assets[PascalCaseId]
        // hasOwn 检测的是是否在own property中,如果上面都没有取到
        // 这里会到原型中去找,原型中放的都是注册到全局的组件
        const res = assets[id] || assets[camelizedId] || assets[PascalCaseId]
        // res 还为空,开发环境下会给出警告信息,就没有找到合适的组件

      举个例子,看一下会创建组件的情况:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      // render vnode with component
      Vue.component('global-component', {
      props: ['msg']
      })

      const vm = new Vue({
      data: { message: 'hello world' },
      components: {
      'MyComponent': {
      props: ['msg']
      }
      }
      })

      const h = vm.$createElement
      const vnode = h('my-component', { props: { msg: vm.message } })
      const vnode2 = h('global-component', { props: { msg: vm.message } })
    • 其他未知情况,使用new VNode(/*...*/)创建一个节点。

  • tag是其他情况的传参,主要是Class<Component>FunctionObjectvoid时,使用createComponent创建一个组件。

得到vnode后还要做一些处理:

  • 如果vnode是一个数组,直接返回。

  • 如果vnode有值:

    • ns也就是namespace有值,就将节点及其子节点都设置上ns字段。在浏览器环境中getTagNamespace的实现如下:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      export function getTagNamespace (tag: string): ?string {
      if (isSVG(tag)) {
      return 'svg'
      }
      // basic support for MathML
      // note it doesn't support other MathML elements being component roots
      if (tag === 'math') {
      return 'math'
      }
      }
    • 调用traverse深度绑定data.styledata.class,确保用户在使用:style:class时,能够响应变化。traverse的实现等看到observer这部分的代码时再细说。

  • 其他情况返回一个空节点。

到这里createElement函数就了解的差不多了,这也加深了我们对vm.$createElement的理解,以后使用render选项时也会灵活很多。到目前为止文件core/util/options.js中的函数就都看完了。

但是还遗留了createComponent函数的实现没有看,下一篇文章将会看这个函数的实现。

本文作者:意林
本文链接:http://shinancao.cn/2020/01/12/Vue-Source-Code-03/
版权声明:本博客所有文章除特别声明外,均采用 CC BY-NC-SA 3.0 许可协议。转载请注明出处!