美文网首页
虚拟DOM是啥?

虚拟DOM是啥?

作者: dingFY | 来源:发表于2020-07-08 17:49 被阅读0次

一、浏览器渲染HTML的步骤

  1. HTML被HTML解析器解析成DOM Tree, CSS则被CSS解析器解析成CSSOM Tree。
  2. DOM Tree和CSSOM Tree解析完成后,被附加到一起,形成渲染树(Render Tree)。
  3. 节点信息计算(重排),这个过程被叫做Layout(Webkit)或者Reflow(Mozilla)。即根据渲染树计算每个节点的几何信息生成布局。
  4. 渲染绘制(重绘),这个过程被叫做(Painting 或者 Repaint)。即根据计算好的信息绘制整个页面。
  5. Display显示到屏幕上

以上5步简述浏览器的一次渲染过程,每一次的dom更改或者css几何属性更改,都会引起一次浏览器的重排/重绘过程。

二、什么是虚拟DOM(三步骤)

虚拟DOM(Virtual DOM)就是用JS来模拟DOM结构。

Virtual DOM 算法:

步骤一:用JS对象生成Virtual DOM树,然后用这个树构建一个真正的 DOM 树,插到文档当中

步骤二:当状态变更的时候,重新构造一棵新的对象树,然后用新的树和旧的树进行比较,记录两棵虚拟DOM树的差异

步骤三:把步骤二所记录的差异应用到步骤一所构建的真正的DOM树上,视图就更新了

三、为什么要使用虚拟DOM?(两个前提)

【1】前提一:直接操作DOM很慢

    let div = document.createElement('div')
    let props = ''
    for (let key in div) {
      props = `${props} ${key}`
    }
    console.log(props)

如上图所示,如果我们把一个简单的div元素的属性都打印出来,你会看到真正的 DOM 元素非常庞大,由此可以得知DOM是很慢的。而且操作它们的时候你要小心翼翼,轻微的触碰可能就会导致页面重排。浏览器里一遍又一遍的渲染DOM是非常非常消耗性能的,常常会出现页面卡死的情况,影响用户的体验;所以尽量减少对DOM的操作成为了优化前端性能的必要手段,虚拟DOM就是将DOM的对比放在了js层,通过高效的 diff 算法计算,避免对整棵DOM树进行变更,而是进行针对性的视图变更,从而提高渲染效率。

【2】前提二:JavaScript很快

得益于V8引擎的出现,让JavaScript可以高效地运行,在性能上有了极大的提高。相对于 DOM 对象,原生的 JavaScript 对象处理起来更快,而且更简单。DOM 树上的结构、属性信息我们都可以很容易地用 JavaScript 对象表示出来,为Virtual DOM的产生提供了大前提。

四、Virtual DOM 算法实现

【4.1】用js来模拟DOM节点

用js对象模拟DOM节点的好处是,页面的更新可以先全部反映在js对象上,操作内存中的js对象的速度显然要快多了。等更新完后,再将最终的js对象映射成真实的DOM,交由浏览器去绘制。

那具体怎么实现呢?看一下Element方法的具体实现:

    /*
      tagName: 节点名(如div)
      props: 节点的属性(如class)
      children: 子节点(如ul的li)。
      除了这三个参数会被保存在对象上外,还保存了key和count。
    */
    function Element(tagName, props, children) {

      // 保证只能通过如下方式调用:new Element()
      if (!(this instanceof Element)) {
        return new Element(tagName, props, children);
      }

      // 设置虚拟dom相关属性
      this.tagName = tagName;
      this.props = props || {};
      this.children = children || [];
      this.key = props ? props.key : undefined;

      let count = 0;
      this.children.forEach((child) => {
        if (child instanceof Element) {
          count += child.count;
        }
        count++;
      });
      this.count = count;
    }

有了js对象后,最终还需要将其映射成真实的DOM:根据DOM名调用源生的createElement创建真实DOM,将DOM的属性全都加到这个DOM元素上,如果有子元素继续递归调用创建子元素,并appendChild挂到该DOM元素上。这样就完成了从创建虚拟DOM到将其映射成真实DOM的全部工作。

    Element.prototype.render = function () {
      // 创建标签
      const el = document.createElement(this.tagName);

      // 设置标签属性
      const props = this.props;
      for (const propName in props) {
        setAttr(el, propName, props[propName]);
      }

      // 依次创建子节点标签
      this.children.forEach((child) => {
        //如果子节点仍然为element,则递归的创建子节点,否则直接创建文本类型节点
        const childEl = (child instanceof Element) ? child.render() : document.createTextNode(child);
        el.appendChild(childEl);
      });
      return el;
    };

对一个虚拟的DOM对象,调用其原型的render方法,就可以产生一颗真实的DOM树。

vdom.render();

通过上面的Element方法,我们可以很简单地用javascript表示DOM结构。例如:

const vdom = Element('div', { id: 'virtual-container' }, [
    Element('p', {}, ['Virtual DOM']),
    Element('ul', {}, [
        Element('li', { class: 'item' }, ['Item 1']),
        Element('li', { class: 'item' }, ['Item 2']),
        Element('li', { class: 'item' }, ['Item 3']),
    ]),
    Element('div', {}, ['Hellow World']),
]);

const root = vdom.render(); // 生成真实的DOM树
document.getElementById('virtualDom').appendChild(root); // 添加到页面

上面的vdom是一个虚拟的DOM对象,调用其原型的render方法,就可以产生一颗真实的DOM树。如下:

  <div id="virtualDom">
    <div id="virtual-container">
      <p>Virtual DOM</p>
      <ul>
        <li class="item">Item 1</li>
        <li class="item">Item 2</li>
        <li class="item">Item 3</li>
      </ul>
      <div>Hello World</div>
    </div>
  </div>

