虚拟DOM和diff算法
虚拟DOM
背景:以前使用jq等命令式的完成一些DOM操作,伴随着前端工程化的不断发展,涌现了react、vue等MVVM框架,不用再关心具体的DOM操作,而是把重点放在了基于数据状态的操作,一旦数据更改,相应的DOM元素也会跟着变化,这种声明式的开发方式极大地提高了开发体验,更好地帮助我们实现组件复用、逻辑解耦等
虚拟DOM
本质上是JS对象,是真实DOM树的抽象
由于单次DOM API调用性能就不够好,频繁调用就会迅速积累损耗,但是我们又不可能不去操作DOM,并且很多DOM API的读写都涉及回流重绘,会更加地消耗性能,因此解决问题的本质是要减少不必要的DOM API调用,虚拟DOM将DOM的比对操作放在JS层,减少不必要的DOM API的调用,进而减少回流重绘,提高性能
虚拟DOM不一定比真实DOM更快,如果有大量数据改变时,虚拟DOM还存在diff算法的比对过程,上述性能优势仅仅适用于大量数据的渲染并且改变的数据只是其中一小部分的情况
虚拟DOM更加优秀的地方在于:
- 它打开了函数式的UI编程的大门,
UI=f(data)
这种方式构建UI,开发者不需要再去考虑具体的DOM操作,减少不必要的DOM API调用,可以以更加声明式的方式书写代码 - 虚拟DOM是以js对象为基础而不依赖具体的平台环境,支持跨平台开发,比如React Native、node等
react是如何通过一个js对象将虚拟DOM和真实DOM对应起来的呢?
-
虚拟DOM本质上是通过React.createElement函数创建的
-
craeteElement接收三个参数:
type
、config
、children
-
type:虚拟DOM的类型,可以是DOM元素类型,也可以是React组件类型
-
config:传入的元素上的属性组成的对象
-
children:是一个数组,代表该元素的子元素
-
最终会生成一个对象:
-
含有一个
$$typeof
属性值为REACT_ELEMENT_TYPE
,代表这是个React元素 -
key和ref从config中被单独抽取出来,放在ReactElement下
-
props包含了两部分:除去了key和ref的config、children数组,数组成员也是通过React.createElement生成的对象
-
_owner:就是Fiber
-
通过以上这些属性,react就可以用js对象把dom树上的结构信息、属性信息轻易表达出来了
-
直接通过React.createElement()创建虚拟DOM过于繁琐,JSX创建DOM的实现方式是React.createElement()的语法糖
Diff算法
设计动机
调用render()时会创建一棵React元素组成的树,在下一次state或props更新时,相同的render()会返回一棵不同的树,react需要找出两棵树的差异部分并且渲染到页面上
为了得到将一棵树转换成另一棵树的最少操作次数,最开始最优的算法也有O(n^3)的复杂度,于是react基于两个假设:
- 两个不同类型的元素会产生不同的树
- 开发者可以通过设置key属性,来判断哪些子元素在不同的渲染下可以保持不变
提出了O(n)的diff算法
为了降低复杂度,diff算法会预设三个限制:
- 只对同级元素进行diff,如果有一个DOM在其前后两次更新中跨越了层级,react不会尝试复用它
- 两个不同类型的元素会产生不同的树,如果div变成p,react会销毁div创建p
- 可以通过key值来暗示哪些子元素在不同的渲染下能保持稳定
Diff算法
diff算法的本质是对比render()返回的jsx对象newChild
和current Fiber
,生成workInProgress Fiber
从diff算法的入口函数reconcileChildFibers
出发,该函数会根据newChild
(jsx对象)类型调用不同的处理函数
根据同级节点数量将diff分为两类:
- 当
newChild
为object
、number
、string
,代表同级只有一个节点 - 当
newChild
为Array
,代表同级有多个节点
单节点diff
对于单个节点,比如object,会进入reconcileSingleElement
:
- react先判断key是否相同,key相同则判断type是否相同,都相同时DOM节点才可复用
- 当
fiber!==null
(存在对应的DOM节点) 且key相同type不同时执行deleteRemainingChildren
将fiber及其兄弟节点都标记删除 - 当
fiber!==null
且key不相同时仅将该fiber节点标记删除
- 当

例如:当前页面3个li都要删除,然后插入p
// 当前页面显示的
ul > li * 3
// 这次需要更新的
ul > p
在reconcileSingleElement
中遍历之前的3个li,寻找本次更新的p是否可以复用之前的li:
- 当key相同时type不同,直接将3个li都标记删除,key相同的都不能复用,则其它都没有机会被复用了
- 当key不同时,代表遍历到的fiber节点不能被p复用,其兄弟节点还有可能被复用,于是只将该fiber节点标记删除
因此react同一层一定要保持key值的唯一性
多节点diff
相较于删除和新增,更新组件发生的频率更高,所以diff会优先判断当前节点是否属于更新
Diff算法
的整体逻辑会经历两轮遍历:
- 第一轮遍历:处理
更新
的节点。 - 第二轮遍历:处理剩下的不属于
更新
的节点
同级的Fiber节点是由sibling
指针链接形成的单链表,不支持双指针遍历,因此需要两轮遍历
第一轮遍历
let i = 0
,遍历newChildren
,将newChildren[i]
与oldFiber
比较,判断DOM节点
是否可复用- 如果可复用,
i++
,继续比较newChildren[i]
与oldFiber.sibling
,可以复用则继续遍历 - 如果不可复用,分两种情况:
key
不同导致不可复用,立即跳出整个遍历,第一轮遍历结束。key
相同type
不同导致不可复用,会将oldFiber
标记为DELETION
,并继续遍历
- 如果
newChildren
遍历完(即i === newChildren.length - 1
)或者oldFiber
遍历完(即oldFiber.sibling === null
),跳出遍历,第一轮遍历结束
从这里我们可以看出保持同一层次下key值的稳定性是有多重要
第二轮遍历
第一轮遍历完我们有以下四种情况:
newChildren
和oldFiber
遍历完:最理想的情况,此时diff算法结束newChildren
没变遍历完,oldFiber
遍历完:已有的DOM节点都复用了,还有新加入的结点,此时只需要遍历剩下的newChildren
为生成的workInProgress fiber
依次标记为Placement
newChildren
遍历完,oldFiber
没遍历完:本次更新比之前的节点数量少,所以需要遍历剩下的oldFiber
,依次标记为Deletion
newChildren
与oldFiber
都没遍历完,说明有节点在本次更新中改变了位置
处理移动的节点:
- 由于节点改变了位置,所以不能再用索引对比前后的节点,此时就需要用到key
- 为了快速找到key对应的oldFiber,我们将所有未处理的oldFiber存入key为key,value为oldFiber的Map中
- 然后遍历剩余的newChildren,通过
newChildren[i].key
就能从map中取出相同key对应oldFiber
标记节点是否移动:
- 要寻找移动的节点,就要先找一个参照物
- 以最后一个可复用的节点在oldFiber中的位置索引
lastPlacedIndex
为参照 - 由于本次更新中节点是按
newChildren
的顺序排列。在遍历newChildren
过程中,每个遍历到的可复用节点
一定是当前遍历到的所有可复用节点
中最靠右的那个,即一定在lastPlacedIndex
对应的可复用的节点
在本次更新中位置的后 - 那么我们只需要比较
遍历到的可复用节点
在上次更新时是否也在lastPlacedIndex
对应的oldFiber
后面,就能知道两次更新中这两个节点的相对位置改变没有 - 我们用变量
oldIndex
表示遍历到的可复用节点
在oldFiber
中的位置索引。如果oldIndex < lastPlacedIndex
,代表本次更新该节点需要向右移动 lastPlacedIndex
初始为0
,每遍历一个可复用的节点,如果oldIndex >= lastPlacedIndex
,则lastPlacedIndex = oldIndex
例如:
// 之前
abcd
// 之后
acdb
===第一轮遍历开始===
a(之后)vs a(之前)
key不变,可复用
此时 a 对应的oldFiber(之前的a)在之前的数组(abcd)中索引为0
所以 lastPlacedIndex = 0;
继续第一轮遍历...
c(之后)vs b(之前)
key改变,不能复用,跳出第一轮遍历
此时 lastPlacedIndex === 0;
===第一轮遍历结束===
===第二轮遍历开始===
newChildren === cdb,没用完,不需要执行删除旧节点
oldFiber === bcd,没用完,不需要执行插入新节点
将剩余oldFiber(bcd)保存为map
// 当前oldFiber:bcd
// 当前newChildren:cdb
继续遍历剩余newChildren
key === c 在 oldFiber中存在
const oldIndex = c(之前).index;
此时 oldIndex === 2; // 之前节点为 abcd,所以c.index === 2
比较 oldIndex 与 lastPlacedIndex;
如果 oldIndex >= lastPlacedIndex 代表该可复用节点不需要移动
并将 lastPlacedIndex = oldIndex;
如果 oldIndex < lastplacedIndex 该可复用节点之前插入的位置索引小于这次更新需要插入的位置索引,代表该节点需要向右移动
在例子中,oldIndex 2 > lastPlacedIndex 0,
则 lastPlacedIndex = 2;
c节点位置不变
继续遍历剩余newChildren
// 当前oldFiber:bd
// 当前newChildren:db
key === d 在 oldFiber中存在
const oldIndex = d(之前).index;
oldIndex 3 > lastPlacedIndex 2 // 之前节点为 abcd,所以d.index === 3
则 lastPlacedIndex = 3;
d节点位置不变
继续遍历剩余newChildren
// 当前oldFiber:b
// 当前newChildren:b
key === b 在 oldFiber中存在
const oldIndex = b(之前).index;
oldIndex 1 < lastPlacedIndex 3 // 之前节点为 abcd,所以b.index === 1
则 b节点需要向右移动
===第二轮遍历结束===
最终acd 3个节点都没有移动,b节点被标记为移动
react中key值的作用
开发者可以通过key来暗示哪些子元素在不同的渲染下能够保持稳定,提高性能
// 更新前
<div>
<p key="a">A</p>
<h3 key="b">B</h3>
</div>
// 更新后
<div>
<h3 key="b">B</h3>
<p key="a">A</p>
</div>
如果没有key,react会认为div的第一个子节点由p变为h3,第二个子节点由h3变为p,会销毁并新建节点
但是如果用key指明节点前后对应关系后,react就会判断h3和p在更新后还可以复用,只是需要交换下顺序
一般不用索引作为key,因为在多节点diff中可能存在节点移动的情况
如何理解fiber
Fiber的起源
在React15及以前,Reconciler采用递归的方式创建虚拟DOM,递归过程是不能中断的,如果组件树的层级很深,递归会占用线程很多时间,造成卡顿
为了解决这个问题,React16将递归的无法中断的更新重构为异步的可中断更新,由于曾经用于递归的虚拟DOM数据结构已无法满足需求,于是,全新的Fiber架构应运而生
Fiber的含义
Fiber包含三层含义:
- 作为架构来说,之前React15的Reconciler采用递归的方式执行,数据保存在递归调用栈中,所以被称为
stack Reconciler
,React16的reconciler基于Fiber节点实现,被称为Fiber Reconciler
,Fiber节点构成的Fiber树就对应DOM树 - 作为静态的数据结构来说,每个Fiber对应一个ReactElement,保存了该组件的类型(函数组件/class组件…)、对应的DOM节点等信息
- 作为动态的工作单元来说,每个Fiber节点保存了本次更新中该组件改变的状态、要执行的工作(删除、插入、更新)
React的双缓存机制
双缓存:在内存中构建并直接替换的技术
在React中最多会同时存在两棵Fiber树,当前屏幕显示内容对应的Fiber树为current Fiber树
,正在内存中构建的Fiber树称为workInProgress Fiber树
current Fiber树
中的Fiber节点被称为current fiber
,workInProgress Fiber树
中的Fiber节点被称为workInProgress fiber
,它们通过alternate属性连接
currentFiber.alternate === workInProgressFiber;
workInProgressFiber.alternate === currentFiber;
React
应用的根节点通过使current
指针在不同Fiber树
的rootFiber
间切换来完成current Fiber
树指向的切换
即当workInProgress Fiber树
构建完成交给Renderer
渲染在页面上后,应用根节点的current
指针指向workInProgress Fiber树
,此时workInProgress Fiber树
就变为current Fiber树
每次状态更新都会产生新的workInProgress Fiber树
,通过current
与workInProgress
的替换,完成DOM
更新