浅析Virtual DOM

164 阅读7分钟
目前大型项目大部分都使用React、Vue等虚拟dom框架进行开发,取代了之前的JQuery。


虚拟dom框架的哲学是 View = F(State)。

虚拟dom帮忙做了State到View的转换过程,使得开发者不再需要手动操作dom,方便项目维护。且模块抽象后,容易复用。笔者大学时曾经帮同学写交易管理页面,用JQuery一把梭,体会到了,项目越开发,后面各模块状态变得越难以维护。 

有了状态后,状态变化时,框架如何知道要更改哪部分视图呢?直接根据新的状态,生成DOM全量替换DOM Tree也吗?显而易见,该做法会导致重新渲染整个页面,性能太差了。React, Vue等框架的解决方案为通过使用Virtual DOM来表示真实DOM,并通过Diff算法计算得出需要替换的DOM子树,以最小化重渲染区域。为什么需要Virtual DOM呢?

在浏览器中,视图就是DOM Tree来表示的。 但我们知道,在浏览器中,JS与DOM是两个独立 的模块,用Chromium描述来说,它们像是两块岛屿,使用JS操作DOM时,需要从一座岛到另外一座岛,成本很高。而JS自身执行在V8的加持下,其实是非常快的。创建一万个JS对象也仅需1ms。因此,通过使用Virtual DOM来表示DOM,以最快速地计算出需要被更新的结点。


如图,左边为JS对象表示的Virtual DOM,右边为DOM Tree中的Node结点。

那么Virtual Node又是如何渲染成DOM结点,又是如何更新的呢?

一般来说,Virtual Node的接口如下,通过如下几个属性,即可渲染成对应的DOM结点。

以下是VNode转成HTMLElement简化代码


如此,界面初始化时,便可根据状态生成Virtual DOM,再生成DOM结点,插入到DOM Tree中进行绘制。

状态更新一般都是通过浏览器事件触发,事件触发后,生成Virtual DOM Tree,通过对比前后Virtual Node的属性,来决定是否复用该结点。决定是否复用的算法,就是大名鼎鼎的diff算法。很多框架常常吹嘘自己的diff算法多么快,实际上,diff算法只是减少浏览器重绘制区域的大小。实际上,通过原生的方式直接修改需要更新的DOM会更快。Virtual DOM宣称的快,仅是相对于其他Virtual DOM框架而言。

当然,目前V8已经足够快,绝大部分情况下,diff所需的时间都是处于可接受的范围内。降低一点性能,使得项目更易于维护、更容易复用组件,在大部分场景下都是划算的。

大部分Virtual DOM框架中,diff算法实现代码占据了框架了半壁江山。按照时间复杂度,可以分为两类。

一类为经典学院派,研究最小树编辑距离,即一颗树要如何更新才能最少操作变成另一颗树,时间复杂度为O(n³)。因此早期Virtual DOM框架一直没有用到成产环境中。

另一类为实践派通过大胆假设后的时间复杂度为O(n)的算法。web中,很少会有DOM结点层级发生移动的情况。因此,该算法通过两个原则,将diff算法时间复杂度降低到O(n)。

  1. 类型不同,则不复用。如DOM  tag类型不同或组件类不同
  2. 同级别结点中,如顺序变化时需要复用,则可通过key进行标识

如此,绝大部分情况下,diff速度都足够快,保证了框架性能。

而在具体实现中,又分为几类。

第一类为实现最为简单的,React diff算法。

首先,判断是组件还是DOM类型。如果是组件,判断前后是否是相同的类(component.type 是否相同)。如果是DOM结点,则判断tag是否相同。 如果相同,则认为可以复用,更新属性,否则,退出复用逻辑。


假设目前渲染的组件为Form,由于渲染组件类型不同,React不会对此进行复用。同理,tag为div后变为span,也不会进行复用。

对于同一级别的结点,如果想要提高性能,可通过key进行标识。实际上,同一级别上的几个结点,由于有了唯一的key,可看成是一串不重复字符串中的每个字符。因此,问题可转换为,一个字符串转换为另一个字符串的操作,在算法中,称为编辑距离。


可以认为,更新目标为将'1234'更新为'4321'。

而React的实现,采用了较为简单的编辑距离实现,优点是实现简单,容易理解。 缺点为可能对DOM有多次操作。


如key ’1‘,’2‘,’3‘,’4‘ 更新为’4‘,’3‘,’2‘,’1‘。会将’4‘,’3‘,’2‘,’1‘的属性分别更新到’1‘,’2‘,’3‘,’4‘中。如下图所示 。实际上,将结点位置互换对DOM的操作会更少。


第二类在第一类的基础上,加入了双端对比。优点为更好的复用结点,缺点为实现相对第一类稍微更复杂。如果两端的结点key相同,则进行互换。

以下代码为简易实现


下面为代码流程示意图。方便大家更好理解。

假设oldChildren的key分别为’1‘,’2‘,’3‘,’4‘,’5‘,’6‘。

newChildren的key分别为’3‘,’4‘,’1‘,’2‘,’5‘,’7‘。

oldStartVnode key 为’1‘,newStartVnode key为'3',oldEndVnode key为'6',newEndVnode key 为'7'。

显然 oldStartVnode !== newStartVnode !== newEndVnode。且 oldStartVnode !== newStartVnode !== oldEndVnode。

对比新旧结点,发现旧结点需要往左边移动。


同样,由于oldStartVnode !== newStartVnode !== newEndVnode。且 oldStartVnode !== newStartVnode !== oldEndVnode。

key为’4‘的结点需要往左边移动。newStartIndex继续 +1


此时, oldStartVnode.key === newStartVnode.key。于是更新结点,oldStartIndex++; newStartIndex++; 


由于 oldStartVnode.key === newStartVnode.key,继续往后对比,oldStartIndex++; newStartIndex++;



遇到undefined值的结点,跳过。 新旧结点key值都为’5‘。oldStartIndex++; newStartIndex++;



由于在旧结点列表中,无key为’7‘的结点,因此,需要创建新结点插入到key为’6‘结点前。

再往后,newStartIndex > newEndIndex,因此while循环结束。

此时,进入到后面清除多余结点和插入未遍历的结点条件分支。


key为’6‘的对应的DOM结点被移除。

至此,某个结点children的diff过程结束。实际项目中,对于整颗Virtual DOM Tree,都递归地进行diff,更新结点。

第二类具体实现可参考 snabbdom, 该框架代码实现优雅,并且vue也参考了该库。


第三类为最小编辑距离实现,从算法上保证了绝大部分case下,均能对DOM进行最少次数的操作。

此处,翻译了 ivi 中diff的介绍,原理为,先通过两端对比,再判断元素是否需要移动,最后通过「最长上升子序列」算出最少编辑次数,更新结点。


具体代码实现可以参考 ivi 以及 mithril。都是精美小巧的 Virtual DOM库,虽没有事件合成,但剩下体积小巧。五脏虽小,功能俱全。


再简单说下JSX,实际上,JSX只不过是将特定类似于写HTML的语法转换成了Virtual DOM。

<App className=myClassName />

将被JSX编译器插件转换为React.createElement(App,  { myClassName })

实际上,以上三种实现是各框架对diff时间和DOM操作次数之间的取舍。第一类认为,与其费时费力去高精度diff,还不如直接生成新结点替换。第二类认为,进行了双端对比后,DOM操作次数会少很多,同时,代码仍然较简单易维护,diff时间也相对第一类接近。第三类认为,既然DOM操作很昂贵,不如直接高精度diff,尽量做到最小化操作DOM次数。

在笔者看来,三个方案中,方案二是较为平衡的一种,保持代码简洁的同时,让大多数场景下DOM操作减少。

最后总结下,Virtual DOM框架基本流程如下

  1. 根据props生成Virtual DOM Tree。即用JS Object表示的一颗树结构
  2. 状态变化时(一般是浏览器事件,此处又分为框架是否代理事件),重新生成新的Virtual DOM Tree
  3. 根据diff算法,对比新旧结点,最小化更新浏览器DOM子树