React-Diff解析
在使用React的时候,我们一直被提示要保证数组循环的过程中,每一项都有一个唯一的标识符key,但是有时候却不明白为什么需要有一个唯一的标识符。
它在2个数组进行对比的时候有什么作用, React是怎么做到复用原有的fiber结构的。这整个过程发生在调和阶段,主要是把vnode转换成对应的fiber结构。
原理综述
React的数组虚拟dom对比主要的逻辑在reconcileChildrenArray中,它负责通过key和元素类型,查看是否可以利用原有的虚拟dom,如果不能利用,就将React.createElement创建的对象转换成新的vnode。
主要分几个部分
- 从新vnode列表页面中的第一个vnode开始查看,是否和旧列表中的第一个fiber匹配。如果匹配的话,就继续循环,直到没有匹配的时候,停止循环。
- 新的vnode在旧的fiber不存在,但是又没有循环完,说明新的vnode是新建的,走创建流程
- 新vnode和旧的fiber列表如果只是位置上的改变,通过一个map映射表进行查找和替换。
function reconcileChildrenArray(returnFiber, currentFirstChild, newChildren, lanes) {}
// 参数解释
// 1. returnFiber: 列表父元素
// 2. currentFirstChild: 父元素的第一个子元素
// 3. newChildren: 新的React.createElement创建的对象数组,此时还没有变成fiber结构
场景
数组对比主要是分为几种情况
- 新增数组元素
- 删除了某一个元素
- 在原有的基础上进行移动位置
新增数组元素
在原有的列表(A,B,C) 的基础上添加一个D, 新的列表为A,B,C,D, 我们来看看React是怎么操作。
主要流程:
- 在循环新的子列表的时候, 通过调用
updateSlot函数,React发现旧的列表的第一个元素oldFiber和新列表第一个元素的key和元素类型都相等(newChildRen[newIdx]),然后就将oldFiber复用给新的元素 - 继续循环
newChildRen, 同时oldFiber的指针通过oldFiber.sibling后移。直到找不到相等的元素后,结束循环。 - 循环结束后,发现
oldFiber指向为null, 如果newIdx < newChildren.length就说明之后的节点都是属于新增内容,所以会走到新创建fiber的流程。
删除元素
在原有的列表(A,B,C) 的基础上删除一个C, 新的列表为A,B。
主要流程:
- 前2步都和新增元素一样,直到
newChildren循环完成。 - 发现
oldFiber还有指向,并且(newIdx === newChildren.length),所以会执行删除逻辑,从父fiberreturnFiber中删除掉oldFiber部分,直到为null
移动元素
在原有的列表(A,B,C)基础上,我们移动元素打乱之前的顺序,新的列表为C,B,A
主要流程:
- 再第一次循环新列表的时候,React发现
oldFiber和新的列表第一个子元素并不匹配,就结束第一次循环 - 此时新的列表元素都没有处理
newIdx = 0,旧的元素也没有处理oldFiber不等于null。 - React为了降低时间复杂度,通过
mapRemainingChildren函数将旧的fiber列表转换成一个map, 赋值给existingChildren - 再次循环
newChildren, 从existingChildren中查找是否包含相应的元素,如果存在就复用原有的fiber,如果不存在,就根据元素对象(React.createElement创建的)新建一个fiber。 - 在查找的过程中,有一个
lastPlacedIndex变量,它主要是记录着旧列表中最大的索引,由于新列表的是从头开始循环的,如果匹配的fiber旧列表中,有fiber的index<lastPlacedIndex, 就标记为需要移动。比如例子中lastPlacedIndex的值为2, 当遍历到B的时候,B在旧列表中的index为1,但是newChildren需要放在C的后面,所以是要移动。
源码部分
通过上面的图解析,我们大概已经知道了运行的场景,下面我们来通过源码来查看一下距离的流程。首先明白几个名字的作用,不需要去深究内容。
lastPlacedIndex: 记录旧的fiber列表中,已经遍历的最大索引
oldFiber: 旧的fiber列表中的第一个元素。 newChildren: 新的vnode列表
updateSlot: 根据oldFiber和newChildren[newIdx] 判断是否匹配,如果匹配就根据pendingProps更新旧的fiber, 做到复用。如果oldFiber不存在,说明是新建,就根据pendingProps新建一个fiber返回。
placeChild: 找到遍历过程中旧的fiber列表中的最大索引
deleteRemainingChildren:删除旧的fiber
shouldTrackSideEffects: 更新阶段为true, 非更新阶段为false
reconcileChildrenArray接受4个参数
returnFiber: 旧的fiber列表的父容器。currentFirstChild: 第一个子fiber(上面例子中的A元素)newChildren: 新的vnode数组,此时还没有调和成fiberlanes: 更新的优先级
源码分步(第一步)
这个是第一个部分,可以和我们的删除尾部元素相对应,就是保留原有的旧的fiber顺序,遍历完newChildren后,删除对于的旧的fiber。
function reconcileChildrenArray(returnFiber, currentFirstChild, newChildren, lanes) {
var resultingFirstChild = null; // 记录返回的新fiber列表的第一个,之后会用于递归调和
var previousNewFiber = null; // 记录fiber链,第一个之后通过sibling链接
var oldFiber = currentFirstChild;
var lastPlacedIndex = 0;
var newIdx = 0;
var nextOldFiber = null;
// 旧的fiber存在,开始第一次循环
for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
if (oldFiber.index > newIdx) {
nextOldFiber = oldFiber;
oldFiber = null;
} else {
nextOldFiber = oldFiber.sibling; // 赋值旧的列表中下一个fiber
}
// 这里updateSlot是查找新的vnode是否和oldFiber相匹配,如果匹配旧返回新的,如果不匹配就返回null
var newFiber = updateSlot(returnFiber, oldFiber, newChildren[newIdx], lanes);
if (newFiber === null) {
// TODO: This breaks on empty slots like null children. That's
// unfortunate because it triggers the slow path all the time. We need
// a better way to communicate whether this was a miss or null,
// boolean, undefined, etc.
if (oldFiber === null) {
oldFiber = nextOldFiber;
}
// 没有找到就结束循环
break;
}
if (shouldTrackSideEffects) {
if (oldFiber && newFiber.alternate === null) {
// 匹配后要删除老的fiber的一些数据
deleteChild(returnFiber, oldFiber);
}
}
// 记录当前的已经处理的最大索引,用于标记之后的fiber是移动还是新增等
lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
if (previousNewFiber === null) {
// 第一次运行的时候,记录新列表的第一个子fiber
resultingFirstChild = newFiber;
} else {
// 非第一次运行,通过sibling链式新的fiber
previousNewFiber.sibling = newFiber;
}
previousNewFiber = newFiber;
oldFiber = nextOldFiber; // 赋值旧的fiber中的下一个
}
if (newIdx === newChildren.length) {
// 新的vnode列表遍历完成后,删除老的还没有使用过的fiber
deleteRemainingChildren(returnFiber, oldFiber);
return resultingFirstChild;
}
}
源码分步(第二步)
如果在上面的流程并没有走到return resultingFirstChild中,我们来看看接下来的流程
function reconcileChildrenArray(returnFiber, currentFirstChild, newChildren, lanes) {
// xxxxxx 省略上一步的代码
// 此时说明旧的fiber列表已经处理完。
if (oldFiber === null) {
// 但是新的vnode列表并没有处理完,说明要根据vnode元素新增fiber
for (; newIdx < newChildren.length; newIdx++) {
// 根据上一步未处理完的vnode, 创建新的fiber
var _newFiber = createChild(returnFiber, newChildren[newIdx], lanes);
if (_newFiber === null) {
continue;
}
// oldFiber为null,标记当前的fiber为新增元素
lastPlacedIndex = placeChild(_newFiber, lastPlacedIndex, newIdx);
if (previousNewFiber === null) {
// 首次处理
resultingFirstChild = _newFiber;
} else {
previousNewFiber.sibling = _newFiber;
}
previousNewFiber = _newFiber;
}
return resultingFirstChild;
}
}
源码分步(第三步)
第三步主要是处理位置变化的逻辑,根据旧的fiber列表生成一个map映射,查找复用的fiber节点
function reconcileChildrenArray(returnFiber, currentFirstChild, newChildren, lanes) {
// xxxxxx 省略上一步的代码
// 生成旧的fiber的map映射(key或者Index作为key, fiber节点作为value)
var existingChildren = mapRemainingChildren(returnFiber, oldFiber);
// 再次开始循环
for (; newIdx < newChildren.length; newIdx++) {
// 在map中查找是否有存在的,如果有对应的key存在,就更新对应的fiber并返回,如果没有,就要新增fiber
var _newFiber2 = updateFromMap(existingChildren, returnFiber, newIdx, newChildren[newIdx], lanes);
if (_newFiber2 !== null) {
if (shouldTrackSideEffects) {
// _newFiber2的alternate如果不为null,就说明是复用
if (_newFiber2.alternate如果不为null !== null) {
// 删除map中的对应元素
existingChildren.delete(_newFiber2.key === null ? newIdx : _newFiber2.key);
}
}
lastPlacedIndex = placeChild(_newFiber2, lastPlacedIndex, newIdx);
if (previousNewFiber === null) {
resultingFirstChild = _newFiber2;
} else {
previousNewFiber.sibling = _newFiber2;
}
previousNewFiber = _newFiber2;
}
} // 结束循环
if (shouldTrackSideEffects) {
// 处理完后,如果还有存在在map中的,就要进行删除处理,因为新的vnode列表已经转换完成
existingChildren.forEach(function (child) {
return deleteChild(returnFiber, child);
});
}
return resultingFirstChild; // 整体结束,返回第一个子元素,用于之后的调和
}
在这里reconcileChildrenArray就完全的执行完成了。
总结
React在调和过程中,采用的复用旧的fiber的过程,整体来说,是通过循环递增的方法,通过标记最大的移动偏移量进行移动标记。
和Vue2.0的双指针对比,这样也有一个弊端,比如下面的一组更新,在Vue中,我们只需要讲A节点移动到最后就可以了,一次移动。但是在React中,我们会首先找到B,然后遍历到C,然后再到A,这样就多了2次的查找。