虚拟DOM怎么生成(vm._render)

749 阅读4分钟

想了解一下虚拟DOM是怎么生成的。

我之前简单介绍过DOM是如何解析模板然后渲染的,如果你还不太清楚的话可以跳到我的这篇文章了解一下:《Vue相关指令(一)》,其中有一小节拓展了一下从模板到抽象语法树的过程,然后最那一小节的最后面讲到了:抽象语法树会变成render函数代码字符串。也就是说后面的工作都交给render来做了,render会给我们返回一个虚拟的DOM。

还是先简单解释一下render是干啥的吧~反正我以后也会单独开一篇文章来讲(其实我的文章都是按自己记笔记的顺序来讲的嘿嘿,就一边复习,想到啥就查一查然后写进来,很随意的那种,我怎么又讲废话了..)

在我印象里render就是个又长又臭的东西,当时学他学的很痛苦。对于我们使用者而言他其实和<template>是差不多的功能,但是<template>使用标签的方式去告诉VUE我们要生成什么样的DOM结构,而render是用函数的方式去生成虚拟DOM结构,然后VUE会根据这些虚拟DOM生成我们想要的DOM结构。

例如在模板中:

<h1>{{ blogTitle }}</h1>

在render中就要写成这样的格式:

var vm = new Vue({
    el:...,
    data:{
        blogTitle : "haha"
    },
    render: function (createElement) {
      return createElement('h1', this.blogTitle)
    }
})

看起来有点麻烦对吧,但是人家还是有好处的,这里先了解一下他是什么东西就好了。

其实render有两种格式,我们自己写的时候是这样的格式,但是在VUE内部编译的时候往往是那种大长串的代码字符串,在前面的文章中我们已经体验过了..就是用_l_c拼拼凑凑生成的代码字符串。

那么_render又是什么东西呢?他是怎么生成虚拟DOM的呢?来看看它的源码,这段代码已经被我精简了,提取了核心代码:

//vm._render()调用render函数,会返回一个VNode,在生成VNode的过程中,会动态计算getter,同时推入到dep里面  
Vue.prototype._render = function (): VNode {
    const vm: Component = this
    //拿到选项中的render函数 这里的_parentVnode就是当前组件的父 VNode
    const { render, _parentVnode } = vm.$options
    //处理作用域插槽
    if (_parentVnode) {
      vm.$scopedSlots = normalizeScopedSlots(
        _parentVnode.data.scopedSlots,
        vm.$slots,
        vm.$scopedSlots
      )
    }

    vm.$vnode = _parentVnode
    let vnode
    //执行render函数 得到虚拟节点 其实render函数就是那些代码字符串
    try {
      //其中render是就是之前讲了几次的,那些_l,_c拼拼凑凑形成的代码字符串啦。或者是我们自己在创建实例的时候写的render函数。
      //vm._renderProxy把vm做了一层代理 vm里面有_c等函数,还有data啥的。
      //$createElement是给我们自己在data中写render属性的时候用的,我们自己写render的时候第一个参数就是他啦。
      vnode = render.call(vm._renderProxy, vm.$createElement)
    } catch (e) {
      //...在生成虚拟节点的时候可能会出现一些错误,需要做一些错误处理
    }
    //挂载vnode父节点,最后返回vnode
    vnode.parent = _parentVnode
    return vnode
  }

执行了render就等于是拿到了虚拟DOM。render已经没啥好讲的了,不是我们自己写的函数就是之前讲过的拼拼凑凑的函数,我们都知道他的真面目的。vm._renderProxy和vm.$createElement还没见过,那就看一看这两个东西~

vm._renderProxy

当我们一执行Vue,就会调用initMixin(initMixin是VUE初始化的入口,初始化一些实例属性和事件,生命周期啥的)对实例进行初始化,这个函数的代码很长,唯一与_renderProxy相关的一句就是initProxy(vm);。所以我们跳过initMixin,直接来看初始化initProxy函数。

