doubleyong
管理员
管理员
  • 最后登录2026-05-25
  • 发帖数1198
  • 最爱沙发
  • 喜欢达人
  • 原创写手
  • 社区居民
  • 忠实会员
阅读:6089回复:0

[vue][转]为什么 Vue 中不要用 index 作为 key?(diff 算法详解)

楼主#
更多 发布于:2020-04-07 22:51
为什么不要以index作为key?

节点reverse场景
假设我们有这样的一段代码:
   
<div id="app">
      <ul>
        <item
          :key="index"
          v-for="(num, index) in nums"
          :num="num"
          :class="`item${num}`"
        ></item>
      </ul>
      <button @click="change">改变</button>
    </div>
    <script src="./vue.js"></script>
    <script>
      var vm = new Vue({
        name: "parent",
        el: "#app",
        data: {
          nums: [1, 2, 3]
        },
        methods: {
          change() {
            this.nums.reverse();
          }
        },
        components: {
          item: {
            props: ["num"],
            template: `
                    <div>
                       pw_num
                    </div>
                `,
            name: "child"
          }
        }
      });
    </script>

其实是一个很简单的列表组件,渲染出来 1 2 3 三个数字。我们先以 index 作为key,来跟踪一下它的更新。
我们接下来只关注 item 列表节点的更新,在首次渲染的时候,我们的虚拟节点列表 oldChildren 粗略表示是这样的:
[
  {
    tag: "item",
    key: 0,
    props: {
      num: 1
    }
  },
  {
    tag: "item",
    key: 1,
    props: {
      num: 2
    }
  },
  {
    tag: "item",
    key: 2,
    props: {
      num: 3
    }
  }
];

在我们点击按钮的时候,会对数组做 reverse 的操作。那么我们此时生成的 newChildren 列表是这样的:
[
  {
    tag: "item",
    key: 0,
    props: {
+     num: 3
    }
  },
  {
    tag: "item",
    key: 1,
    props: {
+     num: 2
    }
  },
  {
    tag: "item",
    key: 2,
    props: {
+     num: 1
    }
  }
];

发现什么问题没有?key的顺序没变,传入的值完全变了。这会导致一个什么问题?
本来按照最合理的逻辑来说,旧的第一个vnode 是应该直接完全复用 新的第三个vnode的,因为它们本来就应该是同一个vnode,自然所有的属性都是相同的。
但是在进行子节点的 diff 过程中,会在 旧首节点和新首节点用sameNode对比。 这一步命中逻辑,因为现在新旧两次首部节点 的 key 都是 0了,
然后把旧的节点中的第一个 vnode 和 新的节点中的第一个 vnode 进行 patchVnode 操作。
这会发生什么呢?我可以大致给你列一下:
首先,正如我之前的文章props的更新如何触发重渲染?里所说,在进行 patchVnode 的时候,会去检查 props 有没有变更,如果有的话,会通过 _props.num = 3 这样的逻辑去更新这个响应式的值,触发 dep.notify,触发子组件视图的重新渲染等一套很重的逻辑。
然后,还会额外的触发以下几个钩子,假设我们的组件上定义了一些dom的属性或者类名、样式、指令,那么都会被全量的更新。


updateAttrs
updateClass
updateDOMListeners
updateDOMProps
updateStyle
updateDirectives


而这些所有重量级的操作(虚拟dom发明的其中一个目的不就是为了减少真实dom的操作么?),都可以通过直接复用 第三个vnode 来避免,是因为我们偷懒写了 index 作为 key,而导致所有的优化失效了。
节点删除场景
另外,除了会导致性能损耗以外,在删除子节点的场景下还会造成更严重的错误,
可以看sea_ljf同学提供的这个demo。
假设我们有这样的一段代码:
<body>
  <div id="app">
    <ul>
      <li v-for="(value, index) in arr" :key="index">
        <test />
      </li>
    </ul>
    <button @click="handleDelete">delete</button>
  </div>
  </div>
