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>
那么在上面的代码中点击切换状态按钮,将不会清除用户已经输入的内容。因为两个模板使用了相同的元素,<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>
当有 key 设置和没有 key 设置时,input 元素表现并不是一样的。我们今天就来简单分析一下为什么出现这种现象。
AST 是一样
Vue 使用虚拟 DOM 来构建 Tree 结构,采用diff算法
来对比新旧虚拟节点,从而更新节点。虚拟 DOM 的本质就是 AST,所以我们从最初的 AST 入手,上面的代码实例经过编译之后会生成这样一棵 AST ,需要注意的是不管是有 key 还是无key 生成的 AST 基本是一样的,唯一的区别就是元素属性上是否有 key 键值对的存在。
AST 本质也是一个多层嵌套的🌲状结构。🌲的每一个节点都包含了当前元素的所有信息。
这里需要注意的是,在实例中使用的是条件渲染,条件渲染中状态为 false 的元素是不会渲染的,在生成的 DOM Tree 也是不存在的,但是也并不是完全不存在。
原因在于条件渲染
的节点描述对象中,会存在一个名为ifConditions
的描述。 ifConditions 是撒?
ifConditions
其实是条件渲染的集合
,在 Vue 的parse阶段
进行 AST 生成时,会将条件渲染元素
进行收集。每一个ifCondjiitions元素 的 block 描述
就是节点内容。
也就意味着,虽然状态为 false 的元素
虽然没有渲染,但是 Vue 还是生成了它的描述对象。
在编译生成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 进行区分
。
基于这两个核心点,使得虚拟DOM
的 Diff 算法的复杂度
从 O(n^3)
降到了O(n)
。diff 算法并不是本文的重点,这里不做过多的输出,本文的重点在于元素 key 的设置
对 元素复用的影响。
无 key 的 diff
首先我们看看无key状态下
更新流程是如何走的。这种重点注意 input 元素的更新
。
当在输入框输入值,点击toggle
按钮,进行切换时,会触发更新。整个触发更新的调用栈如下图:
最终会调用 updateChildren
进行元素的对比更新。对比很简单,同层的 Vnode list 的所有 Vnode 全部
对比一遍。这个对比按照一种简单的对比策略进行比较:
old vnode 的首
和new vnode 的首
进行比较。old vnode 的尾
和new vnode 的尾
进行比较。old vnode 的首
和new vnode 的尾
进行比较。old vnode 的尾
和new vnode 的首
进行比较。- 其他状态
在每一次比较的过程中,都会用到一个方法叫做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
。
已经可以判断为相同节点,然后调用 patchVnode。这就决定了在不带 key 的情况下,input 元素在更新时,直接被复用了。
这里可能有同学还会疑问❓元素被复用,为什么 placeholder 会改变
?
其实在进行**patchVnode**
时,会进行元素 data (记录元素属性、class、style、事件等等的一个集合)的更新。
这样一来就将旧的节点完美的复用了。
有 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 = 1
,email: input key = 2
。通过createKeyToOldIdx
找到一个旧节点 key 到新节点 index 的映射
。不要误认为是旧节点 key 到新节点 key 的映射
。这里是旧节点 key 到新节点 index 的映射
。
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: input
key 为 2,在映射中找不到对应的关系。所以会重新创建元素
。这也就是当都有 key 设置和没有 key 设置时,input 元素表现不是一样的原因了
。因为元素被重新创建,所以原本的输入也没有了。
还有一种情况,username: input key = 1
,email: 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 映射
的速度更快。