《vue.js设计与实现》读书笔记(二):diff算法

70 阅读4分钟

写在前面

当前笔记是在读书和写代码的同时做的记录,后续也未做整理,可能在某些方面写的比较简单。

更加详细的内容请直接查看项目代码

QA

Q: diff的目的

A:当新旧 vnode 的子节点都是一组节点时,以最小的性能开销完成更新操作

简单的diff算法

我们为了最大限度的复用Dom,添加了属性key

当我们进行更新时,当遇到oldChildrennewChildren同为Array的情况下,我们会根据patchFlag是否标记key进行不同方式的更新。

在vue中,vFor指令下会对当前的VNode.patchFlag 添加KEYED_FRAGMENT 标记,以便做到上述的判断。

  1. 当没有key

我们取oldChildrennewChildren数量的最小值,然后遍历,依次做出更新

for (let i = 0; i < minLength; i++) {
  patch(c1[i], c2[i], container)
}

之后针对新增、删除的节点,做出二次处理

/**
 * 有新增的
 */
if (oldL < newL) {
  for (let i = minLength; i < newL; i++) {
    patch(null, c2[i], container)
  }
}

/**
 * 有删除的
 */
  if (oldL > newL) {
  for (let i = minLength; i < oldL; i++) {
    unmount(c1[i])
  }
}
  1. 当有key

我们通过两重for循环,依次找到key相同的两个节点,并记录旧节点的下标lastIndex,之后更新这两个节点

patch(oldVnode, newVnode, container)

在遍历到下一个节点时,需要判断下标是否大于lastIndex,如果大于,则是正常现象,并再次记录,如果出现小于,则说明当前节点的位置发生了改变,我们就需要在更新后将当前的节点插入到上一个节点之后。

const preVNode = c2[i - 1] // 1. 拿到上一个节点
if (preVNode) {
  const anchor = preVNode.el!.nextSibling as Node // 2. 上一个节点的真实节点,它的下一个节点

   /**
     * 插入到上一个节点与一个节点原下一个节点之间
     * newA、anchor
     * 
     * 插入
     * 
     * newA、newB、anchor
     * 
     * 放在anchor前面
     */
  insert(newVnode.el!, container, anchor)
}

解决了位置问题还要解决添加与删除的问题。

我们创建了一个find变量,它表示在本次遍历中是否找到相同的key。如果没有找到,则说明本次进入遍历的newVNode是一个新节点,我们需要添加进去

patch(null, newVnode, container, anchor)

我们在两重循环结束后,需要根据oldChildren在进行依次遍历,比较旧列表中每一个节点的key都能找到新节点,如果找不到,那它就是被删除的节点。

unmount(oldVNode)

双端比较

简单的diff中我们通过双重循环来进行新旧节点列表的对比,是自上而下的比较方法,但它的性能并不是最优的,比如如下例子。

const oldList = [
  h('p', {key: 1}, '哈哈哈1'),
  h('p', {key: 2}, '哈哈哈2'),
  h('p', {key: 3}, '哈哈哈3'),
]

const newList = [
  h('p', {key: 3}, '哈哈哈3'),
  h('p', {key: 1}, '哈哈哈1'),
  h('p', {key: 2}, '哈哈哈2')
]

我们用之前的方式进行更新时,大致需要进行如下步骤

  1. 找到key === 3的节点,确定下标
  2. 找到key === 1的节点,移动节点
  3. 找到key === 2的节点,移动节点

那么我们通过肉眼观察可以看到,其实只需要将key === 3的节点移动到头部即可,为此我们需要对首尾进行同时比较。

let oldStartIdx = 0
let oldEndIdx = c1.length -1
let newStartIdx = 0
let newEndIdx = c2.length -1

/**
 * 四个索引的节点
 */
let oldStartVNode = c1[oldStartIdx]
let newStartVNode = c2[newStartIdx]
let oldEndVNode = c1[oldEndIdx]
let newEndVNode = c2[newEndIdx]

我们先将首尾下标以及对应的节点声明出来,当我们对某一对对应的节点做出处理后,就需要改动这些下标已经引用的节点

/**
 * 开启循环
 * 
 * oldStartIdx <= oldEndIdx 说明旧列表还没处理完
 * newStartIdx <= newEndIdx 说明新列表还没处理完
 */
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
  ...
}
  1. 头部节点相同: oldStartVNode.key === newStartVNode.key

我们先打上补丁,patch(oldStartVNode, newStartVNode, container)

现在需要修改下标以及引用

oldStartVNode = c1[++oldStartIdx]
newStartVNode = c2[++newStartIdx]

由于两个都是头部节点,那么只需要修改对应的startVNode以及startIdx,都向后进一位

  1. 尾部节点相同:oldEndVNode.key === newEndVNode.key

    同上,但是此时作出修改的是endVNode以及endIdx,向前进一位

  2. 新头与旧尾相同:oldEndVNode.key === newStartVNode.key

    打上补丁...

    此时我们需要进行位置移动

    insert(oldEndVNode.el!, container, oldStartVNode.el!)
    

    我们要将匹配到的旧尾的节点移动到头部,之后修改下标

    oldEndVNode = c1[--oldEndIdx]
    newStartVNode = c2[++newStartIdx]
    

    旧尾向前进一,新头向后进一

  3. 新尾与旧头相同:oldStartVNode.key === newEndVNode.key

    进行位置移动

insert(oldStartVNode.el!, container, oldEndVNode.el!.nextSibling)


将旧头移动到末尾

```js
oldStartVNode = c1[++oldStartIdx]
newEndVNode = c2[--newEndIdx]
  1. 以上四种情况外

    此时我们遇到了尴尬的情况,例子如下

const oldList = [
  h('p', {key: 1}, '哈哈哈1'),
  h('p', {key: 2}, '哈哈哈2'),
  h('p', {key: 3}, '哈哈哈3'),
  h('p', {key: 4}, '哈哈哈4'),
]

const newList = [
  h('p', {key: 3}, '哈哈哈3'),
  h('p', {key: 4}, '哈哈哈4'),
  h('p', {key: 1}, '哈哈哈1'),
  h('p', {key: 2}, '哈哈哈2')
]

四种情况都对不上,那我们要怎么办呢?

我们只看新头,也就是key === 3的节点,对旧节点做出查找

  const idxInold = c1.findIndex(vnode => vnode && vnode.key === newStartVNode.key)

从旧节点中找到对应节点的下标,之后就是打补丁并移动位置

 if (idxInold > 0) {
    // 找到了
    const vnodeToMove = c1[idxInold]

    // 打个补丁
    patch(vnodeToMove, newStartVNode, container)

    // 将这个节点放到真实节点最前方
    insert(vnodeToMove.el!, container, oldStartVNode.el)

    // 已经处理过这个节点,所以从c1中去除
    ;(c1[idxInold] as VNode | undefined) = undefined
  } else {
    // 没找到,那么这个节点就是新加的
    // 挂载并放在旧节点列表的头部
    patch(null, newStartVNode, container, oldStartVNode.el)
  }

  newStartVNode = c2[++newStartIdx]

本阶段只处理了新列表中的头部节点,所以只移动newStartIdx即可。注意:我们在本阶段处理节点时,将对应的旧列表中的节点改成了undefined,这就会导致在某个阶段所取到的旧列表中的节点会是undefined,所以我们还需要加两层判断

if (!oldStartVNode) {
  console.log('oldStartVNode 为空')
  // 说明该节点被处理过了
  oldStartVNode = c1[++oldStartIdx]
} else if (!oldEndVNode) {
  console.log('oldEndVNode 为空')

  oldEndVNode = c1[--oldEndIdx]
}

在遇到undefined的情况,直接修改对应的下标。

现在while循环结束,正常情况下,oldEndIdx < oldStartIdx 表示旧列表处理完成,newEndIdx < newStartIdx 新列表处理完成。 但我们还需要考虑到有新增节点和卸载节点的情况。

  1. oldEndIdx < oldStartIdx && newStartIdx <= newEndIdx 新列表还有未处理的节点
if (oldEndIdx < oldStartIdx && newStartIdx <= newEndIdx) {
  for (let i = newStartIdx; i <= newEndIdx; i++) {
    patch(null, c2[i], container, oldStartVNode.el)
  }
}

我们从此时的newStartIdx下标出发,将剩余的节点打上补丁并插入到此时的oldStartVNode之前,因为在while结束之后,oldStartVNode已经到了对应的位置

  1. newEndIdx < newStartIdx && oldStartIdx <= oldEndIdx 旧列表还有未处理的节点
for (let i = oldStartIdx; i <= oldEndIdx; i++) { 
  console.log('去卸载了', i, c1[i])
  unmount(c1[i])
}

不需要多说,直接循环卸载掉即可。

快速diff

快速diff是vue3中引用的算法,比vue2中的双端算法更优。

我们要分几个步骤:

  1. 预处理
  2. 对剩余节点的判断处理
  3. 过滤出需要移动的节点组
  4. 移动节点

预处理

首先,我们需要对diff的两者进行预处理。举个例子:

const old = [
  h('p', {key: 1}, '哈哈哈1'),
  h('p', {key: 2}, '哈哈哈2'),
  h('p', {key: 3}, '哈哈哈3'),
  h('p', {key: 4}, '哈哈哈4'),
  h('p', {key: 6}, '哈哈哈6'),
  h('p', {key: 5}, '哈哈哈5')
]

const new = [
  h('p', {key: 1}, 'n哈哈哈1'),
  h('p', {key: 3}, 'n哈哈哈3'),
  h('p', {key: 4}, 'n哈哈哈4'),
  h('p', {key: 2}, 'n哈哈哈2'),
  h('p', {key: 7}, 'n哈哈哈7'),
  h('p', {key: 5}, 'n哈哈哈5'),
]

可以看到在本例子中,两者的头部节点与尾部节点都是相同的,所以我们可以先patch前后相同的节点。

let j = 0

let oldVNode = c1[j]
let newVNode = c2[j]

/**
 * 对比前置节点
 * 
 * 直到找到带有不同key的节点
 */
while (oldVNode.key === newVNode.key) {
  console.log('处理前置节点', oldVNode, newVNode)
  patch(oldVNode, newVNode, container)

  j++

  oldVNode = c1[j]
  newVNode = c2[j]
}

我们从头部开始,依次对比两方的key,当key相同时直接更新,然后继续向后对比。

同理,处理尾部节点。

let oldEndIdx = c1.length - 1
let newEndIdx = c2.length - 1

let oldEndVNode = c1[oldEndIdx]
let newEndVNode = c2[newEndIdx]

/**
 * 对比后置节点
 * 
 */
while (oldEndVNode.key === newEndVNode.key) {
  console.log('处理后置节点', oldEndVNode, newEndVNode)

  patch(oldEndVNode, newEndVNode, container)

  oldEndVNode = c1[--oldEndIdx]
  newEndVNode = c2[--newEndIdx]
}

对剩余节点的判断处理

现在我们处理了前后比较容易处理的节点,当前还剩如下节点。

const old = [
  // h('p', {key: 1}, '哈哈哈1'),
  h('p', {key: 2}, '哈哈哈2'),
  h('p', {key: 3}, '哈哈哈3'),
  h('p', {key: 4}, '哈哈哈4'),
  h('p', {key: 6}, '哈哈哈6'),
  // h('p', {key: 5}, '哈哈哈5')
]

const new = [
  // h('p', {key: 1}, 'n哈哈哈1'),
  h('p', {key: 3}, 'n哈哈哈3'),
  h('p', {key: 4}, 'n哈哈哈4'),
  h('p', {key: 2}, 'n哈哈哈2'),
  h('p', {key: 7}, 'n哈哈哈7'),
  // h('p', {key: 5}, 'n哈哈哈5'),
]

那么我们需要分几种情况讨论

  1. 当旧节点已经处理完成(j > oldEndIdx) 但是新节点还有剩余(j <= newEndIdx)

此时就说明,在新节点中剩下的都是新增的节点,所以我们直接挂载

if (j > oldEndIdx && j <= newEndIdx) {
  const anchorIdx = newEndIdx + 1

  const anchor = anchorIdx < c2.length ? c2[anchorIdx].el : null

  while (j <= newEndIdx) {
    console.log('处理新增节点', c2[j])
    patch(null, c2[j++], container, anchor)
  }
}
  1. 当新节点已经处理完成(j > newEndIdx) 但是旧节点还有剩余(j <= oldEndIdx)

此时说明,在旧节点中剩余的节点都是更新之后不再需要的,我们直接卸载

if (j > newEndIdx && j <= oldEndIdx) {
 /**
   * 新列表处理完了,但旧列表还有
   */
 while (j <= oldEndIdx) {
   console.log('卸载旧节点', c1[j])

   unmount(c1[j++])
 }
}
  1. 两方都有剩余

我们首先需要得到还没有处理的节点数量,以及未处理的起始位置

// 没有预处理的数量
const count = newEndIdx - j + 1

const oldStartIdx = j
const newStartIdx = j

通过遍历新列表,生成一个key与索引的对应关系

/**
 * 索引表
  */
const keyIndex = new Map()

/**
 * 建立新列表中,key与索引的对应关系
  */
for (let i = newStartIdx; i <= newEndIdx; i++) {
  keyIndex.set(c2[i].key, i)
}

之后就开始根据旧列表遍历key,更新节点

/**
 * 用来做节点移动的判断
  */
let moved = false
let pos = 0

/**
 * 用来记录已处理的节点数量
  */
let patched = 0

for (let i = oldStartIdx; i <= oldEndIdx; i++) {
  oldVNode = c1[i]
  /**
   * 只有当更新数量小于等于需要更新的数量时,才进入更新
    * 
    * 否则直接卸载
    */
  if (patched <= count) {
    const k = keyIndex.get(oldVNode.key)

    if (typeof k !== 'undefined') {
      // 匹配到了新列表中的索引

      newVNode = c2[k]

      patch(oldVNode, newVNode, container)

      patched++ // 记录更新的数量

      source[k - newStartIdx] = i

      if (k < pos) {
        /**
         * 此时说明当前的节点发生了移动
          * 
          * 原理如同简单的diff中的下标大小判断
          */
        moved = true
      } else {
        pos = k
      }
    } else {
      unmount(oldVNode)
    }
  } else {
    unmount(oldVNode)
  }
}

依次遍历剩余的旧列表中的节点,如果在新列表的索引对应中找得到相同的key,那么就做出更新patch,并增加一下更新的数量,当更新数量超过新列表中的已有的数量时,那么证明旧列表中剩下的没有更新的都是已经被抛弃的节点。

在更新之于,我们还维护里一个坐标数组source,他用来记录新节点在旧节点中对应的位置索引。

// 现在我们有如下节点。
const old = [
  // h('p', {key: 1}, '哈哈哈1'),
  h('p', {key: 2}, '哈哈哈2'),
  h('p', {key: 3}, '哈哈哈3'),
  h('p', {key: 4}, '哈哈哈4'),
  h('p', {key: 6}, '哈哈哈6'),
  // h('p', {key: 5}, '哈哈哈5')
]

const new = [
  // h('p', {key: 1}, 'n哈哈哈1'),
  h('p', {key: 3}, 'n哈哈哈3'),
  h('p', {key: 4}, 'n哈哈哈4'),
  h('p', {key: 2}, 'n哈哈哈2'),
  h('p', {key: 7}, 'n哈哈哈7'),
  // h('p', {key: 5}, 'n哈哈哈5'),
]


const source = new Array(count).fill(-1)
// source = [-1, -1, -1, -1]

// 在循环结束后,source则会变成

// source = [2, 3, 1, -1]

如何判断是否发生节点移动呢。

我们的做法类似与简单的diff算法中一样

let moved = false
let pos = 0

for (let i = oldStartIdx; i <= oldEndIdx; i++) {
  oldVNode = c1[i]

  const k = keyIndex.get(oldVNode.key)

  if (k < pos) {
    /**
     * 此时说明当前的节点发生了移动
      * 
      * 原理如同简单的diff中的下标大小判断
      */
    moved = true
  } else {
    pos = k
  }
}

我们正在遍历旧列表,而k则是当前的节点在新列表中的索引,在遍历中我们会记录当前节点的坐标pos,比如key === 2的节点,他的k则是3,而到key === 3的节点时,k变成了1。我们可以允许节点整体向后推移,但如果后方的节点跑到了前面,那么就会标记这组节点发生了移动moved = true

移动节点

在预处理后,两方都有剩余节点的情况下,我们通过遍历,记录了key的对应索引,以及是否发生了节点移动。

我们对source求出最长增长子序列的索引

/**
 * 通过算法得出的不需要移动位置的节点索引
 * 
 * source: [2, 3, 1, -1]
 * 
 * seq: [0,1]
 */
const seq = lis(source)

为什么要求最长增长子序列的索引呢?

为了移动节点时的性能提升,我们要尽可能的减少移动的频率,求得结果为[0,1],则这两个索引对应的节点不需要移动,只需要移动其他索引对应的节点即可。

我们开始从后往前遍历(因为dom的api,insert是将dom插入到节点前方,所以需要先确定后置节点才更方便操作。)

let s = seq.length - 1
let i = count - 1

/**
 * 从后往前遍历source, 比对seq,找出需要移动的节点
 */
for (i; i >= 0; i--) {
  if (source[i] === -1) {
    // 从旧列表中得到的索引是-1,说明是新增的节点
    const pos = i + newStartIdx // 拿到新列表中的索引
    const newVNode = c2[pos]

    const nextPos = pos + 1

    // 锚点,新列表中该节点的后置节点
    const anchor = nextPos < c2.length ? c2[nextPos].el : null

    patch(null, newVNode, container, anchor)
  } else if (i !== seq[s]) {
    // 如果此索引不再seq中,则是需要移动的节点
    const pos = i + newStartIdx // 拿到新列表中的索引
    const newVNode = c2[pos]

    const nextPos = pos + 1

    // 锚点,新列表中该节点的后置节点
    const anchor = nextPos < c2.length ? c2[nextPos].el : null

    insert(newVNode.el!, container, anchor)
  } else {
    // 此节点不需要移动
    s--
  }
}

开始循环,分成了三种情况

  1. source的索引是-1时,这是一个没有在旧列表中存在的节点,所以我们直接挂载到它在新列表中的下一个节点的前方。

  2. 当前的索引与子序列中的索引不同,那么我们就移动节点到它在新列表中的下一个节点的前方。

  3. 当前索引与子序列中的索引相同,那么它是一个固定节点,我们将循环子序列的s向前进一位,开始对比下一个子序列

最长增长子序列算法

说明

题目

我们将问题分成两步。

  1. 得到最长子序列数组,注意:并不是增长的

具体方案就一句话:用栈结构,如果值比栈内所有值都大则入栈,否则替换比它大的最小数,最后的栈就是答案

var lengthOfLIS = function(nums) {
  const res = [nums[0]] // 先确定一个初始的值

  /**
   * 开始循环nums中的值
   */
  for (let i = 1; i < nums.length; i++) {
      let cur = nums[i]
      let isLarger = false

      /**
       * 将当前的值与res中的每一个值进行比较
       * 
       * 找到比他大的最小值
       * 
       * 找到后就将其替换,并做出标记`isLarger`
       */
      for (let j = 0; j < res.length; j++) {
          if (res[j] >= cur && !isLarger) {
              res[j] = cur
              isLarger = true
              break;
          }
      }

      /***
       * 如果当前的值比res中的每一个值都大,则填充到末尾
       */
      if (!isLarger) {
          res.push(cur)
      }
  }

  return res
};

现在我们进行代入

const nums = [2, 3, 1, -1]

lengthOfLIS(nums) // 结果:[-1, 3]

可以看到结果并不符合我们的要求,因为它不是递增的,而且我们需要的是索引。

为此我们进行改造。

将res中记录的值改为索引比较简单,只需要修改一下调用(nums[res[j]]) 与添加(res.push(i))即可

试想,我们在比较大小之后,将当前值赋值到了res中对应的位置res[j] = cur,如果res是个二位数组,将赋值改为追加,那么它是不是就可以记录当前位置曾经赋过的值

var lengthOfLIS = function(list) {
    const res = [[0]] // 二维数组

  for (let i = 1; i < list.length; i++) {
    let cur = list[i]
    let isLarger = false

    for (let j = 0; j < res.length; j++) {
      const resTask = res[j] // res中当前位置的一维数组
      if (list[resTask.at(-1)] > cur) { // 用数组的最后一位进行判断
        resTask.push(i)
        isLarger = true
        break;
      }
    }

    if (!isLarger) {
      res.push([i]) // 添加一个数组到二维数组中
    }
  }

  return res
};

此时进行带入

const nums = [2, 3, 1, -1]

lengthOfLIS(nums) // 结果:[ [ 0, 2, 3 ], [ 1 ] ]
  1. 找到二维数组的递增

在二维数组中找到递增的一维数组就比较简单了。

我们从最后一位往前推,拿到最后一个一维数组的最后一个元素,作为结果的最后一位,然后向前比较,找到前一个一维数组中比当前数字小的这一位,然后继续向前比较

let endNum = res.at(-1)!.at(-1)! // 拿到最后一个一维数组的最后一个元素作为比较值

const result = [endNum] // 结果数组

for (let i = res.length - 2; i >= 0; i--) { // 从倒数第二个开始向前遍历
  const curRes = res[i] // 当前的一维数组
  let length = curRes.length - 1
  let num = curRes[length]

  /**
   * 循环这个一维数组
   * 
   * 直到找到比比较值小的数字
   */
  while(num > endNum) {
    num = curRes[--length]
  }

  // 修改比较值
  endNum = num
  // 向结果中添加
  result.unshift(num)
}

最后的得到的result既是最长增长子序列。

写在最后

更完善的代码请看my-vue