ダブルエンドの差分アルゴリズムを理解するには、図を見てください。

序文

最近、Vue のソースコードを学習し、関連する diff アルゴリズムについても学びました。

なぜ diff アルゴリズムが必要なのでしょうか?

Vue では、コンポーネントの出力は仮想 DOM です。新旧ノードの VDOM を比較する必要があります。このとき、パフォーマンスを向上させるために、ノードを再利用して DOM 操作を減らし、diff アルゴリズムが必要になります。 。

Reactの差分アルゴリズム

let lastIndex = 0
for (let i = 0; i < nextChildren.length; i++) {
  const nextVNode = nextChildren[i]
  let j = 0,
    find = false;
  for (j; j < prevChildren.length; j++) {
    const prevVNode = prevChildren[j]
    if (nextVNode.key === prevVNode.key) {
      find = true
      patch(prevVNode, nextVNode, container)
      if (j < lastIndex) {
        // 需要移动
        const refNode = nextChildren[i - 1].el.nextSibling
        container.insertBefore(prevVNode.el, refNode)
        break
      } else {
        // 更新 lastIndex
        lastIndex = j
      }
    }
  }
  if (!find) {
    // 挂载新节点
    const refNode =
      i - 1 < 0
        ? prevChildren[0].el
        : nextChildren[i - 1].el.nextSibling
    mount(nextVNode, container, false, refNode)
  }
}
// 移除已经不存在的节点
for (let i = 0; i < prevChildren.length; i++) {
  const prevVNode = prevChildren[i]
  const has = nextChildren.find(
    nextVNode => nextVNode.key === prevVNode.key
  )
  if (!has) {
    // 移除
    container.removeChild(prevVNode.el)
  }
}

上記のコードは中心的な内容にすぎず、いくつかの変数については以下で説明します。

  • nextChildren:新しいVNode
  • prevChildren:旧VNode
  • key: ノードの一意の識別子。次の簡単な例は、キーの値を理解します。
// 旧的 VNode
const prevVNode = h('div', null, [
  h('p', { key: 'a' }, '节点1'),
  h('p', { key: 'b' }, '节点2'),
  h('p', { key: 'c' }, '节点3')
])

// 新的 VNode
const nextVNode = h('div', null, [
  h('p', { key: 'd' }, '节点4'),
  h('p', { key: 'a' }, '节点1'),
  h('p', { key: 'b' }, '节点2')
])

// h 函数的返回值
return {
    _isVNode: true,
    flags,
    tag,
    data,
    key: data && data.key ? data.key : null,
    children,
    childFlags,
    el: null || container // 要挂载的节点
 }
  • el.nextSibling: は DOM 属性で、ノードelの(つまり、el同じレベルのノード内のノードのすぐ後ろのノード) を意味します。
  • insertBefore: は DOM API のメソッドで、指定したノードの親ノードの子ノード リスト内の指定した位置の前に新しい子ノードを挿入できます。

分析プロセス:

  1. 同じ層、つまり 2 層の for ループ上の古いノードと新しいノードを比較すると、時間計算量は O(n²) になります。
  2. 新しいノードの子ノードは、値が一意であるため、古いノードの子ノードと比較されます。keyそれらが同じである場合、patchメソッドが呼び出され、古いノードと新しいノードのプロパティとバインディング イベントが一致しているかどうかを比較します。かわった。
  3. ノードを比較するlastIndexときに、古いノードの最後の子ノードの値がlastIndex変更されない場合、最初のブランチに入り、そのノードを挿入して置き換えます。
  4. findこの変数は新しく追加されたノードをマークするために使用され、マウント時にそれが最初のノードであるかどうかを判断する必要があります。
  5. 存在しないノードを削除するには、再度トラバースする必要があります。

Vue の両端差分アルゴリズム

React の差分アルゴリズムには明らかな欠点があり、古いノードの最後の子ノードが前面にマウントされている場合、子ノード全体を移動する必要があります。例: 古い VNode: A、B、C、D、新しい VNode: D、A、B、C。

image.png

// 当新的 children 中有多个子节点时,会执行该 case 语句块
let oldStartIdx = 0 // 定义旧节点开始索引
let oldEndIdx = prevChildren.length - 1 // 定义旧节点结束索引
let newStartIdx = 0 // 定义新节点开始索引
let newEndIdx = nextChildren.length - 1 // 定义新节点结束索引
let oldStartVNode = prevChildren[oldStartIdx] // 定义旧节点开始位置对应的虚拟节点
let oldEndVNode = prevChildren[oldEndIdx] // 定义旧节点结束位置对应的虚拟节点
let newStartVNode = nextChildren[newStartIdx] // 定义新节点开始位置对应的虚拟节点
let newEndVNode = nextChildren[newEndIdx] // 定义新节点结束位置对应的虚拟节点

