如何理解虚拟DOM

Posted 种麦南山下

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了如何理解虚拟DOM相关的知识,希望对你有一定的参考价值。

一、js 操作DOM

假如现在你需要写一个像下面一样的表格的应用程序,这个表格可以根据不同的字段进行升序或者降序的展示。

这个应用程序看起来很简单,你可以想出好几种不同的方式来写。最容易想到的可能是,在你的 javascript 代码里面存储这样的数据:

var sortKey = "new" // 排序的字段,新增(new)、取消(cancel)、净关注(gain)、累积(cumulate)人数
var sortType = 1 // 升序还是逆序
var data = [..., ..., .., ..] // 表格数据

用三个字段分别存储当前排序的字段、排序方向、还有表格数据;然后给表格头部加点击事件:当用户点击特定的字段的时候,根据上面几个字段存储的内容来对内容进行排序,然后用 JS 或者 jQuery 操作 DOM,更新页面的排序状态(表头的那几个箭头表示当前排序状态,也需要更新)和表格内容。

这样做会导致的后果就是,随着应用程序越来越复杂,需要在JS里面维护的字段也越来越多,需要监听事件和在事件回调用更新页面的DOM操作也越来越多,应用程序会变得非常难维护。后来人们使用了 MVC、MVP 的架构模式,希望能从代码组织方式来降低维护这种复杂应用程序的难度。但是 MVC 架构没办法减少你所维护的状态,也没有降低状态更新你需要对页面的更新操作(前端来说就是DOM操作),你需要操作的DOM还是需要操作,只是换了个地方。

既然状态改变了要操作相应的DOM元素,为什么不做一个东西可以让视图和状态进行绑定,状态变更了视图自动变更,就不用手动更新页面了。这就是后来人们想出了 MVVM 模式,只要在模版中声明视图组件是和什么状态进行绑定的,双向绑定引擎就会在状态更新的时候自动更新视图。

MVVM 可以很好的降低我们维护状态 -> 视图的复杂程度(大大减少代码中的视图更新逻辑)。但是这不是唯一的办法,还有一个非常直观的方法,可以大大降低视图更新的操作:一旦状态发生了变化,就用模板引擎重新渲染整个视图,然后用新的视图更换掉旧的视图。就像上面的表格,当用户点击的时候,还是在JS里面更新状态,但是页面更新就不用手动操作 DOM 了,直接把整个表格用模版引擎重新渲染一遍,然后设置一下innerhtml就完事了。

听到这样的做法,经验丰富的你一定第一时间意识这样的做法会导致很多的问题。最大的问题就是这样做会很慢,因为即使一个小小的状态变更都要重新构造整棵 DOM,性价比太低;而且这样做的话,input和textarea的会失去原有的焦点。最后的结论会是:对于局部的小视图的更新,没有问题(Backbone就是这么干的);但是对于大型视图,如全局应用状态变更的时候,需要更新页面较多局部视图的时候,这样的做法不可取。

但是这里要明白和记住这种做法,因为后面你会发现,其实 Virtual DOM 就是这么做的,只是加了一些特别的步骤来避免了整棵 DOM 树变更

另外一点需要注意的就是,上面提供的几种方法,其实都在解决同一个问题:维护状态,更新视图。在一般的应用当中,如果能够很好方案来应对这个问题,那么就几乎降低了大部分复杂性。

DOM是很慢的。如果我们把一个简单的div元素的属性都打印出来,你会看到:

 而这仅仅是第一层。真正的 DOM 元素非常庞大,这是因为标准就是这么设计的。而且操作它们的时候你要小心翼翼,轻微的触碰可能就会导致页面重排,这可是杀死性能的罪魁祸首。

相对于 DOM 对象,原生的 JavaScript 对象处理起来更快,而且更简单。DOM 树上的结构、属性信息我们都可以很容易地用 JavaScript 对象表示出来:

var element = 
  tagName: 'ul', // 节点标签名
  props:  // DOM的属性,用一个对象存储键值对
    id: 'list'
  ,
  children: [ // 该节点的子节点
    tagName: 'li', props: class: 'item', children: ["Item 1"],
    tagName: 'li', props: class: 'item', children: ["Item 2"],
    tagName: 'li', props: class: 'item', children: ["Item 3"],
  ]

上面对应的HTML写法是:

<ul id='list'>
  <li class='item'>Item 1</li>
  <li class='item'>Item 2</li>
  <li class='item'>Item 3</li>
</ul>

既然原来 DOM 树的信息都可以用 JavaScript 对象来表示,反过来,你就可以根据这个用 JavaScript 对象表示的树结构来构建一棵真正的DOM树。

