深刻理解react中的虚拟DOM、diff算法

文章结构:html

  • React中的虚拟DOM是什么?
  • 虚拟DOM的简单实现(diff算法)
  • 虚拟DOM的内部工做原理
  • React中的虚拟DOM与Vue中的虚拟DOM比较

 

React中的虚拟DOM是什么?

    虽然React中的虚拟DOM很好用,可是这是一个无意插柳的结果。   前端

   React的核心思想:一个Component拯救世界,忘掉烦恼,今后再也不操心界面。

1. Virtual Dom快,有两个前提

1.1 Javascript很快

   Chrome刚出来的时候,在Chrome里跑Javascript很是快,给了其它浏览器很大压力。而如今通过几轮你追我赶,各主流浏览器的Javascript执行速度都很快了。java

   在 https://julialang.org/benchmarks/ 这个网站上,咱们能够看到,JavaScript语言已经很是快了,和C就是几倍的关系,和java在同一个量级。因此说,单纯的JavaScript仍是仍是很快的。node

  

1.2 Dom很慢react

  当建立一个元素好比div,有如下几项内容须要实现: HTML elementElementGlobalEventHandler。简单的说,就是插入一个Dom元素的时候,这个元素上自己或者继承不少属性如 width、height、offsetHeight、style、title,另外还须要注册这个元素的诸多方法,好比onfucos、onclick等等。 这还只是一个元素,若是元素比较多的时候,还涉及到嵌套,那么元素的属性和方法等等就会不少,效率很低。git

  好比,咱们在一个空白网页的body中添加一个div元素,以下所示:github

       

  这个元素会挂载默认的styles、获得这个元素的computed属性、注册相应的Event Listener、DOM Breakpoints以及大量的properties,这些属性、方法的注册确定是须要h耗费大量时间的。web

   尤为是在js操做DOM的过程当中,不只有dom自己的繁重,js的操做也须要浪费时间,咱们认为js和DOM之间有一座桥,若是你频繁的在桥两边走动,显然效率是很低的,若是你的JavaScript操做DOM的方式还很是不合理,那么显然就会更糟糕了。 算法

  而 React的虚拟DOM就是解决这个问题的! 虽然它解决不了DOM自身的繁重,可是虚拟DOM能够对JavaScript操做DOM这一部份内容进行优化redux

  

  好比说,如今你的list是这样:

<ul>
<li>0</li>
<li>1</li>
<li>2</li>
<li>3</li>
</ul>

  你但愿把它变成下面这样:

<ul>
<li>6</li>
<li>7</li>
<li>8</li>
<li>9</li>
<li>10</li>
</ul>

  

  一般的操做是什么? 

  先把0, 1,2,3这些Element删掉,而后加几个新的Element 6,7,8,9,10进去,这里面就有4次Element删除,5次Element添加。共计9次DOM操做

  

  那React的虚拟DOM能够怎么作呢

  而React会把这两个作一下Diff,而后发现其实不用删除0,1,2,3,而是能够直接改innerHTML,而后只须要添加一个Element(10)就好了,这样就是4次innerHTML操做加1个Element添加。共计5此操做,这样效率的提高是很是可观的。

  

二、 关于React

2.1 接口和设计

  在React的设计中,是彻底不须要你来操做DOM的咱们也能够认为,在React中根本就没有DOM这个概念,有的只是Component。 

  当你写好一个Component之后,Component会彻底负责UI,你不须要也不该该去也不可以指挥Component怎么显示,你只能告诉它你想要显示一个香蕉仍是两个梨。

  隔离DOM并不只仅是由于DOM慢,而也是为了把界面和业务彻底隔离,操做数据的只关心数据,操做界面的只关心界面。好比在websocket聊天室的建立房间时,咱们能够首先Component写好,而后当获取到数据的时候,只要把数据放在redux中就好,而后Component就动把房间添加到页面中去,而不是你先拿到数据,而后使用js操做DOM把数据显示在页面上。 

  即我提供一个Component,而后你只管给我数据,界面的事情彻底不用你操心,我保证会把界面变成你想要的样子。因此说React的着力点就在于View层,即React专一于View层。你能够把一个React的Component想象成一个Pure Function,只要你给的数据是[1, 2, 3],我保证显示的是[1, 2, 3]。没有什么删除一个Element,添加一个Element这样的事情。NO。你要我显示什么就给我一个完整的列表。

  另外,Flux虽说的是单向的Data Flow(redux也是),可是实际上就是单向的Observer,Store->View->Action->Store(箭头是数据流向,实现上能够理解为View监听Store,View直接trigger action,而后Store监听Action)。

  

