key 这个特殊的 attribute 主要作为 Vue 的虚拟 DOM 算法提示,在比较新旧节点列表时用于识别 vnode。在没有 key 的情况下,Vue 将使用一种最小化元素移动的算法,并尽可能地就地更新/复用相同类型的元素。如果传了 key,则将根据 key 的变化顺序来重新排列元素,并且将始终移除/销毁 key 已经不存在的元素。

用 key 管理可复用的元素

Vue 会尽可能高效地渲染元素,通常会复用已有元素不是从头开始渲染。这种复用不仅仅是在使用列表时会有成效,当我们在使用 条件渲染时,依然成立。
例如:

  1. // status 默认 true
  2. methods: {
  3. toggle() {
  4. this.status = !this.status;
  5. }
  6. },
  7. <div>
  8. <template v-if="status">
  9. <label>Username:</label>
  10. <input placeholder="Enter your username">
  11. </template>
  12. <template v-else>
  13. <label>Email:</label>
  14. <input placeholder="Enter your email address">
  15. </template>
  16. <div><Button @click="toggle">切换状态</Button></div>
  17. </div>

就地更新策略-演示.gif
那么在上面的代码中点击切换状态按钮,将不会清除用户已经输入的内容。因为两个模板使用了相同的元素,<input> 不会被替换掉——仅仅是替换了它的 placeholder
但是这种方式也并不是都是我们想要的,在某些场景下,我们需要在元素切换的时候是最新的,所以 Vue 为你提供了一种方式来表达“这两个元素是完全独立的,不要复用它们”。只需添加一个具有唯一值的 key attribute 即可:

  1. // status 默认 true
  2. methods: {
  3. toggle() {
  4. this.status = !this.status;
  5. }
  6. },
  7. <div>
  8. <template v-if="status">
  9. <label>Username:</label>
  10. <input placeholder="Enter your username" key="1">
  11. </template>
  12. <template v-else>
  13. <label>Email:</label>
  14. <input placeholder="Enter your email address" key="2">
  15. </template>
  16. <div><Button @click="toggle">切换状态</Button></div>
  17. </div>

就地更新策略(设置key)-演示.gif

当有 key 设置和没有 key 设置时,input 元素表现并不是一样的。我们今天就来简单分析一下为什么出现这种现象。

AST 是一样

Vue 使用虚拟 DOM 来构建 Tree 结构,采用diff算法来对比新旧虚拟节点,从而更新节点。虚拟 DOM 的本质就是 AST,所以我们从最初的 AST 入手,上面的代码实例经过编译之后会生成这样一棵 AST ,需要注意的是不管是有 key 还是无key 生成的 AST 基本是一样的,唯一的区别就是元素属性上是否有 key 键值对的存在。
image.png
image.png
写 Vue 项目时为什么要写 key ? 原理揭秘 - 图5
AST 本质也是一个多层嵌套的🌲状结构。🌲的每一个节点都包含了当前元素的所有信息。
这里需要注意的是,在实例中使用的是条件渲染,条件渲染中状态为 false 的元素是不会渲染的,在生成的 DOM Tree 也是不存在的,但是也并不是完全不存在。
原因在于条件渲染的节点描述对象中,会存在一个名为ifConditions的描述。
ifConditions 是撒?
ifConditions 其实是条件渲染的集合,在 Vue 的parse阶段进行 AST 生成时,会将条件渲染元素进行收集。每一个ifCondjiitions元素 的 block 描述就是节点内容。
image.png
也就意味着,虽然状态为 false 的元素虽然没有渲染,但是 Vue 还是生成了它的描述对象。
写 Vue 项目时为什么要写 key ? 原理揭秘 - 图7
在编译生成render code时,也能将状态为 false 的节点,快速的插入三目表达式。

  1. with (this) {
  2. return _c('div', [
  3. (status)
  4. ? [_c('label', [_v("Username:")]), _v(" "), _c('input', { attrs: { "placeholder": "Enter your username" } })]
  5. : [_c('label', [_v("Email:")]), _v(" "), _c('input', { attrs: { "placeholder": "Enter your email address" } })],
  6. _v(" "),
  7. _c('div', [_c('Button', { on: { "click": toggle } }, [_v("切换状态")])], 1)], 2)
  8. }

这样做的目的是为了在后续状态切换时,能快速响应处理,复用已经生成的描述对象,达到速度的最快。

key 与 diff 算法

我们都知道 Vue 在进行更新时,会进行新旧 Vnode 的 diff 对比来判断虚拟DOM 节点是否可以复用,而虚拟DOM 的 diff 核心在于两个点:

  • 两个相同的组件产生类似的DOM结构不同的组件``产生不同的DOM结构
  • 同一层级一组节点,他们可以通过唯一的 id 进行区分

基于这两个核心点,使得虚拟DOMDiff 算法的复杂度O(n^3)降到了O(n)。diff 算法并不是本文的重点,这里不做过多的输出,本文的重点在于元素 key 的设置对 元素复用的影响。

无 key 的 diff

首先我们看看无key状态下更新流程是如何走的。这种重点注意 input 元素的更新
image.png
当在输入框输入值,点击toggle按钮,进行切换时,会触发更新。整个触发更新的调用栈如下图:
image.png
最终会调用 updateChildren进行元素的对比更新。对比很简单,同层的 Vnode list 的所有 Vnode 全部对比一遍。这个对比按照一种简单的对比策略进行比较:

  • old vnode 的首new vnode 的首进行比较。
  • old vnode 的尾new vnode 的尾进行比较。
  • old vnode 的首new vnode 的尾进行比较。
  • old vnode 的尾new vnode 的首进行比较。
  • 其他状态

