Vue源码之虚拟DOM来自何方?

Posted 天地会珠海分舵

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Vue源码之虚拟DOM来自何方?相关的知识,希望对你有一定的参考价值。

上一篇文章《Vue源码之虚拟DOM长成啥样》我们聊了下虚拟DOM是怎么用javascript对象来描述真实的DOM节点的。

今天我们来聊下虚拟DOM是怎么生成的。

1. vue文件最终会被编译成用来生成虚拟DOM的render函数

我们平常用vue做开发的时候,通常都是将一个组件封装到一个单独的组件中的,大概样子就是:

<template>
  <div>
    <div> msg </div>
  </div>
</template>
<script>
export default 
  name: "App",
  data() 
    return 
      msg: "Hello World",
    ;
  ,
;
</script>

因为。这是个.vue结尾的文件,也就是说是个vue的单文件组件,放到浏览器上是不能被浏览器认识的。换句话说就是浏览器不能直接解析vue格式的文件,它只认识javascript。

那怎么办呢?

很明显,那就需要我们将vue代码编译成javascript代码,而其中很大一部分工作就是模板的编译。

而vue文件的编译,主要是模板编译有两种:

  • 运行时编译。比如我们在new vue的时候指定template,那么这个template就是需要动态编译的
  • 静态编译。我们编写的vue文件,在webpack打包的时候,会用到vue-template-compiler这个第三方包来进行编译。我们可以在项目的package.json下面找到该包的版本信息

这里我们先不会探讨模板编译的实现这一块, 让我们先看下上面这个vue文件大概会编译成什么样子。

var render = function render() 
  var _vm = this,
    _c = _vm._self._c
  return _c("div", [_c("div", [_vm._v(_vm._s(_vm.msg))])])

var staticRenderFns = []
render._withStripped = true

export  render, staticRenderFns 

这就是vue中著名的渲染函数!

每个vue组件都对应一个渲染函数,render函数的执行结果就是生成对应组件的虚拟DOM。我们会一步步分析这个渲染函数是怎么生成虚拟DOM的。

不过开始之前,我们先看下这个渲染函数在vue中是怎么被用起来的。

2. render函数会在组件更新时被渲染watcher检测到并触发

每个vue组件都会有一个渲染watcher来监控数据状态的变化,在组件初始化或者数据更新的时候,会触发渲染函数watcher的getter,实际上就是组件的updateComponent方法:

export function mountComponent(
  vm: Component,
  el: ?Element,
  hydrating?: boolean
): Component 
	...
    updateComponent = () => 
      	const vnode = vm._render();
      	vm._update(vnode, hydrating);
    ;
	new Watcher(
	    vm,
	    updateComponent,
	    noop,
	    
	      ...
	    ,
	    true /* isRenderWatcher */
	  );
	  ...

updateComponent函数做的事情就是:

  • 调用该组件的render函数生成该组件新的虚拟DOM
  • 和该组件的老的虚拟DOM做diff,将变化更新到真实DOM

那么我们看下updateComponent调用的_vm.render方法的关键代码

Vue.prototype._render = function (): VNode 
    const vm: Component = this;
    const  render, _parentVnode  = vm.$options;
    ...
    // render self
    let vnode;
    ...
    vnode = render.call(vm._renderProxy, vm.$createElement);
    ...
    return vnode;
  ;

其中vm指向的组件实例自身。而vm.$options,我们在使用一个组件的时候,通常需要做的事情是先将其import进来:

import App from "./App.vue";
console.log("App:", App);


而在组件初始化时,这个组件就会作为options保存到组件实例的vm.$options下面。

所以这的render就是vm.$options.render,即文章最前面说的vue组件文件编译后生成的render方法。

var render = function render() 
  var _vm = this,
    _c = _vm._self._c
  return _c("div", [_c("div", [_vm._v(_vm._s(_vm.msg))])])

最后将生成的组件的虚拟DOM即vnode返回给上层调用updateComponent,交给update来做diff和真实DOM的更新。

3. render,h函数及$createElement

当我们是用vue的脚手架创建的项目,很大可能你的main.js文件中创建vue实例的代码是如下这样的

import App from "./App.vue";
Vue.config.productionTip = false;
new Vue(
 render: h => h(App)
).$mount('#app')

我们知道vue构建时可以通过不同的参数构建出不同的目标版本,有些版本可能是带有模板编译功能的,所以在创建Vue实例时可以通过指定template来直接上模板代码,然后在运行时对模板来的代码进行编译,而vue编译出来的一些版本可能为了引用的vue体质更少,不会带有模板编译功能,这时就不能提供template,提供了也无法编译,而这时就需要提供这里的render选项。

如代码所示,里面的render和我们前面说的渲染函数是一样的,其目的就是要创建虚拟DOM。它接受了一个叫做h的参数,然后以App作为参数来返回这个函数的调用。稍微整理下,render可以写成:

render: function (h) 
  return h(App);

h在这里只是个形参,实参是由vue传入的,事实上是一个叫做$createElement的函数。如果将形参也写成和实参一样,那么上面的代码就变成:

render: function ($createElement) 
  return $createElement(App);

很明显,这个h函数,也就是$createElement函数的作用就是根据传入的参数来生成虚拟DOM,然后再转由render来返回给vue。

之所以还需要转由render函数来返回,我觉得这里可能更多是想表现vue实现方面的自由度,也就是官网号称的vue是个渐进式框架,你可以用vue给你提供的createElement来创建虚拟节点,也可以自己另外写一个自定义的方法来创建虚拟节点,只要最终的虚拟节点满足vue的VNode的定义就好了。

下面我们分析下$createElement函数

vm.$createElement = (a, b, c, d) => createElement(vm, a, b, c, d, true);

它接受4个参数,这里主要解析下前面三个:

  • a: tag,标签
  • b: data,即class属性之类
  • c:children,即嵌套的h调用。比如
