Vue 组件 diff

Vue


上一篇说了组件初次渲染的过程,这里就趁热打铁,记录一下数据更新后的 diff 场景。

# Diff 场景

组件 vnode 的 diff,总的来说分为三种情况:

# 1. 新增

  • 首次渲染时,老 vnode 不存在
  • 节点在新 vnode 中存在,而在老 vnode 中不存在时,
  • 新旧 vnode 完全不是同一个节点时
// 判断两个节点是不是同一个节点
function sameVnode(a, b) {
  return (
    a.key === b.key &&
    a.asyncFactory === b.asyncFactory &&
    ((a.tag === b.tag &&
      a.isComment === b.isComment &&
      isDef(a.data) === isDef(b.data) &&
      sameInputType(a, b)) ||
      (isTrue(a.isAsyncPlaceholder) && isUndef(b.asyncFactory.error)))
  )
}

if (!isRealElement && sameVnode(oldVnode, vnode)) {
  // patch existing root node
  patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly)
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

# 2. 删除

新 vnode 中不存在,而老 vnode 中存在的节点,在 patch 方法会内将创建好的 Dom 插入到废弃节点后面之后:

// patch

if (isDef(parentElm)) {
  // 在它们的父节点内删除旧节点
  removeVnodes(parentElm, [oldVnode], 0, 0)
}

// -------------------------------------------------------------

function removeVnodes(parentElm, vnodes, startIdx, endIdx) {
  for (; startIdx <= endIdx; ++startIdx) {
    const ch = vnodes[startIdx]
    if (isDef(ch)) {
      removeNode(ch.elm)
    }
  }
}

// ------------------------------------------------------------

