Vue设计与实现:快速 Diff 算法

简单情况下的新旧两组子节点

相同的前置元素

开启一个while循环,让索引j从0开始递增,直到遇到不相同的节点为止

js 复制代码
const oldVNode = {
  type: 'div',
  children: [
    { type: 'p', children: '1', key: 1 },
    { type: 'p', children: '2', key: 2 },
    { type: 'p', children: '3', key: 3 },
  ]
}

const newVNode = {
  type: 'div',
  children: [
    { type: 'p', children: '1', key: 1 },
    { type: 'p', children: '4', key: 4 },
    { type: 'p', children: '2', key: 2 },
    { type: 'p', children: '3', key: 3 },
  ]
}

解决思路:

  1. j从0开始,oldChildren、newChildren依次拿到对应j的vnode
  2. 判断对应j的oldVNode、newVNode的key是否相同
  3. 如果key相同就调用patch更新
js 复制代码
  function patchKeyedChildren(oldN, newN, container) {
    const oldChildren = oldN.children
    const newChildren = newN.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,让其递增
      j++
      oldVNode = oldChildren[j]
      newVNode = newChildren[j]
    }
  }

结果:

相同得后置元素

由于新旧两组子节点的数量可能不一致,所以需要两个索引newEnd和oldEnd分别对应新旧两组子节点的后置索引

解决思路:

  1. 取oldChildren、newChildren的最后一个索引以及对应的最后一个节点
  2. 使用while比较oldChildren、newChildren的最后一个节点的key
  3. 如果key相同,就调用patch更节点,更新oldChildren、newChildren的最后一个索引以及对应的最后一个节点
diff 复制代码
function patchKeyedChildren(oldN, newN, container) {
    const oldChildren = oldN.children
    const newChildren = newN.children

    // 处理相同的前置节点
    // 索引 j 指向新旧两组子节点的开头
    let j = 0
    let oldVNode = oldChildren[j]
    let newVNode = newChildren[j]

    // while 循环向后遍历,直到遇到拥有不同 key 值的节点为止
    while (oldVNode.key === newVNode.key) {
      // 调用 patch 函数进行更新
      console.log(oldVNode)
      console.log(newVNode)
      patch(oldVNode, newVNode, container)
      // 更新索引 j,让其递增
      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 (oldVNode.key === newVNode.key) {
+      // 调用 patch 函数进行更新
+      patch(oldVNode, newVNode, container)
+      // 递减 oldEnd 和 nextEnd
+      oldEnd--
+      newEnd--
+      oldVNode = oldChildren[oldEnd]
+      newVNode = newChildren[newEnd]
+    }
  }

结果:

新增节点

  1. 从新旧头部节点开始比较key,一直到不相同key为止,最终j会指向新旧不相同key的节点索引
  2. 当oldEnd<j,说明旧子节点已经遍历完
  3. 当newEnd>=j,说明新子节点索引j到newEnd之间有还有未处理的节点

解决思路:

  1. 当j > oldEnd && j <= newEnd成立,说明从 j --> newEnd 之间的节点应作为新节点插入
  2. 判断最新newEnd + 1的索引是否小于newChildren.length
  3. 小于就说明最新newEnd对应的不是尾部节点,拿到最新newEnd + 1对应子节点的真实dom作为锚点,将newChildren[j]插入在锚点前面
  4. 大于就说明是尾部节点,锚点为null,将newChildren[j]插入在锚点前面
  5. 当newEnd大于索引j说明新子节点遍历完,需要停止遍历
diff 复制代码
  function patchKeyedChildren(n1, n2, container) {
    const newChildren = n2.children
    const oldChildren = n1.children
    // 更新相同的前置节点
    // 省略部分代码

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

+    // 预处理完毕后,如果满足如下条件,则说明从 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)
+      }
+    }
+
  }

结果:

删除节点

js 复制代码
const oldVNode = {
  type: 'div',
  children: [
    { type: 'p', children: '1', key: 1 },
    { type: 'p', children: '2', key: 2 },
    { type: 'p', children: '3', key: 3 },
  ]
}

const newVNode = {
  type: 'div',
  children: [
    { type: 'p', children: '1', key: 1 },
    { type: 'p', children: '3', key: 3 },
  ]
}

以现在的代码比较以上子节点,会出现newEnd < j,j >= oldEnd,也就是遍历完新子节点但旧子节点还没遍历完