之前的章节所说的,状态变更->重新渲染整个视图的方式可以稍微修改一下:用 JavaScript 对象表示 DOM 信息和结构,当状态变更的时候,重新渲染这个 JavaScript 的对象结构。当然这样做其实没什么卵用,因为真正的页面其实没有改变。

但是可以用新渲染的对象树去和旧的树进行对比,记录这两棵树差异。记录下来的不同就是我们需要对页面真正的 DOM 操作,然后把它们应用在真正的 DOM 树上,页面就变更了。这样就可以做到:视图的结构确实是整个全新渲染了,但是最后操作DOM的时候确实只变更有不同的地方。

这就是所谓的 Virtual DOM 算法。包括几个步骤:

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

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

二、虚拟DOM

虚拟 DOM (Virtual DOM )是由普通的JS对象来描述DOM对象,因为不是真实的DOM对象,所以叫Virtual DOM。

实际上它只是一层对真实DOM的抽象,以JavaScript 对象 (VNode 节点) 作为基础的树,用对象的属性来描述节点,最终可以通过一系列操作使这棵树映射到真实环境上。

Javascript对象中,虚拟DOM 表现为一个 Object对象。并且最少包含标签名 (tag)、属性 (attrs) 和子元素对象 (children) 三个属性,不同框架对这三个属性的名命可能会有差别。

创建虚拟DOM就是为了更好将虚拟的节点渲染到页面视图中,所以虚拟DOM对象的节点与真实DOM的属性一一照应。

vue中同样使用到了虚拟DOM技术,

定义真实DOM

<div id="app">
    <p class="p">节点内容</p>
    <h3> foo </h3>
</div>

实例化vue

const app = new Vue(
    el:"#app",
    data:
        foo:"foo"
    
)

 观察renderrender,我们能得到虚拟DOM

(function anonymous(
) 
	with(this)return _c('div',attrs:"id":"app",[_c('p',staticClass:"p",
					  [_v("节点内容")]),_v(" "),_c('h3',[_v(_s(foo))])]))

通过VNodevue可以对这颗抽象树进行创建节点,删除节点以及修改节点的操作, 经过diff算法得出一些需要修改的最小单位,再更新视图,减少了dom操作,提高了性能。

官网: 渲染函数 & JSX — Vue.js (vuejs.org)

Vue 通过建立一个虚拟 DOM 来追踪自己要如何改变真实 DOM。请仔细看这行代码:

return createElement('h1', this.blogTitle)

createElement 到底会返回什么呢?其实不是一个实际的 DOM 元素。它更准确的名字可能是 createNodeDescription,因为它所包含的信息会告诉 Vue 页面上需要渲染什么样的节点,包括及其子节点的描述信息。我们把这样的节点描述为“虚拟节点 (virtual node)”,也常简写它为“VNode”。“虚拟 DOM”是我们对由 Vue 组件树建立起来的整个 VNode 树的称呼。

三、为什么需要虚拟DOM?

模板引擎没有解决跟踪状态变化的问题是因为,当数据发生变化后,无法获取上一次的状态,只好把界面上的元素删除重新创建(可能造成闪烁,性能较低)。

  • 具备跨平台的优势

由于虚拟DOM 是以 JavaScript 对象为基础而不依赖真实平台环境,所以使它具有了跨平台的能力,比如说浏览器平台、Weex、Node 等,是实现ssr、小程序等的基础。

  • 提升渲染性能。

因为DOM是一个很大的对象,直接操作DOM,即便是一个空的 div 也要付出昂贵的代价,执行速度远不如我们抽象出来的 Javascript 对象的速度快,因此,把大量的DOM操作搬运到 Javascript 中,运用diff算法来计算出真正需要更新的节点,最大限度地减少DOM操作,从而显著提高性能。虚拟DOM的优势不在于单次的操作,而是在大量、频繁的数据更新下,能够对视图进行合理、高效的更新。

四、如何利用虚拟DOM来更新真实DOM?—— diff

diff(different),顾名思义,在构建DOM的过程中,会由diff过程就是比对计算DOM变动的地方,核心是由patch算法将变动映射到真实DOM上,所以视图的创建更新流程就是下面这样:

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

 

聊聊虚拟DOM的真正价值

最近我发现很多面试题里面都有「如何理解虚拟 DOM」这个题,我觉得这个题应该没有想象中那么好答,因为很多人没有真正理解虚拟 DOM 它的价值所在,我这篇从虚拟 DOM 的诞生过程来引出它的价值以及历史地位,帮助你深入的理解它。

什么是虚拟 DOM

