likes
comments
collection
share

写 Vue 项目时为什么要写 key ? 原理揭秘

作者站长头像
站长
· 阅读数 24

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

用 key 管理可复用的元素

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

例如:

// status 默认 true
methods: {
  toggle() {
    this.status = !this.status;
  }
},

<div>
  <template v-if="status">
    <label>Username:</label>
    <input placeholder="Enter your username">
  </template>
  <template v-else>
    <label>Email:</label>
    <input placeholder="Enter your email address">
  </template>
  <div><Button @click="toggle">切换状态</Button></div>
</div>

写 Vue 项目时为什么要写 key ? 原理揭秘

那么在上面的代码中点击切换状态按钮,将不会清除用户已经输入的内容。因为两个模板使用了相同的元素,<input> 不会被替换掉——仅仅是替换了它的 placeholder

但是这种方式也并不是都是我们想要的,在某些场景下,我们需要在元素切换的时候是最新的,所以 Vue 为你提供了一种方式来表达“这两个元素是完全独立的,不要复用它们”。只需添加一个具有唯一值的 key attribute 即可:

// status 默认 true
methods: {
  toggle() {
    this.status = !this.status;
  }
},

<div>
  <template v-if="status">
    <label>Username:</label>
    <input placeholder="Enter your username" key="1">
  </template>
  <template v-else>
    <label>Email:</label>
    <input placeholder="Enter your email address" key="2">
  </template>
  <div><Button @click="toggle">切换状态</Button></div>
</div>

写 Vue 项目时为什么要写 key ? 原理揭秘

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

AST 是一样

Vue 使用虚拟 DOM 来构建 Tree 结构,采用diff算法来对比新旧虚拟节点,从而更新节点。虚拟 DOM 的本质就是 AST,所以我们从最初的 AST 入手,上面的代码实例经过编译之后会生成这样一棵 AST ,需要注意的是不管是有 key 还是无key 生成的 AST 基本是一样的,唯一的区别就是元素属性上是否有 key 键值对的存在。

写 Vue 项目时为什么要写 key ? 原理揭秘

写 Vue 项目时为什么要写 key ? 原理揭秘

写 Vue 项目时为什么要写 key ? 原理揭秘

AST 本质也是一个多层嵌套的🌲状结构。🌲的每一个节点都包含了当前元素的所有信息。

这里需要注意的是,在实例中使用的是条件渲染,条件渲染中状态为 false 的元素是不会渲染的,在生成的 DOM Tree 也是不存在的,但是也并不是完全不存在。

原因在于条件渲染的节点描述对象中,会存在一个名为ifConditions的描述。

ifConditions 是撒?

ifConditions 其实是条件渲染的集合,在 Vue 的parse阶段进行 AST 生成时,会将条件渲染元素进行收集。每一个ifCondjiitions元素 的 block 描述就是节点内容。

写 Vue 项目时为什么要写 key ? 原理揭秘

也就意味着,虽然状态为 false 的元素虽然没有渲染,但是 Vue 还是生成了它的描述对象。

写 Vue 项目时为什么要写 key ? 原理揭秘

在编译生成render code时,也能将状态为 false 的节点,快速的插入三目表达式。

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

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

key 与 diff 算法

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

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

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

无 key 的 diff

首先我们看看无key状态下更新流程是如何走的。这种重点注意 input 元素的更新

写 Vue 项目时为什么要写 key ? 原理揭秘

当在输入框输入值,点击toggle按钮,进行切换时,会触发更新。整个触发更新的调用栈如下图:

写 Vue 项目时为什么要写 key ? 原理揭秘

最终会调用 updateChildren进行元素的对比更新。对比很简单,同层的 Vnode list 的所有 Vnode 全部对比一遍。这个对比按照一种简单的对比策略进行比较:

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

写 Vue 项目时为什么要写 key ? 原理揭秘

在每一次比较的过程中,都会用到一个方法叫做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)
      ) || (
        isTrue(a.isAsyncPlaceholder) &&
        a.asyncFactory === b.asyncFactory &&
        isUndef(b.asyncFactory.error)
      )
    )
  )
}

这个方法就是复用的关键,这个方法的返回值,直接决定了你当前新的 Vnode 能否复用旧的 Vnode

而这个方法首先比较的就是 key 值,然后比较 tag是否一样。当我们没有设置 key 值时,key 都为 undefined,tag 都是 input

写 Vue 项目时为什么要写 key ? 原理揭秘

已经可以判断为相同节点,然后调用 patchVnode。这就决定了在不带 key 的情况下,input 元素在更新时,直接被复用了。

这里可能有同学还会疑问❓元素被复用,为什么 placeholder 会改变

其实在进行patchVnode时, 会进行元素 data (记录元素属性、class、style、事件等等的一个集合)的更新。

写 Vue 项目时为什么要写 key ? 原理揭秘

这样一来就将旧的节点完美的复用了。

有 key 的 diff

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

<div>
  <template v-if="status">
    <label>Username:</label>
    <input placeholder="Enter your username" key="1">
  </template>
  <template v-else>
    <label>Email:</label>
    <input placeholder="Enter your email address" key="2">
  </template>
  <div><Button @click="toggle">切换状态</Button></div>
</div>

更新时的调用栈还是一样的,最终也会调用 updateChildren进行元素的对比更新。不过这次由于 key 值的不一样。新节点跟旧节点头尾交叉对比没有结果时,会根据新节点的key对比旧节点数组中的key,从而找到相应旧节点

比如,我们设置了username: input key = 1email: input key = 2。通过createKeyToOldIdx找到一个旧节点 key 到新节点 index 的映射。不要误认为是旧节点 key 到新节点 key 的映射。这里是旧节点 key 到新节点 index 的映射

写 Vue 项目时为什么要写 key ? 原理揭秘

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
}

创建成功后的映射是:

{ 1: 2}

这这种情况下,email: inputkey 为 2,在映射中找不到对应的关系。所以会重新创建元素这也就是当都有 key 设置和都没有 key 设置时,input 元素表现不是一样的原因了。因为元素被重新创建,所以原本的输入也没有了。

写 Vue 项目时为什么要写 key ? 原理揭秘

当然还有一种情况,一个有 key,一个没有 key,例如:username: input key = 1email: input key = undefined。新 Vnode 没有 key,那么就会采用遍历查找的方式去找到对应的旧节点。

function findIdxInOld(node, oldCh, start, end) {
  for (var i = start; i < end; i++) {
    var c = oldCh[i];
    if (isDef(c) && sameVnode(node, c)) { return i }
  }
}

当新 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 映射的速度更快。