Vue diff算法核心实现~

runtime运行时,diff算法核心实现

在日常我们使用vue进行v-for的时候,都需要传一个key,这个key的作用到底是什么呢?

Vue有一个方法专门为了对比新旧节点是否相同,会用到key,如下:

lua 复制代码
/**
 * 根据 key || type 判断是否为相同类型节点  type即是元素名,组件等等
 */
export function isSameVNodeType(n1: VNode, n2: VNode): boolean {
    return n1.type === n2.type && n1.key === n2.key
}
php 复制代码
//以下两个节点即是相同的节点
const vnode = h('li', {
  key: 1,
}, '1')
const vnode2 = h('li', {
  key: 1,
}, '2')
isSameVNodeType(vnode, vnode2) // true

自前向后diff对比

创建如下对应实例:

less 复制代码
<script>
  const { h, render } = Vue
  const vnode = h('ul', [
    h('li', {
      key: 1
    }, 'a'),
    h('li', {
      key: 2
    }, 'b'),
    h('li', {
      key: 3
    }, 'c'),
  ])
  // 挂载
  render(vnode, document.querySelector('#app'))
  // 延迟两秒,生成新的 vnode,进行更新操作
  setTimeout(() => {
    const vnode2 = h('ul', [
      h('li', {
        key: 1
      }, 'a'),
      h('li', {
        key: 2
      }, 'b'),
      h('li', {
        key: 3
      }, 'd')
    ])
    render(vnode2, document.querySelector('#app'))
  }, 2000);
</script>

自前向后即是从数组下标0开始依次遍历新节点数组和旧节点数组,通过上述isSameVNodeType方法判断是否是同一个节点,如果是则挂载,不是则需要后续的处理。

csharp 复制代码
/**
 * diff
 */
const patchKeyedChildren = (oldChildren, newChildren, container, parentAnchor) => {
    /**
     * 索引
     */
    let i = 0
    /**
     * 新的子节点的长度
     */
    const newChildrenLength = newChildren.length
    /**
     * 旧的子节点最大(最后一个)下标
     */
    let oldChildrenEnd = oldChildren.length - 1
    /**
     * 新的子节点最大(最后一个)下标
     */
    let newChildrenEnd = newChildrenLength - 1
​
    // 1. 自前向后的 diff 对比。经过该循环之后,从前开始的相同 vnode 将被处理
    while (i <= oldChildrenEnd && i <= newChildrenEnd) {
        const oldVNode = oldChildren[i]
        const newVNode = normalizeVNode(newChildren[i])
        // 如果 oldVNode 和 newVNode 被认为是同一个 vnode,则直接 patch 即可
        if (isSameVNodeType(oldVNode, newVNode)) {
            patch(oldVNode, newVNode, container, null)
        }
        // 如果不被认为是同一个 vnode,则直接跳出循环
        else {
            break
        }
        // 下标自增
        i++
    }
}

自后向前的diff对比

创建如下测试实例:

less 复制代码
<script>
  const { h, render } = Vue
​
  const vnode = h('ul', [
    h('li', {
      key: 1
    }, 'a'),
    h('li', {
      key: 2
    }, 'b'),
    h('li', {
      key: 3
    }, 'c'),
  ])
  // 挂载
  render(vnode, document.querySelector('#app'))
​
  // 延迟两秒,生成新的 vnode,进行更新操作
  setTimeout(() => {
    const vnode2 = h('ul', [
      h('li', {
        key: 4
      }, 'a'),
      h('li', {
        key: 2
      }, 'b'),
      h('li', {
        key: 3
      }, 'd')
    ])
    render(vnode2, document.querySelector('#app'))
  }, 2000);
</script>

自后向前即是从数组最后一位开始依次遍历新节点数组和旧节点数组,通过上述isSameVNodeType方法判断是否是同一个节点,如果是则挂载,不是则需要后续的处理。

