乐趣区

关于vue.js:vue源码中的渲染过程是怎样的

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 函数参数规格不对立。举一个简略的例子。

// 没有 data
new Vue({
    el: '#app',
    render: function(createElement) {return createElement('div', this.message)
    },
    data() {
        return {message: 'dom'}
    }
})
// 有 data
new 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 函数的实现思路。其实这两个过程都牵扯到组件,所以这一节对很多环节都无奈深入分析,下一节开始会进入组件的专题。我置信剖析完组件后,读者会对整个渲染过程会有更粗浅的了解和思考。

退出移动版