一、概览
众所周知,react diff算法是在更新时,根据state的更新,计算出变化的节点,再渲染。那么diff是通过什么和什么对比呢,又是如何对比的呢?
首先,我们知道react 16.3之后采用fiber的数据结构,在更新时会存在两个fiber树(虽然不是树结构,为描述方便,称之为树),同时还会存在页面中的真实dom树以及render阶段的jsx对象,如下:
- current fiber
- alternate fiber,也可称为workInProgress fiber
- 真实dom
- jsx
react做的就是对比上面的1和4,最终生成2,最后将2渲染到页面中。
那么diff是在什么阶段发生的呢?react可以分为三个阶段,分别是 - schedule
- reconcile
- render diff算法就是发生在reconcile这个阶段,reconcile会根据state的变化生成jsx,对比jsx和current fiber得到workInProgress fiber。最后提交给render,渲染到浏览器中。
二、diff算法瓶颈
我们知道diff算法就是循环去对比current fiber和jsx,从算法的角度看,它的复杂度是O(n3),如果页面dom体量大的话,这会是一个相当大的开销,那么react是如何解决这个算法瓶颈呢?react通过三个前提来优化这个算法,分别是:
- 只对同级元素进行Diff。如果一个DOM节点在前后两次更新中跨越了层级,那么React不会尝试复用他
- 两个不同类型的元素会产生出不同的树。如果元素由div变为p,React会销毁div及其子孙节点,并新建p及其子孙节点。
- 开发者可以通过 key prop来暗示哪些子元素在不同的渲染下能保持稳定。 通过这样的限制,最终简化算法。基于以上三点限制,我们看看diff到底是怎样的?
三、diff的入口函数
我们从Diff的入口函数reconcileChildFibers出发, reconcileChildFibers函数在reconcile的beginWork阶段(即阶段)调用
/**
returnFiber 父节点fiber
currentFisteChild 当前对比的fiber
newChild 更新后的jsx对象
lanes 优先级,此文不涉及
*/
function reconcileChildFibers(returnFiber, currentFirstChild, newChild, lanes) {
// 处理fragment类型
var isUnkeyedTopLevelFragment = typeof newChild === 'object' && newChild !== null && newChild.type === REACT_FRAGMENT_TYPE && newChild.key === null;
if (isUnkeyedTopLevelFragment) {
newChild = newChild.props.children;
}
var isObject = typeof newChild === 'object' && newChild !== null;
// 处理单节点
if (isObject) {
switch (newChild.$$typeof) {
case REACT_ELEMENT_TYPE:
return placeSingleChild(reconcileSingleElement(returnFiber, currentFirstChild, newChild, lanes));
case REACT_PORTAL_TYPE:
// do something
case REACT_LAZY_TYPE:
// do something
}
}
// 处理多节点
if (isArray$1(newChild)) {
return reconcileChildrenArray(returnFiber, currentFirstChild, newChild, lanes);
}
...more
}
reconcileChildFibers接收currentFisteChild和newChild,最终生成新的workInProgress fiber。可以看到首先会判断newChild的类型,对不同类型的newChild有不同的处理,我们重点看下单节点和多节点两种情况,即typeof newChild === 'object' 和 isArray$1(newChild) === true两种情况
四、单节点diff
这里的单节点是指更新后得到的jsx是单节点,单节点diff最终会进入reconcileSingleElement
function reconcileSingleElement(returnFiber, currentFirstChild, element, lanes) {
var key = element.key;
var child = currentFirstChild;
// 处理多节点变单节点情况,一直循环,直到找到key和type相同的fiber或者遍历了所有child和其子节点
while (child !== null) {
if (child.key === key) {
switch (child.tag) {
case Fragment:
// 针对fragment,deleteRemainingChildren删除该fiber节点及其所有兄弟节点,调用useFiber,复用该节点并返回
default:
{
// 重点关注,如果key相同且type相同,deleteRemainingChildren删除该fiber节点及其所有兄弟节点(因为是单节点,已经找到目标fiber,之后的兄弟节点无需再处理,直接删除),useFiber复用该fiber节点并返回
if (child.elementType === element.type || ( // Keep this check inline so it only runs on the false path:
isCompatibleFamilyForHotReloading(child, element) )) {
deleteRemainingChildren(returnFiber, child.sibling);
var _existing = useFiber(child, element.props);
_existing.ref = coerceRef(returnFiber, child, element);
_existing.return = returnFiber;
{
_existing._debugSource = element._source;
_existing._debugOwner = element._owner;
}
return _existing;
}
break;
}
} // Didn't match.
// key相同,type不相同(比如div变成p),deleteRemainingChildren删除该fiber节点及其所有兄弟节点(因为是单节点,已经找到目标fiber,之后的兄弟节点无需再处理,直接删除)
deleteRemainingChildren(returnFiber, child);
break;
} else {
// key不相同,给child打上flags为Deletion
deleteChild(returnFiber, child);
}
child = child.sibling;
}
// 如果以上未找到element对应的child(没有相同key),即认为element是新增的节点
if (element.type === REACT_FRAGMENT_TYPE) {
// 处理fragment类型的element
} else {
// 根据element创建一个新的fiber并返回,
var _created4 = createFiberFromElement(element, returnFiber.mode, lanes);
_created4.ref = coerceRef(returnFiber, currentFirstChild, element);
_created4.return = returnFiber;
return _created4;
}
}
从上面代码得知
1. react会遍历child和其兄弟节点,对比jsx和目标fiber(即代码中的child)的key以及节点类型type,判断是否存在与element有相同key和type的child
存在,则复用该child属性生成新的fiber,并将新生成的fiber的pendingProps赋值为element.props,在reconcile的completeWork阶段(即归阶段)生成updateQueue(数组,i项为更新属性的key,i+1项为更新的值),并为fiber.flags打上Update标签,render阶段会做相应的dom更新
不存在,删除child节点(child.flags打上Deletion标签,render阶段会删除对应dom节点)
2. 如果遍历所有的child及sibling后未找到目标child,即element是全新的节点,删除所有child及其兄弟节点(child.flags打上Deletion标签,render阶段会删除对应dom节点),并根据element创建新的fiber,并为fiber.flags打上Placement标签
五、多节点diff
首先,多节点diff可能同时存在节点的新增、删除、更新、移动等我这个情况,按照我们日常的编写代码思维,首先判断节点是属于哪种,然后做相应的逻辑。react在平时的开发中发现,更新出现的概率会远大于新增和删除,所以diff会优先处理更新。
多节点diff入口函数为reconcileChildrenArray,也是在reconcile的beginWork阶段(即阶段)调用,diff算法会经历两轮遍历:
1.第一轮遍历会处理节点的更新
// 以下为删减后代码
var resultingFirstChild = null;
var previousNewFiber = null;
var oldFiber = currentFirstChild;
var lastPlacedIndex = 0;
var newIdx = 0;
var nextOldFiber = null;
for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
// 根据newChildren下标和oldFiber的index,找到对比的oldFiber
if (oldFiber.index > newIdx) {
nextOldFiber = oldFiber;
oldFiber = null;
} else {
nextOldFiber = oldFiber.sibling;
}
// 对比oldFiber和newChildren[newIdx]
// updateSlot逻辑与以上单节点diff逻辑相同,如果oldFiber可复用(即更新节点),返回值为更新后的fiber,否则返回null
var newFiber = updateSlot(returnFiber, oldFiber, newChildren[newIdx], lanes);
if (newFiber === null) {
// 如果newFiber为null,退出第一轮循环
if (oldFiber === null) {
oldFiber = nextOldFiber;
}
break;
}
// 记录第一遍循环最后一次复用的fiber节点的index,在第二轮遍历时使用
lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
if (previousNewFiber === null) {
resultingFirstChild = newFiber;
} else {
previousNewFiber.sibling = newFiber;
}
previousNewFiber = newFiber;
oldFiber = nextOldFiber;
}
举个例子:
- 更新前abcd
- 更新后adcb
当newIdx === 0,更新前后a可以复用,updateSlot返回更新后的fiber,循环继续
当newIdx === 1,因为element.type不相同,updateSlot返回null,循环结束,进行第二次遍历,此时lastPlacedIndex === 0
2.第二轮处理第一轮遍历剩下的fiber节点
第一轮遍历结束后,会存在几种情况
- newChildren全部遍历完了,oldFiber未遍历完
- oldFiber全部遍历完了,newChildren未遍历完
- newChildren和oldFiber都未遍历完了
- newChildren和oldFiber都遍历完了,此时diff算法结束 第一种情况,newChildren全部遍历完了,oldFiber未遍历完
if (newIdx === newChildren.length) {
deleteRemainingChildren(returnFiber, oldFiber);
return resultingFirstChild;
}
代码很简单,因为newChildren都遍历完了,剩下的oldFiber全部是删除的节点,标记为DELETION,在renderer阶段删除
第二种情况,oldFiber全部遍历完了,newChildren未遍历完,处理也比较简单
if (oldFiber === null) {
for (; newIdx < newChildren.length; newIdx++) {
var _newFiber = createChild(returnFiber, newChildren[newIdx], lanes);
if (_newFiber === null) {
continue;
}
// 处理dom移动,记录最后一次复用的fiber节点的index
lastPlacedIndex = placeChild(_newFiber, lastPlacedIndex, newIdx);
if (previousNewFiber === null) {
resultingFirstChild = _newFiber;
} else {
previousNewFiber.sibling = _newFiber;
}
previousNewFiber = _newFiber;
}
return resultingFirstChild;
}
因为oldFiber遍历完了,所以剩下的newChildren全是新增的节点,只要循环遍历剩下的newChildren,创建新的fiber
第三种情况,newChildren和oldFiber都未遍历完了,重点关注一下这个情况
// 遍历剩下oldFiber,生成一个以oldFiber.key为key,oldFiber为value的map
var existingChildren = mapRemainingChildren(returnFiber, oldFiber);
// 遍历剩下的newChildren,通过key找到existingChildren对应的fiber,进行diff算法
for (; newIdx < newChildren.length; newIdx++) {
var _newFiber2 = updateFromMap(existingChildren, returnFiber, newIdx, newChildren[newIdx], lanes);
if (_newFiber2 !== null) {
if (shouldTrackSideEffects) {
// 清理存在的alternate,垃圾回收
if (_newFiber2.alternate !== null) {
existingChildren.delete(_newFiber2.key === null ? newIdx : _newFiber2.key);
}
}
// 处理dom移动,记录最后一次复用的fiber节点的index
lastPlacedIndex = placeChild(_newFiber2, lastPlacedIndex, newIdx);
// 将newFiber通过sibling形成一个单向链表
if (previousNewFiber === null) {
resultingFirstChild = _newFiber2;
} else {
previousNewFiber.sibling = _newFiber2;
}
previousNewFiber = _newFiber2;
}
}
其中updateFromMap跟单节点diff算法逻辑一致,最终返回一个新的fiber,我们重点看一下placeChild,这个是处理dom移动
function placeChild(newFiber, lastPlacedIndex, newIndex) {
newFiber.index = newIndex;
if (!shouldTrackSideEffects) {
// Noop.
return lastPlacedIndex;
}
var current = newFiber.alternate;
if (current !== null) {
var oldIndex = current.index;
if (oldIndex < lastPlacedIndex) {
// This is a move.
newFiber.flags = Placement;
return lastPlacedIndex;
} else {
// This item can stay in place.
return oldIndex;
}
} else {
// 处理newFiber是新增情况
newFiber.flags = Placement;
return lastPlacedIndex;
}
}
上面这段代码重点就在于lastPlacedIndex这个字段,lastPlacedIndex记录了最后一个可复用的fiber的index,即oldFiber.index。我们知道要移动一个fiber时,要寻找一个参照物,而lastPlacedIndex就是这个参照物,举个例子:
- 更新前abcd
- 更新后dabc
经历第一次遍历后,因为d下标对应的oldFiber是a,所以退出循环,此时lastPlacedIndex === 0。
开始第二次循环,通过遍历newChildren,第一项为d,找到更新前的d,此时更新前的d的index为3 ,即oldFiber.index > lastPlacedIndex,不发生位移, 返回lastPlacedIndex = oldIndex。
此时遍历到a,a的oldIndex为0,及oldIndex < lastPlacedIndex,a的flags标记为Placement,返回lastPlacedIndex
依次类推,把bc移动到a后面。我们发现abcd -> dabc移动了abc三个节点,而不是d一个节点,所以在编写代码时尽量避免把元素移动到前面的操作。
以上就是diff算法的大体内容
六、diff的最终结果是什么
通过以上内容可知,diff算法的结果是生成新的fiber节点,节点有新增、删除、更新、移动几种情况,那么fiber是如何通过数据来表现这几种情况呢?
- 新增,reactDom将fiber的flags打上Placement
- 删除,reactDom将fiber的flags打上Deletion
- 更新,reactDom将fiber的flags打上Update,并在fiber.updateQueue上保存了更新的属性
- 移动,移动和新增一样,只是打上了在fiber的flags打上Placement,那么reactDom是如何知道placement插入的位置呢,我们知道执行dom的插入有两种方法,insertBefore和appendChild,react会优先去找插入fiber的sibling,如果找到了执行insertBefore,如果没有找到就执行appendChild,从而实现了新节点插入位置的准确性。 最后在render阶段,react会根据fiber的flags类型,执行相应的操作。细心的同学可能发现了,在render阶段react难道是从rootFiber开始遍历寻找有flags的fiber进行更新吗?这不是很消耗性能吗?其实不然,react会在completeWork结束后,根据fiber的flags形成一个effectList链表,记录了所有需要增、删、更新的fiber,最终render处理的是这条链表而不是整个fiber树。