代码已经关联到github: 链接地址 觉得不错可以顺手点个star,这里会持续分享自己的开发经验(:
一种编程的概念,与真实DOM节点对应,一个能表示真实DOM的对象。
在React属于ReactElement
对象,格式如下:
const reactElement = {
key:null,
props:{
className:"",
onClick: () => {},
children:[]
},
type:'div'
}
代码创建:
使用 React.createElement
或者 jsx
React.createElement('div',{className:"",onClick: () => {}},[
//child...
])
二者其实不能简单的概括,因为虚拟DOM最终还是会操作真实DOM。
那为什么还会有人说DOM慢,虚拟DOM快呢?因为在某些情况下,虚拟DOM确实比真实DOM快。首先,我们要理解一个概念,JS计算比操作dom更快
虚拟DOM本质上是js对象,所以可以转化成如小程序的组件,安卓的视图等等。
当前更新的组件与该组件在上次渲染时对应的Fiber节点比较(也就是俗称的Diff算法),将比较的结果生成新Fiber节点这一过程。
key
进行区分。新版的Diff入口函数为: reconcileChildFibers
,属于 React Fiber 任务循环中的一环,会在构建Fiber树阶段运行。
该函数使用旧的fiber节点
与新的ReactElement
对象相比较,返回新的fiber节点:
React包位置:packages/react-reconciler/src/ReactChildFiber.js
function reconcileChildFibers(
returnFiber: Fiber,
currentFirstChild: Fiber | null,
newChild: any,
lanes: Lanes,
): Fiber | null {
const isObject = typeof newChild === 'object' && newChild !== null;
if (isObject) {
// object类型,根据不同的元素类型分别处理
switch (newChild.$$typeof) {
case REACT_ELEMENT_TYPE:
// 调用 reconcileSingleElement 处理
// ...省略
}
}
if (typeof newChild === 'string' || typeof newChild === 'number') {
// 调用 reconcileSingleTextNode 处理
// ...省略
}
if (isArray(newChild)) {
// 调用 reconcileChildrenArray 处理
// ...省略
}
// 一些其他情况调用处理函数
// ...省略
// 以上都没有命中,删除节点
return deleteRemainingChildren(returnFiber, currentFirstChild);
}
从源码中可以看到,DOM diff会根据节点类型去使用不同的比较算法,当然主要还是单节点和多节点的情况,我们下面从分别深入分析二者Diff算法:
我们以object
类型中的是否复用一个React元素为例,Diff会进入到reconcileSingleElement
,其整体逻辑为:
key
和元素类型type
是否一致
stateNode
),
该函数使用旧的fiber节点
与新的ReactElement
对象相比较,返回新的fiber节点,具体源码解析如下:
function reconcileSingleElement(
returnFiber: Fiber,
currentFirstChild: Fiber | null,
element: ReactElement,
lanes: Lanes,
): Fiber {
const key = element.key;
let child = currentFirstChild;
//判断当前节点是否存在
while (child !== null) {
// TODO: If key === null and child.key === null, then this only applies to
// the first item in the list.
//key是否一样
if (child.key === key) {
switch (child.tag) {
//...省略case,核心都是判断tag是否一样 , type相同则表示可以复用
if (child.elementType === element.type) {
//其他兄弟元素都标记为删除
deleteRemainingChildren(returnFiber, child.sibling);
// 使用新的属性克隆当前fiber,并返回
const existing = useFiber(child, element.props);
return existing;
}
}
//key一样,但是tag不一样,后续的兄弟节点也不需要遍历了,自身和兄弟节点均标记为删除
deleteRemainingChildren(returnFiber, child);
break;
} else {
//key不一样,则当前节点不可复用,标记删除
deleteChild(returnFiber, child);
}
//遍历所有同层级的兄弟节点
child = child.sibling;
}
//省略创建新的Fiber并返回
}
可以看出来,单节点的Diff的代码还是比较简单的,唯一不好理解的点在于,将兄弟节点标记为删除的两种情况:
key
一样,但是type
不一样,标记自身和其他兄弟节点删除这是因为要兼容旧的Fiber树渲染了多个节点,而新的只有单个节点的情况。
比如之前有三个节点,更新后只有一个,那当复用了其中一个的时候,其他两个自然要标记为删除;同理如果key一样的节点都不能复用,那所有的节点当然要标记为删除。
多节点的Diff比单节点稍微复杂一些,React根据日常开发中更新频率更高的特别,将Diff分成了两个遍历阶段:
key
不同导致的直接结束遍历;type
不同导致的标记当前fiber节点需要删除,继续遍历newChildren
遍历完毕,或者旧的fiber节点oldFiber
遍历完毕,结束遍历。newChildren
和 oldFiber
均遍历完,说明此处只做更新,不需要二次遍历。newChildren
遍历完, oldFiber
没遍历完,说明此处需要删除节点,所以需要遍历剩下的oldFiber
节点,标记删除。newChildren
没遍历完,oldFiber
遍历完了,说明此处需要插入新节点,且旧节点都复用了,所以需要遍历剩下的newChildren
生成新的fiber节点。newChildren
和oldFiber
都没有遍历完,说明这次更新中有节点改变了位置,需要移动,这也是该Diff难以理解的地方。
对于多节点的Diff,会进入到reconcileChildrenArray
,该函数使用旧的fiber节点
与新的ReactElement
数组相比较,返回数组的第一个fiber节点,具体源码解析如下:
React包位置:packages/react-reconciler/src/ReactChildFiber.js
function reconcileChildrenArray(
returnFiber: Fiber,
currentFirstChild: Fiber | null,
newChildren: Array<*>,
lanes: Lanes,
): Fiber | null {
let resultingFirstChild: Fiber | null = null;
let previousNewFiber: Fiber | null = null;
let oldFiber = currentFirstChild;
let lastPlacedIndex = 0;
let newIdx = 0;
let nextOldFiber = null;
//==第一轮遍历,遍历newChildren
for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
//旧fiber的指针向后移动,保证两次遍历的元素索引是一致的
if (oldFiber.index > newIdx) {
nextOldFiber = oldFiber;
oldFiber = null;
} else {
nextOldFiber = oldFiber.sibling;
}
// 同索引的new和old进行比较, 如果key不同, 返回null
// key相同, 比较type是否一致. type一致则执行useFiber(update逻辑), type不一致则运行createXXX(insert逻辑)
const newFiber = updateSlot(
returnFiber,
oldFiber,
newChildren[newIdx],
lanes,
);
if (newFiber === null) {
// 如果返回null, 表明key不同, 存在非更新节点的情况, 退出循环
if (oldFiber === null) {
oldFiber = nextOldFiber;
}
break;
}
//非初次创建fiber, 此时shouldTrackSideEffects被设置为true
if (shouldTrackSideEffects) {
//type不同,新建了节点,旧的节点需要标记为删除
if (oldFiber && newFiber.alternate === null) {
deleteChild(returnFiber, oldFiber);
}
}
// lastPlacedIndex 最后一个可复用的节点在oldFiber中的位置索引
// 如果当前节点可复用, 则要判断位置是否移动.
lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
//构造新的fiber链表
if (previousNewFiber === null) {
resultingFirstChild = newFiber;
} else {
previousNewFiber.sibling = newFiber;
}
previousNewFiber = newFiber;
oldFiber = nextOldFiber;
}
// newChildren 遍历完, oldFiber 没遍历完,说明此处需要删除节点,所以需要遍历剩下的oldFiber节点,标记删除。
if (newIdx === newChildren.length) {
deleteRemainingChildren(returnFiber, oldFiber);
return resultingFirstChild;
}
// oldFiber 遍历完了,newChildren 没遍历完,说明此处需要插入新节点,且旧节点都复用了,所以需要遍历剩下的newChildren生成新的fiber节点。
if (oldFiber === null) {
//...省略该部分代码
//注意同第一次遍历,如果当前节点可复用, 此处也存在判断位置是否移动.
return resultingFirstChild;
}
//==第二轮遍历,遍历newChildren
// 将旧的fiber转为map,方便使用key快速找到
const existingChildren = mapRemainingChildren(returnFiber, oldFiber);
// 遍历剩余的newChildren,找出可复用的节点
for (; newIdx < newChildren.length; newIdx++) {
//根据key查找旧节点是否可复用
const newFiber = updateFromMap(
existingChildren,
returnFiber,
newIdx,
newChildren[newIdx],
lanes,
);
if (newFiber !== null) {
if (shouldTrackSideEffects) {
if (newFiber.alternate !== null) {
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) {
// newChildren已经遍历完, 那么oldFiber序列中剩余节点都视为删除(打上Deletion标记)
existingChildren.forEach(child => deleteChild(returnFiber, child));
}
return resultingFirstChild;
}
我们的目标是寻找移动的节点,那么我们需要明确:节点是否移动参照物是什么?
React使用的参照物是:最后一个可复用的节点在oldFiber中的位置索引 lastPlacedIndex
。
因为我们更新是按照newChildren
遍历的,在遍历过程中,如果没有移动情况,当前React元素的可复用的节点的索引oldIndex
总是比上次复用的节点索引lastPlacedIndex
大。
所以一旦 当前React元素 使用的_可复用节点 的索引oldIndex
,比上次_可复用节点的 索引lastPlacedIndex
小,那就说明现在这个更新的对应的旧的节点要往右移动。
下面我们跟随源码分析一下:
function placeChild(
newFiber: Fiber,
lastPlacedIndex: number,
newIndex: number,
): number {
//新的fiber节点更新索引
newFiber.index = newIndex;
if (!shouldTrackSideEffects) {
// Noop.
return lastPlacedIndex;
}
//获取可复用的节点fiber
const current = newFiber.alternate;
if (current !== null) {
const oldIndex = current.index;
//该可复用节点之前位置索引小于这次更新需要插入的位置索引,代表该节点需要移动(向右)
if (oldIndex < lastPlacedIndex) {
// This is a move.
newFiber.flags = Placement;
return lastPlacedIndex;
} else {
// This item can stay in place.
// 该可复用节点之前位置索引大于或者等于这次更新需要插入的位置索引,代表该节点不需要移动
// 每遍历一个可复用的节点,如果oldIndex >= lastPlacedIndex,则lastPlacedIndex = oldIndex
return oldIndex;
}
} else {
// This is an insertion.
// 不存在可复用的节点
newFiber.flags = Placement;
return lastPlacedIndex;
}
}
单纯的文字和源码可能还是难以理解,我们下面举个例子逐步分析:
//更新前
abcd
//更新后
abdc
==第一轮遍历==
newChildren === abdc
oldFiber === abcd
lastPlacedIndex === 0
a(之前) vs a(之后)
可复用
oldIndex = 0,等于lastPlacedIndex,不需要移动,更新lastPlacedIndex = oldIndex = 0
b(之前) vs b(之后)
可复用
oldIndex = 1,大于lastPlacedIndex,不需要移动,更新lastPlacedIndex = oldIndex = 1
c(之前) vs d(之后)
key改变,不可复用,结束遍历,此时 lastPlacedIndex = 1
//..省略中间,因为未执行删除和插入
==第二轮遍历==
newChildren === dc
oldFiber === cd
lastPlacedIndex === 1
比较d
oldFiber中存在,可复用
oldIndex = 3, 大于lastPlacedIndex,不需要移动,lastPlacedIndex = oldIndex = 3
比较c
oldFiber中存在,可复用
oldIndex = 2, 小于lastPlacedIndex,需要移动,lastPlacedIndex 不变,还是3
newChildren遍历完毕
==最终==
abd三个节点不动,c节点向右移动到d之后
从例子我们可以看出,节点的顺序是以更新后的元素顺序优先占位,abcd
=> abdc
,React实际上是先定位好abd
的顺序,然后c
向左移动。
这也提醒我们,要避免后面的节点往前移动的操作,因为这样React会保持后面的节点位置不变,前面的节点依次往右移动,耗费性能。比如 abcd
=>dabc
,看起来肯定是d
往最前面移动,但是实际上,React会定位好d
,abc
依次向后移动。
如果还是难以理解或者想调试源码加深理解,可以到https://github.com/wqhui/react-debug下载调试。
两个子元素,删除后一个(不存在key的情况)。
标签类型和标签属性不变,不用更新;子元素从[1,2]变成了[2],1标签没变,但是children变了,更新内容(子元素2的内容放到了这边);子元素2不见了,删除对应dom。
两个子元素,删除后一个(存在key的情况)。
标签类型和标签属性不变,不用更新;子元素从[1,2]变成了[2],但是因为存在key,计算机知晓是 key:1
的元素删除了,2不变,所以会直接删除1,保留2.
React 算法之调和算法
https://react.iamkasong.com/diff/multi.html