2.2 实现

  那么react如何实现呢? 最简单的方法就是当数据变化时,我直接把原先的DOM卸载,而后把最新数据的DOM替换上去。 可是,虚拟DOM哪去了? 这样作的效率显然是极低的。

  因此虚拟DOM就来救场了。

  那么虚拟DOM和DOM之间的关系是什么呢? 

  首先,Virtual DOM并无彻底实现DOM,即虚拟DOM和真正地DOM是不同的Virtual DOM最主要的仍是保留了Element之间的层次关系和一些基本属性。由于真实DOM实在是太复杂,一个空的Element都复杂得能让你崩溃,而且几乎全部内容我根本不关心好吗因此Virtual DOM里每个Element实际上只有几个属性,即最重要的,最为有用的,而且没有那么多乱七八糟的引用,好比一些注册的属性和函数啊,这些都是默认的,建立虚拟DOM进行diff的过程当中你们都一致,是不须要进行比对的。因此哪怕是直接把Virtual DOM删了根据新传进来的数据从新建立一个新的Virtual DOM出来都很是很是很是快。(每个component的render函数就是在作这个事情,给新的virtual dom提供input)。

   因此,引入了Virtual DOM以后,React是这么干的:你给我一个数据,我根据这个数据生成一个全新的Virtual DOM,而后跟我上一次生成的Virtual DOM去 diff,获得一个Patch,而后把这个Patch打到浏览器的DOM上去。完事。而且这里的patch显然不是完整的虚拟DOM,而是新的虚拟DOM和上一次的虚拟DOM通过diff后的差别化的部分。

  

  假设在任意时候有,VirtualDom1 == DOM1 (组织结构相同, 显然虚拟DOM和真实DOM是不可能彻底相等的,这里的==是js中非彻底相等)。当有新数据来的时候,我生成VirtualDom2,而后去和VirtualDom1作diff获得一个Patch(差别化的结果)。而后将这个Patch去应用到DOM1上,获得DOM2。若是一切正常,那么有VirtualDom2 == DOM2(一样是结构上的相等)

  

  这里你能够作一些小实验,去破坏VirtualDom1 == DOM1这个假设(手动在DOM里删除一些Element,这时候VirtualDom里的Element没有被删除,因此两边不同了)。
而后给新的数据,你会发现生成的界面就不是你想要的那个界面了。

  

  最后,回到为何Virtual Dom快这个问题上
        实际上是因为每次生成virtual dom很快,diff生成patch也比较快,而在对DOM进行patch的时候,虽然DOM的变动比较慢可是React可以根据Patch的内容优化一部分DOM操做,好比以前的那个例子。

  重点就在最后,哪怕是我生成了virtual dom(须要耗费时间)哪怕是我跑了diff(还须要花时间)可是我根据patch简化了那些DOM操做省下来的时间依然很可观(这个就是时间差的问题了,即节省下来的时间 > 生成 virtual dom的时间 + diff时间)。因此整体上来讲,仍是比较快。

  

        简单发散一下思路,若是哪一天,DOM自己的已经操做很是很是很是快了,而且咱们手动对于DOM的操做都是精心设计优化事后的,那么加上了VirtualDom还会快吗
固然不行了,毕竟你多作了这么多额外的工做

        可是那一天会来到吗?
        诶,大不了到时候不用Virtual DOM。

注: 此部份内容整理自:https://www.zhihu.com/question/29504639/answer/44680878
 
 
 
 
 
 
 

虚拟DOM的简单实现(diff算法)

目录

  • 1 前言
  • 2 对前端应用状态管理思考
  • 3 Virtual DOM 算法
  • 4 算法实现
    • 4.1 步骤一:用JS对象模拟DOM树
    • 4.2 步骤二:比较两棵虚拟DOM树的差别
    • 4.3 步骤三:把差别应用到真正的DOM树上
  • 5 结语

 

前言

  在上面一部分中,咱们已经简单介绍了虚拟DOM的答题思路和好处,这里咱们将经过本身写一个虚拟DOM来加深对其的理解,有一些本身的思考。

  

对前端应用状态管理思考

  维护状态,更新视图。

 

虚拟DOM算法

  DOM是很慢的,若是咱们建立一个简单的div,而后把他的全部的属性都打印出来,你会看到: 

    var div = document.createElement('div'),
        str = '';
    for (var key in div) {
      str = str + ' ' + key;
    }
    console.log(str);

 

  

 能够看到,这些属性仍是很是惊人的,包括样式的修饰特性、通常的特性、方法等等, 若是咱们打印出其长度,能够获得惊人的227个
  而这仅仅是一层,真正的DOM元素是很是庞大的,这是由于标准就是这么设计的,并且操做他们的时候你要当心翼翼,轻微的触碰就有可能致使页面发生重排,这是杀死性能的罪魁祸首
 
   而相对于DOM对象,原生的JavaScript对象处理起来更快,并且更简单, DOM树上的结构信息咱们均可以使用JavaScript对象很容易的表示出来
    var element = {
      tagName: 'ul',
      props: {
        id: 'list'
      },
      children: {
        {
          tagName: 'li',
          props: {
            class: 'item'
          },
          children: ['Item1']
        }, 
        {
          tagName: 'li',
          props: {
            class: 'item'
          },
          children: ['Item1']
        }, 
        {
          tagName: 'li',
          props: {
            class: 'item'
          },
          children: ['Item1']
        }
      }
    }

  如上所示,对于一个元素,咱们只须要一个JavaScript对象就能够很容易的表示出来,这个对象中有三个属性:

  1. tagName: 用来表示这个元素的标签名。
  2. props: 用来表示这元素所包含的属性。
  3. children: 用来表示这元素的children。

  而上面的这个对象使用HTML表示就是:

<ul id='list'>
  <li class='item'>Item 1</li>
  <li class='item'>Item 2</li>
  <li class='item'>Item 3</li>
</ul>

  

  OK! 既然原来的DOM信息可使用JavaScript来表示,那么反过来,咱们就能够用这个JavaScript对象来构建一个真正的DOM树

  因此以前所说的状态变动的时候会重新构建这个JavaScript对象,而后呢,用新渲染的对象和旧的对象去对比, 记录两棵树的差别,记录下来的就是咱们须要改变的地方。 这就是所谓的虚拟DOM,包括下面的几个步骤:

  1. JavaScript对象来表示DOM树的结构; 而后用这个树构建一个真正的DOM树插入到文档中
  2. 当状态变动的时候,从新构造一个新的对象树,而后用这个新的树和旧的树做对比,记录两个树的差别。 
  3. 把2所记录的差别应用在步骤一所构建的真正的DOM树上,视图就更新了。

Virtual DOM的本质就是在JS和DOM之间作一个缓存,能够类比CPU和硬盘,既然硬盘这么慢,咱们就也在他们之间添加一个缓存; 既然DOM这么慢,咱们就能够在JS和DOM之间添加一个缓存。 CPU(JS)只操做内存(虚拟DOM),最后的时候在把变动写入硬盘(DOM)。 

 

  

算法实现

一、 用JavaScript对象模拟DOM树

    用JavaScript对象来模拟一个DOM节点并不难,你只须要记录他的节点类型(tagName)、属性(props)、子节点(children)。 

element.js
    function Element(tagName, props, children) {
      this.tagName = tagName;
      this.props = props;
      this.children = children;
    }
   module.exports = function (tagName, props, children) {
return new Element(tagName, props, children);
}

经过这个构造函数,咱们就能够传入标签名、属性以及子节点了,tagName能够在咱们render的时候直接根据它来建立真实的元素,这里的props使用一个对象传入,能够方便咱们遍历

基本使用方法以下:

    var el = require('./element');

    var ul = el('ul', {id: 'list'}, [
        el('li', {class: 'item'}, ['item1']),
        el('li', {class: 'item'}, ['item2']),
        el('li', {class: 'item'}, ['item3'])
      ]);

 

然而,如今的ul只是JavaScript表示的一个DOM结构,页面上并无这个结构,全部咱们能够根据ul构建一个真正的<ul>:

   Element.prototype.render = function () {
      // 根据tagName建立一个真实的元素
      var el = document.createElement(this.tagName);
      // 获得这个元素的属性对象,方便咱们遍历。
      var props = this.props;

      for (var propName in props) {
        // 获取到这个元素值
        var propValue = props[propName];

        // 经过setAttribute设置元素属性。 
        el.setAttribute(propName, propValue);
      }

      // 注意: 这里的children,咱们传入的是一个数组,因此,children不存在时咱们用【】来替代。 
      var children = this.children || [];

      //遍历children
      children.forEach(function (child) {
        var childEl = (child instanceof Element)
                      ? child.render()
                      : document.createTextNode(child);
        // 不管childEl是元素仍是文字节点,都须要添加到这个元素中。
        el.appendChild(childEl);
      });

      return el;
    }

 

  因此,render方法会根据tagName构建一个真正的DOM节点,而后设置这个节点的属性,最后递归的把本身的子节点也构建起来,因此只须要调用ul的render方法,经过document.body.appendChild就能够挂载到真实的页面了。 

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>div</title>
</head>
<body>
  <script>

    function Element(tagName, props, children) {
      this.tagName = tagName;
      this.props = props;
      this.children = children;
    }


    var ul = new Element('ul', {id: 'list'}, [
        new Element('li', {class: 'item'}, ['item1']),
        new Element('li', {class: 'item'}, ['item2']),
        new Element('li', {class: 'item'}, ['item3'])
      ]);

    Element.prototype.render = function () {
      // 根据tagName建立一个真实的元素
      var el = document.createElement(this.tagName);
      // 获得这个元素的属性对象,方便咱们遍历。
      var props = this.props;

      for (var propName in props) {
        // 获取到这个元素值
        var propValue = props[propName];

        // 经过setAttribute设置元素属性。 
        el.setAttribute(propName, propValue);
      }

      // 注意: 这里的children,咱们传入的是一个数组,因此,children不存在时咱们用【】来替代。 
      var children = this.children || [];

      //遍历children
      children.forEach(function (child) {
        var childEl = (child instanceof Element)
                      ? child.render()
                      : document.createTextNode(child);
        // 不管childEl是元素仍是文字节点,都须要添加到这个元素中。
        el.appendChild(childEl);
      });

      return el;
    }

    var ulRoot = ul.render();
    document.body.appendChild(ulRoot);
  </script>
</body>
</html>

上面的这段代码,就能够渲染出下面的结果了:

 

二、比较两颗虚拟DOM树的差别

  比较两颗DOM数的差别是Virtual DOM算法中最为核心的部分,这也就是所谓的Virtual DOM的diff算法。 两个树的彻底的diff算法是一个时间复杂度为 O(n3) 的问题。 可是在前端中,你会不多跨层地移动DOM元素,因此真实的DOM算法会对同一个层级的元素进行对比。 

 

上图中,div只会和同一层级的div对比,第二层级的只会和第二层级对比。 这样算法复杂度就能够达到O(n)

  