</body>
<script>
  new Vue({
    name: "App",
    el: '#app',
    data() {
      return {
        arr: [1, 2, 3]
      };
    },
    methods: {
      handleDelete() {
        this.arr.splice(0, 1);
      }
    },
    components: {
      test: {
        template: "<li>pw_Math.random()</li>"
      }
    }
  })
</script>

那么一开始的 vnode列表是:
[
  {
    tag: "li",
    key: 0,
    // 这里其实子组件对应的是第一个 假设子组件的text是1
  },
  {
    tag: "li",
    key: 1,
    // 这里其实子组件对应的是第二个 假设子组件的text是2
  },
  {
    tag: "li",
    key: 2,
    // 这里其实子组件对应的是第三个 假设子组件的text是3
  }
];

有一个细节需要注意,正如我上一篇文章中所提到的为什么说 Vue 的响应式更新比 React 快?,Vue 对于组件的 diff 是不关心子组件内部实现的,它只会看你在模板上声明的传递给子组件的一些属性是否有更新。
也就是和v-for平级的那部分,回顾一下判断 sameNode 的时候,只会判断key、 tag、是否有data的存在(不关心内部具体的值)、是否是注释节点、是否是相同的input type,来判断是否可以复用这个节点。
<li v-for="(value, index) in arr" :key="index"> // 这里声明的属性
  <test />
</li>

有了这些前置知识以后,我们来看看,点击删除子元素后,vnode 列表 变成什么样了。
[
  // 第一个被删了
  {
    tag: "li",
    key: 0,
    // 这里其实上一轮子组件对应的是第二个 假设子组件的text是2
  },
  {
    tag: "li",
    key: 1,
    // 这里其实子组件对应的是第三个 假设子组件的text是3
  },
];

虽然在注释里我们自己清楚的知道,第一个 vnode 被删除了,但是对于 Vue 来说,它是感知不到子组件里面到底是什么样的实现(它不会深入子组件去对比文本内容),那么这时候 Vue 会怎么 patch 呢?
由于对应的 key使用了 index导致的错乱,它会把


原来的第一个节点text: 1直接复用。
原来的第二个节点text: 2直接复用。
然后发现新节点里少了一个,直接把多出来的第三个节点text: 3 丢掉。


至此为止,我们本应该把 text: 1节点删掉,然后text: 2、text: 3 节点复用,就变成了错误的把 text: 3 节点给删掉了。
为什么不要用随机数作为key?
<item
  :key="Math.random()"
  v-for="(num, index) in nums"
  :num="num"
  :class="`item${num}`"
/>

复制代码其实我听过一种说法,既然官方要求一个 唯一的key,是不是可以用 Math.random() 作为 key 来偷懒?这是一个很鸡贼的想法,看看会发生什么吧。
首先 oldVnode 是这样的:
[
  {
    tag: "item",
    key: 0.6330715699108844,
    props: {
      num: 1
    }
  },
  {
    tag: "item",
    key: 0.25104533240710514,
    props: {
      num: 2
    }
  },
  {
    tag: "item",
    key: 0.4114769152411637,
    props: {
      num: 3
    }
  }
];

更新以后是:
[
  {
    tag: "item",
    key: 0.6330715699108844,
    props: {
      num: 1
    }
  },
  {
    tag: "item",
    key: 0.25104533240710514,
    props: {
      num: 2
    }
  },
  {
    tag: "item",
    key: 0.4114769152411637,
    props: {
      num: 3
    }
  }
];


可以看到,key 变成了完全全新的 3 个随机数。
上面说到,diff 子节点的首尾对比如果都没有命中,就会进入 key 的详细对比过程,简单来说,就是利用旧节点的 key -> index 的关系建立一个 map 映射表,然后用新节点的 key 去匹配,如果没找到的话,就会调用 createElm 方法 重新建立 一个新节点。
具体代码在这:
// 建立旧节点的 key -> index 映射表
oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx);
// 去映射表里找可以复用的 index
idxInOld = findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx);
// 一定是找不到的,因为新节点的 key 是随机生成的。
if (isUndef(idxInOld)) {
  // 完全通过 vnode 新建一个真实的子节点
  createElm();
}

