本篇文章将以 React 作为切入点,分析理解虚拟 DOM
1. DOM 是什么
对于 DOM 的解释,很多官方文档(比如 MDN )都是这样描述的:
📚 DOM 即文档对象模型,Document Object Model。它是 HTML 和 XML 文档的编程接口,提供了对文档的结构化的表述,并定义了一种方式可以使从程序中对该结构进行访问,从而改变文档的结构,样式和内容。
这种用专业术语堆砌的解释 DOM 可能会让人感到云里雾里,或者完全听不懂,更别提理解了,所以我们对上述 DOM 的概念再次翻译。
以 HTML 和 JavaScript 来说明,从网络传给渲染引擎的 HTML 文件字节流是无法直接被渲染引擎理解的,所以渲染引擎在其内部会通过一个叫 HTML 解析器(HTMLParser)的模块将 HTML 字节流转化为它能够理解的内部结构,这个结构就是 DOM,它提供了对 HTML 文档结构化的表述。从页面的视角来看,DOM 是生成页面的基础数据结构;从 JavaScript 脚本视角来看,DOM 提供给 JavaScript 脚本操作的接口,通过这套接口,JavaScript 可以对DOM 结构进行访问,从而改变文档的结构、样式和内容。
简言之,DOM 这个名词有两层含义,第一个是是表述文档的内部数据结构,这是站在浏览器方面的看法。从编程语言来看,它提供了一套访问和操纵文档的 API。当然,作为前端开发工作者,我们更多的理解是后者。
注意 ⚠️:DOM 并不属于 JavaScript 语言的一部分,它是 JavaScript 的运行平台(浏览器)提供的,比如在node.js中就没有 DOM。浏览器中的 DOM 对应的是 HTML 页面中的元素节点,它本身和 JS 对象没有什么关联,但是webkit 渲染引擎和 JS 引擎之间通过 V8 Binding 在 V8 内部会把原生 DOM 对象映射为 JS 对象,我们称之为Wrapper objects(包装对象)。因此,我们平时在写代码时,操作 DOM 对象就是操作的这种包装对象,和操作 JS 对象是一样的。
2. 虚拟 DOM 是什么
Virtaul DOM,翻译过来它相对于原生真实的 DOM 来说叫“虚拟DOM”,简称 VDOM。参照 DOM 的解释,顾名思义,Virtual DOM 并不是真正的 DOM,而是一种描述 DOM 应该是什么样子的数据结构。Virtaul DOM 是随着React 的兴起正式进入广大开发者的视角中的。React 官方对 Virtaul DOM 的定义如下:
📚:Virtual DOM 是一种编程概念。在这个概念里,UI 以一种理想化的,或者说“虚拟的”表现形式被保存于内存中,并通过如 ReactDOM 等类库使之与“真实的” DOM 同步。
也就是说,Virtaul DOM 其实可以从宏观和微观两个角度来理解,微观上 Virtaul DOM 本身只一个在内存中的 JS对象,这个 JS 对象包含了整个原生 DOM 结构的信息;宏观上就是如同 React 官方所描述的那样,Virtual DOM是一种编程概念,我们使用 JS 对象描述真实的 DOM 节点。当这个真实的 DOM 节点需要变化时,我们先去更改JS 对象,然后通过这个 JS 对象来同步更改 DOM 节点。
3. 为什么会出现虚拟 DOM
注意 ⚠️:我们可以肯定的是,Virtaul DOM 是随着 React 的兴起正式进入广大开发者的视角中的,但对于Virtaul DOM 的生命起源还无法考证,有人说它是由 FaceBook 团队提出来的,最早运用在 React 上,但也有人说其实这种做法早在 d3.js 中就有实现。因此,对于为什么会出现虚拟 DOM 这个问题,我们的讨论范围并不是Virtaul DOM 的源头,而是基于 React 来分析,也就是 React 中为什么会出现虚拟 DOM。
我们经常会说到真实的 DOM 操作代价昂贵,操作频繁还会引起页面卡顿影响用户体验,因此就误以为 React 引入虚拟 DOM 是为了解决这个浏览器性能问题。但实际真的如此吗?答案是否定的。
当然,对于真实的 DOM 操作耗费性能这一点毋庸置疑。在 Web 开发中,我们总需要将变化的数据实时反应到 UI上,这时就需要对 DOM 节点进行操作,稍微了解浏览器加载页面原理的前端同学应该都知道网页性能问题其实大都出现在 DOM 节点的频繁操作上,这是因为通过 JavaScript 操纵 DOM 是会影响到整个渲染流水线的。举个简单的例子说明,比如我们调用 document.body.appendChild(node) 往 body 节点上添加一个元素,调用该 API 之后会引发一系列的连锁反应。首先渲染引擎会将 node 节点添加到 body 节点之上,然后触发样式计算、布局、绘制、栅格化、合成等任务,我们把这一过程称为重排,形象地理解就是牵一发而动全身。并且这个过程涉及到的浏览器渲染线程与 JS 引擎线程为互斥的关系,当 JS 引擎执行时渲染线程会被挂起,这种两个线程之间的上下文切换势必会很耗性能。另外,对于 DOM 的不当操作还有可能引发强制同步布局和布局抖动的问题,这些操作都会大大降低渲染效率。
不过对于简单的页面来说,其 DOM 结构还是比较简单的,所以上面这种操作 DOM 的问题并不会对用户体验产生太多影响。但是对于一些复杂的页面来说,其 DOM 结构是非常复杂的,执行一次重排或者重绘操作都是非常耗时的,如果还需要不断地去修改 DOM 树,那么渲染引擎就需要不断地进行重排、重绘或者合成等操作,这就给我们带来了真正的性能问题。
对于这种性能问题,有多种优化方案,比如现代浏览器中就对其做了优化,把 DOM 操作积累起来做批量处理。浏览器会维护一个队列,把所有引起回流和重绘的操作放入队列中,如果队列中的任务数量或者时间间隔达到一个阈值的,浏览器就会将队列清空,进行一次批处理,这样可以把多次回流和重绘变成一次。
注意⚠️:在有些情况下,浏览器还是会立即重排或重绘。比如请求如下 DOM 元素布局信息:currentStyle 或getComputedStyle() 、scrollTop/Left/Width/Height 等。因为这些值都是动态计算的,所以浏览器需要尽快完成页面的绘制,然后计算返回值,从而打乱了重排或重绘的优化。
但这些都只是优化方案,解决上述问题的本质是要减少 JavaScript 对 DOM 的操作,即减少不必要的 DOM API 调用。在 React 中,虚拟 DOM 的出现根本意义上不是为了解决 JS 频繁操作 DOM 而引起的性能问题,因为如果通过 JS 来操作DOM,那么无论用什么方式,多少动作都需要执行,虚拟 DOM 并没有减少操作。(虚拟 DOM 并不能消除原生的 DOM 操作,我们仍然需要通过浏览器提供的 DOM 接口来操作真实 DOM 树,才能使页面发生改变。)
这时你可能会产生疑惑,那到底是为什么要设计虚拟 DOM 出来呢?
看起来虚拟 DOM 的设计似乎是多此一举,先别着急,让我们一起来简单看看整个 DOM 操作的演化过程。传统的原生开发需要开发人员手动管理 DOM,数据和 DOM 操作糅合在一起,开发效率低下。DOM 操作模式的每一次革新,背后都是前端对效率和体验的进一步追求,比如 jQuery 的出现使 DOM 操作变得简单 -- 它将 DOM API 封装为了相对简单和优雅的形式,同时一口气做掉了跨浏览器的兼容工作,并且提供了链式API 调用、插件扩展等一系列能力用于进一步解放生产力,最终达到我们喜闻乐见的“写得更少,做得更多”效果。虽然 jQuery 帮助我们能够以更舒服的姿势操作 DOM,但它并不能从根本上解决 DOM 操作量过大的情况下给前端开发所带来的压力。随着前端工程化的不断发展,涌现了诸如React、Vue(没有用过 Angular 😅)等一系列 MVVM 模式的前端框架,这些框架公有的特色就是实现了框架自动管理 DOM,开发者再也不用关心具体 DOM 的操做,而只需要把重点放在基于数据状态的操做上,一旦数据更改,跟它绑定的那个地方的 DOM 也会跟着变化,这种方式使前端开发的效率得到进一步的提升。前面我们有提过,Virtaul DOM 是随着 React 的兴起正式进入广大开发者的视角中,React 的核心思想就是跟踪组件状态变化并将更新后的状态更新到屏幕上,即数据驱动视图。Virtaul DOM 为 React 数据驱动视图这一思想提供了高度可用的载体,为 React 框架自动进行 DOM 的操作提供了可能。React 官方文档在介绍虚拟 DOM 是什么的时候也有说到:
虚拟 DOM 这种方式赋予了 React 声明式的 API,开发者告诉 React 希望让 UI 是什么状态,React 就确保DOM 匹配该状态。这使开发者可以从属性操作、事件处理和手动 DOM 更新这些在构建应用程序时必要的操作中解放出来。
DOM 操作的演化过程实质上就是前端框架/库的发展过程,不难看出,在这个过程中主要矛盾并不在于性能,而在于研发体验/研发效率。虚拟 DOM 是前端开发们为了追求更好的研发体验和研发效率而创造出来的高阶产物,它出现的根本意义在于改变了开发模式。
📚 数据驱动视图:开发者不再需要关注某个数据的变化如何更新到一个或多个具体的 DOM 元素,而只需关注状态转移以及最终 UI 长什么样,当数据发生变化,React 框架会自动根据新的状态重新构建 UI,使开发者从复杂的 UI 操作中解放出来。借用 Facebook 介绍 React的视频 中聊天应用的例子,当一条新的消息发送过来时,在传统开发的思路下,开发过程需要知道哪条数据过来了以及如何将新的 DOM 结点添加到当前 DOM 树上;而基于 React 的开发思路,开发者永远只需关心数据整体,至于两次数据之间的 UI 如何变化则完全交给框架去做。
为什么很多人会误以为 React 引入虚拟 DOM 是为了解决浏览器性能问题呢?其实,关于 Virtual DOM 性能的误解,可以追溯到 React 正式发布那会。在 2013 年,React 前团队核心成员 Pete Hunt在《React:重新思考最佳实践》的演讲中提到:
截图来自 JSConf EU 2013 《重新思考最佳实践》
这个观点在前端圈子里迅速 📣 传播,以至于到现在很多人一谈到 Virtual DOM 的优势就会说 “原生 DOM 操作太慢了,Virtual DOM 更快些”。 Virtual DOM 操作一定是比原生 DOM 操作快吗 🧐 ?实质上,比对性能是需要以严格的限定条件为前提的。你会发现,React 官方文档中也从来没有把 Virtual DOM 作为性能层面的卖点对外输出过,仅提到 Virtual DOM 让我们能够用声明式的方式来描述我们的目的,从而得到更高效的研发模式。并且后来很多人去研究也发现直接操作 DOM 的性能并不会低于虚拟 DOM,甚至还会优于。知乎上有一个比较火 🔥 的问题,网上都说操作真实 DOM 慢,但测试结果却比 React 更快,为什么?Vue 的作者尤雨溪就有对该问题进行相关问答:
没有任何框架可以比纯手动的优化 DOM 操作更快,因为框架的 DOM 操作层需要应对任何上层 API 可能产生的操作,它的实现必须是普适的。
如果一定要谈及性能,那也只能是和现代游览器对 DOM 操作所做的批量处理优化类似,React 中虚拟 DOM 做了同样的批量处理优化:虚拟 DOM 模式将页面改变的内容应用到虚拟 DOM 上,而不是直接应用到 DOM 上。当变化被应用到虚拟 DOM 上时,虚拟 DOM 并不急着去渲染页面,而仅仅是调整虚拟 DOM 的内部状态,等到虚拟DOM 收集到足够的改变时,再把这些变化一次性应用到真实的 DOM 上。
如果你对 React Virtual DOM 的实现比较熟悉的话(不熟悉也没关系,因为下一个小节我们就会对其进行探讨),那你一定知道 React 采用了双缓存技术,在 React 中最多会同时存在两棵虚拟 DOM 树。其中一棵树对应着当前在屏幕中显示的内容,另外一棵则是在更新过程中在内存构建的对应即将要刷新到屏幕的未来状态。React通过 Diff 算法来对比这两棵树的差异,然后再对不同之处进行真实 DOM 更新渲染。显而易见,虚拟 DOM 的差异比较并非毫无代价,因为通过 Diff 算法寻找差异的这个运算过程相对于直接操作 DOM 来说,其实就已经是多出的中间处理过程。在我个人理解看来,虚拟 DOM 的操作优化只是中和了通过 Diff 算法寻找差异所带来的代价,最后使得 React 仍然能保持一个还不错的性能。
4. 虚拟 DOM 是如何支持 React 实现数据驱动视图的?
注意 ⚠️:关于这个问题,本篇文章在这里只做简单介绍,后续会单独成文分析 React Diff 算法。
FaceBook 团队给 React 内置了一套 Diff 算法,真实的 DOM 构造会被模拟成一个虚拟 DOM 树结构,每当数据变化时,都会重新构建整个虚拟 DOM 树,然后通过 React Diff 算法对当前整个虚拟 DOM 树和上一次的虚拟 DOM树进行对比,计算出 Virtual DOM 中改变的部分,最后仅仅将需要变化的部分进行实际的 DOM 操作。
即 React 中 Virtual DOM 的实现有三步骤:生成 Virtual DOM 树、对比两棵树的差异、更新视图。
React 虚拟 DOM 执行流程,图来自于李兵《游览器工作原理与实践》
5. Virtual DOM 的优势
虚拟 DOM 最大的一个特点就是为数据驱动视图这一思想提供了高度可用的载体,使得前端开发能够实现高效的声明式编程,从而让代码更简洁也更容易维护。
除此之外,虚拟 DOM 还提供了更好的跨平台的能力,因为 Virtual DOM 是对真实渲染内容的一层抽象,它以JavaScript 对象为基础而不依赖具体的平台环境,因此可以适用于其他的平台,如 Node、Weex、Native 等。若没有这一层抽象,那么视图层将和渲染平台紧密耦合在一起,为了描述同样的视图内容,你可能要分别在 Web 端和 Native 端写完全不同的两套甚至多套代码。同一套虚拟 DOM,可以对接不同平台的渲染逻辑,从而实现“一次编码,多端运行”。
总结
到此为止,再次审视 Virtual DOM,可以简单得出如下结论:
从微观角度来看,虚拟 DOM 本身只一个在内存中的 JS对象,这个 JS 对象包含了整个原生 DOM 结构的信息;从宏观角度理解,虚拟 DOM 是一种编程概念,即我们使用 JS 对象描述真实的 DOM 节点,当真实的 DOM 节点需要变化时,我们先去更改 JS 对象,然后通过这个 JS 对象来同步更改 DOM 节点。
在以往的传统前端开发中,编程方式是命令式的,由开发者直接操纵 DOM,告诉浏览器该怎么干。这样的问题就是,大量的代码被用于操作 DOM 元素,且代码可读性差,可维护性低。虚拟 DOM 的引入开启 🔛 了从命令式编程走向声明式编程的大门,得益于虚拟 DOM 的支持,React 改变了这种传统的开发模式,摒弃了直接操作 DOM的细节,只关注数据的变动,DOM 操作由框架来完成,从而大幅度提升了开发效率,以及代码的可读性和可维护性。与此同时,由于虚拟 DOM 是以 JavaScript 对象为基础而不依赖具体的平台环境,所以虚拟 DOM 还提供了很好的跨平台能力。
资料 📚
网上都说操作真实 DOM 慢,但测试结果却比 React 更快,为什么?-- 尤雨溪的回答 - 知乎
Pete Hunt -- 2013 JSConf 《React:重新思考最佳实践》演讲
李兵《游览器工作原理与实践》-- 虚拟 DOM:虚拟 DOM 和实际的 DOM 有何不同?