react 多节点 diff 简易实现

Diff 的目的

react 是一个数据驱动的框架,通过将数据与 UI 关联起来达到数据更新时同时更新 UI 更新的目的。对于 react web app 来说,数据的变动最终会转化为 dom 的变化。当然 react 并不会对 dom 进行直接比较,而是对比变化前的 fiber。对 fiber 的 diff 最终会反映到 dom 上。

先假设在 fiber 变化时不使用 diff 算法,即一旦 fiber 改变则删除变化前的所有 fiber 并插入变化后的 fiber 。这种方法虽然简便,但存在性能问题,因为 dom 的删除和创建都需要耗费时间。例如,fiber 从 a, b, c 变为 a, c, b。只需要将 b 插入到 c 之后即可,无需创建任何 fiber 。因此,需要一种方法来标记元素的变更,这就是 diff 算法。

如果变化后都存在多个元素,则属于多节点的 diff。多节点的 fiber diff 对于每一个 fiber 实际只存在两种情况:

  1. Placement: 需要移动某个 fiber 的位置或插入一个新 fiber
  2. Deletion: 需要删除某个 fiber

为什么移动或新增 dom 都属于同一种情况,因为 react 实际上最终会调用 Node.insertBefore() 来进行 placement 操作,其定义如下:

Node.insertBefore() 方法在参考节点之前插入一个拥有指定父节点的子节点。 如果给定的子节点是对文档中现有节点的引用 ,insertBefore() 会将其从当前位置移动到新位置 (在将节点附加到其他节点之前,不需要从其父节点删除该节点)。

因此 react 并不关心该 fiber 是移动(已经存在)还是新增(不存在需要创建)。例如 fiber 从 a, b, c, d 变为 a, c, b,d,那么 react 会将 b 这个 fiber 标记为 Placement。其余 fiber 不变。在最终进行 dom 变化时调用 parent.insertBefore(d, b)。因此 diff 的目的并不是要严格的找出 fiber 从哪个位置移动到哪个位置,只需要得出哪些需要删除,哪些需要 Placement 即可。

算法

假设存在 now 以及 before 两个 fiber 集合。为了简化场景,认为 now 中的 fiber 在 before 中都存在。这时候问题可以转换为 如何移动 before 中的元素将其转换为 now。react处理办法为 右移 before 中的部分 fiber 将其转换为 now。例如,before 以及 after 中 key 的顺序为:

before: a, b, c
now: a, c, b

那么标记 b 为 Placement 即可。对于这个任务,我们将 上一个位置不变的元素在 now 中的位置记为 lastKeepIndex,当遍历 now 数组中的每个 fiber 时,如果该 fiber 在 before 数组中存在,且 。则说明当前所遍历到得 fiber 在:

  • 变动前: 位于 lastKeepIndex 所标记的 fiber 的左边。
  • 变动后: 位于 lastKeepIndex 所标记的右边,因为当前 fiber 为遍历到的位置为最靠右边的 fiber。

这就意味这这个 fiber 是需要移动的。如果不满足这个条件,则需要该 fiber 相对 lastKeepIndex 所标记的 fiber 位置没有变动,无需改变。
当然,实际上不可能 now 中的 fiber 在 before 中都能找到。但这种同样直接标记为 Placement 即可。同时在 before 中却不在 now 中的需要元素标记为 Deletion。为了方便这里我们定义 4 种类型的 Diff:

type Key = string | number

interface FiberNode {
    'key': Key
}

// Move 与 Insert 实际上都为 Placement,这里为了方便说明将其分为两种
type Diff = 'Move'|'Insert'|'Deletion'|'None'

整个 diff 的逻辑为:

function diff(before: FiberNode[], now: FiberNode[]): [Key, Diff][]{
    // 将 fiber 转换为 [key, index] 方式供后续使用
    const beforeMap = new Map(before.map((n, i) => [n.key, i]));
    let lastKeepIndex = -1;
    const addOrmove = now.map(n => {
        const beforeIndex = beforeMap.get(n.key)
        let mutation: Diff = null;
        // 如果这个 fiber 在 before 中不存在,则直接标记为 Insert
        if(beforeIndex === undefined){ 
            mutation = 'Insert'
        }else{
            // 删除可以复用的 fiber,遍历完成后剩下的则为需要标记删除的 fiber
            beforeMap.delete(n.key); 
            // 满足条件,表明该 fiber 需要移动
            if(beforeIndex < lastKeepIndex){
                mutation = 'Move';
            }else{
                // 否则不需要移动,该 beforeIndex 作为新的 lastKeepIndex 
                lastKeepIndex = beforeIndex;
                mutation = 'None'
            }
        }
        return [n.key, mutation] as [Key, Diff]
    })
    // 标记需要删除的 fiber
    const deletion = Array.from(beforeMap.entries()).map(n => [n[0], 'Deletion'] as [Key, Diff])
    return [...addOrmove, ...deletion]
}
const before: FiberNode[] = [
    {key: 'a'},
    {key: 'b'},
    {key: 'c'},
    {key: 'd'},
    {key: 'x'},
]

const after: FiberNode[] = [
    {key: 'f'},
    {key: 'c'},
    {key: 'b'},
    {key: 'e'},
    {key: 'a'},
    {key: 'd'}
]
console.info(diff(before, after))
[ [ 'f', 'Insert' ],
  [ 'c', 'None' ],
  [ 'b', 'Move' ],
  [ 'e', 'Insert' ],
  [ 'a', 'Move' ],
  [ 'd', 'None' ],
  [ 'x', 'Deletion' ] ]

如何应用到真实 dom

在得到 diff 的结果后,react 通过两个 dom 操作函数来将 diff 应用到真实的 dom:

  1. Node.insertBefore()
  2. Node.appendChild()

第一个函数对应于变化后需要进行 Placement 有兄弟节点的情况,例如 fiber 从 a,b,c,d 变化为 a,c,b,d。此时 b 被标记为 Placement。react 会找到变化后它的第一个不需要变动的兄弟节点即为 d,并调用 parent.insertBefore(d, b) 。完成后真实的 dom 就从 a,b,c,d 变成 a,c,b,d。

第二个函数对应于变化后需要进行 Placement 不存在兄弟节点的情况,例如 fiber 从 a,b,c 变化为 a,c,b 此时 b 被标记为 Placement,但其不存在兄弟节点。react 会调用 parent.appendChild(b) 。完成后真实的 dom 就从 a,b,c 变成 a,c,b。

当然,真实的情况比这要更复杂。因此插入 dom 必定要先找到 fiber 树中真正的 dom 节点。而 fiber 树实际上是用户自定义组件 fiber 以及真实 dom fiber 组合在一起的,如何找到真实的兄弟 dom 节点对应的 fiber 也是一个比较复杂的任务。

一点扩展

react 通过 diff 算法来进行性能优化,减少 dom 的创建和删除。那么 react 采用的优化是否为 最优化呢?答案是:否。例如存在这样一个特殊的例子:

before: 1, 2, 3, ..., 999
now: 999, 1, 2, 3, ..., 998

由于 react diff 算法的局限,这里需要将 1 从 998 移动到 999 之后,但实际上我们一眼就能看出最简单的方法是将 999 移动到 1 之前。这也就是最近很多框架开始使用最长上升子序列来优化 diff 算法的原因。那么问题来了,你知道为什么这里 react 需要移动 998 个元素,或者说为什么最长上升子序列可以解决整个问题吗?

你可能感兴趣的:(react 多节点 diff 简易实现)