本质上是 JavaScript 对象,这个对象就是更加轻量级的对 DOM 的描述。

对,就是这么简单!

就是一个复杂一点的对象而已,没什么好说的,重点是为什么要有这个东西,以及有了这个描述有什么好处才是我们今天要介绍的内容。

为什么要有虚拟 DOM

再谈为什么要用虚拟 DOM 之前,先来聊一聊 React 是怎么诞生的,毕竟在了解历史背景,再去思考他的诞生,就知道是必然会出现的。

再查了很多关于 React 的历史相关的文章,这篇文章我感觉比较值得令我信服:React 是怎样炼成的[1]

众所周知,Facebook 是 PHP 大户,所以 React 最开始的灵感就来自于 PHP。

字符串拼接时代 - 2004

在 2004 年这个时候,大家都还在用 PHP 的字符串拼接来开发网站:

$str = '<ul>';
foreach ($talks as $talk) {
$str += '<li>' . $talk->name . '</li>';
}
$str += '</ul>';

这种方式代码写出来不好看不说,还容易造成 XSS 等安全问题。

应对方法是对用户的任何输入都进行转义(Escape)。但是如果对字符串进行多次转义,那么反转义的次数也必须是相同的,否则会无法得到原内容。如果又不小心把 HTML 标签(Markup)给转义了,那么 HTML 标签会直接显示给用户,从而导致很差的用户体验。

XHP 时代 - 2010

到了 2010 年,为了更加高效的编码,同时也避免转义 HTML 标签的错误,Facebook 开发了 XHP 。XHP 是对 PHP 的语法拓展,它允许开发者直接在 PHP 中使用 HTML 标签,而不再使用字符串。

$content = <ul />;
foreach ($talks as $talk) {
$content->appendChild(<li>{$talk->name}</li>);
}

这样的话,所有的 HTML 标签都使用不同于 PHP 的语法,我们可以轻易的分辨哪些需要转义哪些不需要转义。

不久的后来,Facebook 的工程师又发现他们还可以创建自定义标签,而且通过组合自定义标签有助于构建大型应用。

JSX - 2013

到了 2013 年,前端工程师 Jordan Walke 向他的经理提出了一个大胆的想法:把 XHP 的拓展功能迁移到 JS 中。首要任务是需要一个拓展来让 JS 支持 XML 语法,该拓展称为 JSX。因为当时由于 Node.js 在 Facebook 已经有很多实践,所以很快就实现了 JSX。

可以猜想一下为什么要迁移到 js 中,我猜想应该是前后端分离导致的。

const content = (
<TalkList>
{ talks.map(talk => <Talk talk={talk} />)}
</TalkList>
);

React

在这个时候,就有另外一个很棘手的问题,那就是在进行更新的时候,需要去操作 DOM,传统 DOM API 细节太多,操作复杂,所以就很容易出现 Bug,而且代码难以维护。

然后就想到了 PHP 时代的更新机制,每当有数据改变时,只需要跳到一个由 PHP 全新渲染的新页面即可。

从开发者的角度来看的话,这种方式开发应用是非常简单的,因为它不需要担心变更,且界面上用户数据改变时所有内容都是同步的。

为此 React 提出了一个新的思想,即始终整体“刷新”页面

当发生前后状态变化时,React 会自动更新 UI,让我们从复杂的 UI 操作中解放出来,使我们只需关于状态以及最终 UI 长什么样。

下面看看局部刷新和整体刷新的区别。

图片来自于极客时间王沛老师的《React 进阶与实战》

局部刷新:

// 下面是伪代码
var ul = find(ul) // 先找到 ul
ul.append(`<li>${message3}</li>`) //然后再将message3插到最后

// 想想如果是不插到最后一个,而是插到中间的第n个
var ul = find(ul) // 先找到 ul
var preli = find(li(n-1)) // 再找到 n-1 的一个 li
preli.next(`<li>${message3}</li>`) // 再插入到 n-1 个的后面

整体刷新:

UI = f(messages) // 整体刷新 3 条消息,只需要调用 f 函数

// 这个是在初始渲染的时候就定义好的,更新的时候不用去管
function f(messages) {
return <ul>
{messages.map(message => <li>{ message }</li>)}
</ul>

}

这个时候,我只需要关系我的状态(数据是什么),以及 UI 长什么样(布局),不再需要关系操作细节。

这种方式虽然简单粗暴,但是很明显的缺点,就是很慢。

另外还有一个问题就是这样无法包含节点的状态。比如它会失去当前聚焦的元素和光标,以及文本选择和页面滚动位置,这些都是页面的当前状态。

Diff