也就是说,咱们的这个更新过程可以这样描述:
123 -> 前面重新创建三个子组件 -> 321123  -> 删除、销毁后面三个子组件 -> 321。
发现问题了吧?这是毁灭性的灾难,创建新的组件和销毁组件的成本你们晓得的伐……本来仅仅是对组件移动位置就可以完成的更新,被我们毁成这样了。

总结
经过这样的一段旅行,diff 这个庞大的过程就结束了。
我们收获了什么?
用组件唯一的 id(一般由后端返回)作为它的 key,实在没有的情况下,可以在获取到列表的时候通过某种规则为它们创建一个 key,并保证这个 key 在组件整个生命周期中都保持稳定。
如果你的列表顺序会改变,别用 index 作为 key,和没写基本上没区别,因为不管你数组的顺序怎么颠倒,index 都是 0, 1, 2 这样排列,导致 Vue 会复用错误的旧子节点,做很多额外的工作。列表顺序不变也尽量别用,可能会误导新人。

千万别用随机数作为 key,不然旧节点会被全部删掉,新节点重新创建,你的老板会被你气死。

反驳标题之前,请先看这段
这篇文章发布以后,很多小伙伴提出了自己的建议和优化。但是也有很多人在评论区说,既然 index 只是在某些特定的场景下会出问题,那 列表顺序保持不变 的情况下还是可以接着用。这样做有什么问题呢?

团队代码规范,假设这样一个场景吧,你这边代码里全部写的 :key="index",有一个新人入职了跟着写,结果他的场景是删除和乱序的,这种情况你一个个讲原理指正?这就是统一代码规范和最佳实践的作用啊。eslint 甚至也专门有一个 rule 叫做 react/no-array-index-key,为什么要有这些约束和规范?如果社区总结了最佳实践,为什么一定要去打破它?这都是值得思考的。

就像 == 操作符,为什么要禁止?就是因为隐式转换会出很多问题,你说你熟背隐式转换所有原理,你能保证团队所有小伙伴都熟背?何苦有更简单的 === 操作符可以用。

说开发效率的问题,index 作为 key 我在上面已经提到了好几种会出问题的情况了,还是坚持要用,就因为简单。那么 TypeScript 也没有火起来的必要吗?它需要多写很多代码,“效率” 很低,为什么它火了?不是因为用 JavaScript 就一定会出现类型错误,而是因为用了 TypeScript 可以更好的保证你代码的稳定性。正如用了 id 作为key,可以比 index 更好的保证稳定性,更何况用 id 也不费事啊。完全都不像 TypeScript 带来的额外的语法成本。

所谓的列表顺序稳定,这个稳定你真的能保证吗?除了你前端写死的永远不变的一个列表,就假设你的列表没有在头部新增一项(导致节点全部依次错误复用),在任意位置 删除一项(有时导致错误删除)等这些会导致 patch 过程出现问题的操作。
就举个很简单的例子,你的“静态”列表的顺序是[1, 2, 3],数据库里突然加入了一条新数据0,那么你认为的不会变的列表的就变成了[0, 1, 2, 3]。然后,1 节点就错误的和 0节点进行 patchVnode, 2 节点就错误的和 1 节点进行 patch、导致原本只需要把新增的0节点插入到头部,然后分别对 1 -> 1、2 -> 2、3 -> 3 进行 patchVnode即可(基本没有变化),变成了毁灭的全量更新。(如果子组件是个很重的组件呢?它的每一项都会经历完整的 vm._update(vm._render()))过程,因为 props 变了。


作者:晨曦时梦见兮
链接:https://juejin.im/post/5e8694b75188257372503722
来源:掘金
知识需要管理,知识需要分享
游客


返回顶部

公众号

公众号