# Virtual DOM 的概念与应用

# 前言

# 追根溯源

虚拟 DOM 出现的本质原因是解决直接操作 DOM 导致的重绘和重排的问题。

关于虚拟 DOM: 为什么要有虚拟 DOM 的出现,因为操作频繁真正 dom 会导致页面重排和重绘这两个性能问题,间接导致卡顿。

# 虚拟 DOM

# 什么是虚拟 DOM

所谓的 vitural dom,也就是虚拟节点。它通过 JS 的 Object 对象模拟 DOM 中的节点,然后再通过特定的 render 方法将其渲染成真实的 DOM 节点。dom diff 是通过 JS 层面的计算,返回一个 patch 对象,即补丁对象,再通过特定的操作解析 patch 对象,完成页面的重新渲染。

那么虚拟 DOM 做了哪些事情?

思路:将页面的改变的内容应用到虚拟 DOM 上,而不是直接应用到 DOM 上。变化被应用到 虚拟 DOM 上时,虚拟 DOM 并不着急去渲染页面,仅仅是调整虚拟 DOM 的内部状态,这样就不会频繁触发重绘和重排,操作 DOM 的代价也变低。这也是为什么我们提倡在 Vue 中尽量通过改变数据触发 Vue 的虚拟 DOM 更新逻辑,不要直接自己去操作 DOM。在虚拟 DOM 接受到足够的改变时,再把这些变化应用到真实的 DOM 上(直接设置 innerHTML 即可)

# 实现虚拟 DOM

  • 详细思路:
    • 用 JavaScript 对象模拟 DOM。
    • 把此虚拟 DOM 转成真实 DOM 并插入页面中。
    • 如果有事件发生修改了虚拟 DOM。
    • 比较两棵虚拟 DOM 树的差异,得到差异对象。
    • 把差异对象应用到真正的 DOM 树上。

具体实现主要通过三个函数:

  • element(构建虚拟 DOM)
  • diff(对比新旧虚拟 DOM)
  • patch(在真正的 DOM 元素应用变更)

patches 对象的结构记录,

{
  type:{}
  patches: {}
}

1
2
3
4
5

# Diff 算法

比较两棵 DOM 树的差异是 Virtual DOM 算法最核心的部分,这也是所谓的 Virtual DOM 的 diff 算法。两个树完全的 diff 算法是一个时间复杂度为 O(n^3) 的问题。但是在前端当中,你很少会跨越层级地移动 DOM 元素(对于跨越层级去移动 DOM 元素的场景,应该如何处理复用呢,没有做处理)。所以 Virtual DOM 只会对同一层级的元素进行对比:

这也是为什么要给 Vue 建议给子节点绑定 Key 呢?

这是因为如果我们把 div 的子节点重新排序而不是修改的情况下,例如 p,ul,div 的顺序替换成了 div,p,ul。这个该怎么对比呢?如果按照同层级进行顺序对比的话,它们都会被替换掉(根据节点标识的 index 不同)。如 p 和 div 的 tagName 不同,p 会被 div 所替代。最终,三个节点都会被替换,这样 DOM 开销就非常大。而实际上是不需要替换节点,而只需要经过移动节点就可以达到,我们只需知道怎么进行移动。

对于重排子节点来说,要想复用旧的子节点,不能使用 tagName 进行对比,因为 tagName 是可重复的。所以需要给子节点添加上唯一标识 key,列表对比的时候,使用 key 进行对比,这样才能复用老的 DOM 树上的节点。

var diffs = listDiff(oldChildren, newChildren, 'key') 这里的 diffs 获取了

function diffChildren(oldChildren, newChildren, index, patches, currentPatch) {
  var diffs = listDiff(oldChildren, newChildren, "key");
  newChildren = diffs.children;

  if (diffs.moves.length) {
    var reorderPatch = { type: patch.REORDER, moves: diffs.moves };
    currentPatch.push(reorderPatch);
  }

  var leftNode = null;
  var currentNodeIndex = index;
  _.each(oldChildren, function(child, i) {
    var newChild = newChildren[i];
    currentNodeIndex =
      leftNode && leftNode.count
        ? currentNodeIndex + leftNode.count + 1
        : currentNodeIndex + 1;
    dfsWalk(child, newChild, currentNodeIndex, patches);
    leftNode = child;
  });
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

在进行 patch 的过程中

oldTree vs newTree -> diffDom -> 应用到真正的 DOM 树上。

上面的 key 性能优化,主要用在 patch 的时候是对原来的 dom 节点进行复用,还是重新 render() 一个 dom 节点出来。

function reorderChildren(node, moves) {
  var staticNodeList = _.toArray(node.childNodes);
  var maps = {};
  // 先把所有旧渲染 DOM 节点的 `key` 做一个映射
  _.each(staticNodeList, function(node) {
    if (node.nodeType === 1) {
      var key = node.getAttribute("key");
      if (key) {
        maps[key] = node;
      }
    }
  });

  _.each(moves, function(move) {
    var index = move.index;
    if (move.type === 0) {
      // remove item
      if (staticNodeList[index] === node.childNodes[index]) {
        // maybe have been removed for inserting
        node.removeChild(node.childNodes[index]);
      }
      staticNodeList.splice(index, 1);
    } else if (move.type === 1) {
      // insert item,在这里可以匹配复用旧节点
      var insertNode = maps[move.item.key]
        ? maps[move.item.key].cloneNode(true) // reuse old item //
        : typeof move.item === "object"
        ? move.item.render()
        : document.createTextNode(move.item);
      staticNodeList.splice(index, 0, insertNode);
      node.insertBefore(insertNode, node.childNodes[index] || null);
    }
  });
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34

# 看看 Vue 与 React 框架虚拟 DOM 的实现

# 说说 Vue 的 diff 算法

# React

# 实战应用

# 解决 Vue 列表更新错乱问题

# 描述

# 分析

# 解决

# 小结

# 参考资料

Last Updated: 7/3/2020, 9:01:28 AM