上述自前向后,在执行如上测试实例时,第一个节点就会对比不成功直接会跳出循环,所以需要自后向前处理;

kotlin 复制代码
// 2. 自后向前的 diff 对比。经过该循环之后,从后开始的相同 vnode 将被处理
while (i <= oldChildrenEnd && i <= newChildrenEnd) {
    const oldVNode = oldChildren[oldChildrenEnd]
    const newVNode = normalizeVNode(newChildren[newChildrenEnd])
    if (isSameVNodeType(oldVNode, newVNode)) {
        patch(oldVNode, newVNode, container, null)
    } else {
        break
    }
    oldChildrenEnd--
    newChildrenEnd--
}

新节点多余旧节点的diff对比

创建如下实例:

less 复制代码
<script>
  const { h, render } = Vue
​
  const vnode = h('ul', [
    h('li', {
      key: 1
    }, 'a'),
    h('li', {
      key: 2
    }, 'b'),
  ])
  // 挂载
  render(vnode, document.querySelector('#app'))
  // 延迟两秒,生成新的 vnode,进行更新操作
  setTimeout(() => {
    const vnode2 = h('ul', [
      h('li', {
        key: 3
      }, 'c'),
      h('li', {
        key: 1
      }, 'a'),
      h('li', {
        key: 2
      }, 'b'),
    ])
    render(vnode2, document.querySelector('#app'))
  }, 2000);
</script>
  1. 以上测试实例,首先会进行自前向后的diff对比,但是第一次对比就会break跳出;
  2. 接下来进行自后向前的diff对比,挂载后两个节点ba;
  3. 此时oldChildrenEnd的值变为**-1**,不再满足自后向前的循环条件,跳出循环;

注意:此时索引i大于oldChildrenEnd,小于等于newChildrenEnd,那么就代表新节点多余旧节点。

代码实现:

css 复制代码
// 3. 新节点多余旧节点时的 diff 比对。
if (i > oldChildrenEnd) {
    if (i <= newChildrenEnd) {
        //此时newChildren为0   nextPos = 1
        const nextPos = newChildrenEnd + 1
        //锚点  以哪个元素为基准 的前面出入多出的元素
        const anchor =
              // 1 < 2成立  newChildren[nextPos].el 对应旧节点当中的a ,也就是多余节点会被插入到a的前面
            nextPos < newChildrenLength ? newChildren[nextPos].el : parentAnchor
        while (i <= newChildrenEnd) {
            patch(null, normalizeVNode(newChildren[i]), container, anchor)
            i++
        }
    }
}

旧节点多余新节点的diff对比

创建如下对应实例:

less 复制代码
<script>
  const { h, render } = Vue
​
  const vnode = h('ul', [
    h('li', {
      key: 3
    }, 'c'),
    h('li', {
      key: 1
    }, 'a'),
    h('li', {
      key: 2
    }, 'b'),
  ])
  // 挂载
  render(vnode, document.querySelector('#app'))
​
  // 延迟两秒,生成新的 vnode,进行更新操作
  setTimeout(() => {
    const vnode2 = h('ul', [
      h('li', {
        key: 1
      }, 'a'),
      h('li', {
        key: 2
      }, 'b'),
    ])
    render(vnode2, document.querySelector('#app'))
  }, 2000);
</script>

当旧节点多余新节点的时候,我们只需要找到新节点中没有的卸载即可;

  1. 以上测试实例,首先会进行自前向后的diff对比,但是第一次对比就会break跳出;
  2. 接下来进行自后向前的diff对比,挂载后两个节点ba;
  3. 此时newChildrenEnd的值变为**-1**,不再满足自后向前的循环条件,跳出循环;

注意:此时索引i大于newChildrenEnd,小于等于oldChildrenEnd,那么就代表旧节点多余新节点。

代码实现:

scss 复制代码
// 4. 旧节点多与新节点时的 diff 比对。
else if (i > newChildrenEnd) {
    while (i <= oldChildrenEnd) {
        unmount(oldChildren[i])
        i++
    }
}
ini 复制代码
//unmout方法  
const unMount = vnode => {
   const child = vnode.el
   const parent = child.parentNode
    if (parent) {
      parent.removeChild(child)
    }
  }

乱序下的diff对比

最长递增子序列

假设有如下两组节点

复制代码
旧节点:1,2,3,4,5,6
新节点:1,3,2,4,6,5

我们可以根据新节点生成递增子序列:

  1. 1 3 6
  2. 1 2 4 6
  3. 1 4 5
  4. ....

根据我们之前的四种场景可知,所谓的 diff,其实说白了就是对 一组节点 进行 添加、删除、打补丁 的对应操作。那么除了以上三种操作之外,其实还有最后一种操作方式,那就是 移动

对于以上的节点对比而言,如果我们想要把 旧节点转化为新节点 ,那么将要涉及到节点的 移动,所以问题的重点是:如何进行移动。

那么接下来,我们来分析一下移动的策略,整个移动根据递增子序列的不同,将拥有两种移动策略:

  1. 1、3、6 递增序列下:
    1. 因为 1、3、6 的递增已确认,所以它们三个是不需要移动的,那么我们所需要移动的节点无非就是 2、4、5
    2. 所以我们需要经过 三次 移动
  1. 1、2、4、6 递增序列下:
    1. 因为 1、2、4、6 的递增已确认,所以它们四个是不需要移动的,那么我们所需要移动的节点无非就是 两个 3、5
    2. 所以我们需要经过 两次 移动

由以上分析,我们可知:最长递增子序列的确定,可以帮助我们减少移动的次数

最长递增子序列求解算法实现:

scss 复制代码
/**
 * 获取最长递增子序列下标
 * 维基百科:https://en.wikipedia.org/wiki/Longest_increasing_subsequence
 * 百度百科:https://baike.baidu.com/item/%E6%9C%80%E9%95%BF%E9%80%92%E5%A2%9E%E5%AD%90%E5%BA%8F%E5%88%97/22828111
 */
 function getSequence(arr) {
  // 获取一个数组浅拷贝。注意 p 的元素改变并不会影响 arr
  // p 是一个最终的回溯数组,它会在最终的 result 回溯中被使用
  // 它会在每次 result 发生变化时,记录 result 更新前最后一个索引的值
  const p = arr.slice()
  // 定义返回值(最长递增子序列下标),因为下标从 0 开始,所以它的初始值为 0
  const result = [0]
  let i, j, u, v, c
  // 当前数组的长度
  const len = arr.length
  // 对数组中所有的元素进行 for 循环处理,i = 下标
  for (i = 0; i < len; i++) {
    // 根据下标获取当前对应元素
    const arrI = arr[i]
    // 
    if (arrI !== 0) {
      // 获取 result 中的最后一个元素,即:当前 result 中保存的最大值的下标
      j = result[result.length - 1]
      // arr[j] = 当前 result 中所保存的最大值
      // arrI = 当前值
      // 如果 arr[j] < arrI 。那么就证明,当前存在更大的序列,那么该下标就需要被放入到 result 的最后位置
      if (arr[j] < arrI) {
        p[i] = j
        // 把当前的下标 i 放入到 result 的最后位置
        result.push(i)
        continue
      }
      // 不满足 arr[j] < arrI 的条件,就证明目前 result 中的最后位置保存着更大的数值的下标。
      // 但是这个下标并不一定是一个递增的序列,比如: [1, 3] 和 [1, 2] 
      // 所以我们还需要确定当前的序列是递增的。
      // 计算方式就是通过:二分查找来进行的
​
      // 初始下标
      u = 0
      // 最终下标
      v = result.length - 1
      // 只有初始下标 < 最终下标时才需要计算
      while (u < v) {
        // (u + v) 转化为 32 位 2 进制,右移 1 位 === 取中间位置(向下取整)例如:8 >> 1 = 4;  9 >> 1 = 4; 5 >> 1 = 2
        // https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Operators/Right_shift
        // c 表示中间位。即:初始下标 + 最终下标 / 2 (向下取整)
        c = (u + v) >> 1
        // 从 result 中根据 c(中间位),取出中间位的下标。
        // 然后利用中间位的下标,从 arr 中取出对应的值。
        // 即:arr[result[c]] = result 中间位的值
        // 如果:result 中间位的值 < arrI,则 u(初始下标)= 中间位 + 1。即:从中间向右移动一位,作为初始下标。 (下次直接从中间开始,往后计算即可)
        if (arr[result[c]] < arrI) {
          u = c + 1
        } else {
          // 否则,则 v(最终下标) = 中间位。即:下次直接从 0 开始,计算到中间位置 即可。
          v = c
        }
      }
      // 最终,经过 while 的二分运算可以计算出:目标下标位 u
      // 利用 u 从 result 中获取下标,然后拿到 arr 中对应的值:arr[result[u]]
      // 如果:arr[result[u]] > arrI 的,则证明当前  result 中存在的下标 《不是》 递增序列,则需要进行替换
      if (arrI < arr[result[u]]) {
        if (u > 0) {
          p[i] = result[u - 1]
        }
        // 进行替换,替换为递增序列
        result[u] = i
      }
    }
  }
  // 重新定义 u。此时:u = result 的长度
  u = result.length
  // 重新定义 v。此时 v = result 的最后一个元素
  v = result[u - 1]
  // 自后向前处理 result,利用 p 中所保存的索引值,进行最后的一次回溯
  while (u-- > 0) {
    result[u] = v
    v = p[v]
  }
  return result
}

创建如下测试实例:

less 复制代码
<script>
  const { h, render } = Vue
​
  const vnode = h('ul', [
    h('li', {
      key: 1
    }, 'a'),
    h('li', {
      key: 2
    }, 'b'),
    h('li', {
      key: 3
    }, 'c'),
    h('li', {
      key: 4
    }, 'd'),
    h('li', {
      key: 5
    }, 'e')
  ])
  // 挂载
  render(vnode, document.querySelector('#app'))
​
  // 延迟两秒,生成新的 vnode,进行更新操作
  setTimeout(() => {
    const vnode2 = h('ul', [
      h('li', {
        key: 1
      }, 'new-a'),
      h('li', {
        key: 3
      }, 'new-c'),
      h('li', {
        key: 2
      }, 'new-b'),
      h('li', {
        key: 6
      }, 'new-f'),
      h('li', {
        key: 5
      }, 'new-e'),
    ])
    render(vnode2, document.querySelector('#app'))
  }, 2000);
</script>
  1. 执行上述测试实例,首先自前向后对比,挂载new-a,退出循环;
  2. 执行自后向前对比,挂载new-e,退出循环;
  3. 新节点多于或少于旧节点情况都不是,此时就要处理乱序的diff对比;

注意:此时i=1,newChildrenEnd=3,oldChildrenEn=3

  • 此时需要处理下标1 - 3的节点,也就是从i开始到newChildrenEnd结束

乱序diff代码实现:

ini 复制代码
else {
      // 旧节点开始的索引  1
      const oldStartIndex = i
      // 新节点开始的索引  1
      const newStartIndex = i
      // 遍历新节点数组生成一个索引与值的映射表  
      /**
       * [
       *  3 => 1
          2 => 2
          6 => 3
       * ]
       */
      const keyToNewIndexMap = new Map()
      for (i = newStartIndex; i <= newChildrenEnd; i++) {
        // normalizeVNode 将数组中每个节点转成vnode
        const nextChild = normalizeVNode(newChildren[i])
        if (nextChild.key != null) {
          keyToNewIndexMap.set(nextChild.key, i)
        }
      }
​
      let j
      // 已处理节点的数量
      let patched = 0
      // 待处理的节点数量  3   1
      const toBePatched = newChildrenEnd - newStartIndex + 1
      // 是否需要移动的节点
      let moved = false
      // 最大的新节点索引
      let maxNewIndexSoFar = 0
      const newIndexToOldIndexMap = new Array(toBePatched)
      for (i = 0; i < toBePatched; i++) newIndexToOldIndexMap[i] = 0
      // 遍历旧节点数组 
      for (i = oldStartIndex; i <= oldChildrenEnd; i++) {
        const prevChild = oldChildren[i]
        // 如果已经处理的节点大于等于待处理节点的数量  则卸载该节点  开始下次循环
        if (patched >= toBePatched) {
          unMount(prevChild)
          continue
        }
        // 新节点索引值
        let newIndex
        // 如果当前旧节点的key不为空 则在事先处理好的映射表中获取新节点的索引值
        if (prevChild.key != null) {
          // 2
          newIndex = keyToNewIndexMap.get(prevChild.key)
        }
        // 如果在映射表中没有找到 表明新节点没有该节点 则直接卸载该旧节点
        if (newIndex === undefined) {
          unMount(prevChild)
        } else {
          // [0,2,0]  存储新节点的下标
          newIndexToOldIndexMap[newIndex - newStartIndex] = i + 1
          // 如果新节点的值不大于最大的新节点索引值 则代表此时不递增了  需要移动节点
          if (newIndex >= maxNewIndexSoFar) {
            maxNewIndexSoFar = newIndex
          } else {
            moved = true
          }
          patch(prevChild, newChildren[newIndex], container, null)
          patched++
        }
      }
      // 如果有节点需要移动 则获取最长递增子序列下标
      const increasingNewIndexSequence = moved
        ? getSequence(newIndexToOldIndexMap)
        : []
      j = increasingNewIndexSequence.length - 1
      for (i = toBePatched - 1; i >= 0; i--) {
        const nextIndex = newStartIndex + i
        const nextChild = newChildren[nextIndex]
        const anchor =
          nextIndex + 1 < newChildrenLength
            ? newChildren[nextIndex + 1].el
            : parentAnchor
        // 如果存储的新节点的下标为0  则代表新节点中无对应的旧节点 此时 需要直接挂载旧节点
        if (newIndexToOldIndexMap[i] === 0) {
          patch(null, nextChild, container, anchor)
        } else if (moved) {
          // 移动节点
          if (j < 0 || i !== increasingNewIndexSequence[j]) {
            move(nextChild, container, anchor)
          } else {
            j--
          }
        }
      }
    }
javascript 复制代码
//move 方法
function move(vnode, container, anchor) {
    const { el } = vnode
    container.insertBefore(el, anthor || null)
  }
相关推荐
@大迁世界3 分钟前
TypeScript 的本质并非类型,而是信任
开发语言·前端·javascript·typescript·ecmascript
GIS之路12 分钟前
GDAL 实现矢量裁剪
前端·python·信息可视化
是一个Bug16 分钟前
后端开发者视角的前端开发面试题清单(50道)
前端
Amumu1213818 分钟前
React面向组件编程
开发语言·前端·javascript
持续升级打怪中39 分钟前
Vue3 中虚拟滚动与分页加载的实现原理与实践
前端·性能优化
GIS之路43 分钟前
GDAL 实现矢量合并
前端
hxjhnct1 小时前
React useContext的缺陷
前端·react.js·前端框架
前端 贾公子1 小时前
从入门到实践:前端 Monorepo 工程化实战(4)
前端
菩提小狗1 小时前
Sqlmap双击运行脚本,双击直接打开。
前端·笔记·安全·web安全
前端工作日常1 小时前
我学习到的AG-UI的概念
前端