vue中key的使用

957 阅读10分钟

思考以下问题

  1. vue中key的作用是什么?
  2. 使用key和不使用key的区别?
  3. v-for 为什么要使用唯一的key?

接下来就一一探讨以上问题

key是每一个vnode的唯一的id,也是diff算法的一种优化策略,还可以根据key,更准确,
更快速的查找到对应的vnode节点
如果不使用 key,Vue 会使用一种算法来最小化元素的移动并且尽可能尝试就地修改/复用
相同类型元素而使用 key 时,它会基于 key 的顺序变化重新排列元素,并且那些使用了已经不存在的
key 的元素将会被移除/销毁。
有相同父元素的子元素必须有唯一的 key。重复的 key 会造成渲染错误。

看到以上的解答你可能还会产生一些疑惑

  1. key是diff算法的一种优化策略,如何优化 ?
  2. 存在key和不存在key,diff算法有什么却别?
  3. v-for 为什么推荐使用返回的唯一的id为key而不是index? 知之者不知所已然,必受所累。嗷嗷待哺的求知者们接下来就让我们掀开diff算法的面纱,一探究竟

想要搞懂以上问题我们首先需要知道

diff算法

diff算法即差异查找算法。

Vue的diff策略

1. 传统的计算两颗树的差异时间复杂度为O(n^3),
2. 在vue中是采用对树的节点进行同层比较,所以时间复杂度是O(n)

Vue diff算法的基于什么策略

1. Web UI 中 DOM 节点跨层级的移动操作特别少,可以忽略不计 (tree-diff)
2. 拥有相同类的两个组件将会生成相似的树形结构,拥有不同类的两个组件将会生成不同的树形结
  (component diff)
3. 对于同一层级的一组子节点,它们可以通过唯一 id 进行区分(element-diff)

Vue diff算法的原因以及目的

1. 它的出现是为了通过对比新旧节点计算出需要改动的最小变化。
2. 核心思想:尽可能的复用老节点( 创建新DOM节点并移除旧DOM节点和更新已存在的DOM节点
         这两种方式里创建新DOM节点的开销肯定是远大于更新或移动已有的DOM节点)

正文(vue2 && vue3)

vue2 diff流程

新老节点不同

1. 创建新节点 以当前旧节点为参考 插入到DOM
2. 删除旧节点

新老节点相同

1. 如果两个节点引用一致 直接返回
2. 内部都是文本节点 新旧不同 更新文本节点的内容
3. 只有新的有子节点 移除旧节点的内容 批量添加
4. 只有老的有子节点 批量移除
5. 两者都有子节点且不同 执行updateChildren更新子节点

接下来重点理解updateChildren

在重点理解updateChildren之前我需要先了解sameVnode方法,该方法是判断两个节点是否为同一节点

// 参见Vue2源码 core/vdom/patch.js
function sameVnode (a, b) {
    return (
        a.key === b.key && (
            (
                a.tag === b.tag &&
                a.isComment === b.isComment &&
                isDef(a.data) === isDef(b.data) &&
                sameInputType(a, b)
            ) || (
                isTrue(a.isAsyncPlaceholder) &&
                a.asyncFactory === b.asyncFactory &&
                isUndef(b.asyncFactory.error)
            )
        )
    )
}

根据这个方法可以知道key是判断两个节点是否为同一节点的首要条件。值得注意的是,如果新旧vnodekey值都未定义的话那么两个key都为undefineda.key === b.key 是成立的

updateChildren方法中,这个方法会对新旧vnode进行diff,然后将比对出的结果用来更新真实的DOM

// 参见Vue2源码 core/vdom/patch.js
function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
    ...
    while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
        if (isUndef(oldStartVnode)) {
            ...
        } else if (isUndef(oldEndVnode)) {
            ...
        } else if (sameVnode(oldStartVnode, newStartVnode)) {
            ...
        } else if (sameVnode(oldEndVnode, newEndVnode)) {
            ...
        } else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
            ...
        } else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left
            ...
        } else {
            if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
            idxInOld = isDef(newStartVnode.key)
                ? oldKeyToIdx[newStartVnode.key]
                : findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)
            if (isUndef(idxInOld)) { // New element
                createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
            } else {
                vnodeToMove = oldCh[idxInOld]
                if (sameVnode(vnodeToMove, newStartVnode)) {
                    patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
                    oldCh[idxInOld] = undefined
                    canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)
                } else {
                    // same key but different element. treat as new element
                    createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
                }
            }
            newStartVnode = newCh[++newStartIdx]
        }
    }
    ...
}

在vue2 中updateChildren是采用首尾交叉遍历对比(头头对比,尾尾对比,首尾交叉对比)一共四种情况,下面是某一情况的对比过程。值得一提的是 vue是一边diff一边更新dom的与react是不一样的 diff.gif

如果四种情况都没发生则会进入while循环的else分支中

会根据newStartVnodekey去对比oldCh数组中的key,从而找到相应oldVnode

首先通过createKeyToOldIdx方法创建一个关于oldChmap

if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
function createKeyToOldIdx (children, beginIdx, endIdx) {
    let i, key
    const map = {}
    for (i = beginIdx; i <= endIdx; ++i) {
        key = children[i].key
        if (isDef(key)) map[key] = i
    }
    return map
}

这个map中将所有定义了keyoldVnode在数组中的index值作为键值,它的key作为键名存储起来,然后赋给oldKeyToIdx

idxInOld = isDef(newStartVnode.key) ? oldKeyToIdx[newStartVnode.key] : 
findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)
function findIdxInOld (node, oldCh, start, end) {
    for (let i = start; i < end; i++) {
        const c = oldCh[i]
        if (isDef(c) && sameVnode(node, c)) return i
    }
}

如果newStartVnodekey存在的话,根据keyoldKeyToIdx中寻找到与newStartVnode对应的oldVnodeindex 根据这个index就可以在oldCh找到与之相同key的vnode 进行复用,

而如果newStartVnode并没有设置key,则通过findIdxInOld方法遍历oldCh来获取与newStartVnode互为sameVnodeoldVnode,返回这个oldVnodeoldCh数组的index

在这里就可以回答第一个和第二个问题了

 1. key是diff算法的一种优化策略,如何优化 ?
 2. 存在key和不存在key,diff算法有什么却别?
 
 有key存在时我们可以通过map映射快速定位到对应的oldVnod然后进行patch,
 没有key值时我们需要遍历这个oldCh数组然后去一一进行比较,相比之下肯定是key存在时diff效率更高。

那么设置key值就一定能提高diff效率吗?

 // 答案是否定的
 
<div v-for="i in arr">{{ i }}</div>
// 如果我们的数组是这样的
[1, 2, 3, 4, 5]

// 它的渲染结果是这样的 所有的 key: undefined
<div>1</div> 
<div>2</div> 
<div>3</div>  
<div>4</div>  
<div>5</div>  

// 将它打乱
[4, 1, 3, 5, 2]

// 渲染结果是这样的 期间只发生了DOM节点的文本内容的更新
<div>4</div>
<div>1</div> 
<div>3</div>
<div>5</div>
<div>2</div>

// 如果我们给这个数组每一项都设置了唯一的key
[{id: 'A', value: 1}, {id: 'B', value: 2}, {id: 'C', value: 3}, {id: 'D', value: 4}, {id: 'E', value: 5}]
// 它的渲染结果应该是这样的
<div>1</div>  // key: A
<div>2</div>  // key: B
<div>3</div>  // key: C
<div>4</div>  // key: D
<div>5</div>  // key: E

// 将它打乱
[{id: 'D', value: 4}, {id: 'A', value: 1}, {id: 'C', value: 3}, {id: 'E', value: 5}, {id: 'B', value: 2}]
// 渲染结果是这样的  期间只发生了DOM节点的移动
<div>4</div>  // key: D
<div>1</div>  // key: A
<div>3</div>  // key: C
<div>5</div>  // key: E
<div>2</div>  // key: B

在简单模板中这种情况比较特殊,因为没有设置key所以都是undefined 根据之前提到的 此时根据sameVnode的判断条件,这些新旧节点的keytag等属性全部相同。已经判定为对应的节点(不再执行头尾交叉对比)然后直接进行patchVnode,根本没有走后面的那些else。每一次循环新旧节点都是相对应的,只需要更新其内的文本内容就可以完成DOM更新,这种原地复用的效率无疑是最高的。