while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) { // 循环比较旧节点和新节点
  if (!oldStartVNode) { 
  // 如果旧节点开始位置的虚拟节点不存在,则将旧节点开始索引增加,并重新定义旧节点开始位置对应的虚拟节点
    oldStartVNode = prevChildren[++oldStartIdx]
  } else if (!oldEndVNode) { 
  // 如果旧节点结束位置的虚拟节点不存在,则将旧节点结束索引减少,并重新定义旧节点结束位置对应的虚拟节点
    oldEndVNode = prevChildren[--oldEndIdx]
  } else if (oldStartVNode.key === newStartVNode.key) { 
  // 如果旧节点开始位置虚拟节点的 key 和新节点开始位置虚拟节点的 key 相同,则进行 patch 操作
    patch(oldStartVNode, newStartVNode, container)
    oldStartVNode = prevChildren[++oldStartIdx]// 对旧节点开始索引和新节点开始索引都加 1,表示这两个节点已经匹配完成
    newStartVNode = nextChildren[++newStartIdx]
  } else if (oldEndVNode.key === newEndVNode.key) { 
  // 如果旧节点结束位置虚拟节点的 key 和新节点结束位置虚拟节点的 key 相同,则进行 patch 操作
    patch(oldEndVNode, newEndVNode, container)
    oldEndVNode = prevChildren[--oldEndIdx] // 对旧节点结束索引和新节点结束索引都减 1,表示两个节点已经匹配完成
    newEndVNode = nextChildren[--newEndIdx]
  } else if (oldStartVNode.key === newEndVNode.key) { 
  // 如果旧节点开始位置虚拟节点的 key 和新节点结束位置虚拟节点的 key 相同,则进行 patch 操作
    patch(oldStartVNode, newEndVNode, container) // 这个操作是将旧节点开始位置虚拟节点移动到旧节点结束位置之后,并更新其对应的真实节点的位置
    container.insertBefore(
      oldStartVNode.el,
      oldEndVNode.el.nextSibling
    )
    oldStartVNode = prevChildren[++oldStartIdx] // 对旧节点开始索引和新节点结束索引都加 1,表示这两个节点已经匹配完成
    newEndVNode = nextChildren[--newEndIdx]
  } else if (oldEndVNode.key === newStartVNode.key) { 
  // 如果旧节点结束位置虚拟节点的 key 和新节点开始位置虚拟节点的 key 相同,则进行 patch 操作
    patch(oldEndVNode, newStartVNode, container) // 这个操作是将旧节点结束位置虚拟节点移动到旧节点开始位置之前,并更新其对应的真实节点的位置
    container.insertBefore(oldEndVNode.el, oldStartVNode.el)
    oldEndVNode = prevChildren[--oldEndIdx] // 对旧节点结束索引和新节点开始索引都减 1,表示两个节点已经匹配完成
    newStartVNode = nextChildren[++newStartIdx]
  } else {
    const idxInOld = prevChildren.findIndex( // 查找新节点开始位置虚拟节点在旧节点中的位置,并返回对应的索引
      node => node.key === newStartVNode.key
    )
    if (idxInOld >= 0) { // 如果返回的索引不小于 0,则说明新节点开始位置虚拟节点在旧节点中存在
      const vnodeToMove = prevChildren[idxInOld] // 根据返回的索引,找到旧节点中对应的虚拟节点,并将其移动到旧节点开始位置之前,并更新其对应的真实节点的位置
      patch(vnodeToMove, newStartVNode, container)
      prevChildren[idxInOld] = undefined // 将该虚拟节点在旧节点中的位置设为 undefined,表示其已经被移动过了
      container.insertBefore(vnodeToMove.el, oldStartVNode.el)
    } else { // 如果返回的索引小于 0,则说明新节点开始位置虚拟节点在旧节点中不存在
      // 新节点
      mount(newStartVNode, container, false, oldStartVNode.el) // 在旧节点开始位置之前插入新节点,并创建其对应的真实节点
    }
    newStartVNode = nextChildren[++newStartIdx] // 对新节点开始索引加 1,表示该节点已经处理完成
  }
}
if (oldEndIdx < oldStartIdx) { // 如果旧节点结束索引小于旧节点开始索引,则说明旧节点已经全部处理完成,此时需要将剩余的新节点添加到真实 DOM 中
  // 添加新节点
  for (let i = newStartIdx; i <= newEndIdx; i++) {
    mount(nextChildren[i], container, false, oldStartVNode.el)
  }
}

分析プロセス:

次の状況:

  • 古いノードの先頭ポインタと末尾ポインタが空の場合は、その位置を再度ポイントします。後続のノード処理プロセスでは、いくつかの古いノードに値が割り当てられますundefined
  • ヘッドツーヘッドおよびテールツーテールの比較、関数patch呼び出しを行った後、古いヘッド ノードと新しいヘッド ノードを下に移動します。
  • 头尾和尾头比较,进行patch函数调用,再将新节点进行调用。insertBefore方法,和拿到el.nextSibling属性,进行DOM的插入操作,再使指针分别移动。
  • 上面情况都不符合时,通过遍历去比对key值,能找到则进行patch操作,使该旧节点值为undefined,不能找到则进行mount方法的调用,进行挂载新节点,此时仅需要新节点头指针进行移动。

图解举例具体分析:

diff整体策略为:深度优先,同层比较

  1. 比较只会在同层级进行, 不会跨层级比较

img

  1. 比较的过程中,循环从两边向中间收拢

img

下面举个vue通过diff算法更新的例子:

新旧VNode节点如下图所示:

第一次循环后,发现旧节点D与新节点D相同,直接复用旧节点D作为diff后的第一个真实节点,同时旧节点endIndex移动到C,新节点的 startIndex 移动到了 C,我们可以看到进行了container.insertBefore(oldEndVNode.el, oldStartVNode.el)函数的调用,进行了一次DOM的插入操作。

第二次循环后,同样是旧节点的末尾和新节点的开头(都是 C)相同,同理,diff 后创建了 C 的真实节点插入到第一次创建的 D 节点后面。同时旧节点的 endIndex 移动到了 B,新节点的 startIndex 移动到了 E

第三次循环中,发现E没有找到,这时候只能直接创建新的真实节点 E,插入到第二次创建的 C 节点之后。同时新节点的 startIndex 移动到了 A。旧节点的 startIndex 和 endIndex 都保持不动

第四次循环中,发现了新旧节点的开头(都是 A)相同,于是 diff 后创建了 A 的真实节点,插入到前一次创建的 E 节点后面。同时旧节点的 startIndex 移动到了 B,新节点的startIndex 移动到了 B

第五次循环中,情形同第四次循环一样,因此 diff 后创建了 B 真实节点 插入到前一次创建的 A 节点后面。同时旧节点的 startIndex移动到了 C,新节点的 startIndex 移动到了 F

新节点的 startIndex 已经大于 endIndex 了,需要创建 newStartIdx 和 newEndIdx 之间的所有节点,也就是节点F,直接创建 F 节点对应的真实节点放到 B 节点后面

什么时候需要diff?

当数据发生改变时,set方法会调用Dep.notify通知所有订阅者Watcher,订阅者就会调用patch给真实的DOM打补丁,更新相应的视图。这个设计模式是订阅发布者模式,有兴趣了解更详细的流程可以去看我的另一篇文章。你不会只知道MVVM的概念吧? - 掘金 (juejin.cn)

Vue3又对diff做了什么优化?

vue3diff算法中相比vue2增加了静态标记

关于这个静态标记,其作用是为了会发生变化的地方添加一个flag标记,下次发生变化的时候直接找该地方进行比较

下图这里,已经标记静态节点的p标签在diff过程中则不会比较,把性能进一步提高

关于静态类型枚举如下

export const enum PatchFlags {
  TEXT = 1,// 动态的文本节点
  CLASS = 1 << 1,  // 2 动态的 class
  STYLE = 1 << 2,  // 4 动态的 style
  PROPS = 1 << 3,  // 8 动态属性,不包括类名和样式
  FULL_PROPS = 1 << 4,  // 16 动态 key,当 key 变化时需要完整的 diff 算法做比较
  HYDRATE_EVENTS = 1 << 5,  // 32 表示带有事件监听器的节点
  STABLE_FRAGMENT = 1 << 6,   // 64 一个不会改变子节点顺序的 Fragment
  KEYED_FRAGMENT = 1 << 7, // 128 带有 key 属性的 Fragment
  UNKEYED_FRAGMENT = 1 << 8, // 256 子节点没有 key 的 Fragment
  NEED_PATCH = 1 << 9,   // 512
  DYNAMIC_SLOTS = 1 << 10,  // 动态 solt
  HOISTED = -1,  // 特殊标志是负整数表示永远不会用作 diff
  BAIL = -2 // 一个特殊的标志,指代差异算法
}

Vue3中对不参与更新的元素,会做静态提升,只会被创建一次,在渲染时直接复用

这样就免去了重复的创建节点,大型应用会受益于这个改动,免去了重复的创建操作,优化了运行时候的内存占用

<span>你好</span>

<div>{{ message }}</div>

没有做静态提升之前

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createBlock(_Fragment, null, [
    _createVNode("span", null, "你好"),
    _createVNode("div", null, _toDisplayString(_ctx.message), 1 /* TEXT */)
  ], 64 /* STABLE_FRAGMENT */))
}

做了静态提升之后

const _hoisted_1 = /*#__PURE__*/_createVNode("span", null, "你好", -1 /* HOISTED */)

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createBlock(_Fragment, null, [
    _hoisted_1,
    _createVNode("div", null, _toDisplayString(_ctx.message), 1 /* TEXT */)
  ], 64 /* STABLE_FRAGMENT */))
}

// Check the console for the AST
  • 静态内容_hoisted_1被放置在render 函数外
  • 每次渲染的时候只要取 _hoisted_1 即可
  • 同时 _hoisted_1 被打上了 PatchFlag ,静态标记值为 -1
  • 特殊标志是负整数表示永远不会用于 Diff

おすすめ

転載: juejin.im/post/7240487843223781434