initProxy = function initProxy(vm) {
  //如果支持proxy
  if (hasProxy) {
    var options = vm.$options;
    //看看有没有用户手写的render函数
    //手写的函数中当存在_withStripped时,使用getHandler,否则hasHandler
    var handlers = options.render && options.render._withStripped ?
      getHandler :
      hasHandler;
    //操作_renderProxy的时候要先经过代理的拦截
    //这层代理会在模板渲染时对一些非法或者不存在的字符串进行判断,做数据的过滤筛选。
    vm._renderProxy = new Proxy(vm, handlers);
  //如果不支持
  } else {
    //直接就把vm赋值给_renderProx
    vm._renderProxy = vm;
  }
};

有时候使用类似webpack这样的打包工具时,我们将使用vue-loader进行模板编译,这个时候options.render 是存在的,并且_withStripped的属性也会设置为true。

这里我们不讨论这种情况哈,我们看看getHandler和hasHandler。

hasHandler

其实这两个东西的名字很直白,handler中的钩子是has就叫hasHandler,钩子是get就叫getHandler。先讲一下hasHandler,他能判断一个对象是否拥有一个属性。

has钩子可以用来拦截with语句下的作用对象,例如:

var obj = {
    a: 1
}
var nObj = new Proxy(obj, {
    has(target, key) {
        console.log(target) // { a: 1 }
        console.log(key) // a
        return true
    }
})

with(nObj) {
    a = 2
}

所以我们的render函数执行的时候,由于里面有with函数,函数里面操作vm实例的属性的时候也会被他拦截。

但是只有render函数是自动生成的时候才有with呀,如果是我们手写的呢?我测试了一下,当render是我们手写的时候,除非用了xxx in this; Reflect.has();这样的语句,否则是不会触发拦截器的,所以说拦截器主要是针对于编译生成的render代码。

那他有啥用嘞?先看看他的代码:

const hasHandler = {
    has (target, key) {
        // 先得到in出来的结果
        const has = key in target
        // 如果key在allowedGlobals(里面定义了一些全局变量)之内,或者key是以下划线 _ 开头的字符串,则为真
        const isAllowed = allowedGlobals(key) || (typeof key === 'string' && key.charAt(0) === '_')
        // 如果has和isAllowed都为假,说明真的找不到,这时候使用warnNonPresent函数打印错误
        if (!has && !isAllowed) {
            // warnNonPresent会通过warn打印一段警告信息说"在渲染的时候引用了key,但是在实例对象上并没有定义 key 这个属性或方法"
            warnNonPresent(target, key)
        }
        // 返回has,没有就返回!isAllowed,因为对于全局对象即使没有在原型链上找到也不需要报错
        return has || !isAllowed
    }

所以我们举个简单的例子,来演示hasHandler触发的过程。

const vm = new Vue({
    el: '#app',
    //使用了a
    template: '<div>{{a}}</div>',
    //但是没有定义a
    data: {}
})
//编译的过程中可以看见他输出错误信息

VUE处理template的时候,就会得到一个render渲染函数,大概长这个样子:

vm.$options.render = function () {
    // render 函数的 this 指向实例的 _renderProxy
    with(this){
        return _c('div', [_v(_s(a))])   // 在这里访问 a,相当于访问 vm._renderProxy.a
    }
}

然后我们在Vue.prototype._render中执行了render,返回一个VNode。

//Vue.prototype._render
vnode = render.call(vm._renderProxy, vm.$createElement)

再执行render的过程中,由于call的作用,with绑定的作用域变成了vm._renderProxy,所以我们在with中访问的变量都会经过vm._renderProxyhandler,也就是hasHandler

当在with中访问变量a的时候,出发了hasHandler,由于他真的找不到这个变量,所以执行了warnNonPresent,输出错误。

image-20200418110954285

getHandler

var handlers = options.render && options.render._withStripped ?
  getHandler :
  hasHandler;

根据上面这段代码,我们可以知道:这个函数会在我们手写了render函数,而且函数中有_withStripped属性的时候才赋值给handlers。也就是说,只有当我们手动设置这个_withStripped属性为true的时候才会触发。

所以说,对于这么一段代码:

var vm = new Vue({
    el: '#app',
    data: {
        test: 1
    },
    render: function (h) {
        return h('div', this.a)
    }
})

不会触发hasHandler的拦截,也不会触发getHandler的拦截。既不会报错,也看不到结果。

如果想要在render中得到警告,就要手动设置render._withStrippedtrue。

const render = function (h) {
    return h('div', this.a)
}
render._withStripped = true
 
var vm = new Vue({
    el: '#app',
    render,
    data: {
        test: 1
    }
})

这个getHandler函数的代码也是差不多的,都是用来报错的:

const getHandler = {
    get (target, key) {
        if (typeof key === 'string' && !(key in target)) {
            warnNonPresent(target, key)
        }
        return target[key]
    }
}

为什么要这么做呢?因为在webpack配合vue-loader的环境中,vue-loader会借助工具将template编译成不用with语句包裹的形式,然后设置render._withStripped = true。由于他编译生成的render函数都是用"."的方式去访问vm中的变量的,所以无法触发hasHandler函数(就像我们自己写的render一样无法触发),这时候只能设置_withStripped去触发getHandler了。

好了,终于讲完这个vm._renderProxy了,接下来我们看vm.$createElement。

vm.$createElement

我们在源代码中直接找到vm.$createElement定义,在initRender方法中,这个方法也是一个用来初始化VUE实例上的一些属性的方法。里面的东西有点多,有机会再讲吧,就先把createElement挑出来讲。

// 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 = (a, b, c, d) => createElement(vm, a, b, c, d, false)
// normalization is always applied for the public version, used in
// user-written render functions.
vm.$createElement = (a, b, c, d) => createElement(vm, a, b, c, d, true)

这两个方法都是我们眼熟的,其中_c就是之前拼凑render代码字符串的时候常见的。内个$createElement嘞,是我们手写render的时候传入的参数。

我们之前手写render的时候一般是这样写的:

render: function (createElement) {
  return createElement('h2', 'Title')
}

其实我们也可以这样写:

render: function () {
  return this.$createElement('h2', 'Title')
}

这只是一个小小的扩展。我们可以看到这两种格式的render都交给createElement这个函数来处理了,只有最后一个参数不一样。

那我们就具体看看createElement是什么吧~

createElement

  function createElement(
    context,
    tag,
    data,
    children,
    normalizationType,
    alwaysNormalize
  ) {
    //检测data的类型,判断data是不是数组,是不是基本类型,看看data有没有传入。
    if (Array.isArray(data) || isPrimitive(data)) {
      //把children移到normalizationType参数的位置
      normalizationType = children;
      //把data移到children参数的位置
      children = data;
      //把data赋值为undefined
      data = undefined;
    }
    //当这个参数为true的时候说明是用户手写的
    if (isTrue(alwaysNormalize)) {
      normalizationType = ALWAYS_NORMALIZE;
    }
    //context表示vnode上下文环境,也就是vm实例
    //tag是个标签,告诉他希望生成什么节点,可以为字符串,组件,以及函数。
    //data代表是vnode数据。
    //children表示当前vnode子节点。但是需要被规范为标准的vnode数组。
    return _createElement(context, tag, data, children, normalizationType)
  }

说实话这个操作把我给看懵了,干啥呀这是,移来移去的,而且看了好几篇文章都说是"把参数往前移动",看得我有点怀疑人生,这不是往后移动吗?哎,真是把我看蒙了。

然后我们又要讲回内个手写的render了,本来其实不是很想讲,但是通过这个理解传参似乎比较快一点。你看呀,不管是手写的render和VUE自动拼接形成的render不都是这么几个参数嘛。所以我们可以直接通过手写render的时候传递的参数来看看,那些abcd都是些什么。

现在我们来了解在render中调用createElement的时候的完整参数:

createElement(标签名(必需), 与模板中属性对应的数据对象(可选), 子级虚拟节点(可选));

我们之前了解过render的其中一种手写方式,如下:

render: function (createElement) {
  return createElement('h1', this.blogTitle)
}
//这个时候传递的参数是createElement(vm, 'h1', this.blogTitle, undefined, undefined, true)

现在看看完整版:

render: function (createElement) {
  return createElement('h1',{
      //在attrs中,我们可以传入一些写在HTML标签中的特性
      attrs: {
        id: 'foo'
      }
  } ,this.blogTitle)
}
//这个时候传递的参数是createElement(vm, 'h1', attrs:{id: 'foo'}, this.blogTitle, undefined, true)
//生成的代码是<h1 id = "foo"> blogTitle对应的值 </h1>

中间的这个参数对象在VUE中叫做数据对象,主要是写一些DOM节点上附加的属性,里面可以写的东西有很多,我就不说了,想看的可以点击链接感受一下:深入数据对象

好啦,我们知道这个数据对象可以传也可以不传之后,就能知道那里移来移去是想干什么了,如果我们没有传递数据对象的话,第二个参数写的就是子节点;如果传递了数据对象的话,第二个参数就是数据对象。所以这里主要是做一个参数的兼容,判断有没有传递数据对象,如果没有传递,就把这样的参数格式:

createElement(vm, 'h1', this.blogTitle, undefined, undefined, true)

变成这样的参数格式:

createElement(vm, 'h1', undefined, this.blogTitle, undefined, true)

这样就能正确调用我们的这个函数,参数都能一一对应:

_createElement(context, tag, data, children, normalizationType)

okk,这些abc搞定了,那第四个参数d是什么东西?这个参数手写render的时候是不会传递的,只有当VUE自动拼接render的时候才会传递,请看我之前那一节举的例子:

_c(
    //第一个参数
    'div', 
    //第二个参数
    {
        attrs: {
        "id": "app"
        }
    }, 
   //第三个参数
    _l(
        (infos),
       function (item, key, index) {
            return _c('p', [_v(_s(index) + ":" + _s(key) + ":" + _s(item))])
    }),
    //第四个参数
    0
)

好,知道他会传递了,那他是干嘛的呢?

不急不急~我们先看看_createElement

_createElement

这个函数太长了,我们分开一段一段看:

  function _createElement(
    context,
    tag,
    data,
    children,
    normalizationType
  ) {
    //对data进行校验 
    //isDef是isDefined的缩写,看他有没有被定义,v !== undefined && v !== null
    //看看有没有定义(data).__ob__这个属性
    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()
    }
}

__ob__ 会指向一个Observer对象,每个被双向绑定的对象元素(数组也是对象)都会有一个_ob_。所以说一旦我们传入的数据对象是一个已经被双向绑定的对象,就会报错,然后创造一个空节点。

//看看有没有is属性,如果传递了is属性就用is作为标签名字
//但是注释上这里写的是v-bind相关的语法,有点摸不着头脑
if (isDef(data) && isDef(data.is)) {
  tag = data.is;
}
//如果没有设置标签的名字,就返回一个空节点
if (!tag) {
  return createEmptyVNode()
}
//如果定义了key值而且key值不是基本类型的值,提示:替换成字符串或数字
if (isDef(data) && isDef(data.key) && !isPrimitive(data.key)) {
  {
    warn(
      'Avoid using non-primitive value as key, ' +
      'use string/number value instead.',
      context
    );
  }
}
// support single function children as default scoped slot
// 这一步的意义我还没有弄懂...
// 应该是说支持把数组中第一个函数当作默认作用域插槽,其他的都无视?
if (Array.isArray(children) &&
  typeof children[0] === 'function'
) {
  //如果没传数据就用一个空对象
  data = data || {};
  //设置为默认作用域插槽
  data.scopedSlots = {
    default: children[0]
  };
  //清空子节点
  children.length = 0;
}

上面的都是一些七七八八的过滤和处理,接下来是才是重点

if (normalizationType === ALWAYS_NORMALIZE) {
  children = normalizeChildren(children);
} else if (normalizationType === SIMPLE_NORMALIZE) {
  children = simpleNormalizeChildren(children);
}

simpleNormalizeChildren

export function simpleNormalizeChildren (children: any) {
  for (let i = 0; i < children.length; i++) {
    if (Array.isArray(children[i])) {
      return Array.prototype.concat.apply([], children)
    }
  }
  return children
}

这个方法比较简单,主要就是判断children中的子级是否有数组,有就把children给拍平成一维数组返回。

这个方法会在render函数是通过VUE编译生成的时候调用,主要是针对函数式组件。如果使用了函数式组件,那时候得到的子节点就是一个数组了(一般的组件返回的都是一个根节点),所以会通过数组concat方法把children拍平成一维数组返回。

normalizeChildren

normalizeChildren的调用场景有两种:

  1. render函数是用户手写的。
  2. 编译slot、v-for 的时候会产生嵌套数组的情况,就会调用这个方法。
export function normalizeChildren (children: any): ?Array<VNode> {
  //判断子节点是不是基本类型
  return isPrimitive(children)
    //是的话就如果创建一个文字vnode返回
    ? [createTextVNode(children)]
    //判断是不是数组类型
    : Array.isArray(children)
      //是的话就返回调用normalizeArrayChildren
      ? normalizeArrayChildren(children)
      //否则就返回一个undefined
      : undefined
}

createTextVNode方法能创建一个文字vnode返回:

  function createTextVNode(val) {
    return new VNode(undefined, undefined, undefined, String(val))
  }

normalizeArrayChildren,长代码段预警:

function normalizeArrayChildren (children: any, nestedIndex?: string): Array<VNode> {
  const res = []
  let i, c, lastIndex, last
  //遍历处理每一个子节点
  for (i = 0; i < children.length; i++) {
    c = children[i]
    //如果没定义或者是布尔值就跳过
    if (isUndef(c) || typeof c === 'boolean') continue
    lastIndex = res.length - 1
    last = res[lastIndex]

    if (Array.isArray(c)) {
      // 当前子节点为数组 
      if (c.length > 0) {
        //递归调用normalizeArrayChildren,遍历处理他的子节点
        c = normalizeArrayChildren(c, `${nestedIndex || ''}_${i}`)
        // 当前子节点和上一个处理的子节点,都是文本节点,就合并成一个
        if (isTextNode(c[0]) && isTextNode(last)) {
          res[lastIndex] = createTextVNode(last.text + (c[0]: any).text)
          c.shift()
        }
        res.push.apply(res, c)
      }
    } else if (isPrimitive(c)) {
      // 当前子节点为基础类型 判断上一个处理的节点是否为文本 
      if (isTextNode(last)) {
        // 是就合并成一个
        res[lastIndex] = createTextVNode(last.text + c)
      } else if (c !== '') {
        // 不是而且不为空就把这个基本类型转换为文本节点并且push
        res.push(createTextVNode(c))
      }
    } else {
    ..
  }
  return res
}

为什么要合并文本节点呢?举个例子来说明:

var app = new Vue({
    data: {},
    el: '#app',
    render(createElement) {
        return createElement('div', [1, 2, 3])
    }
})

对于这种情况,数组里面有三个元素,这些元素会变成文本节点,作为div对应的文本节点,最终生成<div>123</div>这样的格式。也就是说文本节点是不能单独作为一个节点存在的,所以如果有多个文本节点(1,2,3)一定要拼接在一起,作为一个文本节点(123),然后再交给一个标签(div),作为他的子节点。

所以当子节点是数组的时候,也要考虑合并文本节点的情况,场景如下:

render(createElement) {
    //子节点是一个数组
    return createElement('div', [1, 2, 3].map((item) => {
        //数组里面都是p节点,节点的内容是文本的数组,要被合并成一个文本节点
        return createElement("p", [item, item, item])
    }))
}

最终生成的结果如下:

<p>111</p>
<p>222</p>
<p>333</p>

okk,回到之前的代码中。接下来要处理一些其他情况,如果当前子节点为vnode节点或者是一个普通对象就会进入到这个else块:

} else {
      // 判断子节点是否为文本节点 上一个处理的是否为文本节点
      if (isTextNode(c) && isTextNode(last)) {
        // 是就合并成一个
        res[lastIndex] = createTextVNode(last.text + c.text)
      } else {
        // default key for nested array children (likely generated by v-for)
        // 判断该节点的属性 并且为他生成默认的key值
        // 判断有没有_isVList标记,表示renderList处理成功
        if (isTrue(children._isVList) &&
          //定义了标签
          isDef(c.tag) &&
          //没有定义key值
          isUndef(c.key) &&
          //传递了nestedIndex
          isDef(nestedIndex)) {
          c.key = `__vlist${nestedIndex}_${i}__`
        }
        //将该节点加入到结果中
        res.push(c)
      }
    }
}

针对上面这块代码有几点要讲一下:

  1. 为什么上面已经处理过了基本类型的文本节点,这里还要再判断一次文本节点呢?其实主要是处理这种情况:如果该节点是一个普通对象,那么如果里面写了text属性,则渲染结果就是text属性,如下:

    var app = new Vue({
        data: {
            items: {
                text: "xixi"
            }
        },
        el: '#app',
        render(createElement) {
            return createElement('div', [this.items])
        }
    })
    //最终渲染结果为xixi
    
  2. _isVList其实就是在执行renderList函数的时候的标记。renderList的代码我们之前已经看过了,就是处理v-for的处理函数,也就是"_l"。如果renderList处理成功就会添加一个_isVList标记,值为true。

  3. nestedIndex是在遍历的子节点是数组的时候传入的,他表示嵌套的索引,对应的代码如下:

    c = normalizeArrayChildren(c, ${nestedIndex || ''}_${i})
    

    最后生成的key值是这样的形式:

    key: "__vlist_1_2__" //表示在vlist渲染列表中的第1个子节点的第2个元素
    

    最后,经过对children的规范化,children变成了一个里面全是VNode的数组。接下来我们讨论下VNode的创建。


我们回到_createElement来,看看他如何生成vnode节点:

if (typeof tag === 'string') {
    let Ctor
    // 命名空间处理
    ns = (context.$vnode && context.$vnode.ns) || config.getTagNamespace(tag)
    // 判断 tag 是否为html原生的保留标签
    if (config.isReservedTag(tag)) {
      vnode = new VNode(
        //platform built-in elements 创建平台保留标签
        config.parsePlatformTagName(tag), 
        data, children, undefined, undefined, context
      )
    // 是否能从 vm.$options.components 中获取组件相关信息
    } else if (isDef(Ctor = resolveAsset(context.$options, 'components', tag))) { 
      // 创建component组件 这个createComponent先不研究了,以后有时间再补充
      vnode = createComponent(Ctor, data, context, children, tag)
    } else {
      // unknown or unlisted namespaced elements
      // check at runtime because it may get assigned a namespace when its
      // parent normalizes children
      // 未知标签 创建vnode
      vnode = new VNode(
        tag, data, children,
        undefined, undefined, context
      )
    }
}

tagdatachildren就是createElement方法中传递的参数。

VNode

参考文章:【Vue原理】VNode - 源码版

我们发现几乎所有的节点都是交给VNode来生成的,所以我们可以看一下VNode这个构造函数:

class VNode {
    constructor (
    tag,  
    data,        
    children,  
    text,         
    elm,         
    context,   
    componentOptions,
    asyncFactory
  ) {
    // 当前节点标签名
    this.tag = tag
    // 当前节点数据对象
    this.data = data
    // 当前节点子节点
    this.children = children
    // 当前节点文本
    this.text = text
    // 当前节点对应的真实DOM节点
    this.elm = elm
    // 当前节点命名空间
    this.ns = undefined
    // 当前节点上下文
    this.context = context
    // 函数化组件上下文
    this.fnContext = undefined
    // 函数化组件配置项
    this.fnOptions = undefined
    // 函数化组件ScopeId
    this.fnScopeId = undefined
    // 子节点key属性
    this.key = data && data.key
    // 组件配置项 
    this.componentOptions = componentOptions
    // 保存组件生成的实例
    this.componentInstance = undefined
    // 当前节点父节点
    this.parent = undefined
    // 是否为原生HTML或只是普通文本
    this.raw = false
    // 静态节点标志
    // 当数据变化的时候,可以忽略去比对他,以提高比对效率
    this.isStatic = false
    // 是否作为根节点插入
    this.isRootInsert = true
    // 是否为注释节点
    this.isComment = false
    // 是否为克隆节点
    this.isCloned = false
    // 是否有v-once指令
    this.isOnce = false
    // 异步组件的工厂方法
    this.asyncFactory = asyncFactory
    // 异步源
    this.asyncMeta = undefined
    // 是否异步的预赋值
    this.isAsyncPlaceholder = false
  }
}

