所谓的 Virtual DOM 到底是什么?

3,241 阅读7分钟

首发地址在知乎,由于个人精力有限,可能无法在此进行快速回应。

背景

最近写了一个 ng-vdom 的 POC,用于将 Virtual DOM 对象渲染为原生 Angular 内容。

React Tutorial 中的 Clock 组件

当然,重要的事情要说三遍:

  • 这是一个 POC!
  • 这是一个 POC!
  • 这是一个 POC!

希望大家不要对 POC 有太多的误解,换句话说,不要有太大的期望。

主要目标

通过最近(以及不那么近)的诸多问题,发现大部分童鞋都对 Angular 有强烈的误解,但认真分析之后,发现问题根本就不在于「对 Angular 的误解」,而是对 Angular 之外的内容误解太大,从而牵连到了作为假想对比目标的 Angular。

而其中最为明显的误解内容,就是 Virtual DOM。

所以,这里专门写一篇文章来解释什么是 Virtual DOM,以及 Virtual DOM 和 Angular 的关系。

术语约定

本文中,如无特殊说明,将约定如下概念:

  • View(视图)仅指代用户真实可见的渲染结果及平台提供的直接表示形式,例如 Web 浏览器中(浏览器所见的)HTML 文本、基于 JavaScript API 的 DOM 树;
  • Template(模版)均被编译为视图操作指令或含有等价信息的数据结构,即提供「操作过程」而非「操作的目标结果」;
  • ViewModel(视图模型)为经由视图驱动的(而非用户定义的)用于对于 Template 中「操作过程」的填充数据,不再具备子层抽象;
  • View Layer(视图层)为包含 View 以及 + ViewModel(如果有)的视图实现必须要素的抽象层次;
  • VVM:MVVM 去掉 Model 的结果,不关心 Model 是否存在;
  • 抽象层次均相对于视图而言,与数据消费层次相反,抽象层次越高意味着离 View 的层次差距越大;

什么是 Virtual DOM?

在 VVM 的组织方式下,View 作为 Template 和 ViewModel 的结合产物。

不过,VVM 本身并不规定 Template 和 ViewModel 的信息量配比,同样的逻辑既可以基于 Template 实现也可以基于 ViewModel 实现,例如:

<!-- flag = true -->
<p>{{ flag ? 'Hello' : 'Hi' }} World!</p>

<!-- greeting = flag ? 'Hello' : 'Hi' -->
<p>{{ greeting }} World!</p>

对应于相同的逻辑。

一个更为成熟的例子是 Angular 的 Template-driven FormsReactive Forms,同样的信息既可以基于模版表达,也能基于 ViewModel 表达:

<!-- name = 'foo' -->
<input [(ngModel)]="name" minlength="3" maxlength="10" required>

<!--
name = formBuilder.control('foo', [
         Validators.minLength(3),
         Validators.maxLength(10),
         Validators.required,
       ])
-->
<input [formControl]="name">

而最为极端的情况,就是将模版中的静态内容强行抽象为 ViewModel,例如:

<p class="foo" style="width: 30px;" id="bar">baz</p>

可以被强制抽象为:

<!--
clazz   = 'foo'
style   = 'width: 30px;'
id      = 'bar'
content = 'baz'
-->
<p [attr.class]="clazz" [attr.style]="style" [attr.id]="id">{{ content }}</p>

或者更进一步抽象出高级指令:

<!--
attrs = {
  clazz: 'foo',
  style: 'width: 30px;',
  id   : 'bar',
}
content = 'baz'
-->
<p [ngAttrs]="attrs">{{ content }}</p>

由于模版中的静态内容本身是「零抽象」(零存储零计算),因此可以认为将静态模版内容提取为 ViewModel 是一个提升抽象层次的过程。

而这个过程还能够继续发展到极限,让模版中的残留信息量将为0,即所有信息仅存在与 ViewModel 当中:

<!--
def = {
  type: 'p',
  props: {
    className  : 'foo',
    style      : { width: 30 },
    id         : 'bar',
    textContent: 'baz',
  },
}
-->
<dynamic [definition]="def"></dynamic>

简单地说,Virtual DOM 是 ViewModel 的极端动态化结果,比 VVM 体系的抽象层次更高。不过由于抽象层次过高,以至于不再存在未被抽象的部分,反而在结果上提升了一致性。

(当然并不是说这是唯一定义。)

那么形如 { type, props } 的结构到底是 View 还是 ViewModel 呢?

都是,也都不是。View、ViewModel 与 Model 是一个用于内容组织与理解的主观划分,本身就和实现搭不上边。

换句话说,同样的数据结构在 React 中渲染就是 View,在 NG-VDOM 中渲染就是 ViewModel。即便后续处理方式完全相同。

为什么 Angular 性能高?

Angular 性能优秀的原因十分「简单」,因为答案就是「简单」。

Angular 的基本设计思路并非「引入一大堆额外成本再想方设法优化」,而是「避免引入不必要的额外成本」。这个理念与 Svelte 基本相同,如果要问 Svelte 为什么快,当然就是因为 VanillaJS 本来就有这么快。

当问出类似于「Angular 做了什么样的优化才这么快?」的问题时,基本就已经跑偏了。

为了尽可能保持零额外成本,Angular 始终采用精确的控制流。例如对于一个 NgIf 动态内容:

<p *ngIf="flag">baz</p>

对应的代码逻辑类似于:

let view = null;

if (!flag && view) {
  view.destroy()
  view = null
}

if (flag && !view) {
  view = createEmbeddedView(template)
}

if (view) {
  checkBindings(view)
}

因此,即便存在于多项由 NgIf 动态确定存在与否的内容,每个 EmbeddedView 仍然受到对应表达式的精确控制。

而在基于 Virtual DOM 的实现中,当有 k 个独立控制的动态内容构成同级元素,也会被认为一个长度为 0~k 的动态数组,需要整体 Diff 过后才能确定变化情况。

而对于静态内容与动态内容混合的情况下,也会在静态内容上浪费不必要的计算,例如:

<!-- list = [0, 1, 2, ..., n - 1] -->
<ul>
  <li>Static 0</li>
  <li>Static 1</li>
  <li>...</li>
  <li>Static m - 1</li>
  <li *ngFor="let i of list">Dynamic {{ i }}</li>
</ul>

由于存在精确控制流,Angular 中需要 Diff 的内容仅为长度为 n 的列表,而基于 Virtual DOM 的实现无法屏蔽静态内容的影响,需要 Diff 长度为 m + n 的列表。

因此,Angular 的高性能主体上只是避免浪费的结果,而非基于什么高效算法的优化。

如何基于 Angular 消费 Virtual DOM 数据?

由于 Virtual DOM 是更高层次的抽象,所以实现上是非常自然地「通过底层 API 实现高层 API」的过程。不过,在 Angular 中这项成本非常低廉,因为已经存在了:

等基础设施。

需要注意的一些场景有:

  • Node key 到 trackByFunction 的映射;
  • 合成事件(的忽略);

正常的 VVM 使用场景下,当我们指定一个循环时,使用的数据结构必然都是相同类型;而在 Virtual DOM 节点的 Diff 中,不同节点应当永远视作不同项,因此不应该直接使用 key 或者 index,而是 keyOfType 或 indexOfType。

此外,由于 Angular 中本身已经存在 Renderer 的抽象,所有事件的预处理和扩展都能通过 Renderer 统一实现,因此再单独抽象出一层合成事件来是毫无意义的。

如何基于 Angular 产出 Virtual DOM 数据?

由于层次关系,这是一个非常无聊的方向(也暂时没有做),也就是在用户已经直接调用底层 API 的情况下,再在底层 API 中调用高层 API。这个过程中唯一发生的变化就是「主动丢弃有价值信息」。

由于存在 Renderer 抽象,产出 Virtual DOM 本身没有任何问题,不过我们知道,Angular 本身会对循环内容进行 Diff,而产出 Virtual DOM 之后,仍然会再次对循环内容进行 Diff,这样会造成不必要的浪费。

为此,需要实现一个最不负责任的 IterableDiffer——对于任何变化,都视作移除了所有旧内容并添加了所有新内容,然后把 trackByFunction 的返回值作为 Node 的 key 使用(需要处理为字符串)。

总结

  • 不浪费就是很好的优化;
  • Virtual DOM 作为牺牲性能简化操作的取舍,从来都不「快」,只是「慢得不多」; Angular 不用 Virtual DOM 是提升性能;
  • 上述项目只是一个 POC!!!