从源码的角度理解 React Diff
在react中,更新UI
必须生成新的JSX
对象(称之为visual DOM
),而新的JSX
对象和老的visual DOM
之间对比以最小的操作次数来获取差异节点
(也称之为副作用),从而高效的更新UI
。
用现在最优的算法解决也需要时间复杂度为O(n^3)
来解决,这对于web页面这么多的节点而言,采用这种算法代价太高了,可以说是灾难。
不过,react在两个假设的基础上提出了一套时间复杂度为O(n)
的算法,而这种算法在操作DOM的场景下都成立。
- 不同类型的元素会产生不同的树,比如之前节点类型是
p
,之后又变成了div
,那么这两个节点是不同的两棵树。 - 节点的属性key值可以限制节点能否复用,如果前后前两个节点的
key
值不同,则两个节点也是不同的两个树。
最重要的一点,执行这两个限制条件的前提是必须是同级对比,如果节点跨层级了,那么也不会去复用,而是去新建一个节点。这样才能保证该算法的时间复杂度为O(n)
,n
为节点的个数。
react中,有两种类型的diff
算法,单节点diff和多节点diff。
从源码看,
diff
算法是在render阶段
的beginWork中。
beginWork
主要有两个过程(mount
和update
):
update
(更新)阶段主要是diff算法
的过程。mount
(挂载)阶段主要是创建Fiber树
的过程。
下面是diff算法
的入口:
// react/packages/react-reconciler/src/ReactChildFiber.old.jsreconcileChildFibers(returnFiber: Fiber,currentFirstChild: Fiber | null,newChild: any,lanes: Lanes,): Fiber | null {const isObject = typeof newChild === 'object' && newChild !== null;if (isObject) {switch (newChild.$$typeof) {case REACT_ELEMENT_TYPE:return placeSingleChild(// 单节点 diffreconcileSingleElement(returnFiber,currentFirstChild,newChild,lanes,),);}}if (typeof newChild === 'string' || typeof newChild === 'number') {// reconcileSingleTextNode}if (isArray(newChild)) {// reconcileChildrenArray 多节点diff}//... 其他处理// 没有找到上述类型,则删除节点return deleteRemainingChildren(returnFiber, currentFirstChild);}return reconcileChildFibers;}
单节点Diff
单节点Diff源码
这里
单节点更新的类型:
- 单节点更新之后为单节点
- 多节点更新之后为单节点
首先看一下入口函数:
// react/packages/react-reconciler/src/ReactChildFiber.old.js/*** returnFiber 指向当前构建的workInProgress Fiber节点 的父级节点* currentFirstChild 当前页面展示的 current Fiber,也是需要更新的节点* element 当前需要更新的最新节点 也就是JSX对象*/function reconcileSingleElement(returnFiber: Fiber,currentFirstChild: Fiber | null,element: ReactElement,lanes: Lanes,): Fiber {const key = element.key;let child = currentFirstChild;// update 阶段 child 不为 nullwhile (child !== null) {if (child.key === key) {switch (child.tag) {case Fragment: {// 处理 Fragment}case Block:// 处理 Blockdefault: {if (child.elementType === element.type) {// 为当前节点的所有兄弟节点打上Deletion的EffectTag,因为单节点一旦复用,其他兄弟节点都会被标记删除// 同时构建EffectList链表deleteRemainingChildren(returnFiber, child.sibling);const existing = useFiber(child, element.props);existing.ref = coerceRef(returnFiber, child, element);existing.return = returnFiber;return existing;}break;}}// Didn't match.deleteRemainingChildren(returnFiber, child);break;} else {// 给需要删除的节点打上Deletion的EffectTag,并且构建effectList链表deleteChild(returnFiber, child);}// 存在 多节点更新之后变为单节点child = child.sibling;}if (element.type === REACT_FRAGMENT_TYPE) {// 处理 Fragment} else {// 创建新的Fiber节点 根据JSX typeconst created = createFiberFromElement(element, returnFiber.mode, lanes);created.ref = coerceRef(returnFiber, currentFirstChild, element);created.return = returnFiber;return created;}}
首先,有一个while
循环,这里有两次含义:
- 首次渲染的时候,因为
currentFiber
不存在,因此currentFirstChild == null
,直接创建Fiber
节点。 - 当更新的时候,
currentFiber
已经存在,如果是多节点更新为单节点,此时会走while
循环。
接着,如果是更新的场景,则会判断是否能够复用节点。这里是单节点diff
的核心。
最后,将当前的Fiber
节点(不管是复用的还是新创建的)返回作为workInProgress.child
的值。
下面为单节点diff的流程图
其中,最核心还是oldFiber
和element
的diff
对比过程,
从diff
单节点的两个场景分别看下如果进行diff
对比,以及最终生成的effectList
。
单节点到单节点对比
如下代码,
<!-- 更新前 --><div><span key='a'>0</span></div><!-- 更新后 --><div><span key='a'>1</span></div>
更新前后,元素的key
和type
都没有变化,因此在diff
过程中可以复用oldFiber
作为当前构建的workInProgress
。
因为前后都是单节点,所以不存在去删除其兄弟节点(为其兄弟节点打上Deletion
的effectTag
),所以diff
过程是没有生成effectList
的,但是节点更新了,肯定会生成effectList
的,这个过程是在render阶段
中completeWork中,
completeWork
也有两个过程(mount
和update
):
update
(更新)阶段主要是计算newProps
和oldProps
的差异,如果存在,则为当前Fiber
打上Update
的effectTag
。mount
(挂载)阶段主要是创建Fiber节点对应的DOM节点
的过程。
所以,更新前后的span
标签的children
更新的,所以span
标签的Fiber
节点会有Update
的effectTag
。
有了effectTag
,在completeUnitOfWork
阶段的最后会生成effectList
这里可以看到在
completeWork
阶段处理effectList
链表的源码
多节点到单节点对比
如下代码:
<!-- 更新前 --><div><span key='a'>0</span><span key='b'>1</span><span key='c'>2</span><span key='d'>3</span></div><!-- 更新后 --><div><span key='c'>4</span></div>
因为oldFiber div
节点是多节点,遍历其每一个节点与更新后的JSX
对象对比,如上图,如果没有复用的节点,就执行deleteRemainingChildren(returnFiber, child);
,给当前oldFiber
节点打上Deletion
的effectTag
,同时生成effectList
链表。
如果碰到可以复用的节点,执行deleteRemainingChildren(returnFiber, child.sibling);
,注意,这里的参数是child.sibling
,因为此时已经匹配到了节点,需要删除当前节点的所有兄弟节点,并且将要删除的节点添加到effectList
链表中。
这样,多节点到单节点的diff
操作结束,返回复用的节点作为父节点的child
。
之后,同样进入render阶段
中的completeWork,对复用的节点进行oldProps
和newProps
的对比,如果有差异,则给当前节点打上Update
的effectTag
,并且将该节点添加到effectList
链表中。
至此,多节点到单节点的整个协调过程
全部结束,生成的effectList
链表被保存在父节点
的firstEffect
属性中。
下图,展示了最终生成的effectList
链表。黄色的线是effectList
的走向。
多节点Diff
从
源码
这里角度来看,多节点Diff
算法会有两轮循环。
多节点主要有以下几种方式需要更新:
- 节点更新
- 节点增加或者减少
- 节点位置变化
由于Web UI的更新主要以更新
为主,所以第一轮循环主要解决节点的更新。
首先看下源码入口:
// 多节点 Diff 入口function reconcileChildrenArray(returnFiber: Fiber,currentFirstChild: Fiber | null,newChildren: Array<*>,lanes: Lanes): Fiber | null {// * 最终返回的fiber节点 - 也就是带有sibling的子链表let resultingFirstChild: Fiber | null = null;// * 中间变量 用来保存每一次新生成的newFiberlet previousNewFiber: Fiber | null = null;// * 用来跟newChildren对比的currentFiberlet oldFiber = currentFirstChild;// * 中间变量 用来保存oldFiber 链表的当前节点let nextOldFiber = null;// * 用来记录Fiber节点对应的DOM节点的位置let lastPlacedIndex = 0;// * 遍历更新元素数组的索引let newIdx = 0;// TODO ...return resultingFirstChild;}
首先可以看到,多节点更新逻辑的输入参数为currentFirstChild
、newChildren
和returnFiber
,以及初始化定义的参数,在后面会具体说明。
returnFiber
就是当前需要对比节点的父节点对应的current Fiber
树。
currentFirstChild
为当前页面呈现的current fiber
树的第一个子Fiber
节点。
newChildren
为页面更新之后的包含新JSX对象
的数组。
而返回的resultingFirstChild
是WorkInProgress Fiber
节点,也就是diff
操作之后最新的Fiber
,可以看到上图右上侧对应的Fiber 双缓存
树上的节点。
第一轮循环
从第一轮循环图可以看到,如果oldFiber
老节点和newChildren[newIdx]
新节点的type
和key
相同,因此,在生成新的 Fiber 节点的时候,会直接复用oldFiber
节点,而这种场景节点数量
、顺序
都没有变化,所以在第一轮循环中,newChildren
和oldFiber
都遍历完了。
// 第一遍轮换for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {if (oldFiber.index > newIdx) {nextOldFiber = oldFiber;oldFiber = null;} else {nextOldFiber = oldFiber.sibling;}const newFiber = updateSlot(returnFiber,oldFiber,newChildren[newIdx],lanes);// key值不同,不能复用,直接跳出循环,进入第二轮循环if (newFiber === null) {if (oldFiber === null) {oldFiber = nextOldFiber;}break;}// shouldTrackSideEffects 更新节点为trueif (shouldTrackSideEffects) {// 新创建的Fiber节点 alternate 为 nullif (oldFiber && newFiber.alternate === null) {deleteChild(returnFiber, oldFiber);}}lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);if (previousNewFiber === null) {resultingFirstChild = newFiber;} else {previousNewFiber.sibling = newFiber;}previousNewFiber = newFiber;oldFiber = nextOldFiber;}// newChildren 遍历结束 直接跳出第一轮循环if (newIdx === newChildren.length) {// We've reached the end of the new children. We can delete the rest.deleteRemainingChildren(returnFiber, oldFiber);return resultingFirstChild;}
第一轮遍历主要判断Fiber节点
是否能复用,而复用的逻辑主要在updateSlot
函数源码这里中。
updateSlot
会根据oldFiber
和newChildren[newIdx]
的key
、elementType
对比,如果key
不同,直接返回null
空节点,此时newFiber=null
,则跳出第一轮循环。
否则根据elementType
判断,相同则复用oldFiber
,不同则创建新的Fiber
的节点,如果是新增的节点,则会在oldFiber
节点打上Delete
的effectTag
,标记需要删除,同时处理current Fiber
树的EffectList
。
否则在新Fiber
节点上打上Placement
的effectTag
,标记需要更新的节点。
最后更新resultingFirstChild
链表,进行下一次循环,直到newChildren
遍历结束,然后跳出循环。
第二轮循环
从第一轮的图看到,只有当oldFiber
和newChildren
都遍历结束之后,第一轮才会结束。
那么进入第二轮循环的条件是:
oldFiber
没有遍历完,newChildren
没有遍历完oldFiber
没有遍历,newChildren
遍历结束oldFiber
遍历完,newChildren
没有遍历完
进入第二轮主要的场景有:
- 节点增加 - 符合上述场景
3
- 节点减少 - 符合上述场景
2
- 节点移动 - 符合上述场景
1
从上图中可以看到,当符合场景3
的时候,会为每一个新增的节点闯将对应的Fiber
节点,然后打上Placement
的effectTag
,然后结束Diff 算法
。
// 符合场景3 的源码if (oldFiber === null) {for (; newIdx < newChildren.length; newIdx++) {// 创建Fiberconst newFiber = createChild(returnFiber, newChildren[newIdx], lanes);if (newFiber === null) {continue;}// 为Fiber添加Placement的TaglastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);if (previousNewFiber === null) {resultingFirstChild = newFiber;} else {previousNewFiber.sibling = newFiber;}previousNewFiber = newFiber;}return resultingFirstChild;}
当符合场景1
和场景2
的时候,此时oldFiber
和newChildren
都没有遍历完,为了降低算法的时间复杂度,采用了空间换时间
的方式。
将剩余的oldFiber
加入了哈希表中,然后只遍历newChildren
,根据newChildren[newIdx]
的key
来判定是否可以复用节点,之后的流程就跟第一轮的复用逻辑一致了。最后,返回workInProgress
的fiber
子树,作为其return Fiber
的child
子属性。
// 符合场景1、2的源码// oldFiber添加Mapconst existingChildren = mapRemainingChildren(returnFiber, oldFiber);for (; newIdx < newChildren.length; newIdx++) {// 复用逻辑const newFiber = updateFromMap(existingChildren,returnFiber,newIdx,newChildren[newIdx],lanes,);if (newFiber !== null) {if (shouldTrackSideEffects) {if (newFiber.alternate !== null) {existingChildren.delete(newFiber.key === null ? newIdx : newFiber.key,);}}lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);if (previousNewFiber === null) {resultingFirstChild = newFiber;} else {previousNewFiber.sibling = newFiber;}previousNewFiber = newFiber;}}// map中还存在oldFiber节点的话,直接当上Deletion的EffectTagif (shouldTrackSideEffects) {existingChildren.forEach(child => deleteChild(returnFiber, child));}return resultingFirstChild;
至此,多节点的Diff算法
的大致过程就结束了,下面针对每种不同的场景看下对应的双缓存的Fiber
树和其产生的effectList
。
节点更新
节点更新有节点属性更新
和节点类型更新
节点属性更新
如下图,更新之前和之后,元素的类型type
和key
都没有变化。
<!-- 更新前 --><div><span className="a">0</span><span className="b">1</span></div><!-- 更新前1 --><div><span className="a">0</span><span className="b">1</span><span className="c">2</span></div><!-- 更新后 --><div><span className="a">0</span><span className="b">2</span></div>
这种场景,在Diff
的过程,会直接复用oldFiber
节点,此时更新后的节点全部遍历了,进入到了newIdx === newChildren.length
,如果还存在oldFiber
没有遍历(更新前1场景),则执行deleteRemainingChildren
将没有遍历的oldFiber
节点都打上Deletion
的effectTag
节点类型更新
如下图,节点类型更新,key
相同,而elementType
不同,不用复用oldFiber
,直接创建new Fiber
节点
<!-- 更新前 --><div><span className="a">0</span><span className="b">1</span></div><!-- 更新后 --><div><span className="a">0</span><div className="b">2</div></div>
类型更新
的节点跟属性更新
的节点之间在执行没有太大的区别,都是在oldFiber
节点打上Deletion
的effectTag
,只是在执行的顺序上有所不同。
我们知道,React Diff 的最终目的是将对比出来具有effectTag
的Fiber
节点生成一个effectList链表
,在commit阶段
遍历此链表进行DOM操作
。
接下来看下在节点更新
中属性更新
和类型更新
在生成effectList
有什么不同?
节点属性更新 - Fiber树
可以看到,当节点属性
更新之后,workInProgress Fiber
中更新的节点都复用了current Fiber
中的节点,因此workInProgress Fiber
中的每一个节点都会指向(alternate
)对应的current Fiber
节点。
当所有的节点都被复用之后(新老节点的位置、个数一致),在Diff 算法
的过程中是不会产生EffectList
的,如下图:
上图是render阶段 - completeWork
生成的effectList
,这里具有effectTag
的节点就是div Fiber节点
,在completeWork阶段
会判断如果workInProgress Fiber节点
具有effectTag
的话,就会将其挂载在其return Fiber 节点
的firstEffect
和lastEffect
属性上。
这里可以看到在
completeWork
阶段处理effectList
链表的源码
节点类型更新 - Fiber树
可以看到,当节点类型
更新之后,workInProgress Fiber
是不会复用oldFiber
的,因此更改的div Fiber
是不会有alternate
指向其current Fiber
的。
由于更新了节点类型,就会产生effectTag
,oldFiber span
节点就会被打上Deletion
的effectTag
,从而在Diff 算法
的过程中产生EffectList
。
如下图:
在第一轮遍历中,div Fiber
不能复用span Fiber
节点,因此新创建了Fiber
节点,只有为span Fiber
节点打上Deletion
的EffectTag
,同时在其return Fiber
节点上产生EffectList
(上图左侧)。之后在completeWork
阶段会在其基础上继续生成带有EffectTag
的effectList
(上图右侧)。
这里可以看到在
Diff 算法
的过程中产生EffectList
的源码
节点增加
如下图:新增了一个子节点
<!-- 更新前 --><div><span className="a">0</span><span className="b">1</span></div><!-- 更新后 --><div><span className="a">0</span><div className="b">1</div><span className="c">2</span></div>
这里,第一轮循环结束之后,会继续遍历newChildren
,生成第三个span Fiber
节点,为其打上Placement
的effectTag
,然后生成effectList
。
下图为节点增加的双缓存Fiber树
同理,不能复用的节点或者新增的节点都会生成Fiber节点,都没有alternate
指向其current Fiber
。
下面看下节点增加最后产生的effectList
新增的fiber
节点打上了effectTag
,在completeWork
中产生了diff 算法
之后的effectList
。
节点减少
如下图:新增了一个子节点
<!-- 更新前 --><div><span className="a">0</span><span className="b">1</span></div><!-- 更新后 --><div><span className="a">0</span></div>
此时,newChildren
遍历结束,oldFiber
还有的话,需要为每一个oldFiber
节点打上Deletion
的effectTag
,然后产生effectList
。
下图为节点减少的双缓存Fiber树
下面看下节点增加最后产生的effectList
节点移动
如下图:节点的位置发生了变化
<!-- 更新前 --><div><span className="a">0</span><span className="b">1</span></div><!-- 更新后 --><div><span className="b">1</span><span className="a">0</span></div>
这里,只是节点的位置发生了变化,因此节点是可以复用的,如下图双缓存Fiber树
这里问题来了,如何对位置变化的Fiber
打上effectTag
呢?
这个例子中,span a
和span b
元素的位置发生了变化,简单的DOM
操作的话,直接获取span a
元素的dom
对象,通过appendChildren
直接插入到div
中。
在diff
的过程中,会将span a
元素的Fiber
对象打上Placement
的EffectTag
,这个过程就是位置变化对比新旧fiber
的过程。
如下图:
在遍历newChildren
的过程中,lastPlaceIndex
会保存可复用节点的上一次位置索引index
,初始值为0
- 如果可复用节点的索引
oldIndex < lastPlaceIndex
,那么当前newChildren[newIdx]
的Fiber
节点会打上Placement
的effectTag
的tag,表示该节点需要移动;同时下一次循环复用该lastPlaceIndex
. - 如果可复用节点的索引
oldIndex >= lastPlaceIndex
, 那么用oldIndex
更新lastPlaceIndex
,表示该节点此次不需要移动。
这里可以看到此次
Diff
的位置对比源码,在placeChild
方法中。
最后,关于多节点的diff
还有其他的场景,上面罗列了一些比较常见的方式。其中最重要的就是判断新老节点是否能够复用,节点的effectTag
以及effectList
的产生。
而最终产生的effectList
会在commit阶段遍历,从而更新真实的DOM
节点。
总结
React diff算法是在被限制场景下进行的高效对比算法,在双缓存 Fiber树
的前提下,构建workInProgress Fiber
树完全是在内存中进行的,这也是diff
操作的一个优点,并不会干扰页面的呈现。
React diff算法也是render阶段(也被称为协调过程)中最重要的一环,在mount
阶段创建workInProgress Fiber
树,然后在commit阶段将workInProgress Fiber
指向current Fiber
树;在update
阶段进行diff
算法,对比current Fiber
节点同级的JSX
对象,生成worKInProgress Fiber
树,同时产生effectList
链表,在commit阶段遍历这条链表,依次执行副作用。
这里React diff算法大致就结束了,在后面会继续深入commit阶段,是如何遍历effectList
链表的。