在官网中对key的解释:
key 特殊 attribute 主要用做 Vue 的虚拟 DOM 算法的提示,以在比对新旧节点组时辨识 VNodes。如果不使用 key,Vue 会使用一种算法来最小化元素的移动并且尽可能尝试就地修改/复用相同类型元素。而使用 key 时,它会基于 key 的顺序变化重新排列元素,并且 key 不再存在的元素将始终被移除/销毁。有相同父元素的子元素必须有唯一的 key。重复的 key 会造成渲染错误。
- diff算法的实现基于两个假设:两个组件产生类似的DOM结构;不同的组件产生不同的DOM结构。
- 统一层级的一组节点,他们可以通过唯一的id进行区分。
- 基于以上两点,使得虚拟DOM的DIFF算法的复杂度从O(n^3)降到了O(n)
key的使用场景
-
在元素切换中: 使用 v-if 和 v-else 切换相同的组件时。vue 为了高效地渲染元素,默认会复用已有的元素而不是重新渲染。使用不同的key时,会区别不同的组件。
-
在transition过渡中: 使用 v-if 和 v-else 切换时相同的节点(如两个简单的div时),不添加key时,vue 为了效率,会使用同一个元素,只替换其中的内容,由于没有元素的插入与删除,所以没有过度效果。添加了key,vue就不会复用元素了,正常显示过度效果。
-
在渲染列表中:
- Vue 更新用 v-for 渲染的元素列表时,由于无法将之前渲染的元素和新的数据项对应,默认采用“就地更新”的策略。如果数据的顺序改变,Vue 不会移动 DOM 元素来匹配数据的顺序,而是更新每个 DOM 元素的内容,确保渲染内容和在数据中所有的位置一致。
- 为列表的每一个元素添加 key,列表数据更新顺序时,会根据key找到已渲染的元素进行复用,对顺序不匹配的元素进行位置的调整,更高效。
组件的更新
当数据发生变化时,会触发渲染 watcher 的回调函数,进而执行组件的更新过程。
在组件的更新过程中会对生成的虚拟 DOM 执行 patch方法,代码在 src\core\vdom\patch.js -patch() 中。其中在比较两个节点时,会通过sameVnode方法判断判断是否时相同的 VNode 来决定不同的更新逻辑:
function sameVnode (a, b) {
return (
a.key === b.key &&
a.asyncFactory === b.asyncFactory && (
(
a.tag === b.tag &&
a.isComment === b.isComment &&
isDef(a.data) === isDef(b.data) &&
sameInputType(a, b)
) || (
isTrue(a.isAsyncPlaceholder) &&
isUndef(b.asyncFactory.error)
)
)
)
}
其中,会根据key进行判断!!
- 如果新旧节点不相同时,会替换已存在的节点,分为3步:
- 创建新节点
- 更新父节点的占位符节点
- 删除旧节点
- 如果新旧节点相同(当不设置key的时候,同类型节点也会走相同节点逻辑!),在 patch 方法,会调用 patchVnode 方法,
...........
if (!isRealElement && sameVnode(oldVnode, vnode)) {
// patch existing root node
patchVnode(oldVnode, vnode, insertedVnodeQueue, null, null, removeOnly)
}
...........
源码在 src\core\vdom\patch.js -patchVnode() 中:
function patchVnode (
oldVnode,
vnode,
insertedVnodeQueue,
ownerArray,
index,
removeOnly
) {
if (oldVnode === vnode) {
return
}
if (isDef(vnode.elm) && isDef(ownerArray)) {
// clone reused vnode
vnode = ownerArray[index] = cloneVNode(vnode)
}
const elm = vnode.elm = oldVnode.elm
if (isTrue(oldVnode.isAsyncPlaceholder)) {
if (isDef(vnode.asyncFactory.resolved)) {
hydrate(oldVnode.elm, vnode, insertedVnodeQueue)
} else {
vnode.isAsyncPlaceholder = true
}
return
}
// reuse element for static trees.
// note we only do this if the vnode is cloned -
// if the new node is not cloned it means the render functions have been
// reset by the hot-reload-api and we need to do a proper re-render.
if (isTrue(vnode.isStatic) &&
isTrue(oldVnode.isStatic) &&
vnode.key === oldVnode.key &&
(isTrue(vnode.isCloned) || isTrue(vnode.isOnce))
) {
vnode.componentInstance = oldVnode.componentInstance
return
}
let i
const data = vnode.data
if (isDef(data) && isDef(i = data.hook) && isDef(i = i.prepatch)) {
i(oldVnode, vnode)
}
const oldCh = oldVnode.children
const ch = vnode.children
if (isDef(data) && isPatchable(vnode)) {
for (i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode)
if (isDef(i = data.hook) && isDef(i = i.update)) i(oldVnode, vnode)
}
if (isUndef(vnode.text)) {
if (isDef(oldCh) && isDef(ch)) {
if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
} else if (isDef(ch)) {
if (process.env.NODE_ENV !== 'production') {
checkDuplicateKeys(ch)
}
if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '')
addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
} else if (isDef(oldCh)) {
removeVnodes(oldCh, 0, oldCh.length - 1)
} else if (isDef(oldVnode.text)) {
nodeOps.setTextContent(elm, '')
}
} else if (oldVnode.text !== vnode.text) {
nodeOps.setTextContent(elm, vnode.text)
}
if (isDef(data)) {
if (isDef(i = data.hook) && isDef(i = i.postpatch)) i(oldVnode, vnode)
}
}
patchVnode 的作用就是把新的 vnode patch 到旧的 vnode 上,我们只关心核心逻辑,分为四步:
- 执行 prepatch 钩子函数
// 执行 prepatch 钩子函数--
let i
const data = vnode.data
if (isDef(data) && isDef(i = data.hook) && isDef(i = i.prepatch)) {
i(oldVnode, vnode)
}
// ------------------------
当更新的 vnode 是一个组件的时候,会执行 prepatch 方法, 在源码 src\core\vdom\create-component.js 中:
const componentVNodeHooks = {
init (vnode: VNodeWithData, hydrating: boolean): ?boolean {
if (
vnode.componentInstance &&
!vnode.componentInstance._isDestroyed &&
vnode.data.keepAlive
) {
// kept-alive components, treat as a patch
const mountedNode: any = vnode // work around flow
componentVNodeHooks.prepatch(mountedNode, mountedNode)
} else {
const child = vnode.componentInstance = createComponentInstanceForVnode(
vnode,
activeInstance
)
child.$mount(hydrating ? vnode.elm : undefined, hydrating)
}
},
prepatch (oldVnode: MountedComponentVNode, vnode: MountedComponentVNode) {
const options = vnode.componentOptions
const child = vnode.componentInstance = oldVnode.componentInstance
updateChildComponent(
child,
options.propsData, // updated props
options.listeners, // updated listeners
vnode, // new parent vnode
options.children // new children
)
},
insert (vnode: MountedComponentVNode) {
const { context, componentInstance } = vnode
if (!componentInstance._isMounted) {
componentInstance._isMounted = true
callHook(componentInstance, 'mounted')
}
if (vnode.data.keepAlive) {
if (context._isMounted) {
// vue-router#1212
// During updates, a kept-alive component's child components may
// change, so directly walking the tree here may call activated hooks
// on incorrect children. Instead we push them into a queue which will
// be processed after the whole patch process ended.
queueActivatedComponent(componentInstance)
} else {
activateChildComponent(componentInstance, true /* direct */)
}
}
},
destroy (vnode: MountedComponentVNode) {
const { componentInstance } = vnode
if (!componentInstance._isDestroyed) {
if (!vnode.data.keepAlive) {
componentInstance.$destroy()
} else {
deactivateChildComponent(componentInstance, true /* direct */)
}
}
}
}
其中,我们关注 prepatch 方法,
prepatch (oldVnode: MountedComponentVNode, vnode: MountedComponentVNode) {
const options = vnode.componentOptions
const child = vnode.componentInstance = oldVnode.componentInstance
updateChildComponent(
child,
options.propsData, // updated props
options.listeners, // updated listeners
vnode, // new parent vnode
options.children // new children
)
},
prepatch 方法就是拿到新的 vnode 的组件配置及组件实例,去执行 updateChildComponent 方法,该方法在 src\core\instance\lifecycle.js -updateChildComponent() 中:
export function updateChildComponent (
vm: Component,
propsData: ?Object,
listeners: ?Object,
parentVnode: MountedComponentVNode,
renderChildren: ?Array<VNode>
) {
if (process.env.NODE_ENV !== 'production') {
isUpdatingChildComponent = true
}
// determine whether component has slot children
// we need to do this before overwriting $options._renderChildren.
// check if there are dynamic scopedSlots (hand-written or compiled but with
// dynamic slot names). Static scoped slots compiled from template has the
// "$stable" marker.
const newScopedSlots = parentVnode.data.scopedSlots
const oldScopedSlots = vm.$scopedSlots
const hasDynamicScopedSlot = !!(
(newScopedSlots && !newScopedSlots.$stable) ||
(oldScopedSlots !== emptyObject && !oldScopedSlots.$stable) ||
(newScopedSlots && vm.$scopedSlots.$key !== newScopedSlots.$key) ||
(!newScopedSlots && vm.$scopedSlots.$key)
)
// Any static slot children from the parent may have changed during parent's
// update. Dynamic scoped slots may also have changed. In such cases, a forced
// update is necessary to ensure correctness.
const needsForceUpdate = !!(
renderChildren || // has new static slots
vm.$options._renderChildren || // has old static slots
hasDynamicScopedSlot
)
vm.$options._parentVnode = parentVnode
vm.$vnode = parentVnode // update vm's placeholder node without re-render
if (vm._vnode) { // update child tree's parent
vm._vnode.parent = parentVnode
}
vm.$options._renderChildren = renderChildren
// update $attrs and $listeners hash
// these are also reactive so they may trigger child update if the child
// used them during render
vm.$attrs = parentVnode.data.attrs || emptyObject
vm.$listeners = listeners || emptyObject
// update props
if (propsData && vm.$options.props) {
toggleObserving(false)
const props = vm._props
const propKeys = vm.$options._propKeys || []
for (let i = 0; i < propKeys.length; i++) {
const key = propKeys[i]
const propOptions: any = vm.$options.props // wtf flow?
props[key] = validateProp(key, propOptions, propsData, vm)
}
toggleObserving(true)
// keep a copy of raw propsData
vm.$options.propsData = propsData
}
// update listeners
listeners = listeners || emptyObject
const oldListeners = vm.$options._parentListeners
vm.$options._parentListeners = listeners
updateComponentListeners(vm, listeners, oldListeners)
// resolve slots + force update if has children
if (needsForceUpdate) {
vm.$slots = resolveSlots(renderChildren, parentVnode.context)
vm.$forceUpdate()
}
if (process.env.NODE_ENV !== 'production') {
isUpdatingChildComponent = false
}
}
updateChildComponent 方法逻辑用于更新 vnode 对应实例的一系列属性,包括占位符 vm.$vnode 的更新、slot、listeners、props更新等。
- 执行 update 钩子函数
if (isDef(data) && isPatchable(vnode)) {
for (i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode)
if (isDef(i = data.hook) && isDef(i = i.update)) i(oldVnode, vnode)
}
在执行完 新vnode的prepatch钩子函数后,会执行所有module的update钩子函数及用户自定义的update钩子函数。
- 完成 patch 过程
const oldCh = oldVnode.children
const ch = vnode.children
if (isDef(data) && isPatchable(vnode)) {
for (i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode)
if (isDef(i = data.hook) && isDef(i = i.update)) i(oldVnode, vnode)
}
if (isUndef(vnode.text)) {
if (isDef(oldCh) && isDef(ch)) {
if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue, removeOnly)
} else if (isDef(ch)) {
if (process.env.NODE_ENV !== 'production') {
checkDuplicateKeys(ch)
}
if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '')
addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
} else if (isDef(oldCh)) {
removeVnodes(oldCh, 0, oldCh.length - 1)
} else if (isDef(oldVnode.text)) {
nodeOps.setTextContent(elm, '')
}
} else if (oldVnode.text !== vnode.text) {
nodeOps.setTextContent(elm, vnode.text)
}
一、如果 vnode 是文本节点,且新旧文本不同,则直接替换文本内容;二、如果不是文本节点,则判断他们的子节点,并分为了几种情况:
- oldCh 与 ch 都存在且不相同时,使用 updateChildren 函数来更新子节点。(重点!!!)
- 如果只有 ch 存在,表示旧节点不需要了。如果旧节点是文本则先将该节点的文本清除,然后通过 addVnodes 将 ch 批量插入到新节点 elm 下。
- 如果只有 oldCh 存在,表示更新的是空节点,则需要将旧的节点,则需要将旧的节点通过 removeVnodes 全部清除。
- 当只有旧节点是文字节点的时候,则清除其节点文本内容。
- 执行 postpatch 钩子函数
if (isDef(data)) {
if (isDef(i = data.hook) && isDef(i = i.postpatch)) i(oldVnode, vnode)
}
执行完 patch 过程后,会执行 postpatch 钩子函数,它是组件自定义的钩子函数,有则执行。
在整个 patchVnode 过程中,最复杂的就是 updateChildren 方法!!!
updateChildren
在源码 src\core\vdom\patch.js -updateChildren() 中:
function updateChildren (parentElm, oldCh, newCh, insertedVnodeQueue, removeOnly) {
let oldStartIdx = 0
let newStartIdx = 0
let oldEndIdx = oldCh.length - 1
let oldStartVnode = oldCh[0]
let oldEndVnode = oldCh[oldEndIdx]
let newEndIdx = newCh.length - 1
let newStartVnode = newCh[0]
let newEndVnode = newCh[newEndIdx]
let oldKeyToIdx, idxInOld, vnodeToMove, refElm
// removeOnly is a special flag used only by <transition-group>
// to ensure removed elements stay in correct relative positions
// during leaving transitions
const canMove = !removeOnly
if (process.env.NODE_ENV !== 'production') {
checkDuplicateKeys(newCh)
}
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
if (isUndef(oldStartVnode)) {
oldStartVnode = oldCh[++oldStartIdx] // Vnode has been moved left
} else if (isUndef(oldEndVnode)) {
oldEndVnode = oldCh[--oldEndIdx]
} else if (sameVnode(oldStartVnode, newStartVnode)) {
patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
oldStartVnode = oldCh[++oldStartIdx]
newStartVnode = newCh[++newStartIdx]
} else if (sameVnode(oldEndVnode, newEndVnode)) {
patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
oldEndVnode = oldCh[--oldEndIdx]
newEndVnode = newCh[--newEndIdx]
} else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm, nodeOps.nextSibling(oldEndVnode.elm))
oldStartVnode = oldCh[++oldStartIdx]
newEndVnode = newCh[--newEndIdx]
} else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left
patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
oldEndVnode = oldCh[--oldEndIdx]
newStartVnode = newCh[++newStartIdx]
} else {
if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
idxInOld = isDef(newStartVnode.key)
? oldKeyToIdx[newStartVnode.key]
: findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)
if (isUndef(idxInOld)) { // New element
createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
} else {
vnodeToMove = oldCh[idxInOld]
if (sameVnode(vnodeToMove, newStartVnode)) {
patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
oldCh[idxInOld] = undefined
canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)
} else {
// same key but different element. treat as new element
createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
}
}
newStartVnode = newCh[++newStartIdx]
}
}
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(oldCh, oldStartIdx, oldEndIdx)
}
}
updateChildren 的逻辑比较复杂,大致逻辑是:
- 将新旧节点的子节点,进行 “头头、尾尾、头尾、尾头” 4种方式进行比较。
- 如果上述方法没有匹配到,如果设置了key,就会用key进行比较。
- 当其中两个能匹配上那么真实dom中的相应节点会移到Vnode相应的位置。
- 如果是oldS和E匹配上了,那么真实dom中的第一个节点会移到最后
- 如果是oldE和S匹配上了,那么真实dom中的最后一个节点会移到最前,匹配上的两个指针向中间移动
- 如果四种匹配没有一对是成功的,那么遍历oldChild,S挨个和他们匹配,匹配成功就在真实dom中将成功的节点移到最前面,如果依旧没有成功的,那么将S对应的节点插入到dom中对应的oldS位置,oldS和S指针向中间移动。
- 在比较的过程中,使用双指针方式,指针会往中间靠近,一旦startIndex > endIndex,说明至少有一个已经遍历完了,比较就会结束,对剩下的vnode执行添加或删除vnode逻辑。
在这些节点 sameVnode(oldStartVnode, newStartVnode) 匹配成功后,就会执行 patchVnode 了,层层递归下去,直至 oldVnode 和 Vnode 中所有的子节点对比完。也将 DOM 的所有补丁打好了。
key 在 diff 算法中的作用
通过上述的分析,我们可以知道,使用 key 我们可以准确的识别出相同节点,进入到 patchVnode 逻辑中,在 updateChildren 中可以进行节点的对比。主要用于 sameVnode 方法中
function sameVnode (a, b) {
return (
a.key === b.key &&
a.asyncFactory === b.asyncFactory && (
(
a.tag === b.tag &&
a.isComment === b.isComment &&
isDef(a.data) === isDef(b.data) &&
sameInputType(a, b)
) || (
isTrue(a.isAsyncPlaceholder) &&
isUndef(b.asyncFactory.error)
)
)
)
}
在没有设置key的时候,key 就是 undefined,在比较的时候key就会是相等的,被永远认为是相同节点。一直走 patchVnode 逻辑,进行相同节点的 patch updateChildern 等过程。和有key的情况是一样的。但是最终 DOM 的操作次数是不一样的,会产生过多的 DOM 操作, 浪费性能。
举个例子:
有 A B C D E 五个节点,向B后插入一个节点 F。
- 在不设置key的情况下:
// 旧
A B C D E
// 新
A B F C D + E
在 patch 的过程中,A B 两个新旧节点对比的时候是相同节点被跳过,在对比 C 和 F 及之后的节点时,由于patch是不同节点,进行三次DOM更新操作(F,C,D),和一次 DOM 创建插入操作(E)。
- 在设置 key 的情况: 结果是直接在C前面插入 F
// 旧
A B C D E
// 新(直接插入F)
A B + F + C D E
我们来看下整个patch过程的逻辑:
// 首次循环patch A
A B C D E
A B F C D E
// 第2次循环patch B
B C D E
B F C D E
// 第3次循环patch E (diff 算法的优化)
C D E
F C D E
// 第4次循环patch D (diff 算法的优化)
C D
F C D
// 第5次循环patch C (diff 算法的优化)
C
F C
// 此时 oldCh 全部处理完毕,newCh中剩下的F,创建F并插入到C前面
整个过程,循环patch了5次,但实际只执行了一次DOM操作,在数据量大的时候大大提高了性能。
- key的作用是为了更高效的更新虚拟DOM,其原理是vue在 patch 过程中通过 key 可以精准判断两个节点是否是同一个,从而避免频繁更新不同元素,使得整个 patch 过程更加高效,减少 DOM 操作量,提高性能。
- 如果不设置key还可能在列表更新时引发一些隐蔽bug
- vue 中在使用相同签名元素的过度切换时,也会用到 key, 其目的是为了让 vue 可以区分他们,否则 vue 只会替换其内部属性不会触发过度效果。
v-for 不建议使用 index 作为 key
简单描述下,当使用 index 作为 key 的时候,使用 v-for 更新已渲染的元素列表时,默认用“就地复用”策略;列表数据改变的时候,它会根据key值去进行patch。
- 在数据顺序变化时,key 所做的优化都会失效,新旧节点的key一样,则还会进行patchVnode 操作,过程中会检查props是否变更,通过修改props的值触发响应式的dep.notify,触发子组件的重新渲染等一套很复杂的逻辑。造成性能浪费。
- 在节点删除时,由于index始终从 0 开始,导致会复用相同index的节点,删除旧节点中多出来的节点,产生错乱。
- 使用随机数作为 key,在进行diff的时候,由于每次 key 都是不一样,无法命中优化策略,会进入 key 的详细对比中,简单来说,利用旧节点的key -> index 建立一个映射表,然后用新节点去匹配,没找到的话,就会新建一个节点。这样就会造成“全量更新”的后果,浪费性能!!
详细过程可参考: