Vue 中 key 的作用和工作原理

3,288 阅读8分钟

在官网中对key的解释:

key 特殊 attribute 主要用做 Vue 的虚拟 DOM 算法的提示,以在比对新旧节点组时辨识 VNodes。如果不使用 key,Vue 会使用一种算法来最小化元素的移动并且尽可能尝试就地修改/复用相同类型元素。而使用 key 时,它会基于 key 的顺序变化重新排列元素,并且 key 不再存在的元素将始终被移除/销毁。有相同父元素的子元素必须有唯一的 key。重复的 key 会造成渲染错误。

  1. diff算法的实现基于两个假设:两个组件产生类似的DOM结构;不同的组件产生不同的DOM结构。
  2. 统一层级的一组节点,他们可以通过唯一的id进行区分。
  3. 基于以上两点,使得虚拟DOM的DIFF算法的复杂度从O(n^3)降到了O(n)

key的使用场景

  1. 在元素切换中: 使用 v-if 和 v-else 切换相同的组件时。vue 为了高效地渲染元素,默认会复用已有的元素而不是重新渲染。使用不同的key时,会区别不同的组件。

  2. 在transition过渡中: 使用 v-if 和 v-else 切换时相同的节点(如两个简单的div时),不添加key时,vue 为了效率,会使用同一个元素,只替换其中的内容,由于没有元素的插入与删除,所以没有过度效果。添加了key,vue就不会复用元素了,正常显示过度效果。

  3. 在渲染列表中:

  • 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进行判断!!

  1. 如果新旧节点不相同时,会替换已存在的节点,分为3步:
  • 创建新节点
  • 更新父节点的占位符节点
  • 删除旧节点
  1. 如果新旧节点相同(当不设置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 是文本节点,且新旧文本不同,则直接替换文本内容;二、如果不是文本节点,则判断他们的子节点,并分为了几种情况:

  1. oldCh 与 ch 都存在且不相同时,使用 updateChildren 函数来更新子节点。(重点!!!)
  2. 如果只有 ch 存在,表示旧节点不需要了。如果旧节点是文本则先将该节点的文本清除,然后通过 addVnodes 将 ch 批量插入到新节点 elm 下。
  3. 如果只有 oldCh 存在,表示更新的是空节点,则需要将旧的节点,则需要将旧的节点通过 removeVnodes 全部清除。
  4. 当只有旧节点是文字节点的时候,则清除其节点文本内容。
  • 执行 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。

  1. 在不设置key的情况下:
// 旧
A B C D E
// 新
A B F C D + E

在 patch 的过程中,A B 两个新旧节点对比的时候是相同节点被跳过,在对比 C 和 F 及之后的节点时,由于patch是不同节点,进行三次DOM更新操作(F,C,D),和一次 DOM 创建插入操作(E)。

  1. 在设置 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操作,在数据量大的时候大大提高了性能。

  1. key的作用是为了更高效的更新虚拟DOM,其原理是vue在 patch 过程中通过 key 可以精准判断两个节点是否是同一个,从而避免频繁更新不同元素,使得整个 patch 过程更加高效,减少 DOM 操作量,提高性能。
  2. 如果不设置key还可能在列表更新时引发一些隐蔽bug
  3. 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 建立一个映射表,然后用新节点去匹配,没找到的话,就会新建一个节点。这样就会造成“全量更新”的后果,浪费性能!!

详细过程可参考: