vue.js

关注公众号 jb51net

关闭
首页 > 网络编程 > JavaScript > javascript类库 > vue.js > vue中v-for循环key值问题

vue中关于v-for循环key值问题的研究

作者:小白路过

这篇文章主要介绍了vue中关于v-for循环key值问题的研究,具有很好的参考价值,希望对大家有所帮助。如有错误或未考虑完全的地方,望不吝赐教

介绍

关于key的作用,官方是这样描述的

key 的特殊属性主要用在 Vue 的虚拟 DOM 算法,在新旧 nodes 对比时辨识 VNodes。

如果不使用 key,Vue 会使用一种最大限度减少动态元素并且尽可能的尝试修复/再利用相同类型元素的算法。

使用 key,它会基于 key 的变化重新排列元素顺序,并且会移除 key 不存在的元素。

有相同父元素的子元素必须有独特的 key。重复的 key 会造成渲染错误。

之前不知道在哪里看的文章,一直以为使用key是为了就地复用,但是从上面的描述中可以看到,实际上不用key,vue也会尽可能的尝试使用复用修复策略,没有设置key的时候,vnode的key值就是undefined,而在源码patchVnode函数中有一个判断函数sameVnode用于判断是否是相同节点。

A.key(undefined )=== B.key(undefined) 

也是判断为相同节点的。

/*
  判断两个VNode节点是否是同一个节点,需要满足以下条件
  key相同
  tag(当前节点的标签名)相同
  isComment(是否为注释节点)相同
  是否data(当前节点对应的对象,包含了具体的一些数据信息,是一个VNodeData类型,可以参考VNodeData类型中的数据信息)都有定义
  当标签是<input>的时候,type必须相同
*/
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)
  )
}

由此也可以看出,如果要想不复用,可以通过给组件以不同的key值来实现,用于强制替换元素/组件而不是重复使用它。

作用

既然key值不是用于就地复用的目的,那为什么要设置这个key值呢?

上面说使用key值时,提到了重新排列顺序及删除的问题。

查看源码中patch函数中对于key值得应用,主要看updateChildren中节点比较部分,在前面的新旧首尾互比中,设置key和不设置key的比对是一样的,在下面这块,设置key的作用就凸显出来了

function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
  ...
  //新旧首尾互比之后
  /*
          生成一个key与旧VNode的key对应的哈希表(只有第一次进来undefined的时候会生成,也为后面检测重复的key值做铺垫)
          比如childre是这样的 [{xx: xx, key: 'key0'}, {xx: xx, key: 'key1'}, {xx: xx, key: 'key2'}]  beginIdx = 0   endIdx = 2  
          结果生成{key0: 0, key1: 1, key2: 2}
        */
        if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
        /*如果newStartVnode新的VNode节点存在key并且这个key在oldVnode中能找到则返回这个节点的idxInOld(即第几个节点,下标)*/
        idxInOld = isDef(newStartVnode.key) ? oldKeyToIdx[newStartVnode.key] : null
        if (isUndef(idxInOld)) { // New element
          /*newStartVnode没有key或者是该key没有在老节点中找到则创建一个新的节点*/
          createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm)
          newStartVnode = newCh[++newStartIdx]
        } else {
          /*获取同key的老节点*/
          elmToMove = oldCh[idxInOld]
          /* istanbul ignore if */
          if (process.env.NODE_ENV !== 'production' && !elmToMove) {
            /*如果elmToMove不存在说明之前已经有新节点放入过这个key的Dom中,提示可能存在重复的key,确保v-for的时候item有唯一的key值*/
            warn(
              'It seems there are duplicate keys that is causing an update error. ' +
              'Make sure each v-for item has a unique key.'
            )
          }
          if (sameVnode(elmToMove, newStartVnode)) {
            /*如果新VNode与得到的有相同key的节点是同一个VNode则进行patchVnode*/
            patchVnode(elmToMove, newStartVnode, insertedVnodeQueue)
            /*因为已经patchVnode进去了,所以将这个老节点赋值undefined,之后如果还有新节点与该节点key相同可以检测出来提示已有重复的key*/
            oldCh[idxInOld] = undefined
            /*当有标识位canMove实可以直接插入oldStartVnode对应的真实Dom节点前面*/
            canMove && nodeOps.insertBefore(parentElm, newStartVnode.elm, oldStartVnode.elm)
            newStartVnode = newCh[++newStartIdx]
          } else {
            // same key but different element. treat as new element
            /*当新的VNode与找到的同样key的VNode不是sameVNode的时候(比如说tag不一样或者是有不一样type的input标签),创建一个新的节点*/
            createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm)
            newStartVnode = newCh[++newStartIdx]
          }
        }
  ...
}

从上面的源码中可以看到,key值作为快速索引,用于查找当前vnode在旧的vnode序列中的位置。

假设:oldVnodes里有

[{xx: xx, key: 'key0'}, {xx: xx, key: 'key1'}, {xx: xx, key: 'key2'}];

