• 4.3 Virtual DOM的创建
    • 4.3.1 数据规范检测
    • 4.3.2 子节点children规范化
    • 4.3.4 实际场景

    4.3 Virtual DOM的创建

    先简单回顾一下挂载的流程,挂载的过程是调用Vue实例上$mount方法,而$mount的核心是mountComponent函数。如果我们传递的是template模板,模板会先经过编译器的解析,并最终根据不同平台生成对应代码,此时对应的就是将with语句封装好的render函数;如果传递的是render函数,则跳过模板编译过程,直接进入下一个阶段。下一阶段是拿到render函数,调用vm._render()方法将render函数转化为Virtual DOM,并最终通过vm._update()方法将Virtual DOM渲染为真实的DOM节点。

    1. Vue.prototype.$mount = function(el, hydrating) {
    2. ···
    3. return mountComponent(this, el)
    4. }
    5. function mountComponent() {
    6. ···
    7. updateComponent = function () {
    8. vm._update(vm._render(), hydrating);
    9. };
    10. }

    我们先看看vm._render()方法是如何将render函数转化为Virtual DOM的。

    回顾一下第一章节内容,文章介绍了Vue在代码引入时会定义很多属性和方法,其中有一个renderMixin过程,我们之前只提到了它会定义跟渲染有关的函数,实际上它只定义了两个重要的方法,_render函数就是其中一个。

    1. // 引入Vue时,执行renderMixin方法,该方法定义了Vue原型上的几个方法,其中一个便是 _render函数
    2. renderMixin();//
    3. function renderMixin() {
    4. Vue.prototype._render = function() {
    5. var ref = vm.$options;
    6. var render = ref.render;
    7. ···
    8. try {
    9. vnode = render.call(vm._renderProxy, vm.$createElement);
    10. } catch (e) {
    11. ···
    12. }
    13. ···
    14. return vnode
    15. }
    16. }

    抛开其他代码,_render函数的核心是render.call(vm._renderProxy, vm.$createElement)部分,vm._renderProxy在数据代理分析过,本质上是为了做数据过滤检测,它也绑定了render函数执行时的this指向。vm.$createElement方法会作为render函数的参数传入。回忆一下,在手写render函数时,我们会利用render函数的第一个参数createElement进行渲染函数的编写,这里的createElement参数就是定义好的$createElement方法。

    1. new Vue({
    2. el: '#app',
    3. render: function(createElement) {
    4. return createElement('div', {}, this.message)
    5. },
    6. data() {
    7. return {
    8. message: 'dom'
    9. }
    10. }
    11. })

    初始化_init时,有一个initRender函数,它就是用来定义渲染函数方法的,其中就有vm.$createElement方法的定义,除了$createElement_c方法的定义也类似。其中 vm._ctemplate内部编译成render函数时调用的方法,vm.$createElement是手写render函数时调用的方法。两者的唯一区别仅仅是最后一个参数的不同。通过模板生成的render方法可以保证子节点都是Vnode,而手写的render需要一些检验和转换。

    1. function initRender(vm) {
    2. vm._c = function(a, b, c, d) { return createElement(vm, a, b, c, d, false); }
    3. vm.$createElement = function (a, b, c, d) { return createElement(vm, a, b, c, d, true); };
    4. }

    createElement 方法实际上是对 _createElement 方法的封装,在调用_createElement前,它会先对传入的参数进行处理,毕竟手写的render函数参数规格不统一。举一个简单的例子。

    1. // 没有data
    2. new Vue({
    3. el: '#app',
    4. render: function(createElement) {
    5. return createElement('div', this.message)
    6. },
    7. data() {
    8. return {
    9. message: 'dom'
    10. }
    11. }
    12. })
    13. // 有data
    14. new Vue({
    15. el: '#app',
    16. render: function(createElement) {
    17. return createElement('div', {}, this.message)
    18. },
    19. data() {
    20. return {
    21. message: 'dom'
    22. }
    23. }
    24. })

    这里如果第二个参数是变量或者数组,则默认是没有传递data,因为data一般是对象形式存在。

    1. function createElement (
    2. context, // vm 实例
    3. tag, // 标签
    4. data, // 节点相关数据,属性
    5. children, // 子节点
    6. normalizationType,
    7. alwaysNormalize // 区分内部编译生成的render还是手写render
    8. ) {
    9. // 对传入参数做处理,如果没有data,则将第三个参数作为第四个参数使用,往上类推。
    10. if (Array.isArray(data) || isPrimitive(data)) {
    11. normalizationType = children;
    12. children = data;
    13. data = undefined;
    14. }
    15. // 根据是alwaysNormalize 区分是内部编译使用的,还是用户手写render使用的
    16. if (isTrue(alwaysNormalize)) {
    17. normalizationType = ALWAYS_NORMALIZE;
    18. }
    19. return _createElement(context, tag, data, children, normalizationType) // 真正生成Vnode的方法
    20. }

    4.3.1 数据规范检测

    Vue既然暴露给用户用render函数去手写渲染模板,就需要考虑用户操作带来的不确定性,因此_createElement在创建Vnode前会先数据的规范性进行检测,将不合法的数据类型错误提前暴露给用户。接下来将列举几个在实际场景中容易犯的错误,也方便我们理解源码中对这类错误的处理。

    1. 用响应式对象做data属性
      1. new Vue({
      2. el: '#app',
      3. render: function (createElement, context) {
      4. return createElement('div', this.observeData, this.show)
      5. },
      6. data() {
      7. return {
      8. show: 'dom',
      9. observeData: {
      10. attr: {
      11. id: 'test'
      12. }
      13. }
      14. }
      15. }
      16. })
    2. 当特殊属性key的值为非字符串,非数字类型时
      1. new Vue({
      2. el: '#app',
      3. render: function(createElement) {
      4. return createElement('div', { key: this.lists }, this.lists.map(l => {
      5. return createElement('span', l.name)
      6. }))
      7. },
      8. data() {
      9. return {
      10. lists: [{
      11. name: '111'
      12. },
      13. {
      14. name: '222'
      15. }
      16. ],
      17. }
      18. }
      19. })
      这些规范都会在创建Vnode节点之前发现并报错,源代码如下:
      1. function _createElement (context,tag,data,children,normalizationType) {
      2. // 1. 数据对象不能是定义在Vue data属性中的响应式数据。
      3. if (isDef(data) && isDef((data).__ob__)) {
      4. warn(
      5. "Avoid using observed data object as vnode data: " + (JSON.stringify(data)) + "\n" +
      6. 'Always create fresh vnode data objects in each render!',
      7. context
      8. );
      9. return createEmptyVNode() // 返回注释节点
      10. }
      11. if (isDef(data) && isDef(data.is)) {
      12. tag = data.is;
      13. }
      14. if (!tag) {
      15. // 防止动态组件 :is 属性设置为false时,需要做特殊处理
      16. return createEmptyVNode()
      17. }
      18. // 2. key值只能为string,number这些原始数据类型
      19. if (isDef(data) && isDef(data.key) && !isPrimitive(data.key)
      20. ) {
      21. {
      22. warn(
      23. 'Avoid using non-primitive value as key, ' +
      24. 'use string/number value instead.',
      25. context
      26. );
      27. }
      28. }
      29. ···
      30. }
      这些规范性检测保证了后续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的数组。
    1. function _createElement() {
    2. ···
    3. if (normalizationType === ALWAYS_NORMALIZE) {
    4. // 用户定义render函数
    5. children = normalizeChildren(children);
    6. } else if (normalizationType === SIMPLE_NORMALIZE) {
    7. // 模板编译生成的的render函数
    8. children = simpleNormalizeChildren(children);
    9. }
    10. }
    11. // 处理编译生成的render 函数
    12. function simpleNormalizeChildren (children) {
    13. for (var i = 0; i < children.length; i++) {
    14. // 子节点为数组时,进行开平操作,压成一维数组。
    15. if (Array.isArray(children[i])) {
    16. return Array.prototype.concat.apply([], children)
    17. }
    18. }
    19. return children
    20. }
    21. // 处理用户定义的render函数
    22. function normalizeChildren (children) {
    23. // 递归调用,直到子节点是基础类型,则调用创建文本节点Vnode
    24. return isPrimitive(children)
    25. ? [createTextVNode(children)]
    26. : Array.isArray(children)
    27. ? normalizeArrayChildren(children)
    28. : undefined
    29. }
    30. // 判断是否基础类型
    31. function isPrimitive (value) {
    32. return (
    33. typeof value === 'string' ||
    34. typeof value === 'number' ||
    35. typeof value === 'symbol' ||
    36. typeof value === 'boolean'
    37. )
    38. }

    4.3.4 实际场景

    在数据检测和组件规范化后,接下来通过new VNode()便可以生成一棵完整的VNode树,注意在_render过程中会遇到子组件,这个时候会优先去做子组件的初始化,这部分放到组件环节专门分析。我们用一个实际的例子,结束render函数到Virtual DOM的分析。

    • template模板形式
      1. var vm = new Vue({
      2. el: '#app',
      3. template: '<div><span>virtual dom</span></div>'
      4. })
    • 模板编译生成render函数
      1. (function() {
      2. with(this){
      3. return _c('div',[_c('span',[_v("virual dom")])])
      4. }
      5. })
    • Virtual DOM tree的结果(省略版)
      1. {
      2. tag: 'div',
      3. children: [{
      4. tag: 'span',
      5. children: [{
      6. tag: undefined,
      7. text: 'virtual dom'
      8. }]
      9. }]
      10. }