virtual DOM快在哪里?

在聊virtual DOM前咱们要先来讲说浏览器的渲染流程.javascript

浏览器如何渲染页面css

做为一名web前端码农,天天都在接触着浏览器.久而久之咱们都会有疑惑,浏览器是怎么解析咱们的代码而后渲染的呢?弄明白浏览器的渲染原理,对于咱们平常前端开发中的性能优化有重要意义。html

因此今天咱们来给你们详细说说浏览器是怎么渲染DOM的。前端

浏览器渲染大体流程java

首先,浏览器会经过请求的 URL 进行域名解析,向服务器发起请求,接收资源(HTML、CSS、JS、Images)等等,那么以后浏览器又会进行如下解析:node

  1. 解析HTML文档,生成DOM Tree
  2. CSS 样式文件加载后,开始解析和构建 CSS Rule Tree
  3. Javascript 脚本文件加载后, 经过 DOM API 和CSSOM API 来操做改动 DOM Tree 和 CSS Rule Tree

而解析完以上步骤后, 浏览器会经过DOM Tree 和CSS Rule Tree来构建 Render Tree(渲染树)。git

根据渲染树来布局,以计算每一个节点的几何信息。github

最后将各个节点绘制到页面上。web

HTML解析算法

<html>
<html>
<head>
    <title>Web page parsing</title>
</head>
<body>
    <div>
        <h1>Web page parsing</h1>
        <p class="text">This is an example Web page.</p>
    </div>
</body>
</html>
复制代码

那么解析的DOM树就是如下这样

CSS解析

/* rule 1 */ div { display: block; text-indent: 1em; }
/* rule 2 */ h1 { display: block; font-size: 3em; }
/* rule 3 */ p { display: block; }
/* rule 4 */ [class="text"] { font-style: italic; }
复制代码

CSS Rule Tree会比照着DOM树来对应生成,在这里须要注意的就是CSS匹配DOM的规则。不少人都觉得CSS匹配DOM树的速度会很快,其实否则。

样式系统从最右边的选择符开始向左侧移动来匹配一条规则。样式系统会一直向左匹配选择符直到规则匹配完毕或者因为出错中止匹配.

这里就衍生出一个问题,为何解析CSS的时候选择从右往左呢?

为了匹配效率。

全部样式规则极有可能数量很大,并且绝大多数不会匹配到当前的 DOM 元素,因此有一个快速的方法来判断「这个 selector 不匹配当前元素」就是极其重要的。

若是正向解析,例如「div div p em」,咱们首先就要检查当前元素到 html 的整条路径,找到最上层的 div,再往下找,若是遇到不匹配就必须回到最上层那个 div,往下再去匹配选择器中的第一个 div,回溯若干次才能肯定匹配与否,效率很低。

能够看如下的例子:

<div>
   <div class="jartto">
      <p><span> 111 </span></p>
      <p><span> 222 </span></p>
      <p><span> 333 </span></p>
      <p><span class='yellow'> 444 </span></p>
   </div>
</div>
<div>
   <div class="jartto1">
      <p><span> 111 </span></p>
      <p><span> 222 </span></p>
      <p><span> 333 </span></p>
      <p><span class='red'> 555 </span></p>
   </div>
</div>

div > div.jartto p span.yellow{
   color:yellow;
}
复制代码

对于上述例子,若是按从左到右的方式进行查找:

1.先找到全部 div 节点;

2.在 div 节点内找到全部的子 div ,而且是 class = “jartto”

3.而后再依次匹配 p span.yellow 等状况;

4.遇到不匹配的状况,就必须回溯到一开始搜索的 div 或者 p 节点,而后去搜索下个节点,重复这样的过程。

试想一下,若是采用从左至右的方式读取 CSS 规则,那么大多数规则读到最后(最右)才会发现是不匹配的,这样会作费时耗能,最后有不少都是无用的;而若是采起从右向左的方式,那么只要发现最右边选择器不匹配,就能够直接舍弃了,避免了许多无效匹配。

因此浏览器 CSS 匹配核心算法的规则是以从右向左方式匹配节点的。这样作是为了减小无效匹配次数,从而匹配快、性能更优。

CSS匹配HTML元素是一个至关复杂和有性能问题的事情。因此,你就会在N多地方看到不少人都告诉你,DOM树要小,CSS尽可能用id和class,千万不要过渡层叠下去,……

构建渲染树

经运行过Javascript脚本后解析出了最终的DOM Tree 和 CSS Rule Tree, 根据这二者,就能合成咱们的Render Tree,网罗网页上全部可见的 DOM 内容,以及每一个节点的全部 CSSOM 样式信息。

为构建渲染树,浏览器大致上完成了下列工做:

  1. 从 DOM 树的根节点开始遍历每一个可见节点。
    • 某些节点不可见(例如脚本标记、元标记等),由于它们不会体如今渲染输出中,因此会被忽略。
    • 某些节点经过 CSS 隐藏,所以在渲染树中也会被忽略,例如,上例中的 span 节点---不会出如今渲染树中,---由于有一个显式规则在该节点上设置了“display: none”属性。
  2. 对于每一个可见节点,为其找到适配的 CSSOM 规则并应用它们。
  3. 输出可见节点,连同其内容和计算的样式。

渲染的注意事项

在这里要说下两个概念,一个是repaint和reflow,这两个是影响浏览器渲染的主要缘由:

  • Repaint--重绘,屏幕的某一部分要从新绘制,好比某个DOM元素的背景颜色改动了,但元素的位置大小没有改变。
  • Reflow--回流,表明着元素的几何尺寸(如位置、宽高、隐藏等)变了,咱们须要从新验证并计算Render Tree。是Render Tree的一部分或所有发生了变化。 由此能够看出,咱们的Reflow的成本要比Repaint高的多,在一些高性能的电脑上也许还没什么,可是若是reflow发生在手机上,那么这个过程是很是痛苦和耗电的。 这也是JQuery在移动端页面上使用的障碍。、

咱们来看一段javascript代码:

var bstyle = document.body.style; // cache
 
bstyle.padding = "20px"; // reflow, repaint
bstyle.border = "10px solid red"; //  再一次的 reflow 和 repaint
 
bstyle.color = "blue"; // repaint
bstyle.backgroundColor = "#fad"; // repaint
 
bstyle.fontSize = "2em"; // reflow, repaint
 
// new DOM element - reflow, repaint
document.body.appendChild(document.createTextNode('dude!'));
复制代码

固然,咱们的浏览器是聪明的,它不会像上面那样,你每改一次样式,它就reflow或repaint一次。通常来讲,浏览器会把这样的操做积攒一批,而后作一次reflow,这又叫异步reflow或增量异步reflow。

虽然浏览器会帮咱们优化reflow的操做,但在实际开发过程当中,咱们仍是得经过几种方法去减小reflow的操做

减小reflow/repaint的方法

  1. 不要一条一条地修改DOM的样式。与其这样,还不如预先定义好css的class,而后修改DOM的className。

    // bad var left = 10, top = 10; el.style.left = left + "px"; el.style.top = top + "px";

    // Good el.className += " theclassname";

    // Good el.style.cssText += "; left: " + left + "px; top: " + top + "px;";

2)把DOM离线后修改。如:

  • 使用documentFragment 对象在内存里操做DOM
  • 先把DOM给display:none(有一次reflow),而后你想怎么改就怎么改。好比修改100次,而后再把他显示出来。
  • clone一个DOM结点到内存里,而后想怎么改就怎么改,改完后,和在线的那个的交换一下。

3)不要把DOM结点的属性值放在一个循环里当成循环里的变量。否则这会致使大量地读写这个结点的属性。

4)千万不要使用table布局。由于可能很小的一个小改动会形成整个table的从新布局。

5)尽量的修改层级比较低的DOM。固然,改变层级比较底的DOM有可能会形成大面积的reflow,可是也可能影响范围很小。


Virtual DOM

Virtual DOM是什么?

