react diff
# 01. diff 算法时机
是在render阶段 beginWork时,针对update组件,将当前组件与 当前组件上次更新时的对应的Fiber节点进行对比(也就是我们所说的diff算法),将对比结果生成新的Fiber节点。
提示
一个DOM节点在某一时刻最多会有四个节点和他相关
- current Fiber: 如果该DOM节点已在页面中,current Fiber 代表该DOM 节点对应的Fiber节点。
- workInProgress. Fiber 如果该DOM节点将在本次更新中渲染到页面中,workInProgress Fiber 代表该DOM节点对应的Fiber节点
- DOM 节点本身
- JSX对象。 ClassComponent的render方法的返回结果, 或者FunctionComponent的调用结果,JSX对象中包括描述DOM节点的信息。
Diff 算法的本质是对比1,4,生成2
# 02. Diff的缺陷以及React的改进
由于前后将两棵树完全对比的算法的复杂程度为o(n3),n是树中元素的数量。
假设有1000个元素进行对比,那就需要10亿次的计算量。
为了降低这个复杂度,React的diff 设置了三个限制:
- 只对同级元素进行diff,如果一个DOM节点在前后两次更新中跨越了层级,那么react不会复用
- 两个不同类型的元素产生出不同的树,如果元素由div变为p,react会销毁div以及子孙节点,将新建p以及子孙节点
- 可以通过key prop来表示哪些元素可以在不同的渲染下保持稳定。
# 03. Diff 是如何实现的?
入口函数:reconcileChildFibers 该函数会根据newChild(即JSX对象)类型调用不同的处理函数
function reconcileChildFibers(
returnFiber: Fiber,
currentFirstChild: Fiber | null,
newChild: any
): Fiber | null {
const isObject = typeof newChild === 'Object' && newChild !== null;
if (isObject) {
// Object 类型,可能是REACT_ELEMENT_TYPE 或者REACT_PORTAL_TYPE
switch (newChild.$$typeOf) {
case REACT_ELEMENT_TYPE:
// 调用reconclieSingleElement 处理
}
}
if (typeof newChild === 'string' || typeof newChild === 'number'){
// 调用reconclieSingNode处理
}
if (isArray(newChild)) {
// 调用reconclieChildFibers处理
}
// ...
// 都没有命中,就会删除节点
return deleteRemainingChildren(returnFiber, currentFistChild)
}
从同级的节点数量将Diff分为两类:
当newChild类型为object、number、string,代表同级只有一个节点
当newChild类型为Array,同级有多个节点
# 04. 单节点diff
单节点入口:reconcileSingleElement,以object为例:
if (typeof newChild === 'object' && newChild !== null) {
switch(newChild.$$typeof) {
case REACT_ELEMENT_TYPE:
return palceSingChild(reconcileSingleElement(returnFiber, currentFirstChild, newChild, lanes))
// ...
}
}
reconcileSingleElement的作用是:会判断上次更新的Fiber节点是否存在对应的DOM节点 => DOM节点是否可以复用,最后会返回workInProgress fiber节点(可能是新生成的也可能是将上次更新的Fiber节点的副本作为本次新生成的的fiber节点)
# DOM节点是否可以复用?
function reconcileSingleElement (
returnFiber: Fiber,
currentFirstChild: Fiber | null,
element: ReactElement
): Fiber {
const key = element.key;
let child = currentFirstchild;
// 1. 是否存在对应的DOM节点
while(child !== null) {
// 上一次存在更新的DOM节点
// 首先比较key是否相同
if (child.key === key) {
// key相同, 接下来比较type是否相同
if (elementType === REACT_FRAGMENT_TYPE) {
if (child.tag === Fragment) {
return _existing;
}
} else {
if (child.elementType === elementType) {
return _existing;
}
}
// key相同但是type不同
// 将该fiber及其兄弟fiber标记为删除
deleteRemainingChildren(returnFiber, child);
} else {
// key不同,将该fiber标记为删除
deleteChild(returnFiber, child);
}
child = child.sibling;
}
// 根据类型,调用不同的生成逻辑 createFiberFromFragment | createFiberFromElement ,返回。
}
# deleteRemainingChildren | deleteChild 区别
在child !== null时, key相同,而type不相同 表示我们已经找到本次更新的元素 对应的上次fiber节点,而type不相同,不能复用。 而key不相同,只是说明当前fiber 不能被本次更新的元素 复用,后面还有兄弟fiber 咩有遍历到,所以在这个阶段只能标记当前fiber 删除。
# 05. 多节点diff
ul > li*2 为例 ,返回值JSX对象的children属性不是单一节点,而是包含2个对象的数组。
调试如下:
if (isArray(newChild)) {
return reconcileChildrenArray(returnFiber, currentFirstChild, newChild, lanes);
}
节点更新(节点属性变化 | 节点类型更新)
节点新增或者减少
节点位置变化(顺序变化)
同级多个节点的diff 属于以上三种类型的一种或者多种。
# diff 思路
提示
虽然本次更新的JSX对象 newChildren为数组形式,但是和newChildren中每个组件进行比较的是current fiber,同级的Fiber节点是由sibling指针链接形成的单链表,即不支持双指针遍历。
即 newChildren[0]与fiber比较,newChildren[1]与fiber.sibling比较。
所以无法使用双指针优化。
diff 算法经历两轮遍历。
- 处理更新的节点。
- 处理剩下的不属于更新的节点
# 第一次遍历: 处理更新的节点
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),跳出遍历,第一轮遍历结束。
带着第一次遍历的结果进行第二次遍历。
# 第二次遍历: 处理剩下的不属于更新的节点
- 情况1: newChildren与oldFiber同时遍历完
说明 在第一轮遍历进行组件更新。Diff结束
- 情况2: newChildren 没有遍历完,oldFiber遍历完
说明有新节点插入。 我们只需要遍历剩下的newChildren为生成的workInProgress fiber依次标记Placement。
if (oldFiber === null) {
for (; newIdx < newChildren.length; newIdx++) {
const newFiber = createChild(returnFiber, newChildren[newIdx], lanes);
if (newFiber === null) {
continue;
}
lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
if (previousNewFiber === null) {
resultingFirstChild = newFiber;
} else {
previousNewFiber.sibling = newFiber;
}
previousNewFiber = newFiber;
}
return resultingFirstChild;
}
- 情况3: newChildren 遍历完,oldFiber没有遍历
说明有节点被删除了。所以需要遍历剩下的oldFiber,依次标记Deletion。
if (newIdx === newChildren.length) {
//我们已经到了newChildren的尽头。 我们可以删除其余的节点
deleteRemainingChildren(returnFiber, oldFiber);
return resultingFirstChild;
}
- 情况4: newChildren和oldFiber都没有遍历
说明有节点更新了位置
reconcileChildrenArray
// 将所有子项添加到键映射以进行快速查找。
const existingChildren = mapRemainingChildren(returnFiber, oldFiber);
for (; newIdx < newChildren.length; newIdx++) {
var _newFiber2 = updateFromMap(existingChildren, returnFiber, newIdx, newChildren[newIdx], lanes);
if (_newFiber2 !== null) {
if (shouldTrackSideEffects) {
if (_newFiber2.alternate !== null) {
// 新的fiber正在进行中,但是如果它已经在当前产生,这意味着我们可以复用这个fiber.我们需要从子列表中删除它,这样我们就不会添加到删除列表中
existingChildren.delete(_newFiber2.key === null ? newIdx : _newFiber2.key);
}
}
// 最后一个可复用的节点在oldFiber中的位置索引
lastPlacedIndex = (_newFiber2, lastPlacedIndex, newIdx);
if (previousNewFiber === null) {
resultingFirstChild = _newFiber2;
} else {
previousNewFiber.sibling = _newFiber2;
}
previousNewFiber = _newFiber2;
}
}
function mapRemainingChildren(returnFiber, currentFirstChild) {
var existingChildren = new Map();
var existingChild = currentFirstChild;
while (existingChild !== null) {
if (existingChild.key !== null) {
existingChildren.set(existingChild.key, existingChild);
} else {
existingChildren.set(existingChild.index, existingChild);
}
existingChild = existingChild.sibling;
}
return existingChildren;
}
# 处理移动的节点
- oldIndex: 遍历到的可复用节点在oldFiber中的位置索引
- lastPlaceIndex:最后一个可复用的节点在oldFiber中的位置索引
lastIndex是不断更新的,表示访问过的节点在集合中的最右的位置。若当前访问节点在旧集合中的位置比lastPlaceIndex大,即靠右,说明它不会影响其他元素的位置,因此不用添加到差异队列中,不执行移动操作,反之则进行移动操作。
- 如果oldIndex < lastPlacedIndex,代表本次更新该节点需要向右移动。
- lastPlacedIndex初始为0,每遍历一个可复用的节点,如果oldIndex >= lastPlacedIndex,则lastPlacedIndex = oldIndex。