react diff
算法是react框架的核心算法,它最大的作用就是在应用更新的时候,找出新旧虚拟节点树的差异,最大程度的复用旧的节点信息,来减少真实的dom
渲染,以此来提高框架的性能。
根据react
的框架设计,整个渲染流程可以分为两个大的阶段:
render
阶段:这个阶段由scheduler
调度程序和Reconciler
调和程序两个模块构成,主要内容是更新任务的调度以及FiberTree
的构建。commit
阶段:根据创建完成的FiberTree
,构建出真实的DOM内容渲染到页面。
而diff
算法正是位于Reconciler
调和流程中【创建fiberTree
】,对于diff
算法来说最核心的作用就是:复用 。在创建fiber
节点的过程中,最大程度的复用旧节点信息,复用之后删除可能剩下的多余旧节点,最后创建新增的节点。
一,函数组件更新
本章节主要分析react diff
算法的具体逻辑,这里我们直接跳转到Reconciler
调和流程中,开始深入diff
算法。
首先,我们观察一个函数组件在更新阶段都会执行到的一个函数updateFunctionComponent
:
js
// 更新函数组件
function updateFunctionComponent(
current,
workInProgress,
Component,
nextProps: any,
renderLanes,
) {
let context;
let nextChildren;
let hasId;
# 1.重新渲染函数组件
nextChildren = renderWithHooks(
current,
workInProgress,
Component,
nextProps,
context,
renderLanes,
);
hasId = checkDidRenderIdHook();
}
if (current !== null && !didReceiveUpdate) {
bailoutHooks(current, workInProgress, renderLanes);
return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes);
}
# 2.进入diff算法更新子节点
reconcileChildren(current, workInProgress, nextChildren, renderLanes);
return workInProgress.child;
}
从这个函数名称我们就可以确定:这个函数的作用就是更新函数组件,它的内容主要可以分为两个模块:
- 调用
renderWithHooks
方法重新渲染一次函数。 - 调用
reconcileChildren
方法生成新的子节点。
在本章我们主要是来分析react
的diff
算法,所以这里我们继续分析第二个模块内容即可。
注意:
reconcileChildren
是专门生成子节点的方法,在很多类型组件中都会执行。
1,reconcileChildren
查看reconcileChildren
内容:
js
// 子节点创建流程
export function reconcileChildren(
current: Fiber | null,
workInProgress: Fiber,
nextChildren: any,
renderLanes: Lanes,
) {
if (current === null) {
// 加载阶段
workInProgress.child = mountChildFibers(
workInProgress,
null,
nextChildren,
renderLanes,
);
} else {
// 更新阶段
workInProgress.child = reconcileChildFibers(
workInProgress,
current.child,
nextChildren,
renderLanes,
);
}
}
reconcileChildren
方法的内容很简单,通过判断旧节点current
是否有值来区分当前为加载阶段还是更新阶段:
- 加载阶段:就会直接根据
nextChildren
内容来创建新的fiber
子节点。 - 更新阶段:就会根据旧节点的内容以及
nextChildren
内容来生成新的子节点。
js
// 子节点调和
function ChildReconciler(shouldTrackSideEffects) {
...
// 单节点diff
function reconcileSingleElement() {}
// 多节点diff
function reconcileChildrenArray() {}
// 调和生成子节点
function reconcileChildFibers() {}
return reconcileChildFibers;
}
// 更新子节点
export const reconcileChildFibers = ChildReconciler(true);
// 加载子节点
export const mountChildFibers = ChildReconciler(false);
需要注意的是:这两个函数其实是同一个方法ChildReconciler
,唯一的区别传入的参数不同,在更新阶段可以追踪副作用。而这两个函数实际的内容就是reconcileChildFibers
方法的内容。
2,reconcileChildFibers
这里我们继续查看reconcileChildFibers
:
js
function reconcileChildFibers(
returnFiber: Fiber,
currentFirstChild: Fiber | null,
newChild: any,
lanes: Lanes,
): Fiber | null {
// 子节点处理
if (typeof newChild === 'object' && newChild !== null) {
# 1,单子节点处理
switch (newChild.$$typeof) {
// 默认的react元素对象类型:单节点处理
case REACT_ELEMENT_TYPE:
return placeSingleChild(
reconcileSingleElement(
returnFiber,
currentFirstChild,
newChild,
lanes,
),
);
// 其他类型省略
...
}
# 2,多子节点的处理,比如div.App下面有三个子节点,它的children为数组
if (isArray(newChild)) {
return reconcileChildrenArray(
returnFiber,
currentFirstChild,
newChild,
lanes,
);
}
}
// newChild还可能为一个文本节点类型
if (
(typeof newChild === 'string' && newChild !== '') ||
typeof newChild === 'number'
) {
return placeSingleChild(
reconcileSingleTextNode(
returnFiber,
currentFirstChild,
'' + newChild,
lanes,
),
);
}
// Remaining cases are all treated as empty.
return deleteRemainingChildren(returnFiber, currentFirstChild);
}
注意这里的newChild
参数,它就是表示新的子节点对应的reactElement
对象,它一般有两种情况:
- 单节点就是一个
reactElement
对象。 - 多节点就是一个由
reactElement
对象组成的数组。
js
// 单节点
newChild = { type: 'div', key: null, ref: null, props: {} } // 一个reactElement对象
// 多节点
newChild = [
{ type: 'div', key: null, ref: null, props: {} },
{ type: 'div', key: null, ref: null, props: {} },
{ type: 'div', key: null, ref: null, props: {} }
]
reactElement
对象就是组件render
之后的返回值【虚拟dom
】,每次渲染都会将reactElement
对象进一步转换为fiber
节点。
接下来我们就分别来解析react
的单节点diff
和多节点diff
流程。
二,单节点diff
当newChild
为一个reactElement
对象时,就会进入reconcileSingleElement
方法,执行单节点diff
流程。
1,reconcileSingleElement
这里我们查看reconcileSingleElement
方法:
js
// 单子节点diff
function reconcileSingleElement(
returnFiber: Fiber,
currentFirstChild: Fiber | null,
element: ReactElement,
lanes: Lanes,
): Fiber {
// 取出最新的react元素对象的key
const key = element.key;
let child = currentFirstChild; // 旧的节点
/**
* 【更新阶段】
* 单节点diff,为什么做成一个循环:
* 因为新的内容只有一个节点,不代表旧的内容只有一个节点,这里可以分为两种情况:
* 1. 新旧都只有一个节点,只执行一次循环的比较
* 2. 旧的有多个节点,新的只有一个节点,就会循环旧的节点,依次和这个新节点进行key的比较,
* 如果key相等,再比较type类型,比如是否都是div类型,
*
*/
while (child !== null) {
// 1.key相等的情况下
if (child.key === key) {
// 取出最新的节点type:比如组件的type或者DOM节点的type
const elementType = element.type;
// 2,组件type也相等的情况下
if (child.elementType === elementType) {
// 在相等的情况下,给剩下的旧节点打上删除标记
deleteRemainingChildren(returnFiber, child.sibling);
// 复用当前旧的节点,生成新的fiber节点
const existing = useFiber(child, element.props);
// 设置初始的ref
existing.ref = coerceRef(returnFiber, child, element);
// 设置父级节点
existing.return = returnFiber;
// 返回新的节点
return existing;
}
// Didn't match.
// key相等但是type不等的情况下,给所有旧节点打上删除标记【比如组件由div变成span了】
deleteRemainingChildren(returnFiber, child);
break;
} else {
// 3,key不相等的情况下,给当前旧的节点打上删除标记
deleteChild(returnFiber, child);
}
// 取出旧的节点的兄弟节点,继续与新的节点进行比较【旧节点可能存在多个】
child = child.sibling;
}
/**
* 1. 加载阶段:直接创建新的fiber节点
* 2. 更新阶段:循环匹配之后,没有匹配到相等的key,则直接使用element对象创建新的fiber节点
*/
const created = createFiberFromElement(element, returnFiber.mode, lanes);
created.ref = coerceRef(returnFiber, currentFirstChild, element);
// 设置新节点父级指针
created.return = returnFiber;
return created;
}
reconcileSingleElement
方法里面的内容并不多,主体是一个while
循环结构,可能大家会疑惑单节点diff
为什么还会有循环,其实单节点与多节点的区分主要是看之前的newChild
变量【即新节点的数据结构】,只要新的子节点是一个单独的reactElement
对象,那它就属于单节点diff
,之所以会出现循环是因为(当前层级)旧的fiber
节点可能会有多个,比如原来有多个div
元素,本次更新修改后就只剩下一个子节点,那它就会进入单节点diff
。
我们继续查看循环体的逻辑,它的循环条件是旧的child
不为null
,这里要说明的是,在更新阶段一定会存在旧的fiber
节点,从当前旧的fiber
节点开始,与当前新的子节点进行比较:
- 如果
key
相等,则继续判断新旧节点的type
类型是否相等。- 如果
type
也相等【比如都是div
类型】,则可以复用当前旧的fiber
节点信息和新的props
,来生成新的fiber
节点。最后将其他可能存在的剩余旧节点打上删除的标记,等待commit
阶段执行删除逻辑。 - 如果
key
相等但是type
不相等【比如由div
变成了span
类型】,说明旧节点都无法复用,则直接给所有旧节点打上删除标记,同时跳出循环。
- 如果
- 如果
key
不相等,则直接给当前旧的fiber
节点打上删除标记,同时更新child
变量,继续比较下一个旧节点。
如果循环执行完成之后,没有匹配到相同的key
,则直接调用createFiberFromElement
方法,使用当前的reactElement
对象来创建新的fiber
节点。
总结 :到此,单节点diff
逻辑就执行完成了,整体的内容是比较简单的,主要就是判断新旧节点的key
和type
是否同时相等,只有同时相等时才能复用旧的fiber
节点,否则就只能创建一个新的fiber
节点。
三,多节点diff
当newChild
是一个由多个reactElement
对象构成的数组时,就会进入reconcileChildrenArray
方法,执行多节点diff
流程。
js
// 多节点diff
if (isArray(newChild)) {
return reconcileChildrenArray(
returnFiber,
currentFirstChild,
newChild,
lanes,
);
}
1,reconcileChildrenArray
这里我们查看reconcileChildrenArray
方法:
js
// 多子节点diff
function reconcileChildrenArray(
returnFiber: Fiber,
currentFirstChild: Fiber | null,
newChildren: Array<*>,
lanes: Lanes,
): Fiber | null {
// 第一个新的子节点
let resultingFirstChild: Fiber | null = null;
// 上一个新建的节点
let previousNewFiber: Fiber | null = null;
// 当前参与diff比较的oldFiber
let oldFiber = currentFirstChild;
let lastPlacedIndex = 0;
let newIdx = 0;
// 下一个参与diff比较的oldFiber
let nextOldFiber = null;
# 第一次循环【循环数据为新的子节点element数组】
// 循环结束条件:循环到新数据最后一个,并且期间对应索引的旧节点一直有值
for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
// 位置不匹配时,设置oldFiber为null,退出循环
if (oldFiber.index > newIdx) {
nextOldFiber = oldFiber;
oldFiber = null;
} else {
// 正常比对情况:从当前旧的子节点取出它的兄弟节点,作为下一个比对的旧节点
nextOldFiber = oldFiber.sibling;
}
/***
*
* 生成新的fiber,【有可能复用current节点信息,创建对应的新的Fiber】【更新的内容主要是props】、
* 但是和Bailout策略不同的是:那边是直接用的原props对象。
* 这里是用的react元素对象生成的新的props对象。
*
*/
// 通过比对key值,生成新的fiber子节点,如果没有匹配到则返回null
const newFiber = updateSlot(
returnFiber,
oldFiber,
newChildren[newIdx], // 对应的react元素对象
lanes,
);
// 如果新子节点为null,说明本次匹配失败
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) {
// We matched the slot, but we didn't reuse the existing fiber, so we
// need to delete the existing child.
deleteChild(returnFiber, oldFiber);
}
}
// 打上插入的标记
lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
if (previousNewFiber === null) {
// TODO: Move out of the loop. This only happens for the first run.
resultingFirstChild = newFiber;
} else {
// TODO: Defer siblings if we're not at the right index for this slot.
// I.e. if we had null values before, then we want to defer this
// for each null value. However, we also don't want to call updateSlot
// with the previous one.
previousNewFiber.sibling = newFiber;
}
// 本次匹配成功的情况下:继续下一轮匹配
previousNewFiber = newFiber;
oldFiber = nextOldFiber;
}
/**
* 第一次循环匹配之后的多种情况:
*/
// 1. 如果索引等于新子节点数组的长度,说明已经匹配完成,可以复用对应的旧的fiber节点,并且删除旧的多余的节点
// 这种情况是完全匹配,或者是执行了尾部删除,可以复用前面全部内容。
if (newIdx === newChildren.length) {
// We've reached the end of the new children. We can delete the rest.
// 删除从当前旧节点剩下的可能存在的节点:打上删除标记
deleteRemainingChildren(returnFiber, oldFiber);
if (getIsHydrating()) {
const numberOfForks = newIdx;
pushTreeFork(returnFiber, numberOfForks);
}
return resultingFirstChild;
}
// 2. 旧的节点利用完成,还存在新节点时
if (oldFiber === null) {
// If we don't have any more existing children we can choose a fast path
// since the rest will all be insertions.
// 多子节点的 创建:创建第一个的child,完成之后退出创建,返回resultingFirstChild结果为第一个child
for (; newIdx < newChildren.length; newIdx++) {
const newFiber = createChild(returnFiber, newChildren[newIdx], lanes);
if (newFiber === null) {
continue;
}
lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
// previousNewFiber === null,表示为第一次循环:
if (previousNewFiber === null) {
// TODO: Move out of the loop. This only happens for the first run.
// 则设置第一个新创建的有效的Fiber节点, 为结果child内容
resultingFirstChild = newFiber;
} else {
// 否则表示不是第一次循环,将新建的节点设置为上一个节点的兄弟节点
previousNewFiber.sibling = newFiber;
}
// 更新上一个新的节点
previousNewFiber = newFiber;
}
if (getIsHydrating()) {
const numberOfForks = newIdx;
pushTreeFork(returnFiber, numberOfForks);
}
// 退出当前函数,返回创建完成的第一个child
return resultingFirstChild;
}
// Add all children to a key map for quick lookups.
// 3. diff时,存在不匹配的情况,将剩下的所有旧的子节点添加到一个map中,执行快速查找 【oldFiber是当前索引位置的旧的子节点】
// 生成一个map结构【fiber.key为键,fiber为值】
const existingChildren = mapRemainingChildren(returnFiber, oldFiber);
// Keep scanning and use the map to restore deleted items as moves.
// 第二次循环:使用map查找
for (; newIdx < newChildren.length; newIdx++) {
// 从map结构中继续查找可能相同的fiber:找到则复用生成,没有找到则直接创建新的fiber
const newFiber = updateFromMap(
existingChildren,
returnFiber,
newIdx,
newChildren[newIdx],
lanes,
);
// 如果新fiber存在【基本都是有值的】
if (newFiber !== null) {
if (shouldTrackSideEffects) {
// 如果新fiber存在并且它的alternate属性也有值,说明是通过复用fiber生成的
// 应该根据它的key,从当前map结构中删除旧的current。【移除已匹配中的键值对】
if (newFiber.alternate !== null) {
// The new fiber is a work in progress, but if there exists a
// current, that means that we reused the fiber. We need to delete
// it from the child list so that we don't add it to the deletion
// list.
existingChildren.delete(
newFiber.key === null ? newIdx : newFiber.key,
);
}
}
// 确定新生成的fiber节点在子列表中的位置【打上插入标记,在commit阶段执行插入】
lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
/**
* 更新变量,继续下一个节点的匹配
*/
if (previousNewFiber === null) {
// 少数情况:在最前面第一次循环逻辑中一个节点都没有匹配到的情况,需要再次更新resultingFirstChild
// 即需要将第一个成功创建的fiber设置为firstChild
resultingFirstChild = newFiber;
} else {
// 一般情况:更新新子节点sibling属性
previousNewFiber.sibling = newFiber;
}
previousNewFiber = newFiber;
}
}
if (shouldTrackSideEffects) {
// Any existing children that weren't consumed above were deleted. We need
// to add them to the deletion list.
existingChildren.forEach(child => deleteChild(returnFiber, child));
}
return resultingFirstChild;
}
多节点diff
算法的内容要比单节点多,不过也并不复杂,接下来我们对这部分逻辑逐个解析。
2,第一轮循环
js
// 第一次循环【循环数据为新的子节点element数组】
// 循环结束条件:循环到新数据最后一个,或者对应索引的旧节点为null
for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
// 位置不匹配时,设置oldFiber为null,退出循环
if (oldFiber.index > newIdx) {
nextOldFiber = oldFiber;
oldFiber = null;
} else {
// 正常比对情况:从当前旧的子节点取出它的兄弟节点,作为下一个比对的旧节点
nextOldFiber = oldFiber.sibling;
}
/***
*
* 生成新的fiber,【有可能复用current节点信息,创建对应的新的Fiber】【更新的内容主要是props】、
* 但是和Bailout策略不同的是:那边是直接用的原props对象。
* 这里是用的react元素对象生成的新的props对象。
*
*/
// 通过比对key值,生成新的fiber子节点,如果没有匹配到则返回null
const newFiber = updateSlot(
returnFiber,
oldFiber,
newChildren[newIdx], // 对应的react元素对象
lanes,
);
// 如果新子节点为null,说明本次匹配失败
if (newFiber === null) {
if (oldFiber === null) {
oldFiber = nextOldFiber;
}
// 只要出现不匹配的情况,则直接退出循环
break;
}
if (shouldTrackSideEffects) {
if (oldFiber && newFiber.alternate === null) {
deleteChild(returnFiber, oldFiber);
}
}
// 打上插入的标记
lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
if (previousNewFiber === null) {
resultingFirstChild = newFiber;
} else {
previousNewFiber.sibling = newFiber;
}
// 本次匹配成功的情况下:继续下一轮匹配
previousNewFiber = newFiber;
oldFiber = nextOldFiber;
}
首先,要说明的几个变量:
oldFiber
代表旧节点树中的一个节点,oldFiber.index
则是该旧节点在其父节点下的索引位置。nextOldFiber
存储的是下一个要比较的旧节点。newIdx
是当前正在参与比较的新节点的索引。
这里我们查看第一轮循环处理:newIdx
默认为0
,表示第一个参与的diff
的新节点,执行循环的条件有两个:
oldFiber
不为null
【当前参与diff
的旧节点】。newIdx
小于新的子节点数组的长度。
只有同时满足这两个条件时,才能执行循环。
循环体内首先会执行一个判断条件:
js
// diff是同位置比较,位置不匹配时,设置oldFiber为null,退出循环
if (oldFiber.index > newIdx) {
nextOldFiber = oldFiber;
oldFiber = null;
} else {
// 正常比对情况:从当前旧的子节点取出它的兄弟节点,作为下一个比对的旧节点
nextOldFiber = oldFiber.sibling;
}
oldFiber
的index
属性表示当前旧节点的位置,与newIdx
进行对比:
react diff
必须是同位置的比较,如果大于newIdx
,说明位置不匹配,设置oldFiber
为null
,退出循环。- 在位置相同的情况下,更新下一个参与比较的旧节点变量
nextOldFiber
的值。
然后调用updateSlot
方法,开始创建新的fiber
节点。
updateSlot
查看updateSlot
方法:
js
// 更新Fiber
function updateSlot(
returnFiber: Fiber,
oldFiber: Fiber | null,
newChild: any,
lanes: Lanes,
): Fiber | null {
// Update the fiber if the keys match, otherwise return null.
// 如果key匹配,则返回fiber,否则返回null
// 取出旧节点的key
const key = oldFiber !== null ? oldFiber.key : null;
// 文本节点处理,略过
if (
(typeof newChild === 'string' && newChild !== '') ||
typeof newChild === 'number'
) {
if (key !== null) {
return null;
}
return updateTextNode(returnFiber, oldFiber, '' + newChild, lanes);
}
# 默认的子节点处理
if (typeof newChild === 'object' && newChild !== null) {
switch (newChild.$$typeof) {
// react元素对象,对应的Fiber
case REACT_ELEMENT_TYPE: {
// 比较相同索引位置的子节点,key值是否相等
if (newChild.key === key) {
// 1.key相等,type相等复用生成新的节点
// 2.key相等,但是type不相等,直接创建新的节点
return updateElement(returnFiber, oldFiber, newChild, lanes);
} else {
// 否则返回null
return null;
}
}
// 其他类型...
}
if (isArray(newChild) || getIteratorFn(newChild)) {
if (key !== null) {
return null;
}
return updateFragment(returnFiber, oldFiber, newChild, lanes, null);
}
}
return null;
}
首先在oldFiber
不为null
时,取出旧的fiber
节点的key
,如果旧节点不存在则设置key
为null
。
js
// 取出旧节点的key
const key = oldFiber !== null ? oldFiber.key : null;
比如出现旧节点与新节点位置不匹配的时候,
oldFiber
可能会被设置为null
。
然后就是根据当前新节点newChild
的值进行不同的处理,这里我们主要关注为对象类型的处理,因为大部分情况下newChild
都是一个reactElement
对象:
js
if (newChild.key === key) {
// 1.key相等,type相等复用生成新的节点
// 2.key相等,但是type不相等,直接创建新的节点
return updateElement(returnFiber, oldFiber, newChild, lanes);
} else {
// key不相等,返回null
return null;
}
判断相同位置 的新旧节点的key
是否相等:
- 如果
key
相等,则调用updateElement
方法创建新的子节点。 - 如果
key
不相等,则直接返回null
。
updateElement
我们我们再查看一下updateElement
方法:
js
function updateElement(
returnFiber: Fiber,
current: Fiber | null, // 旧的fiber
element: ReactElement, // 新的react元素对象
lanes: Lanes,
): Fiber {
const elementType = element.type;
// 更新阶段
if (current !== null) {
// 在key相等的条件下,如果组件类型也相等,比如都是div,则可以复用信息
if (current.elementType === elementType) {
// Move based on index
// 可以复用的情况下:根据旧的fiber,以及新的reactElement对的props,生成新的fiber节点
const existing = useFiber(current, element.props);
existing.ref = coerceRef(returnFiber, current, element);
existing.return = returnFiber;
return existing;
}
}
// Insert
// 加载阶段:直接创建新的fiber
// 更新阶段:节点类型type不同时
const created = createFiberFromElement(element, returnFiber.mode, lanes);
created.ref = coerceRef(returnFiber, current, element);
created.return = returnFiber;
return created;
}
在key
相等时,再判断新旧节点的类型type
是否相等,
- 如果两个条件同时满足,则可以复用当前旧的
fiber
节点信息和新的props
,来生成新的fiber
节点。 - 如果
key
相等,但是节点类型不相等时,则直接使用reactElement
对象创建新的fiber
节点。
js
const newFiber = updateSlot(returnFiber,oldFiber,newChildren[newIdx],lanes);
我们再回到第一轮的循环中,所以updateSlot
方法调用完成最终会有两种情况:
- 返回新的子节点。
- 返回
null
。
js
// 第一次循环【循环数据为新的子节点element数组】
// 循环结束条件:循环到新数据最后一个,并且期间对应索引的旧节点一直有值
for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
...
const newFiber = updateSlot(
returnFiber,
oldFiber,
newChildren[newIdx], // 对应的react元素对象
lanes,
);
# 如果新子节点为null,说明本次key匹配失败
if (newFiber === null) {
if (oldFiber === null) {
oldFiber = nextOldFiber;
}
// 只要出现key不匹配的情况,则直接退出循环
break;
}
if (shouldTrackSideEffects) {
if (oldFiber && newFiber.alternate === null) {
deleteChild(returnFiber, oldFiber);
}
}
# key匹配的情况下,创建了新的子节点
// 打上插入的标记
lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
if (previousNewFiber === null) {
resultingFirstChild = newFiber;
} else {
previousNewFiber.sibling = newFiber;
}
// 本次匹配成功的情况下:继续下一轮匹配
previousNewFiber = newFiber;
// 设置下一个比较的旧节点
oldFiber = nextOldFiber;
}
我们接着看第一轮循环剩下的逻辑:
- 如果新的子节点
newFiber
为null
,说明本次匹配失败,在相同的位置没有匹配到相同的key
,执行break
,结束第一轮的循环diff
逻辑。 - 如果
newFiber
有值,说明生成了新的子节点,然后调用placeChild
方法,根据当前的索引信息确定newFiber
应该被插入到父Fiber
的子列表中的哪个位置【即会给当前的新节点打上一个插入标记】
然后更新previousNewFiber
和oldFiber
变量的值,继续循环执行下一个节点的diff
逻辑。
到此第一轮循环的逻辑就基本结束,我们继续查看剩下的逻辑。
3,第二轮处理
在第一轮循环比较完成之后,会存在以下几个情况,我们逐个分析。
情况一
js
# 1. 如果索引等于新子节点数组的长度,说明已经匹配完成,可以复用对应的旧的fiber节点,并且删除旧的多余的节点
// 这种情况是完全匹配,或者是执行了尾部删除,可以复用前面全部内容。
if (newIdx === newChildren.length) {
// We've reached the end of the new children. We can delete the rest.
// 删除从当前旧节点剩下的可能存在的节点:打上删除标记
deleteRemainingChildren(returnFiber, oldFiber);
return resultingFirstChild;
}
如果newIdx
等于新子节点数组newChildren
的长度,说明新的数据已经匹配完成,这时就可以从当前旧节点oldFiber
开始,删除剩下的可能存在的旧节点,即给每个节点打上删除标记,最后返回第一个新的子节点,多节点diff
执行完成。
业务场景:
- 正常数据更新,子节点数量和位置都无变化。
- 子节点执行了尾部删除操作。
情况二
js
# 2. 旧的节点利用完成,还存在新节点时
if (oldFiber === null) {
// If we don't have any more existing children we can choose a fast path
// since the rest will all be insertions.
// 多子节点的 创建:创建第一个的child,完成之后退出创建,返回resultingFirstChild结果为第一个child
for (; newIdx < newChildren.length; newIdx++) {
# 直接创建剩下的新节点
const newFiber = createChild(returnFiber, newChildren[newIdx], lanes);
if (newFiber === null) {
continue;
}
lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
// previousNewFiber === null,表示为第一次循环:
if (previousNewFiber === null) {
// TODO: Move out of the loop. This only happens for the first run.
// 则设置第一个新创建的有效的Fiber节点, 为结果child内容
resultingFirstChild = newFiber;
} else {
// 否则表示不是第一次循环,将新建的节点设置为上一个节点的兄弟节点
previousNewFiber.sibling = newFiber;
}
// 更新上一个新的节点
previousNewFiber = newFiber;
}
// 退出当前函数,返回创建完成的第一个child
return resultingFirstChild;
}
出现oldFiber
为null
的情况,并且没有满足情况一:说明旧的节点比新的子节点少,说明新的子节点出现了新增【一定是尾部新增,而不是中间插入】,此时旧的节点已经利用完成,而新的子节点还没有创建完成,所以开启一个循环,调用createChild
方法来创建剩下的新节点,多节点diff
执行完成。
业务场景:
- 新子节点出现了尾部新增。
- 渲染全新的列表数据。
情况三
js
# 3. diff时,存在不匹配的情况,将剩下的所有旧的子节点添加到一个map中,执行快速查找
// 生成一个map结构【fiber.key为键,fiber为值】
const existingChildren = mapRemainingChildren(returnFiber, oldFiber);
// Keep scanning and use the map to restore deleted items as moves.
// 第二次循环:使用map查找
for (; newIdx < newChildren.length; newIdx++) {
// 从map结构中继续查找可能相同的fiber:找到则复用生成,没有找到则直接创建新的fiber
const newFiber = updateFromMap(
existingChildren,
returnFiber,
newIdx,
newChildren[newIdx],
lanes,
);
// 如果新fiber存在【基本都是有值的】
if (newFiber !== null) {
if (shouldTrackSideEffects) {
// 如果新fiber存在并且它的alternate属性也有值,说明是通过复用fiber生成的
# 应该根据它的key,从当前map结构中删除旧的current。【移除已匹配中的键值对】
if (newFiber.alternate !== null) {
existingChildren.delete(
newFiber.key === null ? newIdx : newFiber.key,
);
}
}
// 确定新生成的fiber节点在子列表中的位置【打上插入标记,在commit阶段执行插入】
lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
/**
* 更新变量,继续下一个节点的匹配
*/
if (previousNewFiber === null) {
// 少数情况:在最前面第一次循环逻辑中一个节点都没有匹配到的情况,需要再次更新resultingFirstChild
// 即需要将第一个成功创建的fiber设置为firstChild
resultingFirstChild = newFiber;
} else {
// 一般情况:更新新子节点sibling属性
previousNewFiber.sibling = newFiber;
}
previousNewFiber = newFiber;
}
}
这种情况表示在diff
过程中,出现了同位置节点的key
不匹配的情况【位置移动】,旧节点和新节点都还存在未参与比较的数据。
这里会调用mapRemainingChildren
方法,将剩下的旧节点添加到一个map
结构中【key
为键,fiber
为值】,最后返回这个map
结构赋值给变量existingChildren
,然后再次开启一个循环结构,从当前的newIdx
索引开始,处理剩下的新节点。
注意:如果旧节点不存在
key
,就会使用它的index
索引值作为键。
updateFromMap
这里主要是调用一个updateFromMap
方法,我们可以查看一下这个方法具体的逻辑:
js
function updateFromMap(
existingChildren: Map<string | number, Fiber>,
returnFiber: Fiber,
newIdx: number,
newChild: any,
lanes: Lanes,
): Fiber | null {
// 文本节点处理
if (
(typeof newChild === 'string' && newChild !== '') ||
typeof newChild === 'number'
) {
const matchedFiber = existingChildren.get(newIdx) || null;
return updateTextNode(returnFiber, matchedFiber, '' + newChild, lanes);
}
# 常规fiber节点处理
if (typeof newChild === 'object' && newChild !== null) {
switch (newChild.$$typeof) {
// react-element元素
case REACT_ELEMENT_TYPE: {
// 传递新子节点的key来查找,存在则返回旧的fiber,不存在则返回null
const matchedFiber = existingChildren.get(newChild.key === null ? newIdx : newChild.key,) || null;
// 根据matchedFiber,来生成新的fiber节点
return updateElement(returnFiber, matchedFiber, newChild, lanes);
}
}
}
return null;
}
这个方法的内容也比较简单,主要就是根据新节点的key
,从当前这个map
结构中找到匹配的旧节点【如果新节点没有key
,则会使用当前的索引值newIdx
】,然后将匹配到的旧节点传递到updateElement
方法中来生成新的子节点。
这里会存在以下几种情况:
- 没有找到匹配的旧节点,
matchedFiber
为null
,调用updateElement
方法,直接使用newChild
创建新的fiber
节点。 - 找到匹配的旧节点,
matchedFiber
为旧的fiber
节点,调用updateElement
方法,当前current
有值,继续执行判断逻辑:- 如果新旧节点类型
type
也相等,则可以复用当前旧的fiber
节点信息和新的props
,来生成新的fiber
节点。 - 如果
key
相等,但是节点类型不相等时,则直接使用newChild
对象创建新的fiber
节点。
- 如果新旧节点类型
js
# 3. diff时,存在不匹配的情况,将剩下的所有旧的子节点添加到一个map中,执行快速查找
// 生成一个map结构【fiber.key为键,fiber为值】
const existingChildren = mapRemainingChildren(returnFiber, oldFiber);
// Keep scanning and use the map to restore deleted items as moves.
// 第二次循环:使用map查找
for (; newIdx < newChildren.length; newIdx++) {
// 从map结构中继续查找可能相同的fiber:找到则复用生成,没有找到则直接创建新的fiber
const newFiber = updateFromMap(
existingChildren,
returnFiber,
newIdx,
newChildren[newIdx],
lanes,
);
// 如果新fiber存在【基本都是有值的】
if (newFiber !== null) {
if (shouldTrackSideEffects) {
// 如果新fiber存在并且它的alternate属性也有值,说明是通过复用fiber生成的
# 应该根据它的key,从当前map结构中删除旧的current。【移除已匹配中的键值对】
if (newFiber.alternate !== null) {
existingChildren.delete(
newFiber.key === null ? newIdx : newFiber.key,
);
}
}
// 确定新生成的fiber节点在子列表中的位置【打上插入标记,在commit阶段执行插入】
lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
/**
* 更新变量,继续下一个节点的匹配
*/
if (previousNewFiber === null) {
// 少数情况:在最前面第一次循环逻辑中一个节点都没有匹配到的情况,需要再次更新resultingFirstChild
// 即需要将第一个成功创建的fiber设置为firstChild
resultingFirstChild = newFiber;
} else {
// 一般情况:更新新子节点sibling属性
previousNewFiber.sibling = newFiber;
}
previousNewFiber = newFiber;
}
}
我们再回到之前的循环结构中,所以updateFromMap
这个方法最终都会返回一个新节点【可能是复用生成的,也有是直接创建的】,
在创建完新节点之后,如果是通过复用生成的新节点:
js
# 根据它的key,从当前map结构中删除旧的current。【移除已匹配中的键值对】
if (newFiber.alternate !== null) {
existingChildren.delete(
newFiber.key === null ? newIdx : newFiber.key,
);
}
则需要从根据新节点的key
或者index
,从当前的map
结构中删除已经使用过���键值对。
最后同样是更新变量,继续执行执行循环结构,创建剩下的新节点。
所以情况三的逻辑就是:在第一轮循环中出现了key
不匹配的情况后,将剩下的旧节点添加到一个map
结构中,进行map
查找,查找到则复用旧节点来生成新节点,没有查找到则直接创建新的节点,直接循环结束,创建完成所有的新节点。
业务场景:
- 中间插入新节点,出现
key
不匹配的情况。 - 中间删除节点,出现
key
不匹配的情况。 - 新节点数据发生了排序变化【位置移动】,出现
key
不匹配的情况。
出现这几种情况,就会存在剩下的新旧节点,来执行map
查找,最后多节点diff
执行完成。
四,案例
1,情况一
正常更新
js
export default function Index() {
// 成绩单
const [list, setList] = useState([
{name: '小红', score: '90'},
{name: '小明', score: '80'},
{name: '小刚', score: '70'},
])
// 正常数据更新
function handleClick() {
list.forEach(item => {
if (item.name === '小明') {
item.score = '85'
}
})
setList([...list])
}
// 渲染成绩单
const renderList = list.map(item => {
return <div key={item.name}>{item.name + ': '+ item.score}</div>
})
return (
<div className='Index'>
<div>成绩单:</div>
<div className='itemBox'>{renderList}</div>
<button onClick={handleClick}>更新</button>
</div>
)
}
这里我们修改小明的成绩,会触发一次正常的更新和diff
逻辑:因为本次数据无数量变化,无位置变化,所以第一轮循环会完美的匹配所有key
,通过复用旧节点生成新的节点,然后满足情况一的判断条件,最后结束本轮多节点diff
的逻辑。
尾部删除
js
export default function Index() {
// 成绩单
const [list, setList] = useState([
{name: '小红', score: '90'},
{name: '小明', score: '80'},
{name: '小刚', score: '70'},
])
// 尾部删除
function handleClick() {
list.pop()
setList([...list])
}
// 渲染成绩单
const renderList = list.map(item => {
return <div key={item.name}>{item.name + ': '+ item.score}</div>
})
return (
<div className='Index'>
<div>成绩单:</div>
<div className='itemBox'>{renderList}</div>
<button onClick={handleClick}>更新</button>
</div>
)
}
这里我们删除小刚的成绩,会触发一次正常的更新和diff
逻辑:第一轮循环会完美的匹配所有key
,通过复用旧节点生成新的节点,然后满足情况一的判断条件,同时还存在剩下的旧节点【小刚】,所以需要对剩下的旧节点做一个删除操作【打上删除标记】,最后结束本轮多节点diff
的逻辑。
2,情况二
尾部新增
js
export default function Index() {
// 成绩单
const [list, setList] = useState([
{name: '小红', score: '90'},
{name: '小明', score: '80'},
{name: '小刚', score: '70'},
])
// 尾部添加
function handleClick() {
list.push({name: '小强', score: '60'})
setList([...list])
}
// 渲染成绩单
const renderList = list.map(item => {
return <div key={item.name}>{item.name + ': '+ item.score}</div>
})
return (
<div className='Index'>
<div>成绩单:</div>
<div className='itemBox'>{renderList}</div>
<button onClick={handleClick}>更新</button>
</div>
)
}
这里我们新增了小强的成绩,会触发一次正常的更新和diff
逻辑:第一轮循环匹配时,因为新的节点数据变多了,旧的节点不够用,会出现oldFiber
为null
的情况,这时候在完成第三次匹配之后,因为oldFiber
为null
就会退出循环,此时只完成了三个新节点的创建,就会满足情况二的条件:
在这里重新安排一个循环,调用createChild
方法直接创建剩下的新节点,最后结束本轮多节点diff
的逻辑。
全新列表
js
export default function Index() {
// 成绩单
const [list, setList] = useState([])
// 请求数据,渲染全新列表
function handleClick() {
setTimeout(() => {
setList([
{name: '小红', score: '90'},
{name: '小明', score: '80'},
{name: '小刚', score: '70'},
])
}, 1000);
}
// 渲染成绩单
const renderList = list.map(item => {
return <div key={item.name}>{item.name + ': '+ item.score}</div>
})
return (
<div className='Index'>
<div>成绩单:</div>
<div className='itemBox'>{renderList}</div>
<button onClick={handleClick}>更新</button>
</div>
)
}
这里刚开始没有成绩单数据,模拟发起一个请求获取到数据后,渲染新的列表数据。在第一轮循环匹配时,不存在旧的节点数据【即oldFiber
为null
】,所以第一轮循环不会执行。这里会直接来到情况二,安排一个新循环,,调用createChild
方法直接创建全新的的子节点数据,最后结束本轮多节点diff
的逻辑。
3,情况三
情况三的场景都可以归属为【位置移动】的变化,可以大致分为以下几类。
中间插入
js
export default function Index() {
// 成绩单
const [list, setList] = useState([
{name: '小红', score: '90'},
{name: '小明', score: '80'},
{name: '小刚', score: '70'},
])
// 中间插入
function handleClick() {
setList([
{name: '小红', score: '90'},
{name: '小明', score: '80'},
{name: '小兰', score: '75'},
{name: '小刚', score: '70'},
])
}
// 渲染成绩单
const renderList = list.map(item => {
return <div key={item.name}>{item.name + ': '+ item.score}</div>
})
return (
<div className='Index'>
<div>成绩单:</div>
<div className='itemBox'>{renderList}</div>
<button onClick={handleClick}>更新</button>
</div>
)
}
注意: 非尾部添加都属于中间插入【比如头部插入】,这里我们在中间新增一条小兰的成绩。在第一轮循环匹配时,循环到第三次出现了key
不匹配的情况,因为在当前位置旧的节点key
等于小刚,新的节点key
等于小兰,当前newFiber
会为null
,然后退出第一轮的循环。此时剩下的旧节点和新节点都还有数据,就会来到情况三的场景:
将剩下的旧节点【即小刚的数据】添加到一个map
结构中,循环剩下的新节点数据,调用updateFromMap
方法,根据新节点的key
从map
结构中进行查找,如果查找到相同的key
则复用旧节点生成新节点,如果没有查找到则直接创建新的节点。
- 第一次循环没有查找到
key
为小兰的旧节点,则会直接创建新的节点。 - 第二次循环查找到了
key
为小刚的旧节点,则会复用旧节点来生成新的节点。
同时,如果是通过复用生成的新节点,在新节点创建完成之后,还会从当前map
结构中删除已经使用过的键值对。
最后结束本轮多节点diff
的逻辑。
中间删除
js
export default function Index() {
// 成绩单
const [list, setList] = useState([
{name: '小红', score: '90'},
{name: '小明', score: '80'},
{name: '小刚', score: '70'},
])
// 中间删除
function handleClick() {
const arr = list.filter(item => {
return item.name !== '小明'
})
setList(arr)
}
// 渲染成绩单
const renderList = list.map(item => {
return <div key={item.name}>{item.name + ': '+ item.score}</div>
})
return (
<div className='Index'>
<div>成绩单:</div>
<div className='itemBox'>{renderList}</div>
<button onClick={handleClick}>更新</button>
</div>
)
}
注意: 非尾部删除都属于中间删除【比如头部删除】,这里我们在中间删除一条小明的成绩。在第一轮循环匹配时,循环到第二次出现了key
不匹配的情况,因为在当前位置旧的节点key
等于小明,新的节点key
等于小刚,当前newFiber
会为null
,然后退出第一轮的循环。此时剩下的旧节点和新节点都还有数据,就会来到情况三的场景:
将剩下的旧节点【即小明和小刚的数据】添加到一个map
结构中,循环剩下的新节点数据【小刚的数据】,调用updateFromMap
方法,根据新节点的key
从map
结构中进行查找,如果查找到相同的key
则复用生成新节点,如果没有查找到则直接创建新的节点。
当前可以查找到小刚的旧节点数据,就可以直接复用生成新的节点,最后结束本轮多节点diff
的逻辑。
位置移动
js
export default function Index() {
// 成绩单
const [list, setList] = useState([
{name: '小红', score: '90'},
{name: '小明', score: '80'},
{name: '小刚', score: '70'},
])
// 位置移动:小明考了100分,变成了第一名
function handleClick() {
setList([
{name: '小明', score: '100'},
{name: '小红', score: '90'},
{name: '小刚', score: '70'},
])
}
// 渲染成绩单
const renderList = list.map(item => {
return <div key={item.name}>{item.name + ': '+ item.score}</div>
})
return (
<div className='Index'>
<div>成绩单:</div>
<div className='itemBox'>{renderList}</div>
<button onClick={handleClick}>更新</button>
</div>
)
}
小明考了100
分,变成了第一名。在第一轮循环匹配时,循环在第一次就出现了key
不匹配的情况,因为在当前位置旧的节点key
等于小红,新的节点key
等于小明,当前newFiber
会为null
,然后退出第一轮的循环。此时剩下的旧节点和新节点都还有数据,就会来到情况三的场景:
当前的三个新节点都可以在map
结构中找到对应的key
,所以都可以进行复用,根据对应的旧fiber
节点信息和新的props
,来生成新的fiber
节点:
最后结束本轮多节点diff
的逻辑。
五,总结
react的diff
算法可以分为两种情况:
- 单节点
diff
。 - 多节点
diff
。
单节点diff
比较简单,主要就是判断新旧节点的key
和type
是否同时相等,只有同时相等时才能复用旧的fiber
节点,否则就会直接创建一个新的fiber
节点。
多节点diff
可以分为两个阶段:
- 第一轮循环进行同位置的比较,
key
相同则会复用生成新的节点,在循环中只要出现不匹配的key
就会退出循环。 - 第一轮循环执行完成后,会根据结果进行第二阶段的处理,可以分为三种情况:
- 完全匹配:即第一轮循环已经处理完所有的新节点,此时删除可能存在的剩余旧节点,然后结束本轮多节点
diff
的逻辑。 - 新增数据:即第一轮循环完成后,还存在未处理的新节点,则直接进行正常的创建,然后结束本轮多节点
diff
的逻辑。 - 位置移动:即第一轮循环完成后,旧节点和新节点都还剩有未处理的数据,则将剩下的旧节点添加到一个
map
结构中【【key
为键,fiber
为值】】,根据新节点的key
进行map
查找【没有key
,则使用索引值】,查找到则复用旧节点信息和新的props
来生成新节点,没有查找到则直接创建新的节点,循环执行直到创建完成所有的新节点,然后结束本轮多节点diff
的逻辑。
- 完全匹配:即第一轮循环已经处理完所有的新节点,此时删除可能存在的剩余旧节点,然后结束本轮多节点
如果旧节点不存在
key
,就会使用它的index
索引值作为键。