(1)深度遍历优先,记录差别

   在实际的代码中,会对新旧两棵树进行一个深度优先的遍历,这样每个节点就会有一个惟一的标记:

 上面的这个遍历过程就是深度优先,即深度彻底完成以后,再转移位置。 在深度优先遍历的时候,每遍历到一个节点就把该节点和新的树进行对比,若是有差别的话就记录到一个对象里面

    // diff函数,对比两颗树
    function diff(oldTree, newTree) {
      // 当前的节点的标志。由于在深度优先遍历的过程当中,每一个节点都有一个index。
      var index = 0;

      // 在遍历到每一个节点的时候,都须要进行对比,找到差别,并记录在下面的对象中。
      var pathches = {};

      // 开始进行深度优先遍历
      dfsWalk(oldTree, newTree, index, pathches);

      // 最终diff算法返回的是一个两棵树的差别。
      return pathches;
    }

    // 对两棵树进行深度优先遍历。
    function dfsWalk(oldNode, newNode, index, pathches) {
      // 对比oldNode和newNode的不一样,记录下来
      pathches[index] = [...];

      diffChildren(oldNode.children, newNode.children, index, pathches); 
    }

    // 遍历子节点
    function diffChildren(oldChildren, newChildren, index, pathches) {  
      var leftNode = null;
      var currentNodeIndex = index;
      oldChildren.forEach(function (child, i) {
        var newChild = newChildren[i];
        currentNodeIndex = (leftNode && leftNode.count)
        ? currentNodeIndex + leftNode.count + 1
        : currentNodeIndex + 1

        // 深度遍历子节点
        dfsWalk(child, newChild, currentNodeIndex, pathches);
        leftNode = child;
      });
    }

例如,上面的div和新的div有差别,当前的标记是0, 那么咱们可使用数组来存储新旧节点的不一样:

patches[0] = [{difference}, {difference}, ...]

同理使用patches[1]来记录p,使用patches[3]来记录ul,以此类推。

 

(2)差别类型

  上面说的节点的差别指的是什么呢? 对DOM操做可能会:

  1. 替换原来的节点,如把上面的div换成了section。 
  2. 移动、删除、新增子节点, 例如上面div的子节点,把p和ul顺序互换。
  3. 修改了节点的属性。 
  4. 对于文本节点,文本内容可能会改变。 例如修改上面的文本内容2内容为Virtual DOM2.

  因此,咱们能够定义下面的几种类型:

    var REPLACE = 0;
    var REORDER = 1;
    var PROPS = 2;
    var TEXT = 3;

  

    对于节点替换,很简单,判断新旧节点的tagName是否是同样的,若是不同的说明须要替换掉。 如div换成了section,就记录下:

patches[0] = [{
  type: REPALCE,
  node: newNode // el('section', props, children)
}]

  除此以外,若是给div新增了属性id为container,就记录下:

    pathches[0] = [
      {
        type: REPLACE,
        node: newNode 
      }, 
      { 
        type: PROPS,
        props: {
          id: 'container'
        }
      }
    ]

  若是是文本节点发生了变化,那么就记录下:

    pathches[2] = [
      {
        type:  TEXT,
        content: 'virtual DOM2'
      }
    ]

  

  那么若是咱们把div的子节点从新排序了呢? 好比p、ul、div的顺序换成了div、p、ul,那么这个该怎么对比呢? 若是按照同级进行顺序对比的话,他们就会被替换掉,如p和div的tagName不一样,p就会被div所代替,最终,三个节点就都会被替换,这样DOM开销就会很是大,而其实是不须要替换节点的,只须要移动就能够了, 咱们只须要知道怎么去移动。这里牵扯到了两个列表的对比算法,以下。

 

(3)列表对比算法

  假设如今能够英文字母惟一地标识每个子节点:

   旧的节点顺序:
a b c d e f g h i

  如今对节点进行了删除、插入、移动的操做。新增j节点,删除e节点,移动h节点:

   新的节点顺序:
a b c h d f g i j

  如今知道了新旧的顺序,求最小的插入、删除操做(移动能够当作是删除和插入操做的结合)。这个问题抽象出来实际上是字符串的最小编辑距离问题(Edition Distance),最多见的解决算法是 Levenshtein Distance,经过动态规划求解,时间复杂度为 O(M * N)。可是咱们并不须要真的达到最小的操做,咱们只须要优化一些比较常见的移动状况,牺牲必定DOM操做,让算法时间复杂度达到线性的(O(max(M, N))。具体算法细节比较多,这里不累述,有兴趣能够参考代码

   咱们可以获取到某个父节点的子节点的操做,就能够记录下来:
patches[0] = [{
  type: REORDER,
  moves: [{remove or insert}, {remove or insert}, ...]
}]

  可是要注意的是,由于tagName是可重复的,不能用这个来进行对比。因此须要给子节点加上惟一标识key,列表对比的时候,使用key进行对比,这样才能复用老的 DOM 树上的节点。

  这样,咱们就能够经过深度优先遍历两棵树,每层的节点进行对比,记录下每一个节点的差别了。完整 diff 算法代码可见 diff.js

 
 

三、把差别引用到真正的DOM树上

  由于步骤一所构建的 JavaScript 对象树和render出来真正的DOM树的信息、结构是同样的。因此咱们能够对那棵DOM树也进行深度优先的遍历,遍历的时候从步骤二生成的patches对象中找出当前遍历的节点差别,而后进行 DOM 操做。

function patch (node, patches) {
  var walker = {index: 0}
  dfsWalk(node, walker, patches)
}

function dfsWalk (node, walker, patches) {
  var currentPatches = patches[walker.index] // 从patches拿出当前节点的差别

  var len = node.childNodes
    ? node.childNodes.length
    : 0
  for (var i = 0; i < len; i++) { // 深度遍历子节点
    var child = node.childNodes[i]
    walker.index++
    dfsWalk(child, walker, patches)
  }

  if (currentPatches) {
    applyPatches(node, currentPatches) // 对当前节点进行DOM操做
  }
}

  applyPatches,根据不一样类型的差别对当前节点进行 DOM 操做:

 

function applyPatches (node, currentPatches) {
  currentPatches.forEach(function (currentPatch) {
    switch (currentPatch.type) {
      case REPLACE:
        node.parentNode.replaceChild(currentPatch.node.render(), node)
        break
      case REORDER:
        reorderChildren(node, currentPatch.moves)
        break
      case PROPS:
        setProps(node, currentPatch.props)
        break
      case TEXT:
        node.textContent = currentPatch.content
        break
      default:
        throw new Error('Unknown patch type ' + currentPatch.type)
    }
  })
}

 

 
 

五、结语

  virtual DOM算法主要实现上面步骤的三个函数: element、diff、patch,而后就能够实际的进行使用了。 

// 1. 构建虚拟DOM
var tree = el('div', {'id': 'container'}, [
    el('h1', {style: 'color: blue'}, ['simple virtal dom']),
    el('p', ['Hello, virtual-dom']),
    el('ul', [el('li')])
])

// 2. 经过虚拟DOM构建真正的DOM
var root = tree.render()
document.body.appendChild(root)

// 3. 生成新的虚拟DOM
var newTree = el('div', {'id': 'container'}, [
    el('h1', {style: 'color: red'}, ['simple virtal dom']),
    el('p', ['Hello, virtual-dom']),
    el('ul', [el('li'), el('li')])
])

// 4. 比较两棵虚拟DOM树的不一样
var patches = diff(tree, newTree)

// 5. 在真正的DOM元素上应用变动
patch(root, patches)

固然这是很是粗糙的实践,实际中还须要处理事件监听等;生成虚拟 DOM 的时候也能够加入 JSX 语法。这些事情都作了的话,就能够构造一个简单的ReactJS了。

 
 
 
 
  
源码地址: https://github.com/livoras/simple-virtual-dom
参考文章:https://github.com/livoras/blog/issues/13 
相关文章
相关标签/搜索