大部分前端开发者对Virtual DOM这个词都很熟悉了,简单来说,Virtual DOM就是在数据和真实 DOM 之间创建了一层缓冲层。当数据变化触发渲染后,并不直接更新到DOM上,而是先生成 Virtual DOM,与上一次渲染获得的 Virtual DOM 进行比对,在渲染获得的 Virtual DOM 上发现变化,而后将变化的地方更新到真实 DOM 上。 
复制代码

为何说Virtual DOM快?

1)DOM结构复杂,操做很慢

咱们在控制台输入

var div = document.createElement('div')
var str = '' 
for (var key in div) {
    str = str + key + "\n"
}
console.log(str)
复制代码

能够很容易发现,咱们的一个空div对象,他的属性就有几百个,因此说DOM的操做慢是能够理解的。不是浏览器不想好好实现DOM,而是DOM设计得太复杂,没办法。

2)JS计算很快

julialang.org/benchmarks/

Julia有一个Benchmark,Julia Benchmarks, 能够看到Javascript跟C语言很接近了,也就几倍的差距,跟Java基本也是一个量级。 这就说明,单纯的Javascript运行起来其实速度是很快的。

而相对于DOM,咱们原生的JavaScript对象处理起来则会更快更简单.

咱们经过JavaScript,能够很容易的用JavaScript对象表示出来.

var olE = {
  tagName: 'ul', // 标签名
  props: { // 属性用对象存储键值对
    id: 'ul-list',
    class: 'list'
  },
  children: [ // 子节点
    {tagName: 'li', props: {class: 'item'}, children: ["Item 1"]},
    {tagName: 'li', props: {class: 'item'}, children: ["Item 2"]},
    {tagName: 'li', props: {class: 'item'}, children: ["Item 3"]},
  ]
}
复制代码

对应的HTML写法:

<ul id='ol-list'>
  <li class='item'>Item 1</li>
  <li class='item'>Item 2</li>
  <li class='item'>Item 3</li>
</ul>
复制代码

那么,既然咱们能够用javascript来表示DOM,那么表明咱们能够用JavaScript来构造咱们的真实DOM树,当咱们的DOM树须要更新了,那咱们先渲染更改这个JavaScript构造的Virtual DOM树,再更新到真实DOM树上。

因此Virtual DOM算法就是:

一开始先用 JavaScript 对象结构表示 DOM 树的结构;而后用这个树构建一个真正的 DOM 树,插到文

档当中。当状态变动时,从新构造一棵新的对象树。而后用新的树和旧的树进行比较两个树的差别。

而后把差别更新到旧的树上,最后再把整个变动写入真实 DOM。

简单Virtual DOM 算法实现

步骤一:用JS对象模拟DOM树,并构建

用 JavaScript 来表示一个 DOM 节点是很简单的事情,你只须要记录它的节点类型、属性,还有子节点:

// 建立虚拟DOM函数
function Element (tagName, props, children) {
  this.tagName = tagName // 标签名
  this.props = props // 对应属性(如ID、Class)
  this.children = children // 子元素
}

module.exports = function (tagName, props, children) {
  return new Element(tagName, props, children)
}
复制代码

实际应用以下:

var el = require('./element')
// 普通ul和li对象就能够表示为这样
var ul = el('ul', {id: 'list'}, [
  el('li', {class: 'item'}, ['Item 1']),
  el('li', {class: 'item'}, ['Item 2']),
  el('li', {class: 'item'}, ['Item 3'])
])
复制代码

