Virtual DOM 背后的秘密(Diff 篇)

854 阅读9分钟
原文链接: zhuanlan.zhihu.com

前篇在此,回顾下前文,我们提到DOM操作贵的两方面

  • DOM对象很大很重
  • DOM操作会引发restyle/reflow/repaint

以及前端的几个时代变迁:

  • 服务器刷新,到jQuery的命令式操作,再到Backbone的声明式操作DOM,配合Template engine(跟VDOM同为渲染引擎的实现方式 )
  • Angulardirty checking(Model diff思想跟VDOMdiff有相似性)
  • Vuedependency tracking,能协同VDOM正交于框架的实现

正题开始~


re-render everything, diff changes and patch

作为FaceBook的前端框架,React打出生起就自带大厂光环。加上其JSXCSS in JS、单向数据流(Flux、Redux)等各种有趣的语法糖和设计思想。火速在前端框架中斩落头角引领潮流,与此同时也把其背后的渲染引擎VDOM带到了众人面前。

若先抛开它的各种闪光点不谈,只抓住一个记忆点或给出一句话定义,那肯定是:

UI = f(state)

这个如此简洁优美,却又不失表达力的函数(Functional)声明(Declarative),已经把React诠释的很完美,并在此之上赋予了我们无与伦比的编程体验,更好的维护性和可扩展性。

若只考虑其VDOM的价值,大概是这么两个:

  • 普适性:UI代表的可以是(V)DOM,也能是其它的Render,诸如官方自带的 Native, Sever, Test 等。这样就实现了一次学习,随处编写,也就是官方所谓的Learn Once, Write Anywhere~
PS. Java的口号是Write Once, Run Anywhere,还记得jQueryWrite Less, Do More么~
PPS. VDOM只是让UI渲染能跨平台的适配方式之一。
  • (可观的)性能:精准的innerHTML等原生API去操作DOM,注定要快过任何框架的实现。但无法次次手工书写和优化,大部分是全量更新。而VDOM则通过JavaScript层面的协调算法(reconciler),让我们能实现细粒度的精准修改。在保持维护性的基础上,还给予了我们相当不错的性能

其性能到底多可观呢,接下来聊聊diff的算法部分~

更灵活的事件系统也是VDOM比原生性能更好的点,详情可参考:react合成事件和 jQuery 废弃 live 方法矛盾吗?

How to diff, What's the difference
teropa.info/images/onchange_vdom_change.svg

其实diff的核心在于两点:

  • 比对(diff的运算过程)
  • 修改(diff的返回结果,输出生成的patch并操作)
diff的算法源码级讲解,有两篇写的很好也很接地气,想直接阅读的同学可移步:
React 源码剖析系列 - 不可思议的 react diffVirtual DOM 与 diff (Vue 实现)

不过大部分源码解读,过于注重diff的过程。本文则多聊下其中未涵盖的部分,并以patch为导向,谈谈React、Vue、React-like的差异部分。

先列举些知识背景:

  • 树的BFS(广度优先遍历)/DFS(深度优先遍历),需要O(N)的时空复杂度
  • 传统diff算法通过循环递归对节点进行依次对比效率低下,算法复杂度达到O(N^3)
PS. 后者因其追求(完全)比对和(最小)修改,而React、Vue则是放弃了完全最小,才实现从O(N^3) => O(N)

先聊聊比对,以React为例:

https://zhuanlan.zhihu.com/p/20346379
  • 分层diff:不考虑跨层级移动节点,让新旧两个VDOM树的比对无需循环递归(复杂度大幅优化,直接下降一个数量级的首要条件)
稳定的结构也有助于提升渲染性能
  • 找到diff的关键属性,发生以下情况则跳过比对,变为插入或删除操作:
  1. 组件的Type(Tagname)不一致
  2. 列表组件的Key不一致,且旧树中无新Key或反之
PS. 这里需要注意,如果旧树中能找到映射,会涉及(最小)修改算法(下文讨论)
  • 减少diff所需的判断
  1. 减少组件属性的Stateless
  2. 钩子函数跳过比对的shouldComponentUpdate/PureRenderMixin/PureComponent
  3. 在前者基础上实现JavaScript天生不具备的加强版数据结构,让比对更快更精准的Immutable.js
teropa.info/images/onchange_immutable.svg

展开一下,理解Vue@2引入VDOM的目的。除了用它做渲染引擎的代码量少,性能更高(比起Vue@1),可以跨平台之外。还有个原因在此可以谈谈。

前文说过,依赖收集不是没有成本(内存占用更高),且粒度越细越明显,而VDOM呢?

  • diff算法是CPU密集型操作。除所用对象本身内存占用小外,其比对完结后,只需维护VDOM的最终形态,甚至无需维护(创建后跟真实DOM比对,但速度会下降),方便内存回收
移动设备的内存跟CPU同样重要。浪费了大量的CPU,程序会变慢体验变差,但至少还能用。而你浪费了大量内存,操作系统将会杀掉浏览器进程,就没任何体验可谈了
  • 框架最终的本质还是为了找出如何修改,为什么不哪里触发了getter/setter diff哪里,只比对相应的子组件树呢?
树是生活中最常见的分形结构

所以,VDOM结合Vue依赖收集,类似React自带shouldComponentUpdate

  • 前者直接定位所需diff的组件
  • 后者快速跳过无需diff的组件
细节可移步:Announcing Vue.js 2.0

不扯远了,再来讲讲(最小)修改,也是不同框架的差异所在~


本来,传统算法的循环递归只需O(N^2)复杂度,就能完成对比所需的遍历操作,但因涉及寻找修改操作的最优解,才又上升回O(N^3)的数量级。

要理解这点,也需要明确些不同的概念,咱先回到上文的:

  • 分层diff,不用考虑跨层级移动节点
  • 找到diff的关键属性,列表组件的Key不一致,且新旧树中无映射,变为插入或删除操作

所以问题来了,同一层级的节点,要如何更新呢?

  • 修改节点的内容及属性,是数据变化后复用DOM的初级方式
  • 同层级的移动节点,则是更高级的方式
  • 但要如何找到较优或最优的移动策略,达到最终状态?

要解答这个问题,不得不再引入两个新的概念,字符串算法和React-like框架们


String Diff Algorithm

你或许知道,VDOM在社区实现数不胜数,除了React实现外,比较有代表性的还有:

细节可移步:去哪儿网迷你React的研发心得

所以,要如何讲清楚它们的差异,总结加深自己的理解,却又不过度陷入代码的算法细节呢?

咱先针对修改问题做简化,类似下图,把key变成单个字母:

react 的未修复问题,将最后一个节点移动到列表首部的性能堪忧
[a,b,c,d]是老节点列表的key数组
[d,a,b,c]是新节点列表的key数组
React输出的patch结果为move a,b,c

换成两个更具体(优秀)的例子,场景一:

  • 假设输入是[z,h,a,n,g,c,h,e,n]
  • 数据变更为[n,e,h,c,z,h,a,n,g]
PS. 经典算法题之一,反转字符串的变种,满足双端比对的触发条件

场景二,相同的输入:

  • 数据变更为 [c,h,e,n,z,h,a,n,g]
PPS. 经典算法题之二,旋转字符串,理想情况中需要移动的节点已加粗

伪代码大概是这样:

const first = "zhang";
const last = "cHeN"; // key 要保持唯一
const name = [first, last];

// 使用 jsx 书写,方便 react、vue 通用
<h1>
  {name.join("").split("").map((it, i) => (
    <span key={getKeyFn(it, i)}>{it.toLowerCase()}</span>
  ))}
</h1>

lettReverse = () => { name = [last.split("").reverse().join(""), first]}
wordReverse = () => { name = [first, last].reverse()}

为了方便观测VDOM框架的patch结果,我们需要加入三种不同的getKey函数

// 三种设置 key 的方式
getKeyFn1 = (it, i) => i // 用数组下标做 key,其实跟不用一样
getKeyFn2 = () => Math.random() // 随机 key,乱用等于每次重新渲染
getKeyFn3 = (it, i) => it // 稳定、唯一的 key,推荐使用

再配合一些样式手动染色,这样就能看明白框架在二次渲染的过程中,到底是插入删除或称之为替换,还是更新又或者是移动了节点。

对了,还需手动插入一些分隔符,才能观测移动过的节点个数,精确定义最终的patch。这里选择,作为分隔符,以便看起来更像数组原本的结构。

最后,还需抽象出各框架的依赖部分,实现切换版本只需替换几行注释。最终版代码仓库在此,请恕本人文字功力有限,结论含在下图:

补充说明一下~

  • 绿色表示更新的节点,黑色表示复用的节点
  • 红色表示新插入的节点,逗号之间表示老节点被删除或被移动
  • 黄/蓝色表示移动的节点,对应理论最优不动/移动解
  • 前两行在生命周期的二次渲染中上色,后三行则在首次渲染后手工上色
PS. React(-like) 的在线版本在此,暂未提供 3 个同时在线的版本,想调试或体验还请自行clone
PPS. 字符串比对算法,是计算机科学的经典问题,除了Git外,拼写纠错、洗稿检测、基因测序,甚至自然语言处理等领域不要太常用

结语

VDOM作为当下主流前端框架的渲染引擎,是让前端不再局限于浏览器的完美抽象。同时也是通往计算机系统底层的密码本。其背后还有诸多技术细节,比如:

  • 数据结构:组件树DOM树、Immutable
  • 算法:diff、patch的运行细节等
  • 编译器 :从JSXTemplate中生成萃取、静态分析等
  • 调度器:令人期待的Fiber

本次限于篇幅和水平暂时略过,读者若有兴趣可以自行深究~

PS.react@16diff过程变化很大,但算法层面的理念和最终的patch结果还是一致的

参考资料

React 设计中的闪光点

React 源码剖析系列 - 不可思议的 react diff

diff 算法原理概述

如果本文能够为你解决些许关于VDOM diff算法的疑惑,请点个赞吧!