Vue3 源码解读系列(四)——组件更新

组件更新

组件更新流程:

  1. 从头部开始同步

  2. 从尾部开始同步

  3. 挂载剩余的新节点

  4. 删除多余的旧节点

  5. 处理未知的子序列

    • 当两个节点类型相同时,执行更新操作
    • 当新子节点中没有旧子节点中的某些节点时,执行删除操作
    • 当新子节点中多了旧子节点中没有的节点时,执行添加操作

    相对来说,这些操作中最麻烦的就是移动,既要判断哪些节点需要移动,也要清除如何移动。

    移动子节点(如何以最小的时间复杂度移动子节点才是重点

    • 在新旧子节点序列中找出相同节点并更新

      找出多余的节点删除,找出新的节点添加

      找出需要移动的节点,需要遍历对应的序列,如果在遍历旧子序列的过程中需要判断某个节点是否在新子序列中存在,这就需要双重循环,双重循环的复杂度是 O(n2),为了优化这个复杂度,建立索引图,把时间复杂度降低到 O(n)。

    • 建立索引图(空间换时间)

      在开发过程中,会给 v-for 生成的列表中的每一项分配唯一 key 作为项的唯一 ID。

      对比新旧子序列中的节点,key 相同的就是同一个节点,执行执行 patch 更新即可。

    • 移动和挂载新节点

      Vue3 是通过获取最长递增子序列来进行移动的,动态规划解法的时间复杂度为 O(n2),而 Vue3 采用了 ”贪心+二分查找“ 来实现,贪心的时间复杂度为 O(n),二分查找的时间复杂度 O(logn),总时间复杂度为 O(nlogn)。

在这里插入图片描述

/**
 * 比较节点
 */
const patchKeyedChildren = (c1, c2, container, parentAnchor, parentComponent, parentSuspense, isSVG, optimized) => {
  let i = 0 // 新、旧子序列的头部索引
  const l2 = c2.length
  let e1 = c1.length - 1 // 旧子节点的尾部索引
  let e2 = l2 - 1 // 新子节点的尾部索引

  // 1、从头部开始同步
  while (i <= e1 && i <= e2) {
    const n1 = c1[i] // 旧节点
    const n2 = c2[i] // 新节点
    // 相同类型的节点,递归执行 patch 更新节点
    if (isSameVNodeType(n1, n2)) {
      patch(n1, n2, container, parentAnchor, parentComponent, parentSuspense, isSVG, optimized)
    }
    // 节点类型不同 或 不存在新 | 旧节点,则退出
    else {
      break
    }
    i++
  }

  // 2、从尾部开始同步
  while (i <= e1 && i <= e2) {
    const n1 = c1[e1] // 旧节点
    const n2 = c2[e2] // 新节点
    // 相同类型的节点,递归执行 patch 更新节点
    if (isSameVNodeType(n1, n2)) {
      patch(n1, n2, container, parentAnchor, parentComponent, parentSuspense, isSVG, optimized)
    }
    // 节点类型不同 或 不存在新 | 旧节点,则退出
    else {
      break
    }
    e1--
    e2--
  }

  // 3、挂载剩余的新节点
  if (i > e1) {
    if (i <= e2) {
      const nextPos = e2 + 1
      const anchor = nextPos < l2 ? c2[nextPos].el : parentAnchor
      while (i <= e2) {
        // 挂载新节点
        patch(null, c2[i], container, anchor, parentComponent, parentSuspense, isSVG)
        i++
      }
    }
  }

  // 4、删除多余的旧节点
  else if (i > e2) {
    while (i <= e1) {
      // 删除节点
      unmount(c1[i], parentComponent, parentSuspense, true)
      i++
    }
  }

  // 5、处理未知的子序列
  // 5.1、根据 key 建立新子序列的索引图
  const s1 = i // 旧子序列开始索引,从 i 开始记录
  const s2 = i // 新子序列开始索引,从 i 开始记录
  const keyToNewIndexMap = new Map() // 新子序列节点的 key -> index 的索引表
  // 遍历新子序列,记录索引表
  for (i = s2; i <= e2; i++) {
    const nextChild = c2[i]
    keyToNewIndexMap.set(nextChild.key, i)
  }

  // 5.2、正序遍历旧子序列,找到匹配的节点更新,删除不在新子序列中的节点,判断是否有移动节点
  let patched = 0 // 新子序列已更新节点的数量
  const toBePatched = e2 - s2 + 1 // 新子序列待更新节点的数量,等于新子序列的长度
  let moved = false // 是否存在要移动的节点
  let maxNewIndexSoFar = 0 // 用于跟踪判断是否有节点移动
  const newIndexToOldIndexMap = new Array(toBePatched) // 存储新子序列中的节点在旧子序列中的索引,用于确定最长递增子序列
  // 初始化数组,每个元素的只都是 0
  // 0 是一个特殊的值,如果遍历完了仍有元素的值为 0,则说明这个新节点没有对应的旧节点
  for (i = 0; i < toBePatched; i++) {
    newIndexToOldIndexMap[i] = 0
  }
  // 正序遍历旧子序列
  for (i = s1; i <= e1; i++) {
    const prevChild = c1[i]
    // 所有新的子序列节点都已经更新,删除剩余的节点
    if (patched >= toBePatched) {
      unmount(prevChild, parentComponent, parentSuspense, true)
      continue
    }
    let newIndex = keyToNewIndexMap.get(prevChild.key) // 查找旧子序列中的节点在新子序列中的索引
    // 找不到说明旧子序列已经不存在于新子序列中,则删除该节点
    if (newIndex === undefined) {
      unmount(prevChild, parentComponent, parentSuspense, true)
    }
    // 否则更新新子序列中的元素在旧子序列中的索引
    else {
      // 这里加 1 偏移,是为了避免 i 为 0 的特殊情况,影响对后续最长递增子序列的求解
      newIndexToOldIndexMap[newIndex - s2] = i + 1
      // maxNewIndexSoFar 始终存储的是上次求值的 newIndex,如果不是一直递增,则说明有移动
      if (newIndex >= maxNewIndexSoFar) {
        maxNewIndexSoFar = newIndex
      } else {
        moved = true
      }
      // 更新新旧子序列中匹配的节点
      patch(prevChild, c2[newIndex], container, null, parentComponent, parentSuspense, isSVG, optimized)
      patched++
    }
  }

  // 5.3、移动和挂载新节点
  // 仅当节点移动时生成最长递增子序列
  const increasingNewIndexSequence = moved ? getSequence(newIndexToOldIndexMap) : EMPTY_ARR
  let j = increasingNewIndexSequence.length - 1
  // 倒序遍历以便我们可以使用最后更新的节点作为锚点
  for (i = toBePatched - 1; i >= 0; i--) {
    const nextIndex = s2 + 1
    const nextChild = c2[nextIndex]
    // 锚点指向上一个更新的节点,如果 nextIndex 超过新子节点的长度,则指向 parentAnchor
    const anchor = nextIndex + 1 < l2 ? c2[nextIndex + 1].el : parentAnchor
    // 挂载新的子节点
    if (newIndexToOldIndexMap[i] === 0) {
      patch(null, nextChild, container, anchor, parentComponent, parentSuspense, isSVG)
    }
    // 没有最长递增子序列(reverse 的场景)或者当前的节点索引不在最长递增子序列中,需要移动
    else if (moved) {
      if (j < 0 || i !== increasingNewIndexSequence[j]) {
        move(nextChild, container, anchor, 2)
      } else {
        // 倒序递增子序列 
        j--
      }
    }
  }
}

/**
 * 获取最长递增子序列,实际求的是最长递增子序列各项的索引
 */
function getSequence(arr) {
  const p = arr.slice()
  const result = [0] // 长度为 i 的最长递增子序列各项的索引
  let i, j, u, v, c
  const len = arr.length
  // 对数组遍历,依次求解长度为 i 时的最长递增子序列
  for (i = 0; i < len; i++) {
    const arrI = arr[i]
    if (arrI !== 0) {
      j = result[result.length - 1]
      // 当 i 元素大于 i-1 的元素时,添加 i 元素并更新最长子序列
      if (arr[j] < arrI) {
        // 存储在 result 更新前的最后一个索引的值
        p[i] = j
        result.push(i)
        continue
      }
      // 否则往前查找直到找到一个比 i 小的元素,然后插在该元素后面并更新对应的最长递增子序列
      u = 0
      v = result.length - 1
      // 二分搜索,查找比 arrI 小的节点,更新 result 的值
      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]

  // 回溯数组 p,找到最终的索引
  while (u-- > 0) {
    result[u] = v
    v = p[v]
  }
  return result
}

问题:v-for 时能否用 index 作为 key?

答:如果列表只是用于展示的化没有问题,如果列表涉及增、删、改就一定要用唯一标识。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
组件Vue.js 中最重要的概念之一。组件可以让我们将 UI 拆分为独立、可复用的部件,使得代码更加清晰、易于维护。在 Vue.js 中,组件可以分为全局组件和局部组件,其中全局组件可在任何地方使用,而局部组件只能在其父组件中使用。 定义组件时,需要使用 Vue.component() 方法,该方法需要传入两个参数:组件名称和组件配置对象。组件名称应该采用 kebab-case(短横线分隔命名)格式,以便在 HTML 中使用。 示例代码如下: ```javascript // 定义一个名为 button-counter 的新组件 Vue.component('button-counter', { data: function () { return { count: 0 } }, template: '<button v-on:click="count++">You clicked me {{ count }} times.</button>' }) ``` 在上述代码中,我们定义了一个名为 button-counter 的组件,该组件包含一个计数器,每次点击按钮计数器加一。 在 HTML 中使用组件时,需要使用组件名称作为自定义标签来调用组件。示例代码如下: ```html <div id="app"> <button-counter></button-counter> </div> ``` 在上述代码中,我们调用了 button-counter 组件,并将其渲染到了 id 为 app 的 div 元素中。 除了组件的 data 和 template 属性外,还可以使用 props 属性来传递组件之间的数据。使用 props 时,需要在组件的配置对象中定义 props 属性,并在 HTML 中使用 v-bind 指令来传递数据。 示例代码如下: ```javascript // 定义一个名为 todo-item 的新组件 Vue.component('todo-item', { props: ['todo'], template: '<li>{{ todo.text }}</li>' }) // 创建一个 Vue 实例 var app = new Vue({ el: '#app', data: { groceryList: [ { id: 0, text: '蔬菜' }, { id: 1, text: '水果' }, { id: 2, text: '奶酪' } ] } }) ``` 在上述代码中,我们定义了一个名为 todo-item 的组件,并使用 props 属性定义了一个名为 todo 的 prop。在 HTML 中,我们使用 v-bind 指令将 groceryList 数组中的每个对象传递给了 todo-item 组件。示例代码如下: ```html <div id="app"> <ul> <todo-item v-for="item in groceryList" v-bind:todo="item" v-bind:key="item.id"></todo-item> </ul> </div> ``` 在上述代码中,我们使用 v-for 指令遍历 groceryList 数组,并使用 v-bind 指令将数组中的每个对象传递给了 todo-item 组件。注意,我们还需要使用 v-bind:key 指令来为每个列表项指定一个唯一的 key 值。 插槽是 Vue.js 中另一个重要的概念。插槽可以让父组件在子组件中插入任意的 HTML 内容,使得组件更加灵活、可复用。 在子组件中,使用 <slot> 标签来定义插槽。在父组件中,使用子组件的自定义标签来调用组件,并在标签内部插入 HTML 内容。示例代码如下: ```javascript // 定义一个名为 alert-box 的新组件 Vue.component('alert-box', { template: ` <div class="alert-box"> <strong>Error!</strong> <slot></slot> </div> ` }) // 创建一个 Vue 实例 var app = new Vue({ el: '#app' }) ``` 在上述代码中,我们定义了一个名为 alert-box 的组件,并在组件中定义了一个插槽。在 HTML 中,我们调用了 alert-box 组件,并在标签内部插入了一些 HTML 内容。示例代码如下: ```html <div id="app"> <alert-box> <p>Something bad happened.</p> </alert-box> </div> ``` 在上述代码中,我们调用了 alert-box 组件,并在标签内部插入了一些 HTML 内容。该 HTML 内容会被插入到 alert-box 组件的插槽中。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Jackson Mseven

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值