头图

从头写一个 React-like 框架:优化 diff

Showonne

前言

从头写一个 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,否则不能复用。循环中一共有三种情况:

  1. 新旧节点 type 相同,说明新旧节点都存在,并且 type 相同,effectTag 记为 'UPDATE'
  2. 新节点存在且 type 不同,说明该新节点需要被创建,effectTag 记为 'PLACEMENT'
  3. 旧节点存在且 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 之后,我们认为当新旧节点的 typekey 都相等时,新旧节点的 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, newStartNodekeytype 相同,则认为这两个节点可以复用 dom,直接更新属性(effectTag = 'UPDATE')即可,更新完成后,oldStart, newStart 分别指向下一个位置,oldStartNode, newStartNode 也随之变成剩余未进行 diff 的新旧 children 的第一个元素;当 oldStartNode, newStartNode key 不同时,暂停首部的比较,同理再从新旧 children 尾部开始比较,这样就可以先将新旧 children 两端不需要移动的可复用节点优先更新,当 oldStart > oldEndnewStart > 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 列表中的第一个,所以该节点的 afteroldStartNode。如果 oldChildren 中没有可复用的节点,则将 newStartNodeeffectTag 置为 'INSERT' 表示当前位置需要新插入一个节点。 diff 完成后,将 oldChildren 中对应位置的节点置为 null,并将 newStart 指向下一个元素。因为在 diff 的过程中,每次对可复用节点完成更新操作后,都会将 oldChildren 中对应的元素置为 null,因此在循环的最开始,我们要判断一下 oldStartNodeoldEndNode 元素是否存在,不存在则指向下一个元素:

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 之间的关系:

  1. 如果 oldEnd < oldStart,说明旧节点全部参与 diff 后,还有新节点没参与 diff,这些节点是需要直接新增的节点。可以直接遍历剩余的 newChildren,将这些节点依次添加到新 dom 序列的末尾 newChildren[newEnd + 1]
  2. 如果 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 方案有以下改进:

  1. 可以通过 key 更准确地判断新旧 children 中是否有可复用的节点
  2. 会优先从两端处理可直接进行复用的节点,会减少一些 findIndex 的次数
  3. 新的 diff 中采用 'INSERT' (insertBefore) 而不是 'PLACEMENT' (appendChild), 灵活性更好

关于 diff 流程,我从这个博客中学到了很多,里面讲得很详细,还有很多配图帮助理解,最主要的是要想清楚 oldStart, oldEnd, newStart, newEnd 他们中携带的信息,博客中的示例代码是 diff 过程和更新 dom 一起进行的,这一点并不适用于 fiber 架构,所以我进行了一些小改造(先标记 effectTagafter,commit 阶段统一更新 dom),代码在 github 上,有兴趣的同学可以看看(顺手 star 一下也是极好的) ^_^

阅读 341

考拉海购招前端专家,简历投递:showone896@gmail.com, wx: 918677896。

2.8k 声望
1.2k 粉丝
0 条评论
你知道吗?

考拉海购招前端专家,简历投递:showone896@gmail.com, wx: 918677896。

2.8k 声望
1.2k 粉丝
宣传栏