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

转:https://www.cnblogs.com/zhuzhenwei918/p/7271305.html

     

虚拟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(同样是结构上的相等)

回到Virtual DOM

生成virtual dom很快,diff生成patch也比较快

如果哪一天,DOM本身的已经操作非常非常非常快了,那么加上了VirtualDom还会快吗?当然不会

大不了到时候不用Virtual DOM。

 

diff算法

  维护状态,更新视图。

        真正的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']
        }
      }
    }

这个对象中有三个属性:

  1. tagName: 用来表示这个元素的标签名。
  2. props: 用来表示这元素所包含的属性。
  3. children: 用来表示这元素的children。
<ul id='list'>
  <li class='item'>Item 1</li>
  <li class='item'>Item 2</li>
  <li class='item'>Item 3</li>
</ul>

我们就可以用JavaScript对象来构建一个真正的DOM树

 

所谓的虚拟DOM,包括下面的几个步骤:

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

 

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

算法实现

1、 用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>

2、比较两颗虚拟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

3、把差异引用到真正的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)
    }
  })
}

5、结语

  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了。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值