h('div', staticClass:'message, [h('div', tag: 'span',...)] ' 

随后它开始加上组件实例或者vue示例作为参数来调用createElement方法,而createElement基本上就是直接调用_createElement方法,所以这里我们就看下_createElement的关键代码就好了

export function _createElement(
  context: Component,
  tag?: string | Class<Component> | Function | Object,
  data?: VNodeData,
  children?: any,
  normalizationType?: number
): VNode | Array<VNode> 
  ...
  let vnode, ns;
  if (typeof tag === "string") 
    let Ctor;
    ns = (context.$vnode && context.$vnode.ns) || config.getTagNamespace(tag);
    if (config.isReservedTag(tag)) 
      // platform built-in elements
      ...
      vnode = new VNode(
        config.parsePlatformTagName(tag),
        data,
        children,
        undefined,
        undefined,
        context
      );
     else if (
      (!data || !data.pre) &&
      isDef((Ctor = resolveAsset(context.$options, "components", tag)))
    ) 
      // component
      vnode = createComponent(Ctor, data, context, children, tag);
     
    ...
    
   else 
    // direct component options / constructor
    vnode = createComponent(tag, data, context, children);
  
  ...
  return vnode;

  • 当tag是html关键字保留字串如div的时候,直接通过VNode构造函数来创建对应的虚拟节点。
  • 如果不是html关键字的字符串,那么看下我们在组件的配置选项components里有没有对应字串的定义,如果有的话,证明这是个组件,就会调用createComponent来创建组件。
  • 如果tag根本不是字串,那应该就是个import进来的组件,所以这里同样是调用createComponent来进行组件的创建。

要理解我们文章开始的那个render方法,其实我们这里并不需要知道createComponent是怎么工作的,因为里面我们用到的都是HTML预留的标签名。

为了加深理解,这里我们举两个例子。

有模板

 <div class="message"></div>

那么我们可以通过调用h即createElement方法来生成虚拟节点

h('div', staticClass:'message')

对应的虚拟节点(只列出关键数据)如下:


	tag: 'div',
	text: undefined,
	data: 'staticClass: "message",
	children: undefined
	...

再假设有模板如下

<div>
    <div>Hello world</div>
</div>

那么我们可以通过调用h方法来生成虚拟节点:

h('div', , [h('div',  , [text: 'Hello world',...)])

当然,vue在实现时做得更智能点,如果h函数第二个参数data为空的话,其实我们可以什么都不写。所以上面的代码可以稍微整理下:

h('div', [h('div', [text: 'Hello world',...)])

对应的虚拟DOM大概如下:


	tag: 'div',
	text: undefined,
	data: undefined,
	children: [
			tag: 'div', 
			text: undefined,
			data: undefined, 
			children: [
				text: 'Hello world', 
				....
			]
		]
	...

4. 回到vue组件文件编译后的render方法

上面分析了这么多,和我们最开始时看到的vue组件编译后的生成的render方法有什么关系呢?

var render = function render() 
  var _vm = this,
  _c = _vm._self._c
  return _c("div", [_c("div", [_vm._v(_vm._s(_vm.msg))])])

其实,代码里面的_c就是我们上面说的h函数。代码中_c, _v, _s这些奇怪的方法其实都只是简写,它们有着各自的真身。下面我们会将这些真身都找到,这样这个渲染函数就会变得很容易理解了。

首先,我们vue实例对象和组件实例对象在初始化时,都会调用到vue原型的_init方法,在里面会实例对象本身赋予给自身的self属性:

Vue.prototype._init = function (options?: Object) 
    const vm: Component = this;
    ...
    vm._self = vm;
    ...
    initRender(vm);
    ...

函数里面还会调用一系列的初始化方法,其中一个就是initRender,其中很重要的一个功能就是我们渲染函数代码中_c的指向

export function initRender(vm: Component) 
  ...
  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方法,其实就是我们前面说的h函数!

所以上面的代码我们可以改写成:

var render = function render() 
  var _vm = this,
  return h("div", [h("div", [_vm._v(_vm._s(_vm.msg))])])

紧跟着我们需要看下_v和_s,其实它们都是在初始化时再同一个方法中定义的

export function installRenderHelpers(target: any) 
  ...
  target._s = toString;
  ...
  target._v = createTextVNode;
  ...

toString, 其实就真的是toString,将参数编程字符串而已。而_v的真身createTextNode,其实我们在上一篇文章聊VNode定义就已经分析过,它其实只是一个方便我们创建文本类型的虚拟节点的一个辅助方法而已:

export function createTextVNode(val: string | number) 
  return new VNode(undefined, undefined, undefined, String(val));

这时我们的render方法变成

var render = function render() 
  var _vm = this,
  return h("div", [h("div", [createTextVNode(toString(_vm.msg))])])

而这里的_vm.msg,其实就是我们在data中写的msg。在组件初始化的时候,vue会通过defineProperty方法在组件实例对象中把data选项中的所有属性都重新定义一份同名的属性,通常我们将之叫做数据代理。

这样一来,参考我们前面data中对msg的定义,它的值就是’Hello world’。所以我们进一步简化render代码

var render = function render() 
  return h("div", [h("div", [createTextVNode(toString('Hello world'))])])

'Hello world’本身就是String,所以这里就没有必要再toString了

var render = function render() 
  return h("div", [h("div", [createTextVNode('Hello world')])])

createTextNode执行之后就是创建一个文本节点的虚拟DOM

text: 'Hello world', childrent: undefined, ...

所以render进一步简化

var render = function render() 
  return h("div", [h("div", [text: 'Hello world', children: undefined, ...])])

最终生成的虚拟DOM其实如下,其实和上面h函数第二个例子一样:


	tag: 'div',
	text: undefined,
	data: undefined,
	children: [
			tag: 'div', 
			text: undefined,
			data: undefined, 
			children: [
				text: 'Hello world', 
				....
			]
		]
	...

稍微总结下,从上面的分析我们知道,vue组件文件编译后会生成render方法,该方法会在组件初始化或者数据状态发生更新时调用来生成新的虚拟DOM。生成的render函数里面调用了vue的$createElement方法来生成虚拟DOM,h函数_c函数其实和$createElement是同一回事。

我是@天地会珠海分舵,「青葱日历」和「三日清单」 作者。能力一般,水平有限,觉得我说的还有那么点道理的不妨点个赞关注下!

以上是关于Vue源码之虚拟DOM来自何方?的主要内容,如果未能解决你的问题,请参考以下文章

Vue源码虚拟DOM将去往何处?

Vue源码虚拟DOM将去往何处?

Vue源码之模板编译浅析

Vue源码之模板编译浅析

Vue源码之模板编译浅析

Vue源码之虚拟DOM长成啥样