2018-深入理解虚拟 DOM,它真的不快

深入理解虚拟 DOM,它真的不快

使用过 React 的同学对于 Virtual DOM 并不陌生,作为 React 的重要核心概念,Virtual DOM 凭借其高效的 diff 算法,让我们不用关心应用的性能问题,毫无顾忌地修改各种数据状态。在实际的开发中,我们并不需要关注 Virtual DOM 在一个框架中是如何运行的,但是理解 Virtual DOM 的实现原理却是非常有必要的,同时也有助于我们更加深入 React。

前端应用状态管理

在日益复杂的前端应用中,状态管理是一个经常被提及的话题,从早期的刀耕火种时代到 jQuery,再到现在流行的 MVVM 时代,状态管理的形式发生了翻天覆地的变化,我们再也不用维护茫茫多的事件回调、监听来更新视图,转而使用使用双向数据绑定,只需要维护相应的数据状态,就可以自动更新视图,极大提高开发效率。

但是,双向数据绑定也并不是唯一的办法,还有一个非常粗暴有效的方式:一旦数据发生变化,重新绘制整个视图,也就是重新设置一下 innerHTML。这样的做法确实简单、粗暴、有效,但是如果只是因为局部一个小的数据发生变化而更新整个视图,性价比未免太低了,而且,像事件,获取焦点的输入框等,都需要重新处理。所以,对于小的应用或者说局部的小视图,这样处理完全是可以的,但是面对复杂的大型应用,这样的做法不可取。

说到这里,你会说这跟 Virtual DOM 有什么关系呢?其实 Virtual DOM 就是这么做的,只是在高效的 diff 算法计算下,避免对整棵 DOM 树进行变更,而是进行针对性的视图变更,将效率做到最优化。

什么是 Virtual DOM

Virtual DOM 的概念有很多解释,从我的理解来看,主要是三个方面,分别是:一个对象,两个前提,三个步骤。

一个对象指的是 Virtual DOM 是一个基本的 JavaScript 对象,也是整个 Virtual DOM 树的基本。

两个前提分别是 JavaScript 很快和直接操作 DOM 很慢,这是 Virtual DOM 得以实现的两个基本前提。得益于 V8 引擎的出现,让 JavaScript 可以高效地运行,在性能上有了极大的提高。直接操作 DOM 的低效和 JavaScript 的高效相对比,为 Virtual DOM 的产生提供了大前提。

三个步骤指的是 Virtual DOM 的三个重要步骤,分别是:生成 Virtual DOM 树、对比两棵树的差异、更新视图。这三个步骤的具体实现也是本文将简述的一大重点。

Virtual DOM 三板斧

下面就将介绍 Virtual DOM 的三个步骤具体的含义以及实现思路。著作权归作者所有。

  1. 生成 Virtual DOM 树

DOM 是前端工程师最常接触的内容之一,一个 DOM 节点包含了很多的内容,但是一个抽象出一个 DOM 节点却只需要三部分:节点类型,节点属性、子节点。所以围绕这三个部分,我们可以使用 JavaScript 简单地实现一棵 DOM 树,然后给节点实现渲染方法,就可以实现虚拟节点到真是 DOM 的转化。

图片

  1. 对比两棵树的差异

比较两棵 DOM 树的差异是 Virtual DOM 算法最核心的部分,这也是我们常说的的 Virtual DOM 的 diff 算法。在比较的过程中,我们只比较同级的节点,非同级的节点不在我们的比较范围内,这样既可以满足我们的需求,又可以简化算法实现。著作权归作者所有。

图片

比较“树”的差异,首先是要对树进行遍历,常用的有两种遍历算法,分别是深度优先遍历和广度优先遍历,一般的 diff 算法中都采用的是深度优先遍历。对新旧两棵树进行一次深度优先的遍历,这样每个节点都会有一个唯一的标记。在遍历的时候,每遍历到一个节点就把该节点和新的树的同一个位置的节点进行对比,如果有差异的话就记录到一个对象里面。著作权归作者所有。 商业转载请联系作者获得授权,非商业转载请注明出处。

图片

