Diff比较的是什么?
function reconcileChildren(
current: Fiber | null,
workInProgress: Fiber,
nextChildren: any,
renderLanes: Lanes,
) { ... }
在React中, diff由reconcileChildren
函数实现, 我们来看看它的参数:
- workInProgress: 当前处理中的
FiberNode
。 - current: 老的fiber树中对应的
FiberNode
,可能为null。 - nextChildren:
JSX
对象或数组,JSX
也就是ReactElement
。
renderLanes跟优先级相关,这里不用关注。
Diff 算法 其实是比较老的 FiberNode
和新的 ReactElement
来生成新的 FiberNode
的一个过程:
nextChildren
的来源:
- 如果
workInProgress
节点对应的是类组件(ClassComponent
), 通过class的render方法得到。 - 如果
workInProgress
节点对应的是函数组件FunctionComponent
, 通过组件函数运行后得到。 - 其它情况大都来自
workInProgress.pendingProps.children
, 也就是props
中的children
属性。
Diff 分类
Diff 分为: 单节点 Diff和多节点 Diff。
这里的单节点和多节点指的是: nextChildren
是ReactElement
对象(一个)或ReactElement
数组(多个)。
<div>
<div>单节点</div>
</div>
<div>
<div>多节点</div>
<div>多节点</div>
<div>多节点</div>
</div>
单节点 Diff
current
子节点为空
current
的子节点中有可复用的节点, 当FiberNode
和ReactElement
的type
和key
都相同,我们认为其可以复用。 可复用的节点并不代表对应的dom节点完全不改变,有可能会有属性的更新,通常会被打上Update
标签。
current
的子节点中没有可复用的节点
多节点 Diff
但是React团队发现,在日常开发中,相较于新增和删除,更新组件发生的频率更高。所以Diff会优先判断当前节点是否属于更新。
Diff算法的整体逻辑会经历两轮遍历:
第一轮遍历:处理更新的节点。 第二轮遍历:处理剩下的不属于更新的节点。
第一轮遍历
- 依次比较
nextChildren
和current的子节点
, 判断DOM节点
是否可复用。 - 如果可复用, 则继续。
- 如果不可复用,分两种情况:
key
不同导致不可复用,立即跳出整个遍历,第一轮遍历结束。key
相同type
不同导致不可复用,会将oldFiber
标记为DELETION
,并继续遍历。
- 如果
nextChildren
遍历完或者current的子节点
遍历完,跳出遍历,第一轮遍历结束。
第二轮遍历
对于第一轮遍历的结果,我们分别讨论:
nextChildren
和current的子节点
同时遍历完
那就是最理想的情况, 只需第一轮遍历,此时Diff结束。
nextChildren
没遍历完,current的子节点
遍历完
已有的DOM节点
都复用了,这时还有新加入的节点,意味着本次更新有新节点插入,我们只需要遍历剩下的nextChildren
为生成的新的FiberNode
依次标记Placement
。
nextChildren
遍历完,current的子节点
没遍历完
意味着本次更新比之前的节点数量少,有节点被删除了。所以需要遍历剩下的oldFiber
,依次标记Deletion
。
nextChildren
与current的子节点
都没遍历完
这是Diff算法最精髓也是最难懂的部分,我们单独讲解。
nextChildren
与current的子节点
都没遍历完
- 将未遍历完的
current的子节点
保存到一个 Map中,用 key 作为索引, 并且需要记录oldFiber
在current的子节点
中的位置(称为oldIndex
)。 - 遍历未完的
nextChildren
, 通过ReactElement.key
去 Map 中寻找是否有可以复用的节点。- 如果没有找到,则创建新的
FiberNode
,标记Placement
。 - 如果找到,则比较
oldFiber
的oldIndex
和lastPlacedIndex
,lastPlacedIndex
表示最后一个可复用的节点在current的子节点
中的位置索引。,如果oldIndex > lastPlacedIndex
则代表该可复用节点不需要移动,只需更新lastPlacedIndex
(lastPlacedIndex = oldIndex);oldIndex < lastPlacedIndex
, 则 该节点需要向右移动,标记为Placement
。
- 如果没有找到,则创建新的
我们来看一个例子:
旧列表(current子节点): abcde
新列表(nextChildren): abecd
dom节点如何修改
对于dom节点
我们通常需要做的操作有:
- 更新(更新属性)
- 新增
- 删除
- 移动
其中dom节点
的新增
和移动
对应Placement
, 更新
对应Update
, 删除
对应Deletion
。
对Placement
节点使用的appendChild
和insertBefore
来操作DOM
, 如果节点之前不存在就是新增
, 如果存在就是移动
, 下面例子是移动
的情况:
<!-- 改变前: -->
<div class="parent">
<div>A</div>
<div>B</div>
<div>C</div>
<div>
<!-- 改变后: -->
<div class="parent">
<div>A</div>
<div>C</div>
<div>B</div>
<div>
parent.appendChild(B);
<!-- 改变前: -->
<div class="parent">
<div>A</div>
<div>B</div>
<div>C</div>
<div>
<!-- 改变后: -->
<div class="parent">
<div>B</div>
<div>A</div>
<div>C</div>
<div>
parent.insertBefore(A, C);
注: 是使用appendChild还是insertBefore取决于节点是否有兄弟节点。
完整流程
我们一个一个例子来说明:
1. 在render
阶段的beginWork
阶段标记FiberNode
2. 在render
阶段的completeWork
阶段将标记的FiberNode加入副作用队列
需要注意的是: 正常副作用队列的处理是在completeWor,
阶段, 但是该节点(被删除)会脱离workInProgess fiber树
, 不会再进入completeWor,
阶段, 所以在beginWork
阶段提前加入副作用队列。
所以在beginWork
阶段, 如果是需要删除的fiber
, 除了自身打上Deletion
之外, 还要将其添加到父节点的副作用队列中。
3. 在commit
阶段的mutation阶段
处理副作用队列修改DOM
对应的DOM
改变前后状态如下:
<!-- 改变前: -->
<div class="parent">
<div>A</div>
<div>B</div>
<div>C</div>
<div>D</div>
<div>
<!-- 改变后: -->
<div class="parent">
<div>A</div>
<div>C</div>
<div>B</div>
<div>
我们来手动模拟一下这个过程:
- 处理
副作用队列
中第一个FiberNode
, 它是Deletion
, 所以删除对应DOM
.
D.remove();
- 处理
副作用队列
中第二个FiberNode
, 它是Placement
.
parent.appendChild(B);
我这里做了很多简化,实际过程复杂得多,我是为了建立比较直观的理解,关于mutation阶段
的详细的介绍可以参考React技术揭秘-mutation。
参考链接: