前言
在从头写一个 React-like 框架:工程搭建中,我们将 mini React 进行了重构,这次我们来优化一下现有的 diff 逻辑,在 Fiber 架构中,主要有 reconcile 和 commit 两大阶段,diff 的过程发生在 reconcile 阶段。
现有功能
先看一下现有的 reconcileChildren 实现:
function reconcileChildren(WIPFiber: Fiber, children: VNode): void {
let index = 0
let prevSibling = null
const oldChildren = WIPFiber.kids || []
const newChildren = (WIPFiber.kids = arrayfy(children))
const length = Math.max(oldChildren.length, newChildren.length)
while (index < length) {
const oldChild = oldChildren[index]
const currentChild = newChildren[index]
const sameType = oldChild && currentChild && oldChild.type === currentChild.type
if (sameType) {
currentChild.effectTag = 'UPDATE'
// ...
}
if (currentChild && !sameType) {
currentChild.effectTag = 'PLACEMENT'
// ...
}
if (oldChild && !sameType) {
oldChild.effectTag = 'DELETION'
deletions.push(oldChild)
}
// ...
}
}因为新旧 children 的长短不定,我们取两者中较大的长度进行遍历,保证新旧 children 中至少有一个列表所有节点都能被遍历到。如果新旧节点的 type 相同,则认为新旧节点可以复用 DOM,否则不能复用。循环中一共有三种情况:
- 新旧节点
type相同,说明新旧节点都存在,并且 type 相同,effectTag 记为 'UPDATE' - 新节点存在且
type不同,说明该新节点需要被创建,effectTag记为'PLACEMENT' - 旧节点存在且
type不同,说明该旧节点需要被删除,effectTag记为'DELETION'
随后在 commit 阶段,只需要根据不同的 effectTag 进行不同的 dom 操作即可:
// reconciler.js
function commitWork(fiber: Fiber): void {
// ...
if (fiber.effectTag === 'PLACEMENT') {
parentDom.appendChild(fiber.dom)
}
if (fiber.effectTag === 'DELETION') {
commitDeletion(fiber, parentDom as HTMLElement)
if (fiber.ref) {
fiber.ref.current = null
}
return
}
if (fiber.effectTag === 'UPDATE') {
updateDom(
fiber.dom,
fiber.prevProps,
fiber.props
)
}
// ...
}这是一种最简单的 diff 策略,仅根据 type 判断节点是否可以复用,比如在下面的例子中:
<ul>
{
keys.map(key =><li>{key}</li>)
}
</ul>如果 keys 是从 [1, 2, 3] 变为 [3, 2, 1],在 reconcile 过程中,li 节点会全部复用,因为他们的 fiber type 相同,但是稍微变一下就会大有不同:
<ul>
{
keys.map(key => key === 1 ? <div>key 1 div</div> : <li>{key}</li>)
}
</ul>现在 key === 1 时,渲染出的 dom 不再是 li 而是 div, 所以如果 keys 从 [1, 2, 3] 变为 [3, 2, 1] ,在 1 和 3 处分别会进行一次创建 dom 和一次删除 dom,我们需要对这种情况进行优化。
添加 key
首要问题是:仅通过 type 不能准确地找到可复用节点,所以需要额外属性建立新旧节点的映射关系,这个属性就是我们熟知的 key。首先在构建 fiber 节点时检查 key 属性,如果有直接挂载到节点上:
// h.js
export function h(type, props, ...children): VNode {
props = props || {}
const key = props.key || null
while (children.some(child => Array.isArray(child))) {
children = children.flat()
}
return {
type,
// 添加 key 属性
key,
props: {
...props,
children: children.map(child => typeof child === 'object' ? child : createTextElement(child)).filter(e => e != null)
}
}
}有了 key 之后,我们认为当新旧节点的 type 和 key 都相等时,新旧节点的 dom 可以复用。新的 diff 中用到如下变量,分别是新旧 children 的首尾元素:
const oldChildren = WIPFiber.kids || []
const newChildren = (WIPFiber.kids = arrayfy(children))
// 新旧 children 首尾下标
let oldStart = 0
let oldEnd = oldChildren.length - 1
let newStart = 0
let newEnd = newChildren.length - 1
// 新旧 children 的首尾元素
let oldStartNode = oldChildren[oldStart]
let oldEndNode = oldChildren[oldEnd]
let newStartNode = newChildren[newStart]
let newEndNode = newChildren[newEnd]先从新旧 children 的两端尝试寻找可复用的节点,oldStartNode, newStartNode 分别是新旧 children 的第一个节点,如果 oldStartNode, newStartNode 的 key 和 type 相同,则认为这两个节点可以复用 dom,直接更新属性(effectTag = 'UPDATE')即可,更新完成后,oldStart, newStart 分别指向下一个位置,oldStartNode, newStartNode 也随之变成剩余未进行 diff 的新旧 children 的第一个元素;当 oldStartNode, newStartNode key 不同时,暂停首部的比较,同理再从新旧 children 尾部开始比较,这样就可以先将新旧 children 两端不需要移动的可复用节点优先更新,当 oldStart > oldEnd 或 newStart > newEnd 时,证明 oldChildren, newChildren 其中一个已经全部参与过 diff,循环终止:
while (oldStart <= oldEnd && newStart <= newEnd) {
// 首尾 key, type 相同的节点优先更新(effectTag = 'UPDATE')
if (isSame(oldStartNode, newStartNode)) {
clone(newStartNode, oldStartNode)
newStartNode.effectTag = 'UPDATE'
oldStartNode = oldChildren[++oldStart]
newStartNode = newChildren[++newStart]
} else if (isSame(oldEndNode, newEndNode)) {
clone(newEndNode, oldEndNode)
newEndNode.effectTag = 'UPDATE'
oldEndNode = oldChildren[--oldEnd]
newEndNode = newChildren[--newEnd]
}
// ...
}两端的节点 diff 完成后,开始遍历 newChildren 中剩余的节点,因为现在有了 key,通过 findIndex 就可以判断 oldChildren 中有没有可复用的节点,如果有,对新旧节点进行 patch,这里新旧节点在各自 children 中的位置是不同的,后续需要移动节点,所以 effectTag 记为 INSERT,而且在 commit 阶段才会真正进行 dom 操作,这里先通过 after 属性先记录新节点要插入位置之后的节点(因为实际用到的是 insertBefore 方法),由于我们遍历的是 newChildren,说明当前节点在新的渲染中是剩余未 diff 列表中的第一个,所以该节点的 after 是 oldStartNode。如果 oldChildren 中没有可复用的节点,则将 newStartNode 的 effectTag 置为 'INSERT' 表示当前位置需要新插入一个节点。 diff 完成后,将 oldChildren 中对应位置的节点置为 null,并将 newStart 指向下一个元素。因为在 diff 的过程中,每次对可复用节点完成更新操作后,都会将 oldChildren 中对应的元素置为 null,因此在循环的最开始,我们要判断一下 oldStartNode,oldEndNode 元素是否存在,不存在则指向下一个元素:
if (!oldStartNode) {
oldStartNode = oldChildren[++oldStart]
} else if (!oldEndNode) {
oldEndNode = oldChildren[--oldEnd]
} else if (isSame(oldStartNode, newStartNode)) {
// ...
} else if (isSame(oldEndNode, newEndNode)) {
// ...
} else {
const indexInOld = oldChildren.findIndex(child => isSame(child, newStartNode))
// 存在可复用节点,完成新旧节点的 patch 操作,此处的 'INSERT' 表示节点需要被移动
if (indexInOld >= 0) {
const oldNode = oldChildren[indexInOld]
clone(newStartNode, oldNode)
newStartNode.effectTag = 'INSERT'
newStartNode.after = oldStartNode
oldChildren[indexInOld] = undefined
} else {
// 无可复用节点,无需 patch,此处的 'INSERT' 表示节点需要被创建
newStartNode.effectTag = 'INSERT'
newStartNode.after = oldStartNode
}
newStartNode = newChildren[++newStart]
}最后,当 while 循环完成后,我们需要检查一下 oldStart, oldEnd, newStart, newEnd 之间的关系:
- 如果
oldEnd < oldStart,说明旧节点全部参与 diff 后,还有新节点没参与 diff,这些节点是需要直接新增的节点。可以直接遍历剩余的newChildren,将这些节点依次添加到新 dom 序列的末尾newChildren[newEnd + 1] - 如果
newEnd < newStart,说明新节点全部参与 diff 后,还有旧节点没参与 diff,这些节点时需要删除的节点,直接循环剩余的oldChildren,依次删除即可:
if (oldEnd < oldStart) {
for (let i = newStart; i <= newEnd; i++) {
let node = newChildren[i]
node.effectTag = 'INSERT'
node.after = newChildren[newEnd + 1]
}
} else if (newEnd < newStart) {
for (let i = oldStart; i <= oldEnd; i++) {
let node = oldChildren[i]
if (node) {
node.effectTag = 'DELETION'
deletions.push(node)
}
}
}到此,简单优化后的 diff 流程就已经完成了。
结语
相比于旧的 diff 方案,新的 diff 方案有以下改进:
- 可以通过 key 更准确地判断新旧 children 中是否有可复用的节点
- 会优先从两端处理可直接进行复用的节点,会减少一些
findIndex的次数 - 新的 diff 中采用
'INSERT' (insertBefore)而不是'PLACEMENT' (appendChild), 灵活性更好
关于 diff 流程,我从这个博客中学到了很多,里面讲得很详细,还有很多配图帮助理解,最主要的是要想清楚 oldStart, oldEnd, newStart, newEnd 他们中携带的信息,博客中的示例代码是 diff 过程和更新 dom 一起进行的,这一点并不适用于 fiber 架构,所以我进行了一些小改造(先标记 effectTag 和 after,commit 阶段统一更新 dom),代码在 github 上,有兴趣的同学可以看看(顺手 star 一下也是极好的) ^_^
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用。你还可以使用@来通知其他用户。