例如,上面的 div 和新的 div 有差异,当前的标记是 0,那么:patches[0] = [{difference}, {difference}, …]。同理 p 是 patches[1],ul 是 patches[3],以此类推。这样当遍历完整棵树的时候,就可以获得一个完整的差异对象。

在这个差异对象中记录了有改变的节点,每一个发生改变的内容也不尽相同,但也是有迹可循,常见的差异包括四种,分别是:

  • 替换节点
  • 增加/删除子节点
  • 修改节点属性
  • 改变文本内容

所以在记录差异的时候要根据不同的差异类型,记录不同的内容。

更新视图

在第二步得到整棵树的差异之后,就可以根据这些差异的不同类型,对 DOM 进行针对性的更新。与四种差异类型相对应的,是更新视图时具体的更新方法,分别是:

  • replaceChild()
  • appendChild()/removeChild()
  • setAttribute()/removeAttribute()
  • textContent

图片

让我们来看看手动执行 DOM 节点的创建和更新的鸟瞰图。这对于理解虚拟 DOM 如何工作以及它解决了哪些问题非常重要。

在谈论 JavaScript Web 应用程序时,用户界面的更改通过 DOM 操作发生。这个过程分为两个阶段:

  1. JS 部分:定义 JavaScript 世界中的变化
  2. DOM 部分:使用 DOM API 函数和属性执行更改

性能是根据整个过程的速度来衡量的,但了解每部分的速度也很重要,以便了解要优化的内容。

有两种方法可以创建和更新 DOM 树的各个部分。

① 字符串方式创建

使用字符串既快速又简单,但在更新方面并不是非常精细。对于字符串,JS 部分是它如此之快的原因。您可以在几毫秒内创建一段代表 5000 个节点的 HTML。这是一个例子:

const userList = document.getElementById("user-list");

// JS 部分
const html = users
  .map(function (user) {
    return `
    <div id="${user.id}" class=”user”>
      <h2 class="header">${user.firstName} ${user.lastName}</h2>
      <p class="email"><a href=”mailto:${user.email}”>EMAIL</a></p>
      <p class="avg-grade">Average grade: ${user.avgGrade}</p>
      <p class="enrolled">Enrolled: ${user.enrolled}</p>
    </div>
  `;
  })
  .join("");

// DOM 部分
userList.innerHTML = html;

我提到使用这种方法时存在局限性。请考虑以下示例:

const search = document.getElementById("search");
search.innerHTML = `<input class="search" type="text" value="foo">`;
// Change value to "bar"?
search.innerHTML = `<input class="search" type="text" value="bar">`;

虽然看起来上面的内容很简单,但它实际上并不起作用。当我们运行上面的代码时,原始<input>元素被替换而不是更新,例如,如果用户有焦点的字段,他们将失去焦点。

② 使用 DOM 对象

创建和更新 DOM 树的另一种方法是使用 DOM 对象。就你必须编写的代码而言,这种方法非常冗长,而且总体来说它也慢得多。

让我们使用这个方法重写用户列表示例:

const userList = document.getElementById(“user-list”);
// JS part
const = document.createDocumentFragment();
users.forEach(function(user){
  const div = document.createElement(“div”);
  div.id = user.id;
  div.className =“user”;
  const header = document.createElement(“h2”);
  h2 .className =“header”;
  h2.appendChild(
    document.createTextNode(`$ {user.firstName} $ {user.lastName}`)
  );
  // ....
  frag.appendChild(div);
});
// DOM部分
userList.innerHTML =“”;
userList.appendChild(FRAG);

这看起来不太好,但它仍然是创建 DOM 节点的有效方法。它还有一个优点,即我们能够将它与第三方库(如 D3)混合使用,以执行 HTML 字符串不易处理的事情。在真正的优势,虽然是执行粒度更新现有的树时:

const search = document.getElementById(“search”);
search.innerHTML =`<input class ="search" type ="text"value ="foo">`;
//将值更改为“bar”?
search.querySelector("input")。value ="bar";

这次我们结合快速方便的字符串 HTML 方法来创建初始 UI,然后我们使用 DOM 操作方法来更新 value 属性。不像我们第一次这样做,<input>现在没有被替换,所以它不会像第一个例子那样引起 UX 故障。

动手实现 Virtual DOM

对原理有了一定的认识之后,自然是动手实现一番了,GitHub 上有很多对 Virtual DOM 的实现,比如 simple-virtual-dom(https://github.com/livoras/simple-virtual-dom/)、virtual-dom (https://github.com/Matt-Esch/virtual-dom)等,我也对其进行了一个基本的实现,比较简陋,传送门(https://github.com/y8n/simple-virtual-dom)。

进一步思考

Virtual DOM 的原理和实现的说明已经结束了,但是对于 Virtual DOM 的思考远没有结束,Virtual DOM 对前端开发的影响难道就只是一堆算法吗?

  1. 性能对比

首先,先来看一下性能,在诸多的 Virtual DOM 实现中,都会强调算法的高效,那么在实际的使用中,Virtual DOM 的性能到底如何呢?

图片

上图是对一个简单的 DOM 树进行不同方式的操作,由左边的结构更新为右边的结构,通过原生操作、jQuery、Virtual DOM 和 React 四种方式,在 Chrome 的 timeline 中得到的性能对比,在这个图中,我们并没有看出 Virtual DOM 或者 React 的优势,通过对比我们发现,原生的操作要比其他三种方式快,而其他三种方式就相差无几了。当然,这样一个简单测试并没有说明什么,测试的 DOM 结构简单,和我们平时面对的业务场景不是一个量级,代表不了什么,但是起码我们可以看到,这种情况下好像 Virtual DOM 并没有我们想象的性能优势。

图片

在接下来的测试中我们增加测试量。上图分别是使用原生操作、Virtual DOM 和 React 三种方式进行两类测试:插入 10000 个节点 100 次和修改 3000 个节点的属性 100 次。分别取这 100 次的耗时最大值、最小值和平均值。从图中我们可以看到明显的差异,Virtual DOM 和 React 的差异可以理解,毕竟我们自己实现的 Virtual DOM 没有那么庞大,只是针对虚拟 DOM 而实现的,比 React 快一点可以理解,但是原生的操作比 Virtual DOM 和 React 都要快得多,这又是怎么一回事,好像和我们预想的不一样,回到最初,我们提到,Virtual DOM 的产生前提之一就是直接操作 DOM 很慢,现在看来直接操作不但不慢,反而快了很多,这不得不让我产生了怀疑,是我对 Virtual DOM 的理解有误还是对 DOM 的理解有误呢?

再次审视 Virtual DOM

框架存在的意义是什么?是提高性能?提高开发效率?亦或是其他用途,每个人对框架的理解不同,答案也不尽相同。但是不得不承认,存在框架的情况下,项目的可维护性有了极大的提高,而对于其他方面就要做出牺牲,比如性能。在上面的性能测试中,其实完全走入了一个误区,在测试中我们用到的原生的操作其实是“人为”地对操作进行优化之后的结果,而如果抛开人为优化的前提,最终的结果可能就不是这样了。**Virtual DOM 的优势不在于单次的操作,而是在大量、频繁的数据更新下,能够对视图进行合理、高效的更新。**这一点是原生操作远远无法替代的。

到此为止,再次审视 Virtual DOM,可以简单得出如下结论:

  • Virtual DOM 在牺牲部分性能的前提下,增加了可维护性,这也是很多框架的通性
  • 实现了对 DOM 的集中化操作,在数据改变时先对虚拟 DOM 进行修改,再反映到真实的 DOM 中,用最小的代价来更新 DOM,提高效率
  • 打开了函数式 UI 编程的大门
  • 可以渲染到 DOM 以外的端,比如 ReactNative

结语

本文对 Virtual DOM 有一个简单的介绍,包括实现的部分也很简单,甚至对列表的 diff 算法也偷工减料,跟多高级的特性也没有涉及,比如事件绑定、生命周期、JSX 语法等,如果加上这些内容,就是一个小型版的 React 了。

本文旨在让大家了解并认识 Virtual DOM 的基本概念、组成和实现,同时对 Virtual DOM 更深层的意义有所了解,这样在以后用到相关的框架的时候也不会两眼一抹黑了,起码在性能优化上有点认识,比如列表要带上 key 这样基本的优化操作。