为了解决上面说的问题,对于没有改变的 DOM 节点,让它保持原样不动,仅仅创建并替换变更过的 DOM 节点。这种方式实现了 DOM 节点复用(Reuse)。

至此,只要能够识别出哪些节点改变了,那么就可以实现对 DOM 的更新。于是问题就转化为如何比对两个 DOM 的差异。

说到对比差异,可能很容易想到版本控制(git)。

DOM 是树形结构,所以 diff 算法必须是针对树形结构的。目前已知的完整树形结构 diff 算法复杂度为 O(n^3) 。

完整的 Tree diff 实现算法。[2]

但是时间复杂度 O(n^3) 太高了,所以 Facebook 工程师考虑到组件的特殊情况,然后将复杂度降低到了 O(n)。

附:详细的 diff 理解:不可思议的 react diff 。[3]

Virtual DOM

前面说到,React 其实实现了对 DOM 节点的版本控制。

做过 JS 应用优化的人可能都知道,DOM 是复杂的,对它的操作(尤其是查询和创建)是非常慢非常耗费资源的。看下面的例子,仅创建一个空白的 div,其实例属性就达到 231 个。

// Chrome v63
const div = document.createElement('div');
let m = 0;
for (let k in div) {
m++;
}
console.log(m); // 231

对于 DOM 这么多属性,其实大部分属性对于做 Diff 是没有任何用处的,所以如果用更轻量级的 JS 对象来代替复杂的 DOM 节点,然后把对 DOM 的 diff 操作转移到 JS 对象,就可以避免大量对 DOM 的查询操作。这个更轻量级的 JS 对象就称为 Virtual DOM 。

那么现在的过程就是这样:

  1. 维护一个使用 JS 对象表示的 Virtual DOM,与真实 DOM 一一对应
  2. 对前后两个 Virtual DOM 做 diff ,生成 变更(Mutation)
  3. 把变更应用于真实 DOM,生成最新的真实 DOM

可以看出,因为要把变更应用到真实 DOM 上,所以还是避免不了要直接操作 DOM ,但是 React 的 diff 算法会把 DOM 改动次数降到最低。

剩下的历史就不谈了,已经引出这篇文章的重点:虚拟 DOM。详细的历史可见:React 是怎样炼成的[4],文中历史部分内容很多摘抄与此。

总结

传统前端的编程方式是命令式的,直接操纵 DOM,告诉浏览器该怎么干。这样的问题就是,大量的代码被用于操作 DOM 元素,且代码可读性差,可维护性低。

React 的出现,将命令式变成了声明式,摒弃了直接操作 DOM 的细节,只关注数据的变动,DOM 操作由框架来完成,从而大幅度提升了代码的可读性和可维护性。

在初期我们可以看到,数据的变动导致整个页面的刷新,这种效率很低,因为可能是局部的数据变化,但是要刷新整个页面,造成了不必要的开销。

所以就有了 Diff 过程,将数据变动前后的 DOM 结构先进行比较,找出两者的不同处,然后再对不同之处进行更新渲染。

但是由于整个 DOM 结构又太大,所以采用了更轻量级的对 DOM 的描述—虚拟 DOM。

不过需要注意的是,虚拟 DOM 和 Diff 算法的出现是为了解决由命令式编程转变为声明式编程、数据驱动后所带来的性能问题的。换句话说,直接操作 DOM 的性能并不会低于虚拟 DOM 和 Diff 算法,甚至还会优于。

这么说的原因是因为 Diff 算法的比较过程,比较是为了找出不同从而有的放矢的更新页面。但是比较也是要消耗性能的。而直接操作 DOM 就是有的放矢,我们知道该更新什么不该更新什么,所以不需要有比较的过程。所以直接操作 DOM 效率可能更高。

React 厉害的地方并不是说它比 DOM 快,而是说不管你数据怎么变化,我都可以以最小的代价来进行更新 DOM。方法就是我在内存里面用新的数据刷新一个虚拟 DOM 树,然后新旧 DOM 进行比较,找出差异,再更新到 DOM 树上。

框架的意义在于为你掩盖底层的 DOM 操作,让你用更声明式的方式来描述你的目的,从而让你的代码更容易维护。没有任何框架可以比纯手动的优化 DOM 操作更快,因为框架的 DOM 操作层需要应对任何上层 API 可能产生的操作,它的实现必须是普适的。

  • 网上都说操作真实 DOM 慢,但测试结果却比 React 更快,为什么 [5]

另外再提一个点,很多人会把 Diff 、数据更新、提升性能等概念绑定起来,但是你想想这个问题:React 由于只触发更新,而不能知道精确变化的数据,所以需要 diff 来找出差异然后 patch 差异队列。Vue 采用数据劫持的手段可以精准拿到变化的数据,为什么还要用虚拟 DOM?

虚拟 DOM 的作用

要想回答上面那个问题,真的不要仅仅以为虚拟 DOM 或者 React 是来解决性能问题的,好处可还有很多呢。下面我总结了一些虚拟 DOM 好作用。

  • Virtual DOM 在牺牲(牺牲很关键)部分性能的前提下,增加了可维护性,这也是很多框架的通性。
  • 实现了对 DOM 的集中化操作,在数据改变时先对虚拟 DOM 进行修改,再反映到真实的 DOM 中,用最小的代价来更新 DOM,提高效率(提升效率要想想是跟哪个阶段比提升了效率,别只记住了这一条)。
  • 打开了函数式 UI 编程的大门。
  • 可以渲染到 DOM 以外的端,使得框架跨平台,比如 ReactNative,React VR 等。
  • 可以更好的实现 SSR,同构渲染等。这条其实是跟上面一条差不多的。
  • 组件的高度抽象化。

既然虚拟 DOM 有这么多作用,那么上面的问题,Vue 采用虚拟 DOM 的原因是什么呢?

Vue 2.0 引入 vdom 的主要原因是 vdom 把渲染过程抽象化了,从而使得组件的抽象能力也得到提升,并且可以适配 DOM 以外的渲染目标。来自尤大文章:Vue 的理念问题[6]

虚拟 DOM 的缺点

  • 首次渲染大量 DOM 时,由于多了一层虚拟 DOM 的计算,会比 innerHTML 插入慢。
  • 虚拟 DOM 需要在内存中的维护一份 DOM 的副本(跟上面一条其实也差不多,上面一条是从速度上,这条是空间上)。
  • 如果虚拟 DOM 大量更改,这是合适的。但是单一的,频繁的更新的话,虚拟 DOM 将会花费更多的时间处理计算的工作。所以,如果你有一个 DOM 节点相对较少页面,用虚拟 DOM,它实际上有可能会更慢。但对于大多数单页面应用,这应该都会更快。

总结

本文在介绍虚拟 DOM 并没有像其他文章一样去解释它的实现以及相关的 Diff 算法,关于 Diff 算法可以看这篇 文中介绍了很多库的 diff 算法,可见其实 React 的 diff 算法并不算太快。

而是通过历史来得出他的价值体现,从历史怎么看大牛们是怎么一步一步的去解决问题,从历史中看为什么别人能做出这么伟大的东西,而我们不能?

每个伟大的产品都会有非常多的背景支持,都是一步一步发展而来的。

另外洗清了一个错误观念:很多人认为虚拟 DOM 最大的优势是 diff 算法,减少 JavaScript 操作真实 DOM 的带来的性能消耗。

虽然这一个虚拟 DOM 带来的一个优势,但并不是全部。虚拟 DOM 最大的优势在于抽象了原本的渲染过程,实现了跨平台的能力,而不仅仅局限于浏览器的 DOM,可以是安卓和 IOS 的原生组件,可以是近期很火热的小程序,也可以是各种 GUI。

最后希望大家多思考,跟随者浪潮站在浪潮之巅。

参考链接

  • 虚拟 DOM 为何出现以及性能问题? [7]
  • React 是怎样炼成的 [8]

参考资料

[1]

React 是怎样炼成的: https://segmentfault.com/a/1190000013365426#item-4

[2]

完整的 Tree diff 实现算法。: https://grfia.dlsi.ua.es/ml/algorithms/references/editsurvey_bille.pdf

[3]

不可思议的 react diff 。: https://zhuanlan.zhihu.com/p/20346379

[4]

React 是怎样炼成的: https://segmentfault.com/a/1190000013365426

[5]

网上都说操作真实 DOM 慢,但测试结果却比 React 更快,为什么: https://www.zhihu.com/question/31809713

[6]

Vue 的理念问题: https://zhuanlan.zhihu.com/p/23752826

[7]

虚拟DOM为何出现以及性能问题?: https://blog.csdn.net/hjc256/article/details/97135687

[8]

React 是怎样炼成的: https://segmentfault.com/a/1190000013365426

以上是关于如何理解虚拟DOM的主要内容,如果未能解决你的问题,请参考以下文章

前端基础之BOM和DOM

面试官问:如何理解虚拟 DOM ,一个回答让我直接出局!

面试官问:如何理解虚拟 DOM ,我是这样回答的

如何理解虚拟dom

47前端 | 全面理解虚拟DOM,实现虚拟DOM

React算法是个什么鬼,如何理解虚拟DOM