React Diff算法
前置了解
我们知道react采用了双缓存的技术,最多同时存在两棵树,一棵是current Fiber树
(就是当前展示在屏幕上的),另一颗是workInProgress Fiber树
(在内存中构建)。React应用的根结点通过current指针
在不同的Fiber树的rootFiber进行切换,来实现current Fiber树指向的切换
。
当一个组件update
的时候,React会将当前组件
和这个组件在上一次更新时对应的Fiber节点
进行比较,这个就是Diff算法,然后将比较的结果生成新的Fiber节点。
而Diff算法的本质就是对比current Fiber
和JSX对象
(React.createElement生成的对象),生成workInProgress Fiber
Diff的瓶颈解决
diff操作本身存在一定的性能损耗,在最前沿的算法里,对比前后两棵树的算法复杂程度为O(n3),n是树中元素的数量。
那么如果有100个元素,需要执行的计算量也是百万量级的了。
因此,React预设了相关限制:
- 同级元素进行Diff
- 两个不同类型的元素会产生出不同的树,如果元素从
div
变成p
,会销毁div
及其子孙节点,新建p
及其子孙节点。 - 可以通过key让一些子元素在不同的渲染下保持稳定。
如何保持稳定呢?
// 更新前
<div>
<div key="a">a</div>
<h1 key="b">b</h1>
</div>
// 更新后
<div>
<h1 key="b">b</h1>
<div key="a">a</div>
</div>
如果没有key,当它们diff的时候会认为第一个节点从div变成了h1,会销毁并新建。有了key之后就知道它们只是更换了一下顺序,dom节点可以复用。
Diff如何实现
点击查看react的源码 从Diff的入口函数reconcileChildFibers我们可以看到,会根据newChild(JSX对象)类型去调用不同的函数。
// 根据newChild调用不同的函数
function reconcileChildFibers(
returnFiber: Fiber,
currentFirstChild: Fiber | null,
newChild: any,
lanes: Lanes,
): Fiber | null {
// 是否是没有key值的顶层REACT_FRAGMENT_TYPE元素
const isUnkeyedTopLevelFragment =
typeof newChild === 'object' &&
newChild !== null &&
newChild.type === REACT_FRAGMENT_TYPE &&
newChild.key === null;
// 如果是,则将newChild指向他的children
if (isUnkeyedTopLevelFragment) {
newChild = newChild.props.children;
}
// newChild是否是一个对象
const isObject = typeof newChild === 'object' && newChild !== null;
if (isObject) {
// object类型,可能是 REACT_ELEMENT_TYPE 或 REACT_PORTAL_TYPE 或 REACT_LAZY_TYPE
switch (newChild.$$typeof) {
case REACT_ELEMENT_TYPE:
return placeSingleChild(
reconcileSingleElement(
returnFiber,
currentFirstChild,
newChild,
lanes,
),
);
case REACT_PORTAL_TYPE:
return placeSingleChild(
reconcileSinglePortal(
returnFiber,
currentFirstChild,
newChild,
lanes,
),
);
case REACT_LAZY_TYPE:
if (enableLazyElements) {
const payload = newChild._payload;
const init = newChild._init;
// TODO: This function is supposed to be non-recursive.
return reconcileChildFibers(
returnFiber,
currentFirstChild,
init(payload),
lanes,
);
}
}
}
if (typeof newChild === 'string' || typeof newChild === 'number') {
// 调用 reconcileSingleTextNode 处理
return placeSingleChild(
reconcileSingleTextNode(
returnFiber,
currentFirstChild,
'' + newChild,
lanes,
),
);
}
if (isArray(newChild)) {
// 调用 reconcileChildrenArray 处理
return reconcileChildrenArray(
returnFiber,
currentFirstChild,
newChild,
lanes,
);
}
// 其他情况省略
// 以上都没有命中,删除节点
return deleteRemainingChildren(returnFiber, currentFirstChild);
}
我们可以将Diff分为2类:
- 当
newChild
类型是object
、number
、string
时,代表同级只有一个节点。——单节点Diff - 当
newChild
类型为Array
,同级有多个节点。——多节点Diff
单节点Diff
可以查看源码中的reconcileSingleElement
函数,以object为例:
// newChild是否是一个对象
const isObject = typeof newChild === 'object' && newChild !== null;
if (isObject) {
// object类型,可能是 REACT_ELEMENT_TYPE 或 REACT_PORTAL_TYPE 或 REACT_LAZY_TYPE
switch (newChild.$$typeof) {
case REACT_ELEMENT_TYPE:
return placeSingleChild(
reconcileSingleElement(
returnFiber,
currentFirstChild,
newChild,
lanes,
),
);
// ...
如何判断DOM节点是否可以复用?
React会先判断key
是否相同,如果key
相同,则会去判断type
是否相同,如果都相同,那么这个DOM节点
才能复用。
细节:
当
child! == null
并且key相同
且type不同
时会执行deleteRemainingChildren
把child
和它兄弟fiber
都标记删除。(因为此处是单一节点,找到了对应的key,原先的child都可以删了)如果
child!==null
并且key不同
时就仅仅将child
标记删除
多节点Diff
可分为三种情况:
- 节点更新
- 节点新增或减少
- 节点位置变化
思路
根据不同的情况,执行不同的逻辑。日常开发中,更新
组件发生的频率高于新增
和删除
,所以diff会优先判断是否更新
。
整体逻辑会分为两轮遍历:
- 第一轮遍历处理
更新
的节点 - 第二轮遍历处理剩下的不属于
更新
的节点
第一轮遍历
遍历newChildren,将newChildren[i]
与oldFiber
比较,判断节点是否可以复用。一般有以下三种情况:
- key相同,type相同。说明可以复用,则根据
oldFiber
和 新ReactElement
的 props 生成新fiber
。 - key相同,type不同。则无法复用,会将
oldFiber
标记为DELETION
,然后继续遍历 - 如果key不同,则会跳出整个遍历,第一轮遍历结束。
如果newChildren
遍历完了,或者oldFiber
遍历完了,则跳出遍历,第一轮遍历结束。
第二轮遍历
第一轮遍历结束可分为以下情况
newChildren和oldFiber都遍历完了
只需要在第一轮遍历进行组件更新,这时候Diff结束。
newChildren
没遍历完,oldFiber
遍历完了
已有的DOM节点都被复用了,但是还有新增的节点,这时候就需要遍历剩下的newChildren
给生成的workInProgress fiber
标记Placement
newChildren
遍历完了,oldFiber
没遍历完
说明有节点被删除了,所以需要遍历剩下的oldFiber,标记Deletion
newChildren
与oldFiber都没遍历完
说明节点在更新中改变了位置。具体如下。
处理移动的节点
处理移动的节点需要使用到key
,会将未处理的oldFiber
存入一个以key
为key,oldFiber
为value的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
。
这个就是React中的Diff算法,我们可以发现,从性能上,我们可以尽量减少节点从后面移动到前面的操作。