image.png
在每一次比较的过程中,都会用到一个方法叫做sameVnode
这个方法是干什么的了?

  1. function sameVnode(a, b) {
  2. return (
  3. a.key === b.key && (
  4. (
  5. a.tag === b.tag &&
  6. a.isComment === b.isComment &&
  7. isDef(a.data) === isDef(b.data) &&
  8. sameInputType(a, b)
  9. ) || (
  10. isTrue(a.isAsyncPlaceholder) &&
  11. a.asyncFactory === b.asyncFactory &&
  12. isUndef(b.asyncFactory.error)
  13. )
  14. )
  15. )
  16. }

这个方法就是复用的关键,这个方法的返回值,直接决定了你当前新的 Vnode 能否复用旧的 Vnode
而这个方法首先比较的就是 key 值,然后比较 tag是否一样。当我们没有设置 key 值时,key 都为 undefined,tag 都是 input
image.png
已经可以判断为相同节点,然后调用 patchVnode。这就决定了在不带 key 的情况下,input 元素在更新时,直接被复用了。
这里可能有同学还会疑问❓元素被复用,为什么 placeholder 会改变
其实在进行**patchVnode**时,会进行元素 data (记录元素属性、class、style、事件等等的一个集合)的更新。
image.png
这样一来就将旧的节点完美的复用了。

有 key 的 diff

接下来我们看看有 key 状态下更新流程是如何走的。这种重点也是 input 元素的更新

  1. <div>
  2. <template v-if="status">
  3. <label>Username:</label>
  4. <input placeholder="Enter your username" key="1">
  5. </template>
  6. <template v-else>
  7. <label>Email:</label>
  8. <input placeholder="Enter your email address" key="2">
  9. </template>
  10. <div><Button @click="toggle">切换状态</Button></div>
  11. </div>

更新时的调用栈还是一样的,最终也会调用 updateChildren进行元素的对比更新。不过这次由于 key 值的不一样。新节点跟旧节点头尾交叉对比没有结果时,会根据新节点的key对比旧节点数组中的key,从而找到相应旧节点
比如,我们设置了username: input key = 1email: input key = 2。通过createKeyToOldIdx找到一个旧节点 key 到新节点 index 的映射。不要误认为是旧节点 key 到新节点 key 的映射。这里是旧节点 key 到新节点 index 的映射
image.png

  1. function createKeyToOldIdx (children, beginIdx, endIdx) {
  2. let i, key
  3. const map = {}
  4. for (i = beginIdx; i <= endIdx; ++i) {
  5. key = children[i].key
  6. if (isDef(key)) map[key] = i
  7. }
  8. return map
  9. }

创建成功后的映射是:

  1. { 1: 2}

这种情况下,email: inputkey 为 2,在映射中找不到对应的关系。所以会重新创建元素这也就是当都有 key 设置和没有 key 设置时,input 元素表现不是一样的原因了。因为元素被重新创建,所以原本的输入也没有了。
image.png
还有一种情况,username: input key = 1email: input key = undefined。新 Vnode 没有 key,那么就会采用遍历查找的方式去找到对应的旧节点。

  1. function findIdxInOld(node, oldCh, start, end) {
  2. for (var i = start; i < end; i++) {
  3. var c = oldCh[i];
  4. if (isDef(c) && sameVnode(node, c)) { return i }
  5. }
  6. }

当新 Vnode 设置 key 了会通过map映射来查找,找不到就重新创建,当新 Vnode 没有设置 key 就会遍历查找。在遍历查找时,查找机制也是通过sameVnode来对比。

小结

所以当 Vue 进行更新,如果元素没有设置 key 值,同层级上的虚拟 Dom Tree 可能就会进行元素的复用操作,但是只适用于不依赖子组件状态或临时DOM状态(例如:表单输入值)的列表渲染输出。而对于设置了 key 的元素,会根据新节点的 key 去对比旧节点数组中的 key,从而找到相应旧节点(这里对应的是一个key => index 的map映射)。如果没找到就认为是一个新增节点。

总结

在 Vue 的官网也说道,“复用” 的模式是高效的,但是只适用于不依赖子组件状态或临时DOM状态(例如:表单输入值)的列表渲染输出。对于大多数场景来说,组件都有自己的状态。并且在 Vue3.x 版本中,对于 v-if / v-else / v-else-if 的各分支项 key 将不再是必须的,因为现在 Vue 会自动生成唯一的 key。
同理在v-for 列表渲染时,设置的 key 给每一个 vnode唯一 id,可以依靠 key,更准确,更快的拿到 oldVnode 中对应的vnode 节点。

准确

在进行 diff 对比时, sameVnode 函数需要进行判断:a.key === b.key。对于列表渲染来说,已经可以判断为相同节点,然后调用 patchVnode 。在带key的情况下,a.key === b.key对比中可以避免就地复用的情况,所以会更加准确。

更快

利用 key 的唯一性生成 map 对象来获取对应节点,比遍历方式更快。updateChild 函数中,会对新旧节点进行交叉对比,当新节点跟旧节点头尾交叉对比没有结果时,会根据新节点的 key 去对比旧节点数组中的key,从而找到相应旧节点(这里对应的是一个key => index 的map映射),没找到就认为是一个新增节点。而如果没有 key,那么就会采用遍历查找的方式去找到对应的旧节点。一种一个 map 映射,另一种是遍历查找。相比而言。map 映射的速度更快。