React Diffing 自顶向下
站在巨人肩旁上,本片文章主要解决下面的问题
- Diff 算法是怎么来的?
- Diff 算法是如何工作的?
Diffing算法的由来
远古时代我们都是使用的jQuery 或原生JS,每次更新都是梭哈
1. 梭哈更新会有什么问题?
我们需要简单了解一下浏览器是如何渲染的,下图是 WebKit 渲染的主流程
从图中我们可以了解到,远古时代每次更新都会重新执行一下这个流程(重排重绘),毫无疑问这样做性能太差。
2. 如何优化浏览器的更新这个流程呢?
核心思路:减少没有必要的更新,能复用即复用
好处:减少重复DOM节点的生成,减少重排,甚至能够减少重绘
React是怎么做的?
- 用虚拟dom做一个真实DOM映射
- 每次提交后,比较更新虚拟dom(Scheduler + Reconciler)
- 将虚拟dom中的改变同步到真实dom中(Renderer)
为了做到比较更新,所以需要设计一个Diffing算法
缺点:
- 第一次渲染会较慢
- 极端场景下性能不是最优解
Diffing算法
在开始之前先补充下一个小知识点
小拓展:双缓存技术
“双缓存”技术:在内存中构建并直接替换的技术
当我们用
canvas
绘制动画,每一帧绘制前都会调用ctx.clearRect
清除上一帧的画面。如果当前帧画面计算量比较大,导致清除上一帧画面到绘制当前帧画面之间有较长间隙,就会出现白屏。
为了解决这个问题,我们可以在内存中绘制当前帧动画,绘制完毕后直接用当前帧替换上一帧画面,由于省去了两帧替换间的计算时间,不会出现从白屏到出现画面的闪烁情况。
一、Fiber 架构下Diff都干了什么
react 内部的虚拟Dom叫current Fiber (页面中正在渲染的树的映射),每次向虚拟dom提交更新的阶段,其实都是比较 JSX 和 Current Fiber 的阶段,这个阶段的产物就是 workInProgress Fiber 。
在renderer阶段,React就会把workInProgress Fiber的更新一次性同步到Dom中,此时workInProgress变成了current。
二、Diffing 的对象
Fiber
让我们看看 React 是如何实现的
Fiber节点的部分属性
function FiberNode(
tag: WorkTag,
pendingProps: mixed,
key: null | string,
mode: TypeOfMode,
) {
// Fiber对应组件的类型 Function/Class/Host...
this.tag = tag;
// key属性
this.key = key;
// 大部分情况同type,某些情况不同,比如FunctionComponent使用React.memo包裹
this.elementType = null;
// 对于 FunctionComponent,指函数本身,对于ClassComponent,指class,对于HostComponent,指DOM节点tagName
this.type = null;
// Fiber对应的真实DOM节点
this.stateNode = null;
// 用于连接其他Fiber节点形成Fiber树
// 指向父级Fiber节点
this.return = null;
// 指向子Fiber节点
this.child = null;
// 指向右边第一个兄弟Fiber节点
this.sibling = null;
// 索引
this.index = 0;
}
一个个Fiber节点构建成fiber树,图片来源于《React技术揭秘》
JSX
解析前
function List () {
return (
<ul>
<li key="0">0</li>
<li key="1">1</li>
<li key="2">2</li>
<li key="3">3</li>
</ul>
)
}
解析后
{
$$typeof: Symbol(react.element),
key: null,
props: {
children: [
{$$typeof: Symbol(react.element), type: "li", key: "0", ref: null, props: {…}, …}
{$$typeof: Symbol(react.element), type: "li", key: "1", ref: null, props: {…}, …}
{$$typeof: Symbol(react.element), type: "li", key: "2", ref: null, props: {…}, …}
{$$typeof: Symbol(react.element), type: "li", key: "3", ref: null, props: {…}, …}
]
},
ref: null,
type: "ul"
}
实际上就是Fiber和解析后的JSX对象对比
三、Diffing 的规则
同层比较
众所周知,树的diff算法的时间复杂度是O(n^3),而真实的场景跨层级复用节点的情况很少,所以React提出了一套 O(n) 的启发式算法:
- 两个不同类型的元素会产生出不同的树;
- 开发者可以通过设置
key
属性,来告知渲染哪些子元素在不同的渲染下可以保存不变
这样的好处就是,不会每一层的对比都是相对独立的,不会影响到下一层的对比;
在同层对比的过程中有这样几种情况:
- 节点类型变了 销毁老节点和它的子节点,生成新节点
- 节点类型一样,属性或者属性值发生变化 复用老节点 更新
- 删除/新增/改变 节点 Diffing算法的精髓
- 文本变化 只会触发文本的改变
节点Diff分为两种:
- 单节点Diff ——
Element
、Portal
、string
、number
。 - 多节点Diff ——
Array
、Iterator
。
单节点Diff
单节点Diff比较简单,只有key
相同并且type
相同的情况才会尝试复用节点,否则会返回新的节点。
// 当子节点不为null,则复用子节点并删除其兄弟节点
// 当子节点为null,则创建新的fiber节点
function reconcileSingleElement(
returnFiber: Fiber,
// 旧
currentFirstChild: Fiber | null,
//新
element: ReactElement,
lanes: Lanes,
): Fiber {
const key = element.key;
let child = currentFirstChild;
//从当前已有的所有子节点中,找到可以复用的 fiber 对象,并删除它的 兄弟节点
while (child !== null) {
// TODO: If key === null and child.key === null, then this only applies to
// the first item in the list.
if (child.key === key) {
//如果节点类型未改变的话
const elementType = element.type;
if (elementType === REACT_FRAGMENT_TYPE) {
if (child.tag === Fragment) {
//复用 child,删除它的兄弟节点
//因为旧节点它有兄弟节点,新节点只有它一个
deleteRemainingChildren(returnFiber, child.sibling);
// 复制 fiber 节点,并重置 index 和 sibling
// existing就是复用的节点
const existing = useFiber(child, element.props.children);
existing.return = returnFiber;
if (__DEV__) {
existing._debugSource = element._source;
existing._debugOwner = element._owner;
}
return existing;
}
} else {
if (
child.elementType === elementType ||
// Keep this check inline so it only runs on the false path:
(__DEV__
? isCompatibleFamilyForHotReloading(child, element)
: false) ||
// Lazy types should reconcile their resolved type.
// We need to do this after the Hot Reloading check above,
// because hot reloading has different semantics than prod because
// it doesn't resuspend. So we can't let the call below suspend.
(enableLazyElements &&
typeof elementType === 'object' &&
elementType !== null &&
elementType.$$typeof === REACT_LAZY_TYPE &&
resolveLazy(elementType) === child.type)
) {
deleteRemainingChildren(returnFiber, child.sibling);
// 复用当前child fiber
const existing = useFiber(child, element.props);
//设置正确的 ref
existing.ref = coerceRef(returnFiber, child, element);
existing.return = returnFiber;
if (__DEV__) {
existing._debugSource = element._source;
existing._debugOwner = element._owner;
}
return existing;
}
}
// 不匹配删除节点
deleteRemainingChildren(returnFiber, child);
break;
} else {
// key不同直接删除节点
deleteChild(returnFiber, child);
}
child = child.sibling;
}
// 返回 新的Fiber节点
if (element.type === REACT_FRAGMENT_TYPE) {
const created = createFiberFromFragment(
element.props.children,
returnFiber.mode,
lanes,
element.key,
);
created.return = returnFiber;
return created;
} else {
const created = createFiberFromElement(element, returnFiber.mode, lanes);
created.ref = coerceRef(returnFiber, currentFirstChild, element);
created.return = returnFiber;
return created;
}
}
多节点Diff(精髓)
React团队
发现,在日常开发中,相较于新增
和删除
,更新
组件发生的频率更高。所以Diff
会优先判断当前节点是否属于更新
。
首先我们需要明白比较对象的基本性质:Fiber 的children(链表),JSX的children(数组)
怎么比较比较好呢?顺序比?还是两端比?
顺序比!
思考一个问题,为什么不采用两端比较?
顺序比会遇到什么场景?
-
好的情况: 位置没有发生改变的情况下,只进行更新
-
坏的情况:有位置发生改变了,或者下轮更新删除该节点了,移动+更新
上面两种情况都会遇到 节点新增和删除
好的情况
优先先处理好的情况,因为我们是顺序比,所以只需要按照顺序把节点位置没有发生变化的更新一下就好了
我们还将遇到三种情况:
- 情况一: 老的新的同时遍历完,只更新
- 情况二: 老的遍历完了,新的没有遍历完,添加剩下新增的节点
- 情况三:新的遍历完了,老的没有遍历完,删除剩下老的节点
比如:
// 情况一
<!-- 更新前 -->
<div key='a'>a</div>
<div key='b'>c</div>
<div key='c'>c</div>
<!-- 更新后 -->
<div key='a'>b</div>
<div key='b'>c</div>
<div key='c'>a</div>
// 情况二
<!-- 更新前 -->
<div key='a'>a</div>
<div key='b'>c</div>
<div key='c'>c</div>
<!-- 更新后 -->
<div key='a'>b</div>
<div key='b'>c</div>
// 情况三
<!-- 更新前 -->
<div key='a'>a</div>
<div key='b'>c</div>
<!-- 更新后 -->
<div key='a'>b</div>
<div key='b'>c</div>
<div key='c'>a</div>
坏的情况
如何检查出现了坏的情况呢?
出现节点的顺序对不上了,也就是比较到不一样的节点了(Key不同)
比如:
<!-- 更新前 -->
<div key='a'>a</div>
<div key='b'>b</div>
<div key='c'>c</div>
<!-- 更新后 -->
<div key='a'>a</div>
<div key='c'>c</div> // 和b对不上
<div key='e'>e</div>
我们可以把前面的节点作为好的情况
处理,后面的作为坏的情况
处理。
如何处理呢?(难点)
还是先分情况:
- 情况一: 新节点可以复用老节点,移动
- 情况二: 新节点不能复用老节点,新增
- 情况三:复用完毕后,还存在老节点,删除
对于链表增删
和移动
是非常方便的
我们怎么快速找到需要更新的节点呢?
我们需要使用key
。
但是DIff是发在Reconciler阶段,不涉及Dom更新,所以要为每一种操作打上一个标记,以便Render阶段调用对应的API
Reconciler与Renderer不再是交替工作。当Scheduler将任务交给Reconciler后,Reconciler会为变化的虚拟DOM打上代表增/删/更新的标记.
// 放置(替换/新增)
export const Placement = /* */ 0b00000000000000000000000010;// 2
// 更新
export const Update = /* */ 0b00000000000000000000000100;//4
// 放置 + 更新 = 移动
export const PlacementAndUpdate = /* */ Placement | Update;//6
// 删除
export const Deletion = /* */ 0b00000000000000000000001000;//8
insertBefore
removeChild
creatElemen
appendChild
等等就可以实现标记对应的操作
增删比较好处理,就是老的节点中有或者没有的问题。关键是我们如何确定移动的元素
如何处理元素的是否移动
我们怎么知道节点是否移动
?
根据常识,需要有一个参照物才能确定是否移动。
我先告诉你答案,我们采用上一次放置节点的位置(lastPlacedIndex)作为移动的参照物。
我知道大家懵了,接着往下看,我讲带你推出来为什么选择lastPlacedIndex作为参照物。
为什么是采用lastPlacedIndex作为参照物?
我们发现比较移动
和位置变化有关,因此我穷举了各种情况
- 绝对位置
头部
作为参照物可行么?不可行,因为在坏的情况位置相对头部一定发生改变了。❌
- 相对位置,下面说的
位置
都是相对位置
。- 相对
前一个节点
作为参照物可行么?不可行,因为不确定前一个节点的位置是否发生变化。❌ - 相对
后一个节点
作为参照物可行么? 不可行,因为后一个不知道前一个位置是否发生变化。❌ 相对位置发生改变的节点
作为参照物可行么? 不可行,如果可以的话第一个跟谁比?❌相对位置没有发生变化的节点
是否可行么?可行✅之前
,位置一定改变,打上移动的标签之后
,位置一定没变
- 相对
因此我给lastPlacedIndex
下了个定义 : 上一个相对位置没变的元素索引
此外: 相对位置改变会调用Dom Api Node.insertBefore()
,思考一下为什么?
什么时候更新lastPlacedIndex ?
上个元素相对位置没变,就把它的索引更新为lastPlacedIndex
至此,我们已经为每个元素打上对应的标签,Diff完毕,剩下的交给调度器处理把。
Demo
比如 abcdefg => acebg
<!-- 更新前 -->
<div key='a'>a</div>
<div key='b'>b</div>
<div key='c'>c</div>
<div key='d'>d</div>
<div key='e'>e</div>
<div key='f'>f</div>
<!-- 更新后 -->
<div key='a'>a</div>
<div key='c'>c</div>
<div key='e'>e</div>
<div key='b'>b</div>
<div key='g'>g</div>
让我们看着图走一遍流程吧!
图片来自网络侵删
用newChildren进行遍历
好的情况
- A节点:比较key和type,发现A的位置没有变,属于好的情况,打上Update标签并且更新lastPlacedIndex = 0。
执行两个判断操作:
- 判断 oldFiber是否遍历完,如果遍历完,则将newChildren中剩下的元素依次创建新Fiber然后打上新增标签。
- 判断newChildren是否遍历完,如果遍历完,则将剩余的元素打上删除标签。
坏的情况
下一次个元素的key 不同,好的情况结束,出现坏的情况。且下面的都按照坏的情况对比。将oldFibers 用key做一个map。
- C节点:
- 从map中判断是否能被复用,C节点发现能复用
- 判断是否移动,发现C还是在A(上一个相对位置没有发生变化的元素)后面,所以没有移动,打上Update标签并更新lastPlacedIndex = 1
- E节点:
- 从map中判断是否能被复用,E节点发现能复用
- 判断是否移动,发现E还是在C(上一个相对位置没有发生变化的元素)后面,所以没有移动,打上Update标签并更新lastPlacedIndex = 4
- B节点:
- 从map中判断是否能被复用,B节点发现能复用
- 判断是否移动,发现B从E的后面变成前面了,所以位置移动,打上PlacementAndUpdate标签,上个相对位置没变的元素还是E
- G节点:
- 从map中判断是否能被复用,发现不能复用,创建新Fiber,打上Placement标签
React 把diff 算法分成两轮比较
第一轮遍历:处理更新
的节点,规则是 顺序相同且key
也相同的节点。
第二轮遍历:处理剩下的不属于更新
的节点,需要做新增、移动或删除操作。
前置变量定义
function reconcileChildrenArray(
// 父
returnFiber: Fiber,
// 第一个旧的节点
currentFirstChild: Fiber | null,
// 新的孩子节点
newChildren: Array<*>,
// 优先级
lanes: Lanes,
): Fiber | null
// 函数返回的Fiber节点
let resultingFirstChild: Fiber | null = null;
// 上一个兄弟节点
let previousNewFiber: Fiber | null = null;
// 当前节点
let oldFiber = currentFirstChild;
// 上一次放置更新节点的位置
let lastPlacedIndex = 0;
// 当前newChildren遍历的位置
let newIdx = 0;
// 下一个兄弟节点
let nextOldFiber = null;
第一轮遍历:
/**
* 第一轮遍历
* 第一轮遍历的是顺序相同且key也相同的节点,这些节点需要做更新操作
*/
for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
// 如果 oldFiber比新的元素多,那剩下的就不用比了,下一次直接跳出循环
if (oldFiber.index > newIdx) {
nextOldFiber = oldFiber;
oldFiber = null;
} else {
// 每次循环旧的fiber节点都会指向兄弟元素也就是下次循环的fiber节点
nextOldFiber = oldFiber.sibling;
}
// key相同返回fiber节点,key不同返回null
// 如果type相同复用节点,不同返回新节点
const newFiber = updateSlot(
returnFiber,
oldFiber,
newChildren[newIdx],
lanes,
);
// newFiber为null表示key不同,跳出循环
if (newFiber === null) {
// 老fiber可能已经删了,需要重新指定为兄弟节点,以作为第二次遍历的头节点
if (oldFiber === null) {
oldFiber = nextOldFiber;
}
break;
}
// 第一次渲染不需走删除老节点的逻辑
if (shouldTrackSideEffects) {
// newFiber.alternate为null就是新节点,说明type不同创建了新fiber节点
if (oldFiber && newFiber.alternate === null) {
// key相同 type不同删除老的type节点
deleteChild(returnFiber, oldFiber);
}
}
// 放置节点,更新lastPlacedIndex
lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
// 组成新fiber节点链,常规链表操作
if (previousNewFiber === null) {
resultingFirstChild = newFiber;
} else {
previousNewFiber.sibling = newFiber;
}
// 本轮更新完,上个节点要变成prev
previousNewFiber = newFiber;
// 下个节点变成当前节点
oldFiber = nextOldFiber;
}
// 刚好newChildren遍历完,剩下的不用比了直接删掉老的节点
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;
}
// 把剩下的新节点都加入到fiber中
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.
for (; newIdx < newChildren.length; newIdx++) {
const newFiber = createChild(returnFiber, newChildren[newIdx], lanes);
if (newFiber === null) {
continue;
}
lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
if (previousNewFiber === null) {
// TODO: Move out of the loop. This only happens for the first run.
resultingFirstChild = newFiber;
} else {
previousNewFiber.sibling = newFiber;
}
previousNewFiber = newFiber;
}
if (getIsHydrating()) {
const numberOfForks = newIdx;
pushTreeFork(returnFiber, numberOfForks);
}
return resultingFirstChild;
}
第二轮遍历:
// Add all children to a key map for quick lookups.
// 用剩余的oldFiber创建一个key->fiber节点的Map,方便用key来获取对应的旧fiber节点
const existingChildren = mapRemainingChildren(returnFiber, oldFiber);
// Keep scanning and use the map to restore deleted items as moves.
// 第二轮遍历,继续遍历剩余的节点,这些节点可能是需要移动或者删除的
for (; newIdx < newChildren.length; newIdx++) {
// 从map中获取对应对应key的旧节点,返回更新后的新节点
const newFiber = updateFromMap(
existingChildren,
returnFiber,
newIdx,
newChildren[newIdx],
lanes,
);
if (newFiber !== null) {
if (shouldTrackSideEffects) {
// 复用的新节点,从map里删除老的节点,对应的情况可能是位置的改变
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.
// 复用的节点要移除map,因为map里剩余的节点都会被标记Deletion删除
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) {
// 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));
}
//
if (getIsHydrating()) {
const numberOfForks = newIdx;
pushTreeFork(returnFiber, numberOfForks);
}
return resultingFirstChild;
思考题:
- 为什么Fiber Tree 的children采用的是链表?有可能优化么?
- 怎么写JSX性能更好?