前备知识
如果应用每发生一次更新都直接更新真实dom,浏览器会频繁的布局(回流)和绘制(重绘),性能会很差。因此react中使用了虚拟dom,当用户与页面产生交互时,将多次更新整合到一颗新的虚拟dom树中,然后一次性同步到视图。
在一个渲染帧内(主流浏览器是60帧,一个渲染帧约为16.7ms),浏览器要执行js代码、布局、绘制,而js线程和gui线程是互斥的,如果执行js的时间过长,gui线程就没有足够的时间布局和绘制,这一帧的画面就会丢失,呈现出的效果就是掉帧。在老的react架构中,如果虚拟dom树过于庞大,页面在更新时就会卡顿,为了解决这个问题react团队使用fiber架构重构了react。
react在更新时要遍历虚拟dom做一些工作,fiber是链表,遍历时可以中断,并且可以从中断处恢复,而遍历树需要递归,做不到从中断处恢复。异步可中断更新的好处是,遍历一定时间fiber后中断,让浏览器在当前渲染帧有足够的时间布局和绘制,在下一渲染帧继续遍历fiber。
每个fiber有return、child、sibling三个指针,父子之间通过return和child互相连接,兄弟节点之间通过sibling单向连接。
fiber中有type属性,如果是函数组件,type是这个函数;如果是类组件,type是class;如果是原生标签,type是字符串,例如div、span等。
react应用的根节点为fiberRoot,它有一个current指针,renderer会根据current所指的fiber树渲染视图。当应用发生更新时,会在内存中构建一颗fiber树,每个fiber被称作workInProgress,当workInProgress树构建好之后,会让fiberRoot的current指针指向workInProgress树,以此达到上文提到的一次性将多个更新同步到视图上的目的。对于workInProgress而言,渲染树的fiber就是oldFiber,它们之间用alternate指针互指,即workInProgress.alternate === oldFiber,oldFiber.alternate === workInProgress。
更多细节可以参考React源码中使用的数据结构 - 掘金 (juejin.cn)
diff简介
更新时,完全根据新元素创建一颗newFiber树是糟糕的,因此需要复用。
diff的作用是找出oldFiber和newElement的异同处,存在差异的部分新建fiber,相同的部分复用oldFiber。
但传统diff的时间复杂度为,因此react团队提出了一种启发式的diff算法:
- 只进行同级比较
- 如果type改变直接不复用,即使子节点完全一样(正是因为这种可能性特别低才这么设计)
- 通过key进行复用
关于特殊情况:
- 如果oldFiber和newElement都只有一个,判断type和props是否发生改变就可以决定是否复用oldFiber;如果oldFiber不存在,newElement只有一个,直接新建newFiber,此逻辑被单节点diff覆盖。
- 如果oldFiber不存在,newElement有多个,逐个创建newElement,此逻辑被多节点diff覆盖。
下面看一下diff算法的入口,current === null说明是挂载流程,否则是更新流程,reconcileChildFibers就是diff算法的入口。
export function reconcileChildren(current, workInProgress, nextChildren, renderLanes) {
if (current === null) {
workInProgress.child = mountChildFibers(workInProgress, null, nextChildren, renderLanes);
} else {
workInProgress.child = reconcileChildFibers(
workInProgress,
current.child,
nextChildren,
renderLanes,
);
}
}
单节点diff
此情形下,oldFiber的数量为0~n,新元素仅有一个。
如果oldFiber链表不为空,找到key相同的oldFiber,其余的oldFiber全部标记删掉,如果type相同则复用。
如果oldFiber链表为空,或者type不同,根据新元素创建newFiber。
详情请看代码及注释(为了让字体清晰,我尽量使用多行注释)。
function reconcileSingleElement(returnFiber, currentFirstChild, element, lanes) {
const key = element.key;
let child = currentFirstChild;
while (child !== null) {
if (child.key === key) {
const elementType = element.type;
if (
child.elementType === elementType ||
(typeof elementType === 'object' &&
elementType !== null &&
elementType.$$typeof === REACT_LAZY_TYPE &&
resolveLazy(elementType) === child.type)
) {
/*
* key和type都相等,将其他oldFiber打上删除标记、复用此oldFiber
*/
deleteRemainingChildren(returnFiber, child.sibling);
const existing = useFiber(child, element.props);
existing.ref = coerceRef(returnFiber, child, element);
existing.return = returnFiber;
return existing;
}
/*
* key相同但type不同,不能复用、将所有oldFiber打上删除标记,跳出循环
*/
deleteRemainingChildren(returnFiber, child);
break;
} else {
/*
* key不相等就将此oldFiber打上删除标记,继续往下找
*/
deleteChild(returnFiber, child);
}
child = child.sibling;
}
/*
* 根据element创建newFiber
*/
const created = createFiberFromElement(element, returnFiber.mode, lanes);
created.ref = coerceRef(returnFiber, currentFirstChild, element);
created.return = returnFiber;
return created;
}
这里我仅对key相同但type不同,不能复用、将所有oldFiber打上删除标记,跳出循环进行解释,react中不允许同层级有相同的key,key相同就代表着找到了对应的oldFiber,不论能否复用都无须继续找了。
多节点diff
此情形下,oldFiber有多个,新元素也有多个。
if (isArray(newChild)) {
return reconcileChildrenArray(returnFiber, currentFirstChild, newChild, lanes);
}
reconcileChildrenArray内部有一个主循环,其余逻辑用于处理主循环退出时的各种情况。
理想状况(更新)
此时oldFiber链表和newChildren的顺序是一致的(key一一对应),即只发生了更新,没有移动,即使有插入和删除,也是发生在数组末端。
此时会比较二者的key和type,都相等才能复用;key不等,newFiber会是null导致跳出循环;key相等但type不等,会根据newChildren[newIdx]创建一个newFiber,详情请看代码及注释。
for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
if (oldFiber.index > newIdx) { // 跳过空节点
nextOldFiber = oldFiber;
oldFiber = null;
} else {
nextOldFiber = oldFiber.sibling;
}
/*
* 比较oldFiber.key和newChildren[newIdx].key, oldFiber.type和newChildren[newIdx].type
*/
const newFiber = updateSlot(returnFiber, oldFiber, newChildren[newIdx], lanes);
/*
* 如果为null,说明key的顺序改变了,需要跳出循环做其他处理
*/
if (newFiber === null) {
if (oldFiber === null) {
oldFiber = nextOldFiber;
}
break;
}
if (shouldTrackSideEffects) {
if (oldFiber && newFiber.alternate === null) {
/*
* newFiber.alternate === null说明这是新创建的fiber而不是复用的oldFiber,因此要打上删除标记
*/
deleteChild(returnFiber, oldFiber);
}
}
/*
* newFiber.index = newIdx
* 如果不存在current说明需要把newFiber插入进去
* 如果存在current,判断是否发生了移动
*/
lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
/*
* 将newFiber通过sibling连接在一起,也就是组装成链表,resultingFirstChild指向链表头
*/
if (previousNewFiber === null) {
resultingFirstChild = newFiber;
} else {
previousNewFiber.sibling = newFiber;
}
previousNewFiber = newFiber;
oldFiber = nextOldFiber;
}
这里我解释一下updateSlot的作用,先对比key,如果不相等会返回null,导致触发上述代码第15行的break跳出循环;如果key相等且type相等,复用oldFiber;如果key相等但type不等,会根据newChildren[newIdx]创建一个newFiber。
循环正常结束(仅更新或者末端新增、删除)
说明是理想状况,即只有两种可能:
- newChildren遍历完毕导致循环退出
- oldFiber遍历完毕导致循环退出
如果是第一种情况,要么是仅发生了更新要么是末端发生了删除,直接调用deleteRemainingChildren(returnFiber, oldFiber)给剩余的oldFiber打上删除标记,然后返回resultingFirstChild。
如果是第二种情况,发生了末端新增,需要依次创建newFiber,然后连接到resultingFirstChild的末端,最后将resultingFirstChild返回。
if (newIdx === newChildren.length) {
deleteRemainingChildren(returnFiber, oldFiber);
return resultingFirstChild;
}
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;
}
循环非正常结束(移动、非末端插入、删除)
此时可能发生了移动、非末端插入、非末端删除,这三者任意组合,无论多复杂的情况,都会在本次loop中处理。
简而言之,就是复杂情境下导致newChildren的顺序被打乱了,此时需要将剩余的oldFiber放在map中,然后通过key来查找可复用的oldFiber。react官方不建议用index作为key的原因就是在这里。
/*
* 将剩余oldFiber存入map中
*/
const existingChildren = mapRemainingChildren(returnFiber, oldFiber);
for (; newIdx < newChildren.length; newIdx++) {
/*
* 通过key查找oldFiber,如果找不到就根据newChildren[newIdx]新建newFiber
*/
const newFiber = updateFromMap(
existingChildren,
returnFiber,
newIdx,
newChildren[newIdx],
lanes,
);
if (newFiber !== null) {
if (shouldTrackSideEffects) {
if (newFiber.alternate !== null) {
/**
* newFiber !== null && newFiber.alternate !== null
* 说明是复用的oldFiber,将这个oldFiber从map里删除
*/
existingChildren.delete(newFiber.key === null ? newIdx : newFiber.key);
}
}
lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
if (previousNewFiber === null) {
resultingFirstChild = newFiber;
} else {
previousNewFiber.sibling = newFiber;
}
previousNewFiber = newFiber;
}
}
if (shouldTrackSideEffects) {
/*
* 将没复用的oldFiber全部打上删除标记
*/
existingChildren.forEach((child) => deleteChild(returnFiber, child));
}
return resultingFirstChild; // 返回newFiber链表
placeChild
lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx)的作用:
- 给newFiber.index赋值
- 给newFiber的flags赋值,在commit阶段告诉renderer该怎么做
如果newFiber是根据element创建的,newFiber.alternate会是null,这种情况说明它是新增的;如果newFiber是复用的oldFiber,要判断是否需要移动。
如果是新增的或者需要移动,需要打上Placement标记。
function placeChild(newFiber, lastPlacedIndex, newIndex) {
newFiber.index = newIndex;
if (!shouldTrackSideEffects) {
newFiber.flags |= Forked;
return lastPlacedIndex;
}
const current = newFiber.alternate;
if (current !== null) {
const oldIndex = current.index;
if (oldIndex < lastPlacedIndex) {
/*
* 顺序与原本不一致时,在map中找到的oldFiber并且在lastPlacedIndex前面,走此分支
*/
newFiber.flags |= Placement | PlacementDEV;
return lastPlacedIndex;
} else {
/*
* 按顺序复用的oldFiber、在map中找到的oldFiber并且在lastPlacedIndex后面,走此分支
*/
return oldIndex;
}
} else {
/*
* 根据element创建newFiber走此分支
*/
newFiber.flags |= Placement | PlacementDEV;
return lastPlacedIndex;
}
}
这里我解释一下lastPlacedIndex。
为了方便表述,我将上一个按整体顺序被复用的oldFiber记作lastOldFiber。
从代码中可以看到,只有按整体顺序被复用的oldFiber才会使lastPlacedIndex增加,也就是说lastPlacedIndex的作用是标记lastOldFiber的index。
如果oldFiber.index < lastPlacedIndex,说明原始顺序中oldFiber在lastOldFiber之前,而更新后的位置却在之后,当然要将它移动;
否则,说明原始顺序中oldFiber在lastOldFiber之后,与更新后的位置关系大体一致,中间多余的oldFiber早在之前的流程中被标记删除了,或者在本流程中被标记移动,因此只要位置关系大体一致就不需要移动。
例:
<div key="a">a<div>
<div key="b">b<div>
<div key="c">c<div>
<div key="d">d<div>
<div key="e">e<div>
更新时:
[
{ key: 'a' }, // 按顺序复用,oldFiber.index为0,lastPlacedIndex为0,顺序,lastPlacedIndex = 0
{ key: 'b' }, // 按顺序复用,oldFiber.index为1,lastPlacedIndex为0,顺序,lastPlacedIndex = 1
{ key: 'e' }, // 在map中用key查找,oldFiber.index为4,lastPlacedIndex为1,顺序,lastPlacedIndex = 4
{ key: 'c' }, // 在map中用key查找,oldFiber.index为2,lastPlacedIndex为4,逆序,lastPlacedIndex = 4,标记Placement
{ key: 'f' }, // 新建fiber,lastPlacedIndex为4,标记Placement
]
可见,key=“d”的div被删除,key=“c”的div被移动到后面,只要a、b、e的整体顺序没变就不需要移动。
写在后面
删除了源码中的一些细节,这些细节对理解diff没有影响,反而会让代码更加清晰直观,感兴趣的朋友请自行查阅源码。
错误的地方请在评论区指出。