网上有很多,我也看了很多,下面是我看到的最容易理解的也是我最认同的解释,所以就记录一下喽
要解释key的作用,不得不先介绍一下虚拟DOM的Diff算法了。
我们知道,vue和react都实现了一套虚拟DOM,使我们可以不直接操作DOM元素,只操作数据便可以重新渲染页面。而隐藏在背后的原理便是其高效的Diff算法。
vue和react的虚拟DOM的Diff算法大致相同,其核心是基于两个简单的假设:
1. 两个相同的组件产生类似的DOM结构,不同的组件产生不同的DOM结构。
2. 同一层级的一组节点,他们可以通过唯一的id进行区分。
基于以上这两点假设,使得虚拟DOM的Diff算法的复杂度从O(n^3)降到了O(n)。
这里我们借用React’s diff algorithm中的一张图来简单说明一下:
当页面的数据发生变化时,Diff算法只会比较同一层级的节点:
如果节点类型不同,直接干掉前面的节点,再创建并插入新的节点,不会再比较这个节点以后的子节点了。
如果节点类型相同,则会重新设置该节点的属性,从而实现节点的更新。
当某一层有很多相同的节点时,也就是列表节点时,Diff算法的更新过程默认情况下也是遵循以上原则。
比如一下这个情况:
我们希望可以在B和C之间加一个F,Diff算法默认执行起来是这样的:
即把C更新成F,D更新成C,E更新成D,最后再插入E,是不是很没有效率?
所以我们需要使用key来给每个节点做一个唯一标识,Diff算法就可以正确的识别此节点,找到正确的位置区插入新的节点。
所以一句话,key的作用主要是为了高效的更新虚拟DOM。另外vue中在使用相同标签名元素的过渡切换时,也会使用到key属性,其目的也是为了让vue可以区分它们,否则vue只会替换其内部属性而不会触发过渡效果。
之前章节介绍了VNode
如何生成真实Dom
,这只是patch
内首次渲染做的事,完成了一小部分功能而已,而它做的最重要的事情是当响应式触发时,让页面的重新渲染这一过程能高效完成。其实页面的重新渲染完全可以使用新生成的Dom
去整个替换掉旧的Dom
,然而这么做比较低效,所以就借助接下来将介绍的diff
比较算法来完成。
diff
算法做的事情是比较VNode
和oldVNode
,再以VNode
为标准的情况下在oldVNode
上做小的改动,完成VNode
对应的Dom
渲染。
回到之前_update
方法的实现,这个时候就会走到else
的逻辑了:
Vue.prototype._update = function(vnode) {
const vm = this
const prevVnode = vm._vnode
vm._vnode = vnode // 缓存为之前vnode
if(!prevVnode) { // 首次渲染
vm.$el = vm.__patch__(vm.$el, vnode)
} else { // 重新渲染
vm.$el = vm.__patch__(prevVnode, vnode)
}
}
既然是在现有的VNode
上修修补补来达到重新渲染的目的,所以无非是做三件事情:
创建新增节点
删除废弃节点
更新已有节点
接下来我们将介绍以上三种情况分别什么情况下会遇到。
新增节点两种情况下会遇到:
VNode
中有的节点而oldVNode
没有
VNode
中有的节点而oldVNode
中没有,最明显的场景就是首次渲染了,这个时候是没有oldVNode
的,所以将整个VNode
渲染为真实Dom
插入到根节点之内即可,这一详细过程之前章节有详细说明。VNode
和oldVNode
完全不同
VNode
和oldVNode
不是同一个节点时,直接会将VNode
创建为真实Dom
,插入到旧节点的后面,这个时候旧节点就变成了废弃节点,移除以完成替换过程。判断两个节点是否为同一个节点,内部是这样定义的:
function sameVnode (a, b) { // 是否是相同的VNode节点
return (
a.key === b.key && ( // 如平时v-for内写的key
(
a.tag === b.tag && // tag相同
a.isComment === b.isComment && // 注释节点
isDef(a.data) === isDef(b.data) && // 都有data属性
sameInputType(a, b) // 相同的input类型
) || (
isTrue(a.isAsyncPlaceholder) && // 是异步占位符节点
a.asyncFactory === b.asyncFactory && // 异步工厂方法
isUndef(b.asyncFactory.error)
)
)
)
}
上面创建新增节点的第二种情况以略有提及,比较vnode
和oldVnode
,如果根节点不相同就将Vnode
整颗渲染为真实Dom
,插入到旧节点的后面,最后删除掉已经废弃的旧节点即可:
在patch
方法内将创建好的Dom
插入到废弃节点后面之后:
if (isDef(parentElm)) { // 在它们的父节点内删除旧节点
removeVnodes(parentElm, [oldVnode], 0, 0)
}
-------------------------------------------------------------
function removeVnodes (parentElm, vnodes, startIdx, endIdx) {
for (; startIdx <= endIdx; ++startIdx) {
const ch = vnodes[startIdx]
if (isDef(ch)) {
removeNode(ch.elm)
}
}
} // 移除从startIdx到endIdx之间的内容
------------------------------------------------------------
function removeNode(el) { // 单个节点移除
const parent = nodeOps.parentNode(el)
if(isDef(parent)) {
nodeOps.removeChild(parent, el)
}
}
这个才是diff
算法的重点,当两个节点是相同的节点时,这个时候就需要找出它们的不同之处,比较它们主要是使用patchVnode
方法,这个方法里面主要也是处理几种分支情况:
都是静态节点
function patchVnode(oldVnode, vnode) {
if (oldVnode === vnode) { // 完全一样
return
}
const elm = vnode.elm = oldVnode.elm
if(isTrue(vnode.isStatic) && isTrue(oldVnode.isStatic)) {
vnode.componentInstance = oldVnode.componentInstance
return // 都是静态节点,跳过
}
...
}
什么是静态节点了?这是编译阶段做的事情,它会找出模板中的静态节点并做上标记(isStatic
为true
),例如:
{
{title}}
新鲜食材
这里的h2
标签就不是静态节点,因为是根据插值变化的,而p
标签就是静态节点,因为不会改变。如果都是静态节点就跳过这次比较,这也是编译阶段为diff
比对做的优化。
vnode
节点没有文本属性
function patchVnode(oldVnode, vnode) {
const elm = vnode.elm = oldVnode.elm
const oldCh = oldVnode.children
const ch = vnode.children
if (isUndef(vnode.text)) { // vnode没有text属性
if (isDef(oldCh) && isDef(ch)) { // // 都有children
if (oldCh !== ch) { // 且children不同
updateChildren(elm, oldCh, ch) // 更新子节点
}
}
else if (isDef(ch)) { // 只有vnode有children
if (isDef(oldVnode.text)) { // oldVnode有文本节点
nodeOps.setTextContent(elm, '') // 设置oldVnode文本为空
}
addVnodes(elm, null, ch, 0, ch.length - 1)
// 往oldVnode空的标签内插入vnode的children的真实dom
}
else if (isDef(oldCh)) { // 只有oldVnode有children
removeVnodes(elm, oldCh, 0, oldCh.length - 1) // 全部移除
}
else if (isDef(oldVnode.text)) { // oldVnode有文本节点
nodeOps.setTextContent(elm, '') // 设置为空
}
}
else { vnode有text属性
...
}
...
如果vnode
没有文本节点,又会有接下来的四个分支:
1. 都有children
且不相同
updateChildren
方法更详细的比对它们的children
,如果说更新已有节点是patch
的核心,那这里的更新children
就是核心中的核心,这个之后使用流程图的方式仔仔细细说明。2. 只有vnode
有children
oldVnode
要么是一个空标签或者是文本节点,如果是文本节点就清空文本节点,然后将vnode
的children
创建为真实Dom
后插入到空标签内。3. 只有oldVnode
有children
vnode
为标准的,所以vnode
没有的东西,oldVnode
内就是废弃节点,需要删除掉。4. 只有oldVnode
有文本
oldVnode
有而vnode
没有的,清空或移除即可。
vnode
节点有文本属性
function patchVnode(oldVnode, vnode, insertedVnodeQueue) {
const elm = vnode.elm = oldVnode.elm
const oldCh = oldVnode.children
const ch = vnode.children
if (isUndef(vnode.text)) { // vnode没有text属性
...
} else if(oldVnode.text !== vnode.text) { // vnode有text属性且不同
nodeOps.setTextContent(elm, vnode.text) // 设置文本
}
...
还是那句话,以vnode
为标准,所以vnode
有文本节点的话,无论oldVnode
是什么类型节点,直接设置为vnode
内的文本即可。至此,整个diff
比对的大致过程就算是说明完毕了,我们还是以一张流程图来理清思路:
更新子节点示例:
- {
{item.name}}
export default {
data() {
return {
list: [{
id: 'a1',name: 'A'}, {
id: 'b2',name: 'B'}, {
id: 'c3',name: 'C'}, {
id: 'd4',name: 'D'}
]
}
},
mounted() {
setTimeout(() => {
this.list.sort(() => Math.random() - .5)
.unshift({id: 'e5', name: 'E'})
}, 1000)
}
}
上述代码中首先渲染一个列表,然后将其随机打乱顺序后并添加一项到列表最前面,这个时候就会触发该组件更新子节点的逻辑,之前也会有一些其他的逻辑,这里只用关注更新子节点相关,来看下它怎么更新Dom
的:
function updateChildren(parentElm, oldCh, newCh) {
let oldStartIdx = 0 // 旧第一个下标
let oldStartVnode = oldCh[0] // 旧第一个节点
let oldEndIdx = oldCh.length - 1 // 旧最后下标
let oldEndVnode = oldCh[oldEndIdx] // 旧最后节点
let newStartIdx = 0 // 新第一个下标
let newStartVnode = newCh[0] // 新第一个节点
let newEndIdx = newCh.length - 1 // 新最后下标
let newEndVnode = newCh[newEndIdx] // 新最后节点
let oldKeyToIdx // 旧节点key和下标的对象集合
let idxInOld // 新节点key在旧节点key集合里的下标
let vnodeToMove // idxInOld对应的旧节点
let refElm // 参考节点
checkDuplicateKeys(newCh) // 检测newVnode的key是否有重复
while(oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) { // 开始遍历children
if (isUndef(oldStartVnode)) { // 跳过因位移留下的undefined
oldStartVnode = oldCh[++oldStartIdx]
} else if (isUndef(oldEndVnode)) { // 跳过因位移留下的undefine
oldEndVnode = oldCh[--oldEndIdx]
}
else if(sameVnode(oldStartVnode, newStartVnode)) { // 比对新第一和旧第一节点
patchVnode(oldStartVnode, newStartVnode) // 递归调用
oldStartVnode = oldCh[++oldStartIdx] // 旧第一节点和下表重新标记后移
newStartVnode = newCh[++newStartIdx] // 新第一节点和下表重新标记后移
}
else if (sameVnode(oldEndVnode, newEndVnode)) { // 比对旧最后和新最后节点
patchVnode(oldEndVnode, newEndVnode) // 递归调用
oldEndVnode = oldCh[--oldEndIdx] // 旧最后节点和下表重新标记前移
newEndVnode = newCh[--newEndIdx] // 新最后节点和下表重新标记前移
}
else if (sameVnode(oldStartVnode, newEndVnode)) { // 比对旧第一和新最后节点
patchVnode(oldStartVnode, newEndVnode) // 递归调用
nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))
// 将旧第一节点右移到最后,视图立刻呈现
oldStartVnode = oldCh[++oldStartIdx] // 旧开始节点被处理,旧开始节点为第二个
newEndVnode = newCh[--newEndIdx] // 新最后节点被处理,新最后节点为倒数第二个
}
else if (sameVnode(oldEndVnode, newStartVnode)) { // 比对旧最后和新第一节点
patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue) // 递归调用
nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
// 将旧最后节点左移到最前面,视图立刻呈现
oldEndVnode = oldCh[--oldEndIdx] // 旧最后节点被处理,旧最后节点为倒数第二个
newStartVnode = newCh[++newStartIdx] // 新第一节点被处理,新第一节点为第二个
}
else { // 不包括以上四种快捷比对方式
if (isUndef(oldKeyToIdx)) {
oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
// 获取旧开始到结束节点的key和下表集合
}
idxInOld = isDef(newStartVnode.key) // 获取新节点key在旧节点key集合里的下标
? oldKeyToIdx[newStartVnode.key]
: findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)
if (isUndef(idxInOld)) { // 找不到对应的下标,表示新节点是新增的,需要创建新dom
createElm(
newStartVnode,
insertedVnodeQueue,
parentElm,
oldStartVnode.elm,
false,
newCh,
newStartIdx
)
}
else { // 能找到对应的下标,表示是已有的节点,移动位置即可
vnodeToMove = oldCh[idxInOld] // 获取对应已有的旧节点
patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue)
oldCh[idxInOld] = undefined
nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)
}
newStartVnode = newCh[++newStartIdx] // 新开始下标和节点更新为第二个节点
}
}
...
}
函数内首先会定义一堆let
定义的变量,这些变量是随着while
循环体而改变当前值的,循环的退出条件为只要新旧节点列表有一个处理完就退出,看着循环体代码挺复杂,其实它只是做了三件事,明白了哪三件事再看循环体,会发现其实并不复杂:
1. 跳过undefined
为什么会有undefined
,之后的流程图会说明清楚。这里只要记住,如果旧开始节点为undefined
,就后移一位;如果旧结束节点为undefined
,就前移一位。
2. 快捷查找
首先会尝试四种快速查找的方式,如果不匹配,再做进一步处理:
如果匹配,表示它们位置都是对的,Dom
不用改,就将新旧节点开始的下标往后移一位即可。 * 2.2 旧结束和新结束节点比对
如果匹配,也表示它们位置是对的,Dom
不用改,就将新旧节点结束的下标前移一位即可。 * 2.3 旧开始和新结束节点比对
如果匹配,位置不对需要更新Dom
视图,将旧开始节点对应的真实Dom
插入到最后一位,旧开始节点下标后移一位,新结束节点下标前移一位。 * 2.4 旧结束和新开始节点比对
如果匹配,位置不对需要更新Dom
视图,将旧结束节点对应的真实Dom
插入到旧开始节点对应真实Dom
的前面,旧结束节点下标前移一位,新开始节点下标后移一位。
3. key值查找
那就说明是已有的节点,只是位置不对,那就移动节点位置即可。 * 3.2 如果和已有key值不匹配
再已有的key
值集合内找不到,那就说明是新的节点,那就创建一个对应的真实Dom
节点,插入到旧开始节点对应的真实Dom
前面即可。
这么说并不太好理解,结合之前的示例,根据以下的流程图将会明白很多:
↑ 示例的初始状态就是这样了,之前定义的下标以及对应的节点就是start
和end
标记。
↑ 首先进行之前说明两两四次的快捷比对,找不到后通过旧节点的key
值列表查找,并没有找到说明E
是新增的节点,创建对应的真实Dom
,插入到旧节点里start
对应真实Dom
的前面,也就是A
的前面,已经处理完了一个,新start
位置后移一位。
↑ 接着开始处理第二个,还是首先进行快捷查找,没有后进行key
值列表查找。发现是已有的节点,只是位置不对,那么进行插入操作,参考节点还是A
节点,将原来旧节点C
设置为undefined
,这里之后会跳过它。又处理完了一个节点,新start
后移一位。
↑ 再处理第三个节点,通过快捷查找找到了,是新开始节点对应旧开始节点,Dom
位置是对的,新start
和旧start
都后移一位。
↑ 接着处理的第四个节点,通过快捷查找,这个时候先满足了旧开始节点和新结束节点的匹配,Dom
位置是不对的,插入节点到最后位置,最后将新end
前移一位,旧start
后移一位。
↑ 处理最后一个节点,首先会执行跳过undefined
的逻辑,然后再开始快捷比对,匹配到的是新开始节点和旧开始节点,它们各自start
后移一位,这个时候就会跳出循环了。接着看下最后的收尾代码:
function updateChildren(parentElm, oldCh, newCh) {
let oldStartIdx = 0
...
while(oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
...
}
if (oldStartIdx > oldEndIdx) { // 如果旧节点列表先处理完,处理剩余新节点
refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm
addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue) // 添加
}
else if (newStartIdx > newEndIdx) { // 如果新节点列表先处理完,处理剩余旧节点
removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx) // 删除废弃节点
}
}
我们之前的示例刚好是新旧节点列表同时处理完退出的循环,这里是退出循环后为还有没有处理完的节点,做不同的处理:
以新节点列表为标准,如果是新节点列表处理完,旧列表还有没被处理的废弃节点,删除即可;如果是旧节点先处理完,新列表里还有没被使用的节点,创建真实Dom
并插入到视图即可。这就是整个diff
算法过程了,大家可以对比之前的递归流程图再看一遍,相信思路会清晰很多。
最后按照惯例我们还是以一道vue
可能会被问到的面试题作为本章的结束~
面试官微笑而又不失礼貌的问道:
v-for
里建议为每一项绑定key
,而且最好具有唯一性,而不建议使用index
?怼回去:
diff
比对内部做更新子节点时,会根据oldVnode
内没有处理的节点得到一个key
值和下标对应的对象集合,为的就是当处理vnode
每一个节点时,能快速查找该节点是否是已有的节点,从而提高整个diff
比对的性能。如果是一个动态列表,key
值最好能保持唯一性,但像轮播图那种不会变更的列表,使用index
也是没问题的。