详解三种 Diff 算法(源码+图)

avatar
@https://www.tuya.com/

本文由团队成员 念可 撰写,已授权涂鸦大前端独家使用,包括但不限于编辑、标注原创等权益。

前言

在前端的热门框架中,Diff 作为 Virtual DOM 执行更新的核心流程,对于框架的性能提升起到了关键的作用。那么,Diff 算法又是如何实现的呢?接下来本文将带大家了解一下简单 Diff 算法、双端 Diff 算法和快速 Diff 算法的原理及实现。

什么是 Diff 算法?

当新旧 vnode 的子节点都是一组节点时,为了以最小的性能开销完成更新操作,需要比较两组子节点,用于比较的算法就叫作 Diff 算法。我们知道,操作 DOM 的性能开销通常比较大,而渲染器的核心 Diff 算法就是为了解决这个问题而诞生的。

简单 Diff 算法

核心逻辑

拿新的一组子节点中的节点去旧的一组子节点中寻找可复用的节点。如果找到了,则记录该节点的位置索引。把这个位置索引称为最大索引 (lastIndex) 。在整个更新过程中,如果一个节点的索引值小于最大索引,则说明该节点对应的真实 DOM 元素需要移动。

实现思路

1.依次遍历新 children,去旧 children 中查找就具有相同 key 的可复用节点。如果找到对应旧节点的索引 < lastIndex(最大索引),则说明该节点需要移动,反之更新 lastIndex。

2.若新节点没在旧 children 中找到可复用节点,则在当前新节点的上一个节点之后插入新节点。

3.遍历旧 children,查找是否有不存在新 children 中的节点,如果有则卸载改节点。

代码实现

简单 Diff 完整代码及 demo

示例

旧子节点: p-1,p-2,p-3

新子节点:p-3,p-1,p-2

简单 Diff 过程:

  1. 定义最大索引 lastIndex 的初始值为 0

  2. 遍历新的一组子节点

  • 1)新子节点第一个节点 p-3,去旧子节点中查找到相同 key 的节点索引为 2, 不小于当前最大索引值 lastIndex 0,则不需要移动,更新 lastIndex 值为 2

  • 2)新子节点第二个节点 p-1,去旧子节点中查找到相同 key 的节点索引为 0, 小于当前最大索引值 lastIndex 2,则说明该节点对应的真实 DOM 需要移动到上一节点 p-3 的兄弟节点之前,此时,由于 p-3 在真实 DOM 子节点中是最后一个子节点没有兄弟节点,则插入到末尾

  • 3)新子节点第二个节点 p-2,去旧子节点中查找到相同 key 的节点索引为 1, 小于当前最大索引值 lastIndex 2,则说明该节点对应的真实 DOM 需要移动到上一节点 p-1 的兄弟节点之前,此时,由于 p-1 在真实 DOM 子节点中是最后一个子节点没有兄弟节点,则插入到末尾

至此,Diff 过程结束。可以发现在本例中使用简单 Diff 算法进行渲染更新需要两次 DOM 操作,如下图:

可以看到这种方式不是最优的解法,所以还有下面的双端 Diff 和快速 Diff。

双端 Diff 算法

双端 Diff 算法是 Vue.js 2 用于比较新旧两个子节点的方式。

核心逻辑

在新旧两组子节点的四个端点之间分别进行比较,并试图找到可复用的节点。相比简单 Diff 算法,双端 Diff 算法的优势在于,对于同样的更新场景,执行的DOM 移动操作次数更少。

上面的示例,在快速 Diff 中的需要两次 DOM 操作,而在双端 Diff 中只需要一次 DOM 操作,如下图:

实现思路

  1. 首先,双端比较分四个步骤比较,如果找到可复用节点,进行 patch,移动节点。
  • 1)旧的一组子节点中的第一个子节点与新的一组节点中的第一个子节点比较

  • 2)旧的一组子节点中的最后一个子节点与新的一组子节点中的最后一个子节点比较

  • 3)旧的一组子节点中的第一个子节点与新的一组子节点中的最后一个子节点比较

  • 4)旧的一组子节点的最后一个子节点与新的一组子节点中的第一个子节点比较

  1. 以上对比都不满足,则遍历旧的一组子节点,寻找与 newStartVNode 拥有相同 key 值的元素
  • 1)找到了可复用节点,进行patch, 然后将该节点插入到 oldStartVNode 之前,newStartIdx 索引继续移动

  • 2)未找到可复用节点,创建和挂载新节点

  1. 循环结束检查索引值情况,处理剩余节点,新增或删除节点

代码实现

双端 Diff 完整代码及 demo

快速 Diff 算法

快速 Diff 算法是 Vue.js 3 用于比较新旧两个子节点的方式。正如其名,该算法的实测速度非常快,如此高效的Diff算法,下面让我们来剖析下它的实现原理。

相同的前置元素和后置元素

不同于简单Diff算法和双端Diff算法, 快速Diff算法包含预处理步骤, 这其实是借鉴了纯文本Diff算法的思路。

纯文本Diff算法

在纯文本算法中,存在对两段文本进行预处理的过程。如下:

  1. 先进行全等比较, 也称为快捷路径。
if(text1 === text2) return
  1. 处理两段文本相同的前缀和后缀。
TEXT1: I use vue for app development
TEXT2: I use react for app development

对于内容相同的元素不需要进行核心Diff操作,真正需要进行Diff操作的部分是:

TEXT1: vue
TEXT2: react

这实际是简化问题的一种方式,这么做的好处是,在特定情况下我们能够轻松地判断文本的插入和删除。

TEXT1: I like you
TEXT2: I like you too

TEXT1: 
TEXT2: too
// TEXT2在TEXT1基础上增加字符串too

TEXT1: I like you too
TEXT2: I like you

TEXT1: too
TEXT2:
// TEXT2在TEXT1基础上删除字符串too

预处理

快速Diff算法就是借鉴了以上纯文本Diff算法中的预处理步骤。

情况一:新增节点

通过观察可以发现,两组子节点具有相同的前置节点p-1,以及相同的后置节点p-3和p-4。

索引j、newEnd和oldEnd之间的关系

  • 条件一 j > oldEnd 成立:说明在预处理过程中,所有的旧节点都处理完毕了。

  • 条件二 j <= newEnd 成立:说明在预处理过后,在新的一组子节点中,仍然有未被处理的子节点,而这些遗留的节点将被视作新增节点

情况二:删除节点

通过观察可以发现,两组子节点具有相同的前置节点p-1,以及相同的后置节点p-3。

索引j、newEnd和oldEnd之间的关系

  • 条件一 j > newEnd 成立:说明在预处理过程中,所有的新节点都处理完毕了。

  • 条件二 j <= oldEnd 成立:说明在预处理过后,在旧的一组子节点中,仍然有未被处理的子节点,而这些遗留的节点将被视作删除节点

对于相同的前置节点和后置节点,由于它们在新旧两组子节点中的相对位置不变,所以我们无须移动它们,但仍然需要在它们之间打补丁。根据上面的分析,我们可实现如下预处理的代码:

function patchKeyedChildren(n1, n2, container) {
  const newChildren = n2.children
  const oldChildren = n1.children
  // 更新相同的前缀节点
  // 索引 j 指向新旧两组子节点的开头
  let j = 0
  let oldVNode = oldChildren[j]
  let newVNode = newChildren[j]
  // while 循环向后遍历,直到遇到拥有不同 key 值的节点为止
  while (oldVNode.key === newVNode.key) {
    // 调用 patch 函数更新
    patch(oldVNode, newVNode, container)
    j++
    oldVNode = oldChildren[j]
    newVNode = newChildren[j]
  }

  // 更新相同的后缀节点
  // 索引 oldEnd 指向旧的一组子节点的最后一个节点
  let oldEnd = oldChildren.length - 1
  // 索引 newEnd 指向新的一组子节点的最后一个节点
  let newEnd = newChildren.length - 1

  oldVNode = oldChildren[oldEnd]
  newVNode = newChildren[newEnd]

  // while 循环向前遍历,直到遇到拥有不同 key 值的节点为止
  while (oldVNode.key === newVNode.key) {
    // 调用 patch 函数更新
    patch(oldVNode, newVNode, container)
    oldEnd--
    newEnd--
    oldVNode = oldChildren[oldEnd]
    newVNode = newChildren[newEnd]
  }

  // 满足条件,则说明从 j -> newEnd 之间的节点应作为新节点插入
  if (j > oldEnd && j <= newEnd) {
    // 锚点的索引
    const anchorIndex = newEnd + 1
    // 锚点元素
    const anchor = anchorIndex < newChildren.length ? newChildren[anchorIndex].el : null
    // 采用 while 循环,调用 patch 函数逐个挂载新增的节点
    while (j <= newEnd) {
      patch(null, newChildren[j++], container, anchor)
    }
  } else if (j > newEnd && j <= oldEnd) {
    // j -> oldEnd 之间的节点应该被卸载
    while (j <= oldEnd) {
      unmount(oldChildren[j++])
    }
  }
}

判断是否需要进行DOM移动操作

上面预处理过程是处理比较理想化的情况,当处理完相同的前置节点和后置节点后,新旧两组子节点中总会有一组子节点全部被处理完毕。在这种情况下,只需要简单地挂载、卸载节点即可。但有时情况会比较复杂。如下图:

经过预处理后,新旧两组子节点中都有部分节点未被处理,这时就需要进一步处理。怎么处理呢?

  • 判断是否有节点需要移动,以及应该如何移动;

  • 找出那些需要被添加或移除的节点。

代码实现上,预处理过程中处理了理想的新增和删除情况,我们需要增加一个 else 分支来处理非理想情况,如下:

function patchKeyedChildren(n1, n2, container) {
  const newChildren = n2.children
  const oldChildren = n1.children
  // 更新相同的前缀节点
  // 省略部分代码

  // 更新相同的后缀节点
  // 省略部分代码

  // 满足条件,则说明从 j -> newEnd 之间的节点应作为新节点插入
  if (j > oldEnd && j <= newEnd) {
    // 省略部分代码
  } else if (j > newEnd && j <= oldEnd) {
    // j -> oldEnd 之间的节点应该被卸载
    // 省略部分代码
  } else {
    // 增加 else 分支来处理非理性情况
  }
}

一、 构造一个数组source

构造一个数组 source,它的长度等于新的一组子节点在经过预处理之后剩余未处理节点数量,数组 source 中每个元素分别与新的一组子节点中剩余未处理节点对应,并且 source 中每个元素的初始值都是 -1。

数组 source 有什么作用呢?

source 数组将用来存储新的一组子节点中的节点在旧的一组子节点的位置索引,后面将会使用它计算出一个最长递增子序列,并用于辅助完成 DOM 移动的操作

构造 source 数组代码实现
if (j > oldEnd && j <= newEnd) {
  // 省略部分代码
} else if (j > newEnd && j <= oldEnd) {
  // 省略部分代码
} else {
  // 处理非理性情况
  // 构造 source 数组
  const count = newEnd - j + 1  // 新的一组子节点中剩余未处理节点的数量
  const source = new Array(count)
  source.fill(-1)

  // oldStart 和 newStart 分别为起始索引,即 j
  const oldStart = j
  const newStart = j
  
  // 遍历旧的一组子节点
  for (let i = oldStart; i < oldEnd; i++) {
    const oldVNOde = oldChildren[i]
    // 遍历新的一组子节点
    for (let k = newStart; k < newEnd; k++) {
      const newVNode = newChildren[k]
      // 找到拥有相同key值的可复用节点
      if (oldVNode.key === newVNode.key) {
        // 调用patch进行更新
        patch(oldVNOde, newVNode, container)
        // 最后填充 source 数组
        source[k - newStart] = i
      }
    }
  }
}

上面用于填充 source 数组的代码存在怎样的问题?

这段代码采用了两层嵌套的循环,其时间复杂度为 O(n^2),当新旧两组子节点数量较多时两层嵌套的循环会带来性能问题。

怎样优化构造 source 数组的代码

为新的一组子节点构建一张索引表,用来存储节点的 key 和节点位置索引之间的映射。利用它可以快速地填充 source 数组。时间复杂度降至 O(n)。优化后代码实现如下:

if (j > oldEnd && j <= newEnd) {
  // 省略部分代码
} else if (j > newEnd && j <= oldEnd) {
  // 省略部分代码
} else {
  // 处理非理性情况
  // 构造 source 数组
  const count = newEnd - j + 1  // 新的一组子节点中剩余未处理节点的数量
  const source = new Array(count)
  source.fill(-1)

  // oldStart 和 newStart 分别为起始索引,即 j
  const oldStart = j
  const newStart = j
  //构建索引表
  const keyIndex = {}
  for(let i = newStart; i <= newEnd; i++) {
    keyIndex[newChildren[i].key] = i
  }
  // 遍历旧的一组子节点中剩余未处理的节点
  for(let i = oldStart; i <= oldEnd; i++) {
    oldVNode = oldChildren[i]
    // 通过索引表快速找到新的一组子节点中具有相同 key 的节点位置
    const k = keyIndex[oldVNode.key]
    
    if (typeof k !== 'undefined') {
      newVNode = newChildren[k]
      // 调用 patch 函数完成更新
      patch(oldVNode, newVNode, container)
      // 填充 source 数组
      source[k - newStart] = i      
    } else {
      // 没找到
      unmount(oldVNode)
    }
  }
}


二、如何判断节点是否需要移动

如果在子节点遍历过程中遇到的索引值程序递增趋势,则说明不需要移动节点,反之则需要。

此处需新增三个变量 moved、pos 和 patched。

  • moved 初始值 false,代表是否需要移动节点;

  • pos 初始值 0,代表遍历旧的一组子节点的过程中遇到的最大索引值 k。

  • patched 初始值 0,代表已经更新过的节点数量。已经更新过的节点数量应该小于新的一组子节点中需要更新的节点数量。一旦前者超过后者,则说明有多余的节点,我们应该将他们卸载。

在第二个 for 循环内,通过比较变量 k 与变量 pos 的值来判断是否需要移动节点。

if (j > oldEnd && j <= newEnd) {
  // 省略部分代码
} else if (j > newEnd && j <= oldEnd) {
  // 省略部分代码
} else {
  // 处理非理性情况
  // 构造 source 数组
  const count = newEnd - j + 1  // 新的一组子节点中剩余未处理节点的数量
  const source = new Array(count)
  source.fill(-1)

  // oldStart 和 newStart 分别为起始索引,即 j
  const oldStart = j
  const newStart = j
  // 新增两个变量 moved 和 pos
  let moved = false
  let pos = 0
  const keyIndex = {}
  for(let i = newStart; i <= newEnd; i++) {
    keyIndex[newChildren[i].key] = i
  }
  // 新增 patched 变量,代表更新过的节点数量
  let patched = 0
  for(let i = oldStart; i <= oldEnd; i++) {
    oldVNode = oldChildren[i]
    // 如果更新过的节点数量小于等于需要更新的节点数量,则执行更新
    if (patched < count) {
      const k = keyIndex[oldVNode.key]
      if (typeof k !== 'undefined') {
        newVNode = newChildren[k]
        patch(oldVNode, newVNode, container)
        // 每更新一个节点,都将 patched 变量 +1
        patched++
        source[k - newStart] = i
        // 判断节点是否需要移动
        if (k < pos) {
          moved = true
        } else {
          pos = k
        }
      } else {
        // 没找到
        unmount(oldVNode)
      }
    } else {
      // 如果更新过的节点数量大于需要更新的节点数量,则卸载多余的节点
      unmount(oldVNode)
    }
  }
}

如何移动节点

通过上面的处理,我们可以得到 moved 的值,如果 moved 为 true 说明需要移动节点。因此,需要增加个 if 判断分支来处理 DOM 移动的逻辑,如下:

if (j > oldEnd && j <= newEnd) {
  // 省略部分代码
} else if (j > newEnd && j <= oldEnd) {
  // 省略部分代码
} else {
  // 省略部分代码
  for(let i = oldStart; i <= oldEnd; i++) {
    // 省略部分代码
  }
  if (moved){
    // 如果 moved 为真, 则需要进行 DOM 移动操作
  }
}

计算 suorce 最长递增子序列

什么是最长递增子序列?

给定一个数值序列,找到它的一个子序列,并且该子序列中的值是递增的,子序列中的元素在原序列中不一定连续。一个序列可能有很多个递增子序列,其中最长的那个就称为最长递增子序列。

[0, 8, 4, 12]
// 上面的最长递增子序列如下:
[0, 4, 12]
// 或
[0, 8, 12]


  1. 需要求解 source 数值的最长递增子序列
if (moved){
	// 计算最长递增子序列
	const seq = getSequence(source) // [0, 1]
}

子序列 seq 的值为 [0, 1],说明在新的一组子节点中, 重新编号后,索引值为 0 和 1的这两个节点在更新前后顺序没有发生变化,不需要移动。

  1. 创建两个索引值 i 和 s,分析 source,seq,i 和 s 的关系
  • i 指向新的一组子节点中最后一个节点;

  • s 指向最长递增子序列中的最后一个元素。

source,seq,i 和 s 存在下面三种情况:

情况一:source[i] === -1,说明索引为 i 的节点是全新的节点,应该将其挂载

情况二:i !== seq[s],说明该节点需要移动

情况三: i === seq[s] 时,说明该位置的节点不需要移动

if (moved) {
  const seq = getSequence(source)
  // s 指向最长递增子序列的最后一个值
  let s = seq.length - 1
  let i = count - 1
  // for 循环使得 i 递减
  for (i; i >= 0; i--) {
    if (source[i] === -1) {
      // 说明索引为 i 的节点是全新的节点,应该将其挂载
      // 该节点在新 children 中的真实位置索引
      const pos = i + newStart
      const newVNode = newChildren[pos]
      // 该节点下一个节点的位置索引
      const nextPos = pos + 1
      // 锚点
      const anchor = nextPos < newChildren.length
      ? newChildren[nextPos].el
      : null
      // 挂载
      patch(null, newVNode, container, anchor)
    } else if (i !== seq[s]) {
      // 说明该节点需要移动
      // 该节点在新的一组子节点中的真实位置索引
      const pos = i + newStart
      const newVNode = newChildren[pos]
      // 该节点下一个节点的位置索引
      const nextPos = pos + 1
      // 锚点
      const anchor = nextPos < newChildren.length
      ? newChildren[nextPos].el
      : null
      // 移动
      insert(newVNode.el, container, anchor)
    } else {
      // 当 i === seq[s] 时,说明该位置的节点不需要移动
      // 并让 s 
      s--
  }
}

代码实现

最长递增子序列 代码实现

快速 Diff 完整代码及 demo

总结

通过上面的分析,可以发现无论是简单Diff算法,还是双端Diff算法,亦或是快速Diff算法,它们都遵循同样的处理规则:

  • n 判断是否有节点需要移动,以及应该如何移动;

  • 找出那些需要被添加或移除的节点。

另外,双端 Diff 和快速 Diff 都比简单 Diff 增加了一些前置处理,再进行非理想情况的处理:

  • 双端 Diff ,先对新旧节点的头和头、尾和尾、以及头和尾进行了比较

  • 快速 Diff,先处理了相同的前置元素和后置元素

以上就是本文所要讲的关于 Diff 的所有内容,可能有些逻辑讲述的一般,文笔有限。如有问题欢迎讨论。