function removeNode(el) {
  const parent = nodeOps.parentNode(el)
  if (isDef(parent)) {
    nodeOps.removeChild(parent, el)
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

# 3. 更新

这种情况是 diff 过程中处理最为复杂的

# 数据更新

还是以 App.vue 这个组件为例,在组件渲染完成后,点击外层 div 元素,更新 name 的值

<template>
  <div class="content-wrapper" @click="changeName">
    <HelloWorld />
    {{ name }}
  </div>
</template>

<script>
import HelloWorld from './components/HelloWorld.vue'
export default {
  name: 'App',
  components: { HelloWorld },
  data() {
    return {
      name: 'App',
    }
  },
  created() {
    console.log('this is App.vue')
  },
  methods: {
    changeName() {
      this.name = 'App.vue'
    },
  },
}
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27

# patch

当点击完成,name 属性发生改变,组件会重新 render 生成新的 vnode,然后执行 update,再次进入到 patch 过程,只不过与初次渲染不同,此时 oldVnode 以及 vnode 都存在

  • oldVnode

此时 App 组件 children 的最后一个成员的 text 还是 App















 




{
  tag: "div",
  data: {
    staticClass: "content-wrapper",
  },
  children: [
    {
      tag: "vue-component-2-HelloWorld",
      data: {/*...*/},
    },
    {
      tag: undefined,
      data: undefined,
      children: undefined,
      text: " App ",
    }
  ]
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
  • vnode

而新 render 出来的 vnode 则变成了 App.vue















 




{
  tag: "div",
  data: {
    staticClass: "content-wrapper",
  },
  children: [
    {
      tag: "vue-component-2-HelloWorld",
      data: {/*...*/},
    },
    {
      tag: undefined,
      data: undefined,
      children: undefined,
      text: " App.vue ",
    }
  ]
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

此时,由于新旧 vnode 都存在,所以会走到 patchVnode 的逻辑,进行 diff 操作。

# patchVnode

patchVnode 是新旧 Vnode 对比的核心方法,对比的逻辑如下。

  • 新旧节点仅有文本节点,如果文本不同,则直接替换文本内容
  • 新节点没有 text
    • 新旧都存在子节点,则继续对比子节点内容
    • 新节点没有子节点,旧节点有子节点,则删除旧节点所有子节点
    • 新节点有子节点,旧节点没有子节点,则用新的子节点去更新旧节点
    • 旧节点有文本节点,则清空文本节点
// prettier-ignore
function patchVnode( oldVnode, vnode, insertedVnodeQueue, ownerArray, index, removeOnly) {
  // 二者完全相等,则直接返回
  if (oldVnode === vnode) {
    return
  }

  var elm = (vnode.elm = oldVnode.elm)

  // ======= 都是静态节点,直接跳过 ========= //
  if (isTrue(vnode.isStatic) &&
    isTrue(oldVnode.isStatic) &&
    vnode.key === oldVnode.key &&
    (isTrue(vnode.isCloned) || isTrue(vnode.isOnce))
  ) {
    vnode.componentInstance = oldVnode.componentInstance;
    return
  }

  // ...

  var oldCh = oldVnode.children
  var ch = vnode.children
  // ======= 新节点没有text ======= //
  if (isUndef(vnode.text)) {
    if (isDef(oldCh) && isDef(ch)) { // 新旧节点都有子节点
      if (oldCh !== ch) {
        // 两者不相等,则继续对比子节点
        updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
      }
    } else if (isDef(ch)) { // 新节点有子节点,旧节点没有子节点
      // 如果旧节点有文本就清空
      if (isDef(oldVnode.text)) {
        nodeOps.setTextContent(elm, '')
      }
      // 用新节点的子节点去更新DOM
      addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
    } else if (isDef(oldCh)) { // 新节点没有子节点,旧节点有子节点
      // 直接删除旧节点的子节点
      removeVnodes(oldCh, 0, oldCh.length - 1)
    } else if (isDef(oldVnode.text)) { // 旧节点有文本节点,则清空文本节点
      nodeOps.setTextContent(elm, '')
    }
  } else if (oldVnode.text !== vnode.text) {
    // ======== 新旧节点都有 text 且不同则直接替换 ========= //
    nodeOps.setTextContent(elm, vnode.text)
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48

以文中的例子来说,由于新旧节点都拥有子节点,所以会进入到对比子节点的逻辑中去,即 updateChildren

# updateChildren

以下内容引自Vue 原理解析(八):一起搞明白令人头疼的 diff 算法 (opens new window),原文内容已经写得很清晰,这里只是稍微调整了一下顺序。

// prettier-ignore
function updateChildren(parentElm, oldCh, newCh) {
  let oldStartIdx = 0  // 旧第一个下标
  let oldStartVnode = oldCh[0]  // 旧第一个节点
  let oldEndIdx = oldCh.length - 1  // 旧最后下标
  let oldEndVnode = oldCh[oldEndIdx]  // 旧最后节点

  let newStartIdx = 0  // 新第一个下标
  let newStartVnode = newCh[0]  // 新第一个节点
  let newEndIdx = newCh.length - 1  // 新最后下标
  let newEndVnode = newCh[newEndIdx]  // 新最后节点

  let oldKeyToIdx  // 旧节点key和下标的对象集合
  let idxInOld  // 新节点key在旧节点key集合里的下标
  let vnodeToMove  // idxInOld对应的旧节点
  let refElm  // 参考节点

  checkDuplicateKeys(newCh) // 检测newVnode的key是否有重复

  while(oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {  // 开始遍历children

    if (isUndef(oldStartVnode)) {  // 跳过因位移留下的undefined
      oldStartVnode = oldCh[++oldStartIdx]
    } else if (isUndef(oldEndVnode)) {  // 跳过因位移留下的undefine
      oldEndVnode = oldCh[--oldEndIdx]
    }

    else if(sameVnode(oldStartVnode, newStartVnode)) {  // 比对新第一和旧第一节点
      patchVnode(oldStartVnode, newStartVnode)  // 递归调用
      oldStartVnode = oldCh[++oldStartIdx]  // 旧第一节点和下表重新标记后移
      newStartVnode = newCh[++newStartIdx]  // 新第一节点和下表重新标记后移
    }

    else if (sameVnode(oldEndVnode, newEndVnode)) {  // 比对旧最后和新最后节点
      patchVnode(oldEndVnode, newEndVnode)  // 递归调用
      oldEndVnode = oldCh[--oldEndIdx]  // 旧最后节点和下表重新标记前移
      newEndVnode = newCh[--newEndIdx]  // 新最后节点和下表重新标记前移
    }

    else if (sameVnode(oldStartVnode, newEndVnode)) { // 比对旧第一和新最后节点
      patchVnode(oldStartVnode, newEndVnode)  // 递归调用
      nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))
      // 将旧第一节点右移到最后,视图立刻呈现
      oldStartVnode = oldCh[++oldStartIdx]  // 旧开始节点被处理,旧开始节点为第二个
      newEndVnode = newCh[--newEndIdx]  // 新最后节点被处理,新最后节点为倒数第二个
    }

    else if (sameVnode(oldEndVnode, newStartVnode)) { // 比对旧最后和新第一节点
      patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue)  // 递归调用
      nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
      // 将旧最后节点左移到最前面,视图立刻呈现
      oldEndVnode = oldCh[--oldEndIdx]  // 旧最后节点被处理,旧最后节点为倒数第二个
      newStartVnode = newCh[++newStartIdx]  // 新第一节点被处理,新第一节点为第二个
    }

    else {  // 不包括以上四种快捷比对方式
      if (isUndef(oldKeyToIdx)) {
        oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
        // 获取旧开始到结束节点的key和下表集合
      }

      idxInOld = isDef(newStartVnode.key)  // 获取新节点key在旧节点key集合里的下标
          ? oldKeyToIdx[newStartVnode.key]
          : findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)

      if (isUndef(idxInOld)) { // 找不到对应的下标,表示新节点是新增的,需要创建新dom
        createElm(
          newStartVnode,
          insertedVnodeQueue,
          parentElm,
          oldStartVnode.elm,
          false,
          newCh,
          newStartIdx
        )
      }

      else {  // 能找到对应的下标,表示是已有的节点,移动位置即可
        vnodeToMove = oldCh[idxInOld]  // 获取对应已有的旧节点
        patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue)
        oldCh[idxInOld] = undefined
        nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)
      }

      newStartVnode = newCh[++newStartIdx]  // 新开始下标和节点更新为第二个节点

    }
  }
  // ...
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90

函数内首先会定义一堆 let 定义的变量,这些变量是随着 while 循环体而改变当前值的,循环的退出条件为只要新旧节点列表有一个处理完就退出,看着循环体代码挺复杂,其实它只是做了三件事,明白了哪三件事再看循环体,会发现其实并不复杂:

# diff 算法

# 跳过 undefined

为什么会有 undefined,之后的流程图会说明清楚。这里只要记住,如果旧开始节点为 undefined,就后移一位;如果旧结束节点为 undefined,就前移一位。

# 快捷查找

首先会尝试四种快速查找的方式,如果不匹配,再做进一步处理:

  1. 新开始旧开始 节点比对

如果匹配,表示它们位置都是对的,Dom 不用改,就将新旧节点开始的下标往后移一位即可。

  1. 旧结束新结束 节点比对

如果匹配,也表示它们位置是对的,Dom 不用改,就将新旧节点结束的下标前移一位即可。

  1. 旧开始新结束 节点比对

如果匹配,位置不对需要更新 Dom 视图,将旧开始节点对应的真实 Dom 插入到最后一位,旧开始节点下标后移一位,新结束节点下标前移一位。

  1. 旧结束新开始 节点比对

如果匹配,位置不对需要更新 Dom 视图,将旧结束节点对应的真实 Dom 插入到旧开始节点对应真实 Dom 的前面,旧结束节点下标前移一位,新开始节点下标后移一位。

# key 值查找

  1. 如果和已有 key 值匹配

那就说明是已有的节点,只是位置不对,那就移动节点位置即可。

  1. 如果和已有 key 值不匹配

再已有的 key 值集合内找不到,那就说明是新的节点,那就创建一个对应的真实 Dom 节点,插入到旧开始节点对应的真实 Dom 前面即可。

# 示例

<template>
  <ul>
    <li v-for="item in list" :key="item.id">{{ item.name }}</li>
  </ul>
</template>

<script>
export default {
  data() {
    return {
      list: [
        {
          id: 'a1',
          name: 'A',
        },
        {
          id: 'b2',
          name: 'B',
        },
        {
          id: 'c3',
          name: 'C',
        },
        {
          id: 'd4',
          name: 'D',
        },
      ],
    }
  },
  mounted() {
    setTimeout(() => {
      this.list.sort(() => Math.random() - 0.5).unshift({ id: 'e5', name: 'E' })
    }, 1000)
  },
}
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37

这么说并不太好理解,结合上面的示例,根据以下的流程图将会明白很多:

↑ 示例的初始状态就是这样了,之前定义的下标以及对应的节点就是 start 和 end 标记。

↑ 首先进行之前说明两两四次的快捷比对,找不到后通过旧节点的 key 值列表查找,并没有找到说明 E 是新增的节点,创建对应的真实 Dom,插入到旧节点里 start 对应真实 Dom 的前面,也就是 A 的前面,已经处理完了一个,新 start 位置后移一位。

↑ 接着开始处理第二个,还是首先进行快捷查找,没有后进行 key 值列表查找。发现是已有的节点,只是位置不对,那么进行插入操作,参考节点还是 A 节点,将原来旧节点 C 设置为 undefined,这里之后会跳过它。又处理完了一个节点,新 start 后移一位。

↑ 再处理第三个节点,通过快捷查找找到了,是新开始节点对应旧开始节点,Dom 位置是对的,新 start 和旧 start 都后移一位。

↑ 接着处理的第四个节点,通过快捷查找,这个时候先满足了旧开始节点和新结束节点的匹配,Dom 位置是不对的,插入节点到最后位置,最后将新 end 前移一位,旧 start 后移一位。

↑ 处理最后一个节点,首先会执行跳过 undefined 的逻辑,然后再开始快捷比对,匹配到的是新开始节点和旧开始节点,它们各自 start 后移一位,这个时候就会跳出循环了。接着看下最后的收尾代码:

function updateChildren(parentElm, oldCh, newCh) {
  let oldStartIdx = 0
  // ...
  while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
    // ...
  }

  if (oldStartIdx > oldEndIdx) {
    // 如果旧节点列表先处理完,处理剩余新节点
    refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm
    addVnodes(
      parentElm,
      refElm,
      newCh,
      newStartIdx,
      newEndIdx,
      insertedVnodeQueue
    ) // 添加
  } else if (newStartIdx > newEndIdx) {
    // 如果新节点列表先处理完,处理剩余旧节点
    removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx) // 删除废弃节点
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

复制代码我们之前的示例刚好是新旧节点列表同时处理完退出的循环,这里是退出循环后为还有没有处理完的节点,做不同的处理:

以新节点列表为标准,如果是新节点列表处理完,旧列表还有没被处理的废弃节点,删除即可;如果是旧节点先处理完,新列表里还有没被使用的节点,创建真实 Dom 并插入到视图即可。这就是整个 diff 算法过程了,大家可以对比之前的递归流程图再看一遍,相信思路会清晰很多。

Last Updated: 10/21/2024, 4:15:17 PM