如今ul只是一个 JavaScript 对象表示的 DOM 结构,页面上并无这个结构。咱们能够根据这个ul构建真正的

    元素:

    // 构建真实DOM函数
    Element.prototype.render = function () {
      var el = document.createElement(this.tagName) // 根据tagName构建
      var props = this.props
    
      for (var propName in props) { // 设置节点的DOM属性
        var propValue = props[propName]
        el.setAttribute(propName, propValue)
      }
    
      var children = this.children || []
    
      children.forEach(function (child) {
        var childEl = (child instanceof Element)
          ? child.render() // 若是子节点也是虚拟DOM,递归构建DOM节点
          : document.createTextNode(child) // 若是字符串,只构建文本节点
        el.appendChild(childEl)
      })
    
      return el
    }
    复制代码

    咱们的render方法会根据tagName去构建一个真实的DOM节点,设置节点属性,再递归到子元素构建:

    var ulRoot = ul.render() // 将js构建的dom对象传给render构建
    document.body.appendChild(ulRoot) // 真实的DOM对象塞入body
    复制代码

    这样咱们body中就有了ul和li的DOM元素了

    <body>
        <ul id='list'>
          <li class='item'>Item 1</li>
          <li class='item'>Item 2</li>
          <li class='item'>Item 3</li>
        </ul>
    </body>
    复制代码

    步骤二:比较两棵虚拟DOM树的差别

    在这里咱们假设对咱们修改了某个状态或者某个数据,这就会产生新的虚拟DOM

    // 新DOM
    var ol = el('ol', {id: 'ol-list'}, [
      el('li', {class: 'ol-item'}, ['Item 1']),
      el('li', {class: 'ol-item'}, ['Item 2']),
      el('li', {class: 'ol-item'}, ['Item 3']),
      el('li', {class: 'ol-item'}, ['Item 4'])
    ])
    
    // 旧DOM
    var ul = el('ul', {id: 'list'}, [
      el('li', {class: 'item'}, ['Item 1']),
      el('li', {class: 'item'}, ['Item 3']),
      el('li', {class: 'item'}, ['Item 2'])
    ])
    复制代码

    那么咱们会和先和,刚刚上一次生成的虚拟DOM树进行比对.

    咱们应该都很清楚,virtual DOM算法的核心部分,就在比较差别这一部分,也就是所谓的 diff算法。

    由于不多出现跨层级的移动。

    diff算法通常来讲,都是同一层级比对同一层级的

    var patch = {
        'REPLACE' : 0, // 替换
        'REORDER' : 1, // 新增、删除、移动
        'PROPS' : 2, // 属性更改
        'TEXT' : 3 // 文本内容更改
    }
    复制代码

    例如,上面的div和新的div有差别,当前的标记是0,那么:

    // 用数组存储新旧节点的不一样
    patches = [
        // 每一个数组表示一个元素的差别
        [ 
            {difference}, 
        	{difference}
        ],
        [
            {difference}, 
        	{difference}
        ]  
    ] 
    
    patches[0] = [
      {
      	type: REPALCE,
      	node: newNode // el('section', props, children)
      },
      {
      	type: PROPS,
        props: {
            id: "container"
        }
      },   
      {
      	type: REORDER,
          moves: [
              {index: 2, item: item, type: 1}, // 保留的节点
              {index: 0, type: 0}, // 该节点被删除
              {index: 1, item: item, type: 1} // 保留的节点
          ]
      }
    ];
    若是是文本节点内容更改,就记录下:
    patches[2] = [{
      type: TEXT,
      content: "我是新修改的文本内容"
    }]
    
    // 详细算法查看diff.js
    复制代码

    每种差别都会有不一样的对比方式,经过比对后会将差别记录下来,应用到真实DOM上,并把最近最新的虚拟DOM树保存下来,以便下次比对使用。

    步骤三:把差别应用到真正的DOM树上

    经过比对后,咱们已经知道了,差别的节点是哪些,咱们能够方便对真实DOM作最小化的修改。

    // 详情看patch.js
    复制代码

    发现问题

    到这里咱们发现一个问题,不是说 Virtual DOM更快吗? 但是最终你仍是要进行DOM操做呀?那意义何在?还不如一开始咱们就直接进行DOM操做来的方便。

    因此到这里咱们要对Virtual DOM 有一个正确的认识

    网上都说操做真实 DOM 慢,但测试结果却比 React 更快,为何?

    chrisharrington.github.io/demos/perfo…

    最优更改

    Virtual DOM的算法可以向你保证的就是,每一次的DOM操做我都能达到算法上的理论最优,而若是是你本身去操做DOM,这并不能保证。

    其次

    开发模式的更改

    为了让开发者把精力集中在操做数据,而非接管 DOM 操做。Virtual DOM能让咱们在实际开发过程当中,不须要去理会复杂的DOM结构,而只需理会绑定DOM结构的状态和数据便可,这从开发上来讲 就是一个很大的进步