解决思路:

  1. 当newEnd < j,j >= oldEnd,说明遍历完新子节点但旧子节点还没遍历完
  2. 遍历j -> oldEnd 之间的节点调用unmount卸载
  3. 当j>oldEnd说明旧子节点遍历完退出
diff 复制代码
  function patchKeyedChildren(n1, n2, container) {
    const newChildren = n2.children
    const oldChildren = n1.children
    // 更新相同的前置节点
    // 省略部分代码

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

    if (j > oldEnd && j <= newEnd) {
      // 省略部分代码
+    } else if (j > newEnd && j <= oldEnd) {
+      // j -> oldEnd 之间的节点应该被卸载
+      while (j <= oldEnd) {
+        unmount(oldChildren[j++])
+      }
+    }

  }

结果:

复杂情况下的新旧两组子节点

js 复制代码
const oldVNode = {
  type: 'div',
  children: [
    { type: 'p', children: '1', key: 1 },
    { type: 'p', children: '2', key: 2 },
    { type: 'p', children: '3', key: 3 },
    { type: 'p', children: '4', key: 4 },
    { type: 'p', children: '6', key: 6 },
    { type: 'p', children: '5', key: 5 },
  ]
}

const newVNode = {
  type: 'div',
  children: [
    { type: 'p', children: '1', key: 1 },
    { type: 'p', children: '3', key: 3 },
    { type: 'p', children: '4', key: 4 },
    { type: 'p', children: '2', key: 2 },
    { type: 'p', children: '7', key: 7 },
    { type: 'p', children: '5', key: 5 },
  ]
}

构造 source 数组

解决思路:

当处理完新旧子节点相同头尾节点,新的一组子节点中剩余未处理节点对应,创建一个source数组,数组中每一个元素分别跟未处理节点对应对应

diff 复制代码
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)
+}

新增、删除节点

解决思路:

  1. 先遍历新子节点newStart到newEnd,把新子节点key做为索引表的key,索引表的value为新子节点的索引
  2. 再遍历旧子节点,用旧子节点的key去索引表keyIndex去找
  3. 如果有对应的key那么就用patch比较更新
  4. 如果没有对应的key就说明旧子节点需要卸载,调用unmount卸载
diff 复制代码
  if (j > oldEnd && j <= newEnd) {
    // 省略部分代码
  } else if (j > newEnd && j <= oldEnd) {
    // 省略部分代码
  } else {
    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)
+      }
+    }
  }

结果:

判断节点是否需要移动

解决思路:

  1. 在遍历外定义moved、patched、pos,moved代表是否需要移动节点,pos代表遍历旧的一组子节点的过程中遇到的最大索引值k,patched代表更新过的节点数量
  2. 判断patched是否比需要更新的子字节数量大,大于就说明需要更新的子节点已更新完成,需要卸载多余的节点
  3. patched小于需要更新的子字节数量,旧子节点根据顺序遍历,旧子节点的key在索引表keyIndex中找到对应新子节点的key,用patch更新比较新旧子节点,patched+1
  4. 判断新子节点的key对应的索引是否大于最大索引值pos,如果大于,就把新子节点的索引赋值给最大索引值pos
  5. 再遍历比较新子节点的索引是否大于最大索引值pos,如果小于就说明该旧子节点需要移动
diff 复制代码
  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)

    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]
      const k = keyIndex[oldVNode.key]
+      if (patched <= count) {
          if (typeof k !== 'undefined') {
            newVNode = newChildren[k]
            // 调用 patch 函数完成更新
            patch(oldVNode, newVNode, container)
+           // 每更新一个节点,都将 patched 变量 +1
+           patched++
+           // 判断节点是否需要移动
+           if (k < pos) {
+             moved = true
+           } else {
+             pos = k
+           }
          } else {
            // 没找到
            unmount(oldVNode)
          }
        } else {
          // 如果更新过的节点数量大于需要更新的节点数量,则卸载多余的节点
          unmount(oldVNode)
        }
    }
  }

结果:

如何移动元素

最长递增子序列

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

js 复制代码
[ 0, 8, 4, 12 ]
// 最长递增子序列就是 [0, 8, 12]

使用lis函数计算一个数组的最长递增子序列,source数组的最长递增子序列为 [2, 3],其中元素 2 在 该数组中的索引为 0,而数组 3 在该数组中的索引为 1,所以最终结果 为 [0, 1]。

js 复制代码
function lis(arr) {
    const p = arr.slice()
    const result = [0]
    let i, j, u, v, c
    const len = arr.length
    for (i = 0; i < len; i++) {
      const arrI = arr[i]
      if (arrI !== 0) {
        j = result[result.length - 1]
        if (arr[j] < arrI) {
          p[i] = j
          result.push(i)
          continue
        }
        u = 0
        v = result.length - 1
        while (u < v) {
          c = ((u + v) / 2) | 0
          if (arr[result[c]] < arrI) {
            u = c + 1
          } else {
            v = c
          }
        }
        if (arrI < arr[result[u]]) {
          if (u > 0) {
            p[i] = result[u - 1]
          }
          result[u] = i
        }
      }
    }
    u = result.length
    v = result[u - 1]
    while (u-- > 0) {
      result[u] = v
      v = p[v]
    }
    return result
  }

重新对节点进行编号

重新编号是为了让子序列 seq 与新的索引值产生对应关系

节点 p-1 和 p-5已经处理过,所以不需要编号,重新编号后,索引值为 0 和 1 的节点不需要移动。在 新的一组子节点中,节点 p-3 的索引为 0,节点 p-4 的索引为 1,所 以节点 p-3 和 p-4 所对应的真实 DOM 不需要移动。换句话说,只有 节点 p-2 和 p-7 可能需要移动

挂载新节点

解决思路:
  1. for循环的目的是让变量i就是节点的索引按照上图中箭头的方向移动, 以便能够逐个访问新的一组子节点中的节点
  2. 因为i是递减的,所以在for循环中先判断source[i]是否为-1也就是新增节点,i是当前节点在剩余未处理新子节点的数量的索引,所以+newStart(已处理过的头部节点)会得到在新子节点中真实索引值,根据真实索引值pos拿到对应新子节点newVNode
  3. 判断pos + 1是否小于新子节点的长度,小于就说明新子节点newVNode不是尾部节点,把newChildren[nextPos].el赋值给anchor,大于就说明是尾部节点,把null赋值给anchor,最终调用patch对比更新
  4. 判断当前节点的索引i是否跟seq[s]最长递增子序列的最后一个元素相同,如果不相同就说明需要移动,相同就让s指向下一个位置
js 复制代码
if (moved) {
    const seq = lis(sources)

    // s 指向最长递增子序列的最后一个元素
    let s = seq.length - 1
    // i 指向新的一组子节点的最后一个元素
    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]) {
        // 如果节点的索引 i 不等于 seq[s] 的值,说明该节点需要移动
      } else {
        // 当 i === seq[s] 时,说明该位置的节点不需要移动
        // 只需要让 s 指向下一个位置
        s--
      }
    }
  }
结果:

移动节点

解决思路:

移动节点的实现思路类似于挂载全新的节点。不同点在于,移动节点是通过 insert 函数来完成的

diff 复制代码
  if (moved) {
    const seq = lis(sources)

    // s 指向最长递增子序列的最后一个元素
    let s = seq.length - 1
    let i = count - 1
    for (i; i >= 0; i--) {
      if (source[i] === -1) {
        // 省略部分代码
      } 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--
      }
    }
  }
结果:
相关推荐
也无晴也无风雨1 小时前
深入剖析输入URL按下回车,浏览器做了什么
前端·后端·计算机网络
Martin -Tang1 小时前
Vue 3 中,ref 和 reactive的区别
前端·javascript·vue.js
FakeOccupational3 小时前
nodejs 020: React语法规则 props和state
前端·javascript·react.js
放逐者-保持本心,方可放逐3 小时前
react 组件应用
开发语言·前端·javascript·react.js·前端框架
曹天骄4 小时前
next中服务端组件共享接口数据
前端·javascript·react.js
阮少年、4 小时前
java后台生成模拟聊天截图并返回给前端
java·开发语言·前端
郝晨妤6 小时前
鸿蒙ArkTS和TS有什么区别?
前端·javascript·typescript·鸿蒙
AvatarGiser6 小时前
《ElementPlus 与 ElementUI 差异集合》Icon 图标 More 差异说明
前端·vue.js·elementui
喝旺仔la6 小时前
vue的样式知识点
前端·javascript·vue.js
别忘了微笑_cuicui6 小时前
elementUI中2个日期组件实现开始时间、结束时间(禁用日期面板、控制开始时间不能超过结束时间的时分秒)实现方案
前端·javascript·elementui