4.1 Virtual DOM

4.1.1 浏览器的渲染流程

当浏览器接管到一个Html文件时,JS引擎和浏览器的渲染引擎便开始工作了。从渲染引擎的角度,它首先会将html文件解析成一个DOM树,与此同时,浏览器将辨认并加载CSS款式,并和DOM树一起合并为一个渲染树。有了渲染树后,渲染引擎将计算所有元素的地位信息,最初通过绘制,在屏幕上打印最终的内容。JS引擎和渲染引擎尽管是两个独立的线程,然而JS引擎却能够触发渲染引擎工作,当咱们通过脚本去批改元素地位或外观时,JS引擎会利用DOM相干的API办法去操作DOM对象,此时渲染引擎变开始工作,渲染引擎会触发回流或者重绘。上面是回流重绘的两个概念:

  • 回流: 当咱们对DOM的批改引发了元素尺寸的变动时,浏览器须要从新计算元素的大小和地位,最初将从新计算的后果绘制进去,这个过程称为回流。
  • 重绘: 当咱们对DOM的批改只单纯扭转元素的色彩时,浏览器此时并不需要从新计算元素的大小和地位,而只有从新绘制新款式。这个过程称为重绘。

很显然回流比重绘更加消耗性能

通过理解浏览器根本的渲染机制,咱们很容易联想到当一直的通过JS批改DOM时,不经意间会触发到渲染引擎的回流或者重绘,这个性能开销是十分微小的。因而为了升高开销,咱们须要做的是尽可能减少DOM操作。有什么办法能够做到呢?

4.1.2 缓冲层-虚构DOM

虚构DOM是为了解决频繁操作DOM引发性能问题的产物。虚构DOM(上面称为Virtual DOM)是将页面的状态形象为JS对象的模式,实质上是JS和实在DOM的中间层,当咱们想用JS脚本大批量进行DOM操作时,会优先作用于Virtual DOM这个JS对象,最初通过比照将要改变的局部告诉并更新到实在的DOM。只管最终还是操作实在的DOM,但Virtual DOM能够将多个改变合并成一个批量的操作,从而缩小 DOM 重排的次数,进而缩短了生成渲染树和绘制所花的工夫。

咱们看一个实在的DOM蕴含了什么:

浏览器将一个实在DOM设计得很简单,不仅蕴含了本身的属性形容,大小地位等定义,也囊括了DOM领有的浏览器事件等。正因为如此简单的构造,咱们频繁去操作DOM或多或少会带来浏览器的性能问题。而作为数据和实在DOM之间的一层缓冲,Virtual DOM 只是用来映射到实在DOM的渲染,因而不须要蕴含操作 DOM 的办法,它只有在对象中重点关注几个属性即可。

// 实在DOM<div id="real"><span>dom</span></div>// 实在DOM对应的JS对象{    tag: 'div',    data: {        id: 'real'    },    children: [{        tag: 'span',        children: 'dom'    }]}

4.2 Vnode

Vue在渲染机制的优化上,同样引进了virtual dom的概念,它是用Vnode这个构造函数去形容一个DOM节点。

4.2.1 Vnode构造函数

var VNode = function VNode (tag,data,children,text,elm,context,componentOptions,asyncFactory) {    this.tag = tag; // 标签    this.data = data;  // 数据    this.children = children; // 子节点    this.text = text;    ···    ···  };

Vnode定义的属性差不多有20几个,显然用Vnode对象要比实在DOM对象形容的内容要简略得多,它只用来单纯形容节点的要害属性,例如标签名,数据,子节点等。并没有保留跟浏览器相干的DOM办法。除此之外,Vnode也会有其余的属性用来扩大Vue的灵活性。

源码中也定义了创立Vnode的相干办法。

4.2.2 创立Vnode正文节点

// 创立正文vnode节点var createEmptyVNode = function (text) {    if ( text === void 0 ) text = '';    var node = new VNode();    node.text = text;    node.isComment = true; // 标记正文节点    return node};

4.2.3 创立Vnode文本节点

// 创立文本vnode节点function createTextVNode (val) {    return new VNode(undefined, undefined, undefined, String(val))}

4.2.4 克隆vnode

参考Vue3源码视频解说:进入学习

function cloneVNode (vnode) {    var cloned = new VNode(      vnode.tag,      vnode.data,      vnode.children && vnode.children.slice(),      vnode.text,      vnode.elm,      vnode.context,      vnode.componentOptions,      vnode.asyncFactory    );    cloned.ns = vnode.ns;    cloned.isStatic = vnode.isStatic;    cloned.key = vnode.key;    cloned.isComment = vnode.isComment;    cloned.fnContext = vnode.fnContext;    cloned.fnOptions = vnode.fnOptions;    cloned.fnScopeId = vnode.fnScopeId;    cloned.asyncMeta = vnode.asyncMeta;    cloned.isCloned = true;    return cloned  }

留神:cloneVnodeVnode的克隆只是一层浅拷贝,它不会对子节点进行深度克隆。

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._ctemplate外部编译成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函数参数规格不对立。举一个简略的例子。

// 没有datanew Vue({    el: '#app',    render: function(createElement) {        return createElement('div', this.message)    },    data() {        return {            message: 'dom'        }    }})// 有datanew 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前会先数据的规范性进行检测,将不非法的数据类型谬误提前裸露给用户。接下来将列举几个在理论场景中容易犯的谬误,也不便咱们了解源码中对这类谬误的解决。

  1. 用响应式对象做data属性
new Vue({    el: '#app',    render: function (createElement, context) {       return createElement('div', this.observeData, this.show)    },    data() {        return {            show: 'dom',            observeData: {                attr: {                    id: 'test'                }            }        }    }})
  1. 当非凡属性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'    }]  }]}

4.4 虚构Vnode映射成实在DOM

回到 updateComponent的最初一个过程,虚构的DOM树在生成virtual dom后,会调用Vue原型上_update办法,将虚构DOM映射成为实在的DOM。从源码上能够晓得,_update的调用机会有两个,一个是产生在首次渲染阶段,另一个产生数据更新阶段。

updateComponent = function () {    // render生成虚构DOM,update渲染实在DOM    vm._update(vm._render(), hydrating);};

vm._update办法的定义在lifecycleMixin中。

lifecycleMixin()function lifecycleMixin() {    Vue.prototype._update = function (vnode, hydrating) {        var vm = this;        var prevEl = vm.$el;        var prevVnode = vm._vnode; // prevVnode为旧vnode节点        // 通过是否有旧节点判断是首次渲染还是数据更新        if (!prevVnode) {            // 首次渲染            vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false)        } else {            // 数据更新            vm.$el = vm.__patch__(prevVnode, vnode);        }}

_update的外围是__patch__办法,如果是服务端渲染,因为没有DOM_patch办法是一个空函数,在有DOM对象的浏览器环境下,__patch__patch函数的援用。

// 浏览器端才有DOM,服务端没有dom,所以patch为一个空函数  Vue.prototype.__patch__ = inBrowser ? patch : noop;

patch办法又是createPatchFunction办法的返回值,createPatchFunction办法传递一个对象作为参数,对象领有两个属性,nodeOpsmodulesnodeOps封装了一系列操作原生DOM对象的办法。而modules定义了模块的钩子函数。

 var patch = createPatchFunction({ nodeOps: nodeOps, modules: modules });// 将操作dom对象的办法合集做解冻操作 var nodeOps = /*#__PURE__*/Object.freeze({    createElement: createElement$1,    createElementNS: createElementNS,    createTextNode: createTextNode,    createComment: createComment,    insertBefore: insertBefore,    removeChild: removeChild,    appendChild: appendChild,    parentNode: parentNode,    nextSibling: nextSibling,    tagName: tagName,    setTextContent: setTextContent,    setStyleScope: setStyleScope  });// 定义了模块的钩子函数  var platformModules = [    attrs,    klass,    events,    domProps,    style,    transition  ];var modules = platformModules.concat(baseModules);

真正的createPatchFunction函数有一千多行代码,这里就不不便列举进去了,它的外部首先定义了一系列辅助的办法,而外围是通过调用createElm办法进行dom操作,创立节点,插入子节点,递归创立一个残缺的DOM树并插入到Body中。并且在产生实在阶段阶段,会有diff算法来判断前后Vnode的差别,以求最小化扭转实在阶段。前面会有一个章节的内容去解说diff算法。createPatchFunction的过程只须要先记住一些论断,函数外部会调用封装好的DOM api,依据Virtual DOM的后果去生成实在的节点。其中如果遇到组件Vnode时,会递归调用子组件的挂载过程,这个过程咱们也会放到前面章节去剖析。

4.5 小结

这一节剖析了mountComponent的两个外围办法,renderupdate,在剖析前重点介绍了存在于JS操作和DOM渲染的桥梁:Virtual DOMJSDOM节点的批量操作会先间接反馈到Virtual DOM这个形容对象上,最终的后果才会间接作用到实在节点上。能够说,Virtual DOM很大水平进步了渲染的性能。文章重点介绍了render函数转换成Virtual DOM的过程,并大抵形容了_update函数的实现思路。其实这两个过程都牵扯到组件,所以这一节对很多环节都无奈深入分析,下一节开始会进入组件的专题。我置信剖析完组件后,读者会对整个渲染过程会有更粗浅的了解和思考。