为何 Vue 中不要用 index 做为 key?(diff 算法详解)

前言

Vue 中的 key 是用来作什么的?为何不推荐使用 index 做为 key?经常据说这样的问题,本篇文章带你从原理来一探究竟。前端

本文的结论对于性能的毁灭是针对列表子元素顺序被改变、或者子元素被删除的特殊状况,提早说明清楚。vue

本篇已经收录在 Github 仓库,欢迎 Star:node

github.com/sl1673495/b…react

示例

以这样一个列表为例:git

<ul>
  <li>1</li>
  <li>2</li>
</ul>
复制代码

那么它的 vnode 也就是虚拟 dom 节点大概是这样的。github

{
  tag: 'ul',
  children: [
    { tag: 'li', children: [ { vnode: { text: '1' }}]  },
    { tag: 'li', children: [ { vnode: { text: '2' }}]  },
  ]
}
复制代码

假设更新之后,咱们把子节点的顺序调换了一下:算法

{
  tag: 'ul',
  children: [
+ { tag: 'li', children: [ { vnode: { text: '2' }}] },
+ { tag: 'li', children: [ { vnode: { text: '1' }}] },
  ]
}
复制代码

很显然,这里的 children 部分是咱们本文 diff 算法要讲的重点(敲黑板)。数据库

首先响应式数据更新后,触发了 渲染 Watcher 的回调函数 vm._update(vm._render())去驱动视图更新,后端

vm._render() 其实生成的就是 vnode,而 vm._update 就会带着新的 vnode 去走触发 __patch__ 过程。api

咱们直接进入 ul 这个 vnodepatch 过程。

对比新旧节点是不是相同类型的节点:

1. 不是相同节点:

isSameNode为false的话,直接销毁旧的 vnode,渲染新的 vnode。这也解释了为何 diff 是同层对比。

2. 是相同节点,要尽量的作节点的复用(都是 ul,进入👈)。

会调用src/core/vdom/patch.js下的patchVNode方法。

若是新 vnode 是文字 vnode

就直接调用浏览器的 dom api 把节点的直接替换掉文字内容就好。

若是新 vnode 不是文字 vnode

那么就要开始对子节点 children 进行对比了。(能够类比 ul 中的 li 子元素)。

若是有新 children 而没有旧 children

说明是新增 children,直接 addVnodes 添加新子节点。

若是有旧 children 而没有新 children

说明是删除 children,直接 removeVnodes 删除旧子节点

若是新旧 children 都存在(都存在 li 子节点列表,进入👈)

那么就是咱们 diff算法 想要考察的最核心的点了,也就是新旧节点的 diff 过程。

能够打开源码仓库里大体看下这个函数,接下来我会逐步讲解。

updateChildren

经过

// 旧首节点
  let oldStartIdx = 0
  // 新首节点
  let newStartIdx = 0
  // 旧尾节点
  let oldEndIdx = oldCh.length - 1
  // 新尾节点
  let newEndIdx = newCh.length - 1
复制代码

这些变量分别指向旧节点的首尾新节点的首尾

根据这些指针,在一个 while 循环中不停的对新旧节点的两端的进行对比,而后把两端的指针向不断内部收缩,直到没有节点能够对比。

在讲对比过程以前,要讲一个比较重要的函数:sameVnode

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)
      )
    )
  )
}
复制代码

它是用来判断节点是否可用的关键函数,能够看到,判断是不是 sameVnode,传递给节点的 key 是关键。

而后咱们接着进入 diff 过程,每一轮都是一样的对比,其中某一项命中了,就递归的进入 patchVnode 针对单个 vnode 进行的过程(若是这个 vnode 又有 children,那么还会来到这个 diff children 的过程 ):

  1. 旧首节点和新首节点用 sameNode 对比。

  2. 旧尾节点和新尾节点用 sameNode 对比

  3. 旧首节点和新尾节点用 sameNode 对比

  4. 旧尾节点和新首节点用 sameNode 对比

  5. 若是以上逻辑都匹配不到,再把全部旧子节点的 key 作一个映射到旧节点下标的 key -> index 表,而后用新 vnodekey 去找出在旧节点中能够复用的位置。