反正就是存储一些节点信息的对吧~举个例子来看看他

<div class="parent" style="height:0" href="2222">
    111111
</div>

对应的(简洁版)VNode如下,主要提取了一些关键信息。

{    
    tag: 'div',    
    data: {        
        attrs:{href:"2222"},
        staticClass: "parent",        
        staticStyle: {            
            height: "0"
        }
    },    
    children: [{        
        tag: undefined,        
        text: "111111"
    }]
    //elm: div#app.parent 为什么要打注释呢 因为这里在markdown上会报错
}

好啦,这样就可以描述这些节点了。就可以根据这些信息生成真实的DOM节点了。

再解释一下几个组件相关的属性:

  1. parent,表示是组件的外壳节点,例如:

    components: {
        test: {
            template: "<div>haha</div>"
        }
    }
    

    页面中使用组件:

    <div>
        <test></test>
    </div>
    

    这时候会生成两种VNode,其中页面生成的VNode长这样:

    {
        tag: "test",
        children: undefined
    }
    //对应的html是<test></test>
    

    组件内部生成的VNode长这样:

    {
        tag: "h2",
        children: [{
            tag: undefined,
            text: "haha"
        }]
    }
    //对应的html是<div>haha</div>
    

    这时候,第一个VNode就是第二个VNode的外壳节点

    //组件内部的VNode的parent属性
    parent: VNode { tag: "vue-component-1-test" }
    

    第一个VNode会作为父组件和子组件的关联,用于保存一些父组件传给子组件的数据。

  2. componentOptions,放了prop,事件,插槽之类的东西。

    <div>
        <test @event="name = 1" :name = "name">1111</test>
    </div>
    
    //形成的componentOptions如下
    componentOptions:
        Ctor: f VueComponent(options)
        //保存slot
        children: [VNode]
        //保存事件
        listeners: {event: f}
        //保存 props
        propsData: {name: "111"}
        tag: "test"
    

VNode的存放

在实例的_vnode属性中,存放了VUE生成的VNode节点。他可以用来比对更新,如果我们的数据变化了,会生成一个新的VNode,然后和这个旧的_vnode进行对比,就能得到要更新的节点。

在组件实例中会有一个$vnode,因为如果是组件实例的话,_vnode中存放的是外壳节点。

捋一遍流程

最后看一看整个流程吧,对于下面的例子,他是怎么生成虚拟DOM的?

    <div id="app">
        <div href="xxxx">{{test}}</div>
    </div>
  1. _render函数中,执行render.call

    根据前面几篇文章的描述,我们已经了解render代码字符串是怎么生成的了,这里就直接拿了。针对上面的标签,会生成这样的render代码:

    with(this){  
        return _c('div',
            {attrs:{"href":"xxxx"}},
            ["1"]
        )
    }
    

    从这里开始执行_c函数。

  2. 我们知道_c函数其实就是createElement函数,所以开始执行:

    createElement(vm, 'div', {attrs:{"href":"xxxx"}}, ["1"], undefined, false)
    
  3. 由于我们传参没毛病,所以直接执行

    _createElement(vm, 'div', {attrs:{"href":"xxxx"}}, ["1"], undefined)
    
  4. 由于我们的tag是一个字符串,所以执行了new VNode

    vnode = new VNode(
      'div', {attrs:{"href":"xxxx"}}, ["1"],
      undefined, undefined, vm
    );
    
  5. 生成VNode虚拟节点并且进行挂载

参考文章

  1. 【Vue】Vue源码--createElement
  2. Vue 2.0 的 virtual-dom 实现简析
  3. 详解vue的diff算法
  4. 解析vue2.0的diff算法
  5. 深入剖析Vue源码 - 数据代理,关联子父组件
  6. vue源码(七)Vue 的初始化之开篇
  7. Vue源码笔记 — 数据驱动--createElement
  8. vue源码分析三 -- vm._render()如何生成虚拟dom
  9. Vue源码解读之数据绑定
  10. 【Vue原理】VNode - 源码版