本文已参与「新人创作礼」活动,一起开启掘金创作之路
虚拟DOM
是什么
是一个能代表DOM的 JavaScript 对象,包含了 tag、props、children 三个属性。
<div id="app">
<p class="text">hello world!!!</p>
</div>
将上述节点转换成虚拟dom,如下:
{
tag: 'div',
props: {
id: 'app'
},
chidren: [
{
tag: 'p',
props: {
className: 'text'
},
chidren: [
'hello world!!!'
]
}
]
}
优点
- 减少 DOM 操作 虚拟 DOM 可以将多次操作合并为一次操作,比如你添加 1000 个节点,却是一个接一个操作的(减少频率) 虚拟 DOM 借助 DOM diff 可以把多余的操作省掉,比如你添加 1000 个节点,其实只有 10 个是新增的(减少范围)
比较 innerHTML 和 Virtual DOM 的重绘过程如下: innerHTML: render html string + 重新创建所有 DOM 元素 Virtual DOM: render Virtual DOM + diff + 必要的 DOM 更新
- 跨平台 虚拟 DOM 不仅可以变成 DOM,还可以变成小程序、iOS 应用、安卓应用,因为虚拟 DOM 本质上只是一个 JS 对象
缺点
- 需要额外的创建函数,如 createElement 或 h,但可以通过 JSX 来简化成 XML 写法
DOM DIFF
是什么
就是一个函数,我们称之为 patch :patches = patch(oldVNode, newVNode)
浏览器渲染引擎的工作流程:
创建DOM树——创建StyleRules——创建Render树——布局Layout——绘制Painting
实现
通过循环递归对节点进行依次对比。算法复杂度达到 O(n^3),其中 n 是树中节点的总数 具体实现: 比对新老 VDOM 的变化,然后将变化的部分更新到视图上。对应到代码上,就是一个 diff 函数,返回一个 patches (补丁)。
- 先进行Tree diff,看哪些节点(含Component和Element)需要更新。
- 节点 diff过程:——Component节点在自己的属性diff完了,需要深入组件进行Tree diff
旧节点不存在,插入新节点;新节点不存在,删除旧节点; 新旧节点如果都是 VNode,且新旧节点 tag 相同:对比新旧节点的属性,对比新旧节点的子节点差异,通过 key 值进行重排序,key 值相同节点继续向下遍历; 新旧节点如果都是VText,判断两者文本是否发生变化; 其他情况直接用新节点替代旧节点
缺点
- 同级节点对比存在 bug,会出现识别错误的问题
React的DIFF优化:
将复杂度降至O(n) diff 策略前提: 1、Web UI 中 DOM 节点跨层级的移动操作特别少,可以忽略不计。 2、拥有相同类的两个组件将会生成相似的树形结构,拥有不同类的两个组件将会生成不同的树形结构。 3、对于同一层级的一组子节点,它们可以通过唯一 id 进行区分。
通过分层求异的策略,对 tree diff 进行算法优化。对树进行分层比较,两棵树只会对同一层次的节点进行比较。当发现节点已经不存在,则该节点及其子节点会被完全删除掉,不会用于进一步的比较。(若发生跨层级操作,则会发生的操作是删除原有的,创建新的) 通过相同类生成相似树形结构,不同类生成不同树形结构的策略,对 component diff 进行算法优化。如果是同一类型的组件,按照原策略继续比较 virtual DOM tree。如果不是,则将该组件判断为 dirty component,从而替换整个组件下的所有子节点。 对于同一类型的组件,有可能其 Virtual DOM 没有任何变化,如果能够确切的知道这点那可以节省大量的 diff 运算时间,因此 React 允许用户通过 shouldComponentUpdate() 来判断该组件是否需要进行 diff。 通过设置唯一 key的策略,对 element diff 进行算法优化。当节点处于同一层级时,React diff 提供了三种节点操作,分别为:INSERT_MARKUP(插入)、MOVE_EXISTING(移动)和 REMOVE_NODE(删除)。 若element只是位置改变,可添加唯一key进行区分。
如此高效的 diff 到底是如何运作的呢? 首先对新集合的节点进行循环遍历,for (name in nextChildren),通过唯一 key 可以判断新老集合中是否存在相同的节点,if (prevChild === nextChild),如果存在相同节点,则进行移动操作,但在移动前需要将当前节点在老集合中的位置与 lastIndex 进行比较,if (child._mountIndex < lastIndex),则进行节点移动操作,否则不执行该操作。 这是一种顺序优化手段,lastIndex 一直在更新,表示访问过的节点在老集合中最右的位置(即最大的位置),如果新集合中当前访问的节点比 lastIndex 大,说明当前访问节点在老集合中就比上一个节点位置靠后,则该节点不会影响其他节点的位置,因此不用添加到差异队列中,即不执行移动操作,只有当访问的节点比 lastIndex 小时,才需要进行移动操作。
虚拟DOM VS MVVM
相比起 React,其他 MVVM 系框架比如 Angular, Knockout 以及 Vue、Avalon 采用的都是数据绑定:通过 Directive/Binding 对象,观察数据变化并保留对实际 DOM 元素的引用,当有数据变化时进行对应的操作。MVVM 的变化检查是数据层面的,而 React 的检查是 DOM 结构层面的。MVVM 的性能也根据变动检测的实现原理有所不同:Angular 的脏检查使得任何变动都有固定的 O(watcher count) 的代价;Knockout/Vue/Avalon 都采用了依赖收集,在 js 和 DOM 层面都是 O(change): 脏检查:scope digest + 必要 DOM 更新 依赖收集:重新收集依赖 + 必要 DOM 更新
MVVM 渲染列表的时候,由于每一行都有自己的数据作用域,所以通常都是每一行有一个对应的 ViewModel 实例,或者是一个稍微轻量一些的利用原型继承的 "scope" 对象,但也有一定的代价。所以,MVVM 列表渲染的初始化几乎一定比 React 慢,因为创建 ViewModel / scope 实例比起 Virtual DOM 来说要昂贵很多。这里所有 MVVM 实现的一个共同问题就是在列表渲染的数据源变动时,尤其是当数据是全新的对象时,如何有效地复用已经创建的 ViewModel 实例和 DOM 元素。假如没有任何复用方面的优化,由于数据是 “ 全新” 的,MVVM 实际上需要销毁之前的所有实例,重新创建所有实例,最后再进行一次渲染!这就是为什么题目里链接的 angular/knockout 实现都相对比较慢。相比之下,React 的变动检查由于是 DOM 结构层面的,即使是全新的数据,只要最后渲染结果没变,那么就不需要做无用功。
Angular 和 Vue 都提供了列表重绘的优化机制,也就是 “ 提示” 框架如何有效地复用实例和 DOM 元素。比如数据库里的同一个对象,在两次前端 API 调用里面会成为不同的对象,但是它们依然有一样的 uid。这时候你就可以提示 track by uid 来让 Angular 知道,这两个对象其实是同一份数据。那么原来这份数据对应的实例和 DOM 元素都可以复用,只需要更新变动了的部分。或者,你也可以直接 track by index 的话,后续重绘是不会比 React 慢多少的。甚至在 dbmonster 测试中,Angular 和 Vue 用了 track by $index 以后都比 React 快: dbmon (注意 Angular 默认版本无优化,优化过的在下面)
在比较性能的时候,要分清楚初始渲染、小量数据更新、大量数据更新这些不同的场合。Virtual DOM、脏检查 MVVM、数据收集 MVVM 在不同场合各有不同的表现和不同的优化需求。Virtual DOM 为了提升小量数据更新时的性能,也需要针对性的优化,比如 shouldComponentUpdate 或是 immutable data。
初始渲染:Virtual DOM > 脏检查 >= 依赖收集 小量数据更新:依赖收集 >> Virtual DOM + 优化 > 脏检查(无法优化)> Virtual DOM 无优化 大量数据更新:脏检查 + 优化 >= 依赖收集 + 优化 > Virtual DOM(无法/无需优化)>> MVVM 无优化
参考:React虚拟DOM浅析