新的vnodes有

[{xx: xx, key: 'key1'}, {xx: xx, key: 'key3'}, {xx: xx, key: ‘key2'}]

在循环遍历新的vnodes时,例如key1,在oldVnodes中找到了,但是数组索引位置(1)和当前新的vnodes中的位置(0)不一样,表示需要进行节点移动,此时做的操作

patchVnode(elmToMove, newStartVnode, insertedVnodeQueue)

是用旧的vnode队列中key值对应的节点来复用patch生成新的vnode,然后进行节点移动,节点移动后,会将oldVnodes中(1)位置清空,置为undefined。

再例如key3节点,在oldVnodes中没有,就意味着这是一个新的节点,需要进行新建和插入指定位置的操作。

这里需要注意,即便是sameVnode,也是要进行patch操作的,而不是直接拿旧的节点来用。

key值在新旧节点在变化前后顺序不一致的情况下,能够快速的定位,从旧的节点队列中找到具有相同key值得节点来复用渲染。

应用场景

日常开发中最常见的用例是结合 v-for:,开发过程中vue要求为循环生成的节点必须绑定key值

<ul>
  <li v-for="item in items" :key="item.id">...</li>
</ul>

为何不建议用index做key

使用index作为key值,有可能造成错误的渲染。简单的来说,假如有oldVnode集合[A, B, C],删除第一个元素,剩下的vnode集合就是[B,C],当循环patchVnode的时候,oldVnode中A的key是0,vnode中B的key也是0,vue在实际渲染的过程中就会复用A来渲染B

例如下面根据list循环生成三个子组件,以数组index作为key值,当在删除的时候可能会出现异常。

image-20210611195145197

<div class="list">
     <child v-for="(item, i) in list" :data="item" :key="i" @del="onDelChild" ></child>
</div>
<script>
export default {
  data() {
    return {
      list: [
        { text: "circle", id: 1 },
        { text: "trangle", id: 2 },
        { text: "square", id: 3 }
      ]
    };
  },
  methods: {
    onDelChild(item) {
      const idx = this.list.findIndex(it => it.id == item.id);
      this.list.splice(idx, 1);
    }
  }
};
</script>
<template>
  <div>
    {{ data.id }}
    <input v-model="text" />
    <button @click="onDel">delete</button>
    <span @click="onClick">{{ staticText }}</span>
  </div>
</template>
<script>
export default {
  props: ["data"],
  data() {
    return {
      text: "hello",
      staticText: "click Me"
    };
  },
  methods: {
    onDel() {
      this.$emit("del", this.data);
    },
    onClick() {
      this.staticText = "clicked";
    }
  }
};
</script>

先点击第一条的click Me,改变文本内容。

再点击第一条的删除按钮,结果如下图所示,并没有如我们期待的那样删除第一条数据,你会发现视图的一部分确实正确更新了,如前面的id,但是后面的input内容和文本内容都没有按预期的删除第一条。

image-20210611195438831

上面的异常情况中,很明显的用oldVnode集合中的A复用渲染了vnode中的B,oldVnode中的B复用渲染了vnode中的C,然后删除了oldVnode中的C。

注意:上面未正确更新的内容,实际绑定的值是Child子组件内部的自有属性,如果将Child内容改为一下,则可以得到预期效果

<div>
    {{ data.id }}
    <input v-model="data.text" />
    <button @click="onDel">delete</button>
  </div>

初步的结论是,循环渲染子组件,并以index作为key值绑定时,当动态改变父组件中的list集合,vue会按index索引来查找oldVnode中的子节点用以复用,且在进行patch时,只更新了那些由父组件传递过来的prop绑定视图,子组件自身的实例属性不会更新

那么,为什么会出现这种渲染错误的情况?

首先,问题的源头肯定是节点复用,也就是我们上面key值复用其中的一步

if (sameVnode(elmToMove, newStartVnode)) {
  /*如果新VNode与得到的有相同key的节点是同一个VNode则进行patchVnode*/
  patchVnode(elmToMove, newStartVnode, insertedVnodeQueue)
  /*因为已经patchVnode进去了,所以将这个老节点赋值undefined,之后如果还有新节点与该节点key相同可以检测出来提示已有重复的key*/
  oldCh[idxInOld] = undefined
  /*当有标识位canMove实可以直接插入oldStartVnode对应的真实Dom节点前面*/
  canMove && nodeOps.insertBefore(parentElm, newStartVnode.elm, oldStartVnode.elm)
  newStartVnode = newCh[++newStartIdx]
}
patchVnode(elmToMove, newStartVnode, insertedVnodeQueue)

在这里面用eleToMove复用渲染生成newStartVnode时,更新渲染的dom出现了问题。

下面我们来看看这个错误是如何出现的

在进入patchVnode之前,我们首先需要明确当前elmToMove是什么,newStartVnode又是怎样的。

elmToMove是从旧的渲染队列中拿出来的,由此可以看出它是一个完成的已经渲染过的数据,其中包含着数据变更前,child子组件对应的实例及生成的dom等数据。

而newStartVnode则不尽然,它只是一个占位vnode,具体何为占位vnode,可以详细了解下之前学习过的vue源码中create-component这一节。

对于<child></child>这种自定义组件,在调用h函数生成vnode时,会生成一个vue-component-${Ctor.cid}${name}的占位节点,具体可以看源码中create-component.js中的createComponent方法,这里只截取一部分,来简单说明下占位节点也就是当前newStartVnode有哪些内容。

/*创建一个组件节点,返回Vnode节点*/
export function createComponent (
  Ctor: Class<Component> | Function | Object | void,
  data?: VNodeData,
  context: Component,
  children: ?Array<VNode>,
  tag?: string
): VNode | void {
  //.....
// return a placeholder vnode
  /** 组件节点,本质上是一个占位节点,在实际dom中并没有
   * 当出现 <HelloWorld></HelloWorld>  这种组件节点时,
   * 会相应生成一个tag为vue-component-xx-hellowrold的占位vnode
   * 占位vnode没有children
   */
  const name = Ctor.options.name || tag
  const vnode = new VNode(
    `vue-component-${Ctor.cid}${name ? `-${name}` : ''}`,
    data, undefined/** 组件是没有children的 */, undefined, undefined, context,
    { Ctor, propsData, listeners, tag, children }/** componentOptions */
  )
  return vnode
}

这里重点关注一下,占位vnode中componentOptions,后面在尽心patch时会用到,data里面是一些钩子函数,用于组件节点实例的创建或者patch更新,详细的这里不过多解释。

所以,此时新的vnode其实还没有创建实例,生成dom节点,接下来看看patchVnode中如何为新的vnode生成实例和dom节点的

/*patch VNode节点*/
function patchVnode (oldVnode, vnode, insertedVnodeQueue, removeOnly) {
  ...
  const data = vnode.data
  if (isDef(data) && isDef(i = data.hook) && isDef(i = i.prepatch)) {
    /*i = data.hook.prepatch,如果存在的话,见"./create-component componentVNodeHooks"。*/
    // 根据vnode,更新oldVnode子组件实例对应的相关属性
    i(oldVnode, vnode)
  }
  //这里复用,直接将oldVnode的dom赋给了vnode
  const elm = vnode.elm = oldVnode.elm
  const oldCh = oldVnode.children
  const ch = vnode.children
  if (isDef(data) && isPatchable(vnode)) {
    /*调用update回调以及update钩子*/
    for (i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode)
    if (isDef(i = data.hook) && isDef(i = i.update)) i(oldVnode, vnode)
  }
  ...
}

在上面的代码中,导致了后续的渲染错误。

首先,作为组件占位vnode,根据我们上面看到的create-component中Vnode的构造函数中可以看出,vnode.data中是一堆hook函数,用于组件vnode的实例创建和patch更新,这里调用了hook.prepatch方法,用oldVnode来patch生成vnode。然后直接将oldVnode的dom赋给了vnode

我们来看下这个prepatch都做了些啥

prepatch (oldVnode: MountedComponentVNode, vnode: MountedComponentVNode) {
  //props相关的数据在componentOptions里
  const options = vnode.componentOptions
  //实例覆盖:将oldVnode的实例直接赋值给了vnode
  const child = vnode.componentInstance = oldVnode.componentInstance
  updateChildComponent(
    child,
    options.propsData, // updated props
    options.listeners, // updated listeners
    vnode, // new parent vnode
    options.children // new children
  )
}

这里可以看到,在prepatch中,直接将oldVnode的实例直接赋值给了vnode,到这里,问题就出现了。

vnode中那些实例相关的属性(例如data中的数据)就会丢失,转而成为oldVnode中的实例属性。

这里可能会有点绕,简单举个例子

在这里插入图片描述

上面例子中用A节点复用生成B节点的时候,在prepatch的时候将A的实例直接赋值给了B,此时新节点队列中的B对应的实例属性text就是A,渲染后会生成对应的text:A。而oldVnode中B对应的text: B,这就出现了之前我们所说的渲染错误。

那为什么最上面例子中组件节点中的{{ data.id }}能正确渲染呢?

这里就要看下面这段代码

updateChildComponent(
    child,
    options.propsData, // updated props
    options.listeners, // updated listeners
    vnode, // new parent vnode
    options.children // new children
  )

这里的options.propsData用的是vnode.componentOptions中的propsData,而不是oldVnode,所以当对应的propsData有变化的时候,能够正确的渲染。而覆盖后的新旧实例属性为同一实例,无法对比差异,所以会直接复用oldVnode的实例属性dom。

所以,日常开发中,如果需要用index作为key值时,需要明确会不会造成这种渲染错误,否则可能会出现意想不到的难题。

总结

以上为个人经验,希望能给大家一个参考,也希望大家多多支持脚本之家。

您可能感兴趣的文章:
阅读全文