而当我们设置了key之后,则会根据头尾交叉对比结果去执行下面的if else,进行判断之后还需要执行 insertBefore等方法移动真实DOM的节点的位置或者进行DOM节点的添加和删除,这样的查找复用开销肯定要比不带key直接原地复用的开销要高。

vue3 diff流程

新老节点不同

1. 销毁旧节点
2. 据新节点的类型 去挂载不同的节点

处理组件

1. 先判断子组件是否需要更新
2. 如果需要则递归执行子组件的副渲染函数来更新
3. 否则仅仅更新一些 vnode 的属性,并让子组件实例保留对组件 vnode 的引用

处理元素

1. 更新props
2. 更新子节点 子节点有三种类型 纯文本 Vnode数组 和 空

旧节点是纯文本:

新节点也是 做简单的替换
新节点是空 删除
新节点是Vnode数组 批量添加

旧节点是空:

如果新子节点是纯文本,那么在旧子节点的父容器下添加新文本节点即可;
如果新子节点也是空,那么什么都不需要做
如果新子节点是 vnode 数组,那么直接去旧子节点的父容器下添加多个新子节点即可。

旧子节点是 vnode 数组:

如果新子节点是纯文本,那么先删除旧子节点,再去旧子节点的父容器下添加新文本节点
如果新子节点是空,那么删除旧子节点即可
如果新子节点也是 vnode 数组,那么就需要做完整的 diff 新旧子节点了,这是最复杂的情况,内部运用了核心 diff 算法

旧子节点是 vnode 数组:

如果新子节点是纯文本,那么先删除旧子节点,再去旧子节点的父容器下添加新文本节点
如果新子节点是空,那么删除旧子节点即可
如果新子节点也是 vnode 数组,那么就需要做完整的 diff 新旧子节点了,这是最复杂的情况,内部运用了核心 diff 算法

新旧节点都是数组

新旧数组之间的对比,是通过更新、删除、添加和移除节点来完成的,diff算法的核心以就地复用,求解生成新子
节点 DOM 的系列操作。 过程:
1. 同步头节点
2. 同步尾节点
3. 新子节点有剩余 添加新节点 
4. 旧节点有剩余 删除多余节点
5. 处理未知子序列

处理未知子序列

有时会碰到比较复杂的未知子序列:对于移动、删除、添加、更新这些操作,其中最复杂的就是移动操作, Vue针对未知子序列的核心是通过最长递增子序列查找到需要移动的最小值

在查找过程中需要对比新旧子序列,那么我们就要遍历某个序列,如果在遍历旧子序列的过程中需要判断某个节点是否在新子序列中存在,这就需要双重循环,而双重循环的复杂度是 O(n2) ,为了优化这个复杂度,我们可以用一种空间换时间的思路,建立索引图,把时间复杂度降低到 O(n)。

建立索引图

1. 根据循环中的key建立新子序列中的索引图
2. 然后再创建一个新旧子序列索引的映射关系,用于确定最长递增子序列
3. 然后正序遍历旧子序列,看看是否在新子序列的索引图中,如果不再就删除,如果在根据索引去判断这个节点
   是否在最长递增子序列中,如果在就不需要进行移动,如果不再就要进行移动操作
4. 然后在遍历的过程中对新节点打上标记,对于没有被查找的标识为0,需要进行添加操作
// can be all-keyed or mixed
const patchKeyedChildren = (
    c1: VNode[],
    c2: VNodeArrayChildren,
    container: RendererElement,
    parentAnchor: RendererNode | null,
    parentComponent: ComponentInternalInstance | null,
    parentSuspense: SuspenseBoundary | null,
    isSVG: boolean,
    slotScopeIds: string[] | null,
    optimized: boolean
 ) => {
        let i = 0
        const l2 = c2.length
        // 旧节点的尾部索引
        let e1 = c1.length - 1
        // 新节点的尾部索引
        let e2 = l2 - 1
        // 从头部开始同步
        while (i <= e1 && i <= e2) {
            const n1 = c1[i]
            const n2 = (c2[i] = optimized ? cloneIfMounted(c2[i] as VNode) : normalizeVNode(c2[i]))
            if (isSameVNodeType(n1, n2)) {
                // 如果是相同的节点,递归执行patch,更新节点
                patch(
                    n1,
                    n2,
                    container,
                    null,
                    parentComponent,
                    parentSuspense,
                    isSVG,
                    slotScopeIds,
                    optimized
                )
            } else {
                // 否则跳出循环
                break
            }
            i++
      }
      // 从尾部开始同步尾部节点
      while (i <= e1 && i <= e2) {
            const n1 = c1[e1]
            const n2 = (c2[e2] = optimized ? cloneIfMounted(c2[e2] as VNode) : normalizeVNode(c2[e2]))
            if (isSameVNodeType(n1, n2)) {
                // 如果是相同的节点,递归执行patch,更新节点
                patch(
                    n1,
                    n2,
                    container,
                    null,
                    parentComponent,
                    parentSuspense,
                    isSVG,
                    slotScopeIds,
                    optimized
                )

            } else {
                // 否则跳出循环
                break
            }
            e1--
            e2--
    }
    // 新子节点 有剩余要添加的新节点
    if (i > e1) {
        if (i <= e2) {
            const nextPos = e2 + 1
            const anchor = nextPos < l2 ? (c2[nextPos] as VNode).el : parentAnchor
            while (i <= e2) {
                patch(
                    null,
                    (c2[i] = optimized
                    ? cloneIfMounted(c2[i] as VNode)
                    : normalizeVNode(c2[i])),
                    container,
                    anchor,
                    parentComponent,
                    parentSuspense,
                    isSVG,
                    slotScopeIds,
                    optimized
                )
                i++
            }
        }
    // 旧子节点,有剩余要删除的多余节点
    }else if (i > e2) {
        while (i <= e1) {
            // 删除多余的节点
            unmount(c1[i], parentComponent, parentSuspense, true)
            i++
        }
    // 未知的子序列 情况比较复杂
    }else {
        // 旧子序列开始索引,从 i 开始记录
        const s1 = i
        // 新子序列开始索引,从 i 开始记录
        const s2 = i
        // 根据key 建立 新子序列的索引图
        const keyToNewIndexMap: Map<string | number | symbol, number> = new Map()
        for (i = s2; i <= e2; i++) {
            const nextChild = (c2[i] = optimized ? cloneIfMounted(c2[i] as VNode) : normalizeVNode(c2[i]))
            if (nextChild.key != null) {
                if (__DEV__ && keyToNewIndexMap.has(nextChild.key)) {
                    warn(
                    `Duplicate keys found during update:`,
                    JSON.stringify(nextChild.key),
                    `Make sure keys are unique.`
                    )
                }
                // key 对应 i
                keyToNewIndexMap.set(nextChild.key, i)
            }
        }
        // 循环遍历 旧子序列 patch
        let j
        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]
                // toBePatched 代表新子序列的长度
                if (patched >= toBePatched) {
                    // 所有的新的子序列的节点都已经更新,剩余的旧子序列的节点删除
                    unmount(prevChild, parentComponent, parentSuspense, true)
                    continue
                }
                let newIndex
                if (prevChild.key != null) {
                    // 查找旧子序列中的节点在新子序列中的索引
                    newIndex = keyToNewIndexMap.get(prevChild.key)
                } else {
                    // key-less node, try to locate a key-less node of the same type
                    for (j = s2; j <= e2; j++) {
                        if ( newIndexToOldIndexMap[j - s2] === 0 && isSameVNodeType(prevChild, c2[j] as VNode)) {
                            newIndex = j
                            break
                        }
                    }
                }
                // 如果没找到, 说明旧子序列中的节点不存在新子序列中,那么就删除旧子序列的这个节点
                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] as VNode,
                        container,
                        null,
                        parentComponent,
                        parentSuspense,
                        isSVG,
                        slotScopeIds,
                        optimized
                    )
                    patched++
               }
          }

        // 循环遍历旧节点结束
        /*
            moved 为TRUE 说明有移动
            getSequence 计算最长递增子序列, 这是最复杂的算法
            newIndexToOldIndexMap: [5, 3, 4, 0] 里面存的值 是旧的子序列的索引
            5 要移动到4的后面,0占位,需要新增一个节点
            假如 newIndexToOldIndexMap 为 [5, 3, 4, 0] 那么 最长递增子序列就是 [1,2] 里面存的是索引
        */
        const increasingNewIndexSequence = moved ? getSequence(newIndexToOldIndexMap) : EMPTY_ARR
        j = increasingNewIndexSequence.length - 1
        for (i = toBePatched - 1; i >= 0; i--) {
            const nextIndex = s2 + i
            const nextChild = c2[nextIndex] as VNode
            const anchor =
            nextIndex + 1 < l2 ? (c2[nextIndex + 1] as VNode).el : parentAnchor
            if (newIndexToOldIndexMap[i] === 0) {
                // mount new
                patch(
                    null,
                    nextChild,
                    container,
                    anchor,
                    parentComponent,
                    parentSuspense,
                    isSVG,
                    slotScopeIds,
                    optimized
                )
            } else if (moved) {
                if (j < 0 || i !== increasingNewIndexSequence[j]) {
                    move(nextChild, container, anchor, MoveType.REORDER)
                } else {
                    j--
                }
            }
        }
    }
}

判断是否为同一节点

isSameVNodeType(n1: VNode, n2: VNode): boolean {
if (
    __DEV__ &&
    n2.shapeFlag & ShapeFlags.COMPONENT &&
    hmrDirtyComponents.has(n2.type as ConcreteComponent)
) {
    // HMR only: if the component has been hot-updated, force a reload.
    return false
}
    return n1.type === n2.type && n1.key === n2.key
}

总结

不论是vue2的updateChildren还是 vue3的 patchKeyedChildren 从代码中都可以看到 判断是否是同一节点的方法中key是至关重要的因素,设置key可以快速准确的找到与newVnode相对应的oldVnode,提升diff效率

现在讨论第三个问题: v-for 为什么推荐使用返回的唯一的id为key而不是index?

下面看这个例子

const carList =  [
    { car:'BMW' },
    { car:'Benz' },
    { car:'Audi' },
    { car:'HongQi'},
    { car:'BYD' },
 ]
<div v-for="(item,index) in carList " :key="index">{{item.car}}</div> 代码生成5 div
每个div的key为对应的index 即:

div key:0 car:BMW
div key:1 car:Benz
div key:2 car:Audi
div key:3 car:HongQi
div key:4 car:BYD

如果后端返回的数据 中间突然加了一条 比如carList 变成了
carList =  [
    { car:'BMW' },
    { car: 'Rolls-Royce'}
    { car:'Benz' },
    { car:'Audi' },
    { car:'HongQi'},
    { car:'BYD' },
 ]
 如果使用index为key,我们的想法是除了新添加的元素之外其他的应该可以全部复用也是符合vue提倡的原则 尽可能复用已有元素
 你会发现除了第一个key为0的可以复用,其他的全部发生了变化,这样就导致虚拟dom的diff算法在做比较的时候发现,key值相同
 的节点,内容不一致就会重新渲染,这样就是失去了虚拟dom在性能上的优势。
 
如果使用返回的id为key 即carList 变为:
carList =  [
    { car:'BMW',id:1 },
    { car:'Benz' id:2  },
    { car:'Audi' id:3  },
    { car:'HongQi' id:4 },
    { car:'BYD' id:5  }
 ]
代码生成5 div 每个div的key为对应的id 即:
div key:1 car:BMW
div key:2 car:Benz
div key:3 car:Audi
div key:4 car:HongQi
div key:5 car:BYD

即使后端返回数据中插入一条 比如carList 变为
carList =  [
    { car:'BMW',id:1 },
    { car: 'Rolls-Royce',id:5}
    { car:'Benz' id:2  },
    { car:'Audi' id:3  },
    { car:'HongQi' id:4 },
    { car:'BYD' id:5  }
 ]
diff算法发现原本key相同的节点,内容一致,完全复用,只是渲染一个插入的数据即可这也就利用了虚拟dom在性能上的优势