目录
- 前言
- 单节点
- key相同,类型相同
- key不同,类型相同
- key相同,类型不同
- 多节点
- 第一次遍历
- 第二次遍历
- 第三次遍历
- 总结
前言
这篇文章帮助大家梳理一下React中的dom-diff。在React中,根据新的虚拟DOM的不同,分为单节点(指的是同层级只有一个子节点),和多节点(指的是同层级有多个子节点),分别是在reconcileSingleElement
和reconcileChildrenArray
中进行的。下面结合源码和原理图进行详解。
单节点
单节点的dom-diff是在reconcileSingleElement
中进行的,而能否复用的判断依据就是将要更新的虚拟DOM的key
和HTML元素的类型(即div
和 p
的区别)是否和当前(页面上正在渲染的)真实DOM的fiber一致。
如图所示,对于单节点的diff我们按照图中的流程,结合源码进行一一解读
/**
*
* @param {*} returnFiber 根fiber div#root对应的fiber
* @param {*} currentFirstChild 老的FunctionComponent对应的fiber
* @param {*} element 新的虚拟DOM对象
* @returns 返回新的第一个子fiber
*/
function reconcileSingleElement(returnFiber, currentFirstChild, element) {
//新的虚拟DOM的key,也就是唯一标准
const key = element.key; // null
let child = currentFirstChild; //老的FunctionComponent对应的fiber
while (child !== null) {
//判断此老fiber对应的key和新的虚拟DOM对象的key是否一样 null===null
if (child.key === key) {
//判断老fiber对应的类型和新虚拟DOM元素对应的类型是否相同
if (child.type === element.type) {// p div
deleteRemainingChildren(returnFiber, child.sibling);
//如果key一样,类型也一样,则认为此节点可以复用
const existing = useFiber(child, element.props);
existing.ref = element.ref;
existing.return = returnFiber;
return existing;
} else {
//如果找到一key一样老fiber,但是类型不一样,不能此老fiber,把剩下的全部删除
deleteRemainingChildren(returnFiber, child);
}
} else {
deleteChild(returnFiber, child);
}
child = child.sibling;
}
//因为我们现实的初次挂载,老节点currentFirstChild肯定是没有的,所以可以直接根据虚拟DOM创建新的Fiber节点
const created = createFiberFromElement(element);
created.ref = element.ref;
created.return = returnFiber;
return created;
}
key相同,类型相同
<div>
<div key='A'>A</div>
<div key='B'>B</div>
</div>
<!-- 变化到 -->
<div>
<div key='A'>C</div>
</div>
对于上面列举到的情况,新的虚拟DOM匹配到第一个即为相同key和type,我们首先通过deleteRemainingChildren
方法删除掉其它的多余的子节点(上面的 <div key='B'>B</div>
),然后通过useFiber
方法来复用老fiber产生新的fiber,这样就完成我们的复用。
key不同,类型相同
<div>
<div key='A'>A</div>
<div key='B'>B</div>
</div>
<!-- 变化到 -->
<div>
<div key='C'>C</div>
</div>
对于上面列举到的情况,新的虚拟DOM匹配到第一个即为不同key即使type相同也不会往下进行,通过deleteChild
方法删掉第一个子节点,即<div key='A'>A</div>
对应的fiber,然后再对第二个子节点<div key='B'>B</div>
进行对比,发现key依然不同,继续删除,删除完成之后child === null
成立,跳出while
循环,通过createFiberFromElement
方法根据新的虚拟DOM创建新的fiber。
key相同,类型不同
<div>
<div key='A'>A</div>
<div key='B'>B</div>
</div>
<!-- 变化到 -->
<div>
<p key='A'>C</p>
</div>
对于上面列举的情况,第一次匹配到了相同的key但是type不同,依旧是不符合复用的条件,而且此时会通过deleteRemainingChildren
方法删除掉所有子节点,即不会再进行第二次比较,直接就跳出循环,通过createFiberFromElement
方法根据新的虚拟DOM创建新的fiber。
多节点
多节点的diff相对于单节点的diff来说更加复杂一些。这里主要是在方法reconcileChildrenArray
中进行,这个过程最多会经历三次遍历,每次完成相应的功能,下面我们结合源码来具体探究一下。
function reconcileChildrenArray(returnFiber, currentFirstChild, newChildren) {
let resultingFirstChild = null; //返回的第一个新儿子
let previousNewFiber = null; //上一个的一个新的儿fiber
let newIdx = 0;//用来遍历新的虚拟DOM的索引
let oldFiber = currentFirstChild;//第一个老fiber
let nextOldFiber = null;//下一个第fiber
let lastPlacedIndex = 0;//上一个不需要移动的老节点的索引
// 开始第一轮循环 如果老fiber有值,新的虚拟DOM也有值
for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
//先暂下一个老fiber
nextOldFiber = oldFiber.sibling;
//试图更新或者试图复用老的fiber
const newFiber = updateSlot(returnFiber, oldFiber, newChildren[newIdx]);
if (newFiber === null) {
break;
}
//如果有老fiber,但是新的fiber并没有成功复用老fiber和老的真实DOM,那就删除老fiber,在提交阶段会删除真实DOM
if (oldFiber && newFiber.alternate === null) {
deleteChild(returnFiber, oldFiber);
}
//指定新fiber的位置
lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
if (previousNewFiber === null) {
resultingFirstChild = newFiber;//li(A).sibling=p(B).sibling=>li(C)
} else {
previousNewFiber.sibling = newFiber;
}
previousNewFiber = newFiber;
oldFiber = nextOldFiber
}
//新的虚拟DOM已经循环完毕
if (newIdx === newChildren.length) {
//删除剩下的老fiber
deleteRemainingChildren(returnFiber, oldFiber);
return resultingFirstChild;
}
if (oldFiber === null) {
//如果老的 fiber已经没有了, 新的虚拟DOM还有,进入插入新节点的逻辑
for (; newIdx < newChildren.length; newIdx++) {
const newFiber = createChild(returnFiber, newChildren[newIdx]);
if (newFiber === null) continue;
lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
//如果previousNewFiber为null,说明这是第一个fiber
if (previousNewFiber === null) {
resultingFirstChild = newFiber; //这个newFiber就是大儿子
} else {
//否则说明不是大儿子,就把这个newFiber添加上一个子节点后面
previousNewFiber.sibling = newFiber;
}
//让newFiber成为最后一个或者说上一个子fiber
previousNewFiber = newFiber;
}
}
// 开始处理移动的情况
const existingChildren = mapRemainingChildren(returnFiber, oldFiber);
//开始遍历剩下的虚拟DOM子节点
for (; newIdx < newChildren.length; newIdx++) {
const newFiber = updateFromMap(existingChildren, returnFiber, newIdx, newChildren[newIdx]);
if (newFiber !== null) {
//如果要跟踪副作用,并且有老fiber
if (newFiber.alternate !== null) {
existingChildren.delete(newFiber.key === null ? newIdx : newFiber.key);
}
//指定新的fiber存放位置 ,并且给lastPlacedIndex赋值
lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
if (previousNewFiber === null) {
resultingFirstChild = newFiber; //这个newFiber就是大儿子
} else {
//否则说明不是大儿子,就把这个newFiber添加上一个子节点后面
previousNewFiber.sibling = newFiber;
}
//让newFiber成为最后一个或者说上一个子fiber
previousNewFiber = newFiber;
}
}
//等全部处理完后,删除map中所有剩下的老fiber
existingChildren.forEach(child => deleteChild(returnFiber, child));
return resultingFirstChild;
}
这段代码是比较长的,这里全部贴出来就是体现其完整性。下面帮助大家逐步的分析。
<ul key="container">
<li key="A">A</li>
<li key="B">B</li>
<li key="C">C</li>
<li key="D">D</li>
<li key="E">E</li>
<li key="F">F</li>
</ul>
<!-- 变化到 -->
<ul key="container">
<li key="A">A2</li>
<li key="C">C2</li>
<li key="E">E2</li>
<li key="B">B2</li>
<li key="G">G</li>
<li key="D">D2</li>
</ul>
第一次遍历
for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
//先暂下一个老fiber
nextOldFiber = oldFiber.sibling;
//试图更新或者试图复用老的fiber
const newFiber = updateSlot(returnFiber, oldFiber, newChildren[newIdx]);
if (newFiber === null) {
break;
}
if (shouldTrackSideEffects) {
//如果有老fiber,但是新的fiber并没有成功复用老fiber和老的真实DOM,那就删除老fiber,在提交阶段会删除真实DOM
if (oldFiber && newFiber.alternate === null) {
deleteChild(returnFiber, oldFiber);
}
}
//指定新fiber的位置
lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
if (previousNewFiber === null) {
resultingFirstChild = newFiber;//li(A).sibling=p(B).sibling=>li(C)
} else {
previousNewFiber.sibling = newFiber;
}
previousNewFiber = newFiber;
oldFiber = nextOldFiber
}
我们所有的对比都是基于新节点的虚拟DOM和老节点的fiber,当我们对比A1和A2时,会根据updateSlot
方法进行条件判断,发现他们的key和type相同,符合复用条件返回创建好的fiber,我们的操作指针都指向下一个操作节点,开始对下一个节点进行第一次遍历。
当我们对比C2和B时,因为C2和B的key并不相同,updateSlot
返回null
,第一次遍历break
开始进入第二次遍历。
第二次遍历
if (oldFiber === null) {
//如果老的 fiber已经没有了, 新的虚拟DOM还有,进入插入新节点的逻辑
for (; newIdx < newChildren.length; newIdx++) {
const newFiber = createChild(returnFiber, newChildren[newIdx]);
if (newFiber === null) continue;
lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
//如果previousNewFiber为null,说明这是第一个fiber
if (previousNewFiber === null) {
resultingFirstChild = newFiber; //这个newFiber就是大儿子
} else {
//否则说明不是大儿子,就把这个newFiber添加上一个子节点后面
previousNewFiber.sibling = newFiber;
}
//让newFiber成为最后一个或者说上一个子fiber
previousNewFiber = newFiber;
}
}
然而oldFiber
依旧是存在的,会直接进入到第三次遍历,但是我们这里带大家梳理一下,看看是如何操作的。这里的遍历主要是针对新节点还存在,但是老fiber已经没有了,即新更新的节点要多余老节点的情况,我们这里需要做的就是将剩下的新节点的fiber通过createChild
创造出来。
第三次遍历
// 开始处理移动的情况
const existingChildren = mapRemainingChildren(returnFiber, oldFiber);
//开始遍历剩下的虚拟DOM子节点
for (; newIdx < newChildren.length; newIdx++) {
const newFiber = updateFromMap(
existingChildren,
returnFiber,
newIdx,
newChildren[newIdx],
);
if (newFiber !== null) {
//如果要跟踪副作用,并且有老fiber
if (newFiber.alternate !== null) {
existingChildren.delete(newFiber.key === null ? newIdx : newFiber.key);
}
//指定新的fiber存放位置 ,并且给lastPlacedIndex赋值
lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
if (previousNewFiber === null) {
resultingFirstChild = newFiber; //这个newFiber就是大儿子
} else {
//否则说明不是大儿子,就把这个newFiber添加上一个子节点后面
previousNewFiber.sibling = newFiber;
}
//让newFiber成为最后一个或者说上一个子fiber
previousNewFiber = newFiber;
}
}
function mapRemainingChildren(returnFiber, currentFirstChild) {
const existingChildren = new Map();
let existingChild = currentFirstChild;
while (existingChild != null) {
//如果有key用key,如果没有key使用索引
if (existingChild.key !== null) {
existingChildren.set(existingChild.key, existingChild);
} else {
existingChildren.set(existingChild.index, existingChild);
}
existingChild = existingChild.sibling;
}
return existingChildren;
}
接下来我们进行第三次遍历,也就是我们节点移动的情况,这里的复用是比较复杂了。
首先我们会创造一个Map
来承接所有的剩余的老节点,接下来我们会根据key,或者index,来挑选老节点以供复用。找到一个能复用的节点,就会在Map
中删除对应的节点,如果有对应的点就复用,没有就新创建节点。
- 多个节点数量不同、key 不同;
- 第一轮比较 A 和 A,相同可以复用,更新,然后比较 B 和 C,key 不同直接跳出第一个循环;
- 把剩下 oldFiber 的放入 existingChildren 这个 map 中;
- 然后声明一个lastPlacedIndex变量,表示不需要移动的老节点的索引;
- 继续循环剩下的虚拟 DOM 节点;
- 如果能在 map 中找到相同 key 相同 type 的节点则可以复用老 fiber,并把此老 fiber 从 map 中删除;
- 如果能在 map 中找不到相同 key 相同 type 的节点则创建新的 fiber;
- 如果是复用老的 fiber,则判断老 fiber 的索引是否小于 lastPlacedIndex,如果是要移动老 fiber,不变;
- 如果是复用老的 fiber,则判断老 fiber 的索引是否小于 lastPlacedIndex,如果否则更新 lastPlacedIndex 为老 fiber 的 index;
- 把所有的 map 中剩下的 fiber 全部标记为删除;
- (删除#li#F)=>(添加#li#B)=>(添加#li#G)=>(添加#li#D)=>null;
总结
DOM DIFF 的三个规则
- 只对同级元素进行比较,不同层级不对比
- 不同的类型对应不同的元素
- 可以通过 key 来标识同一个节点
第 1 轮遍历
- 如果 key 不同则直接结束本轮循环
- newChildren 或 oldFiber 遍历完,结束本轮循环
- key 相同而 type 不同,标记老的 oldFiber 为删除,继续循环
- key 相同而 type 也相同,则可以复用老节 oldFiber 节点,继续循环
第 2 轮遍历
- newChildren 遍历完而 oldFiber 还有,遍历剩下所有的 oldFiber 标记为删除,DIFF 结束
- oldFiber 遍历完了,而 newChildren 还有,将剩下的 newChildren 标记为插入,DIFF 结束
- newChildren 和 oldFiber 都同时遍历完成,diff 结束
- newChildren 和 oldFiber 都没有完成,则进行节点移动的逻辑
- 第 3 轮遍历
处理节点移动的情况