页面展示效果:

【4.2】Diff算法(比较两颗虚拟DOM树的差异)

上面我们已经完成了创建虚拟DOM并将其映射成真实DOM的工作,这样所有的更新都可以先反映到虚拟DOM上,如何反映呢?需要明确一下Diff算法。

4.2.1、如何比较两颗DOM树

计算两棵树之间差异的常规算法复杂度为O(n3),一个文档的DOM结构有上百个节点是很正常的情况,这种复杂度无法应用于实际项目。针对前端的具体情况:我们很少跨级别的修改DOM节点,通常是修改节点的属性、调整子节点的顺序、添加子节点等。因此,我们只需要对同级别节点进行比较,这样算法复杂的就可以达到O(n),避免了diff算法的复杂性。对同级别节点进行比较的常用方法是深度优先遍历。

4.2.2 、记录节点之间的差异

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

在深度优先遍历的时候,每遍历到一个节点就把该节点和新的的树进行对比。如果有差异的话就记录到一个叫patches的对象里面。

// diff 函数,对比两棵树
function diff (oldTree, newTree) {
  var index = 0 // 当前节点的标志
  var patches = {} // 用来记录每个节点差异的对象
  dfsWalk(oldTree, newTree, index, patches)
  return patches
}

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

  diffChildren(oldNode.children, newNode.children, index, patches)
}

// 遍历子节点
function diffChildren (oldChildren, newChildren, index, patches) {
  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, patches) // 深度遍历子节点
    leftNode = child
  })
}

例如:假设上面的ul有差异,ul的当前标记为3,那么记录为 patches[3] = [ // 用数组存储ul新旧节点的不同 ]

4.2.3、差异类型

由于我们对DOM树采取的是同级比较,因此节点之间的差异可以归结为以下4种类型:

  • 修改节点属性, 用PROPS表示
  • 修改节点文本内容, 用TEXT表示
  • 修改节点类型, 用REPLACE表示
  • 调整子节点,包括移动、增加、删除等,用REORDER表示

我们新创建一棵树,用于和之前的树进行比较

    const newTree = Element('div', { id: 'virtual-container' }, [
      Element('h3', {}, ['Virtual DOM']),                     // REPLACE
      Element('ul', { class: 'itemWrap' }, [                  // PROPS
        Element('li', { class: 'item' }, ['Item 1']),
        // Element('li', { class: 'item' }, ['Item 2']),      // REORDER remove
        Element('li', { class: 'item' }, ['Item 3']),
      ]),
      Element('div', {}, ['after update']),                   // TEXT
    ]);
  • 修改节点属性, 用PROPS表示 // newTree将ul的class属性改了
  • 修改节点文本内容, 用TEXT表示 // newTree将div的节点内容Hello World改成了after update
  • 修改节点类型, 用REPLACE表示 // newTree将p替换成h3
  • 调整子节点,包括移动、增加、删除等,用REORDER表示 // newTree将ul下的第二个li子节点删除

1、对于节点替换,判断新旧节点的tagName和是不是一样的,如果不一样的说明需要替换掉。例如:newTree将p替换成h3,就记录如下:

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

2、如果给元素新增了属性,例如:newTree的ul新增了class属性,就记录如下:

patches[3] = [{
  type: PROPS,
  props: {
    class: "itemWrap"
  }
}]

3、如果是文本节点改变,例如:newTree将div的节点内容Hello World改成了after update,就记录如下:

patches[10] = [{
  type: TEXT,
  content: "after update"
}]

4、如果是删除子节点,例如:newTree将ul下的第二个li子节点删除,就记录如下:

    patches[3] = [{
      type: PROPS,
      props: {
        class: "itemWrap"
      }
    }, {
      type: REORDER,
      moves: [
        { index: 2, type: 0 }
      ]
    }]

5、那如果把我div的子节点重新排序呢?例如p, ul, div的顺序换成了div, p, ul。这个该怎么对比?如果按照同层级进行顺序对比的话,它们都会被替换掉。如p和div的tagName不同,p会被div所替代。最终,三个节点都会被替换,这样DOM开销就非常大。而实际上是不需要替换节点,而只需要经过节点移动就可以达到,我们只需知道怎么进行移动。这牵涉到两个列表的对比算法

4.2.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

【4.3】把差异应用到真实的DOM树上

虚拟DOM有了,Diff也有了,现在就可以将Diff应用到真实DOM上了。

深度遍历DOM将Diff的内容更新进去:

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((currentPatch) => {
        switch (currentPatch.type) {
            case REPLACE: {
                const newNode = (typeof currentPatch.node === 'string')
                    ? document.createTextNode(currentPatch.node)
                    : currentPatch.node.render();
                node.parentNode.replaceChild(newNode, node);
                break;
            }
            case REORDER:
                reorderChildren(node, currentPatch.moves);
                break;
            case PROPS:
                setProps(node, currentPatch.props);
                break;
            case TEXT:
                if (node.textContent) {
                    node.textContent = currentPatch.content;
                } else {
                    // ie
                    node.nodeValue = currentPatch.content;
                }
                break;
            default:
                throw new Error(`Unknown patch type ${currentPatch.type}`);
        }
    });
}

虚拟DOM的目的是将所有操作累加起来,统计计算出所有的变化后,统一更新一次DOM。

【4.4】总结

Virtual DOM 算法主要是实现上面步骤的三个函数:ELement,diff,patch。然后就可以实际的进行使用

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

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

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

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

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

文章每周持续更新,可以微信搜索「 前端大集锦 」第一时间阅读,回复【视频】【书籍】领取200G视频资料和30本PDF书籍资料

相关文章

网友评论

      本文标题:虚拟DOM是啥?

      本文链接:https://www.haomeiwen.com/subject/yonynhtx.html