而后不停的把匹配到的指针向内部收缩,直到新旧节点有一端的指针相遇(说明这个端的节点都被patch过了)。

在指针相遇之后,还有两种比较特殊的状况:

  1. 有新节点须要加入。 若是更新完之后,oldStartIdx > oldEndIdx,说明旧节点都被 patch 完了,可是有可能还有新的节点没有被处理到。接着会去判断是否要新增子节点。

  2. 有旧节点须要删除。 若是新节点先patch完了,那么此时会走 newStartIdx > newEndIdx 的逻辑,那么就会去删除多余的旧子节点。

为何不要以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> {{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的属性或者类名、样式、指令,那么都会被全量的更新。

  1. updateAttrs
  2. updateClass
  3. updateDOMListeners
  4. updateDOMProps
  5. updateStyle
  6. 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>{{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 的时候,只会判断keytag是否有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致使的错乱,它会把

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

至此为止,咱们本应该把 text: 1节点删掉,而后text: 2text: 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.11046018699748683,
    props: {
+ num: 3
    }
  },
  {
    tag: "item",
+ key: 0.8549799545696619,
    props: {
+ num: 2
    }
  },
  {
    tag: "item",
+ key: 0.18674467938937478,
    props: {
+ num: 1
    }
  }
];

复制代码

能够看到,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 这个庞大的过程就结束了。

咱们收获了什么?

  1. 用组件惟一的 id(通常由后端返回)做为它的 key,实在没有的状况下,能够在获取到列表的时候经过某种规则为它们建立一个 key,并保证这个 key 在组件整个生命周期中都保持稳定。

  2. 若是你的列表顺序会改变,别用 index 做为 key,和没写基本上没区别,由于无论你数组的顺序怎么颠倒,index 都是 0, 1, 2 这样排列,致使 Vue 会复用错误的旧子节点,作不少额外的工做。列表顺序不变也尽可能别用,可能会误导新人。

  3. 千万别用随机数做为 key,否则旧节点会被所有删掉,新节点从新建立,你的老板会被你气死。

反驳标题以前,请先看这段

这篇文章发布之后,不少小伙伴提出了本身的建议和优化。可是也有不少人在评论区说,既然 index 只是在某些特定的场景下会出问题,那 列表顺序保持不变 的状况下仍是能够接着用。这样作有什么问题呢?

  1. 团队代码规范,假设这样一个场景吧,你这边代码里所有写的 :key="index",有一个新人入职了跟着写,结果他的场景是删除和乱序的,这种状况你一个个讲原理指正?这就是统一代码规范和最佳实践的做用啊。eslint 甚至也专门有一个 rule 叫作 react/no-array-index-key,为何要有这些约束和规范?若是社区总结了最佳实践,为何必定要去打破它?这都是值得思考的。 就像 == 操做符,为何要禁止?就是由于隐式转换会出不少问题,你说你熟背隐式转换全部原理,你能保证团队全部小伙伴都熟背?何苦有更简单的 === 操做符能够用。

  2. 说开发效率的问题,index 做为 key 我在上面已经提到了好几种会出问题的状况了,仍是坚持要用,就由于简单。那么 TypeScript 也没有火起来的必要吗?它须要多写不少代码,“效率” 很低,为何它火了?不是由于用 JavaScript 就必定会出现类型错误,而是由于用了 TypeScript 能够更好的保证你代码的稳定性。正如用了 id 做为key,能够比 index 更好的保证稳定性,更况且用 id 也不费事啊。彻底都不像 TypeScript 带来的额外的语法成本。

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

❤️感谢你们

1.若是本文对你有帮助,就点个赞支持下吧,你的「赞」是我创做的动力。

2.关注公众号「前端从进阶到入院」便可加我好友,我拉你进「前端进阶交流群」,你们一块儿共同交流和进步。

相关文章
相关标签/搜索