VUE中key的作用与diff算法

3,387 阅读8分钟

之前都是用excel记录知识的,现在想想还是记录在网上把。一个是自己的理解有错误的地方可能会有大神帮忙指点一下,另一个就是查找的时候也方便点(我excel跟typora记录的位置太乱了)

前两天看到一道面试题,vue中key的作用。当时我的第一印象就是v-for中key的用法,在VUE官方是这么介绍的:

当 Vue 正在更新使用 v-for渲染的元素列表时,它默认使用“就地更新”的策略。如果数据项的顺序被改变,Vue 将不会移动 DOM 元素来匹配数据项的顺序,而是就地更新每个元素,并且确保它们在每个索引位置正确渲染。这个类似 Vue 1.x 的 track-by="$index"。为了给 Vue 一个提示,以便它能跟踪每个节点的身份,从而重用和重新排序现有元素,你需要为每项提供一个唯一 key 属性

当时的理解是,假设有个列表的值是1,2,3, 如果列表顺序改变了,变成3,1,2,vue不会重新渲染新的列表,只是更改其index顺序进行更新,这样子就可以节省资源了。这里key值得作用是vue识别节点的机制。注意:key使用字符串或者数值类型。

这样子简单的理解后,我又去官方看了下key的详细介绍。

key 的特殊属性主要用在 Vue 的虚拟 DOM 算法,在新旧 nodes 对比时辨识 VNodes。如果不使用 key,Vue 会使用一种最大限度减少动态元素并且尽可能的尝试就地修改/复用相同类型元素的算法。而使用 key 时,它会基于 key 的变化重新排列元素顺序,并且会移除 key 不存在的元素。 有相同父元素的子元素必须有独特的 key。重复的 key 会造成渲染错误。

这里开始,就涉及到VUE虚拟DOM 的diff算法了。刚好也想了解下这个,便去查找了下相关资料。
超链接1
超链接2
超链接3
以上三个网址是我觉得写的蛮详细的,我也是参考了他们的内容进行了自己的理解。

Diff算法

虚拟DOM

简单来说,vue参照真实的DOM会生成一个DOM树,称为Old Vnode,然后我们更新了某个节点,生成了一个新的DOM树,成为Vnode,这里的DOM树就称为虚拟DOM。diff算法比较的就是Vnode与Old Vnode的区别,然后将区别更新到真实DOM里面,同时将Old Vnode变成Vnode。 DOM树其实就是将真实DO面数据抽取出来,以对象形式模拟树结构,如真实DOM假设是:

<div>
    <p>123</p>
</div>

对象化为虚拟DOM(这里是简单示例,真实的对象结构请参考vue源码中的DOM树对象)

var Vnode = {
    tag: 'div',
    children: [
        { tag: 'p', text: '123' }
    ]
};

简单说下为何要虚拟DOM

虚拟DOM就是为了解决浏览器性能问题而被设计出来的。若一次操作中有10次更新DOM的动作,虚拟DOM不会立即操作DOM,而是将这10次更新的diff内容保存到本地一个JS对象中,最终将这个JS对象一次性attch到DOM树上,再进行后续操作,避免大量无谓的计算量。所以,用JS对象模拟DOM节点的好处是,页面的更新可以先全部反映在JS对象(虚拟DOM)上,操作内存中的JS对象的速度显然要更快,等更新完成后,再将最终的JS对象映射成真实的DOM,交由浏览器去绘制。

关于虚拟DOM,参考的是vue核心之虚拟DOM(vdom)

diff算法的比较方式

  1. 同层比较,如上面div的Old Vnode,跟其Vnode比较,div只会跟同层div比较,不会跟p进行比较,下面是示例图:

这样子比较的好处是降低了时间复杂度,放弃了深度遍历。不过牺牲了一定的性能。如果两个父元素不同,但是子元素完全相同,父元素匹配到不同,Vnode就会替换掉Old Vnode的元素,并没有实现子元素的复用。 这里的sameVnode方法是用来判断两个节点是否可以比较,如果sameVnode(Vnode,Old Vnode)=false,说明两个节点不同,则Vnode直接替换掉Old Vnode元素

function sameVnode (a, b) {
  return (
    a.key === b.key &&  // key值
    a.tag === b.tag &&  // 标签名
    a.isComment === b.isComment &&  // 是否为注释节点
    // 是否都定义了data,data包含一些具体信息,例如onclick , style
    isDef(a.data) === isDef(b.data) &&  
    sameInputType(a, b) // 当标签是<input>的时候,type必须相同
  )
}

同层比较(sameVnode==true),则进行patchVnode比较

\\patchVnode的简单逻辑
    const el = vnode.el = oldVnode.el
    let i, oldCh = oldVnode.children, ch = vnode.children
    if (oldVnode === vnode) return
    if (oldVnode.text !== null && vnode.text !== null && oldVnode.text !== vnode.text) {
        api.setTextContent(el, vnode.text)
    }else {
        updateEle(el, vnode, oldVnode)
        if (oldCh && ch && oldCh !== ch) {
            updateChildren(el, oldCh, ch)
        }else if (ch){
            createEle(vnode) //create el's children dom
        }else if (oldCh){
            api.removeChildren(el)
        }
    }
}

patchVnode的逻辑是:

  1. 如果oldVnode跟vnode完全一致,那么不需要做任何事情
  2. 如果oldVnode跟vnode都是静态节点,且具有相同的key,当vnode是克隆节点或是v-once指令控制的节点时,只需要把oldVnode.elm和oldVnode.child都复制到vnode上,也不用再有其他操作
  3. 否则,如果vnode不是文本节点或注释节点:
  1. 如果oldVnode和vnode都有子节点,且2方的子节点不完全一致,就执行updateChildren
  2. 如果只有oldVnode有子节点,那就把这些节点都删除 如果只有vnode有子节点,那就创建这些子节点
  3. 如果oldVnode和vnode都没有子节点,但是oldVnode是文本节点或注释节点,就把vnode.elm的文本设置为空字符串如果vnode是文本节点或注释节点,但是vnode.text != oldVnode.text时,只需要更新vnode.elm的文本内容就可以

updateChildren函数

这个函数比较复杂,先贴下代码

updateChildren (parentElm, oldCh, newCh) {
    let oldStartIdx = 0, 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
    let idxInOld
    let elmToMove
    let before
    while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
        if (oldStartVnode == null) {   // 对于vnode.key的比较,会把oldVnode = null
            oldStartVnode = oldCh[++oldStartIdx] 
        }else if (oldEndVnode == null) {
            oldEndVnode = oldCh[--oldEndIdx]
        }else if (newStartVnode == null) {
            newStartVnode = newCh[++newStartIdx]
        }else if (newEndVnode == null) {
            newEndVnode = newCh[--newEndIdx]
        }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)
            api.insertBefore(parentElm, oldStartVnode.el, api.nextSibling(oldEndVnode.el))
            oldStartVnode = oldCh[++oldStartIdx]
            newEndVnode = newCh[--newEndIdx]
        }else if (sameVnode(oldEndVnode, newStartVnode)) {
            patchVnode(oldEndVnode, newStartVnode)
            api.insertBefore(parentElm, oldEndVnode.el, oldStartVnode.el)
            oldEndVnode = oldCh[--oldEndIdx]
            newStartVnode = newCh[++newStartIdx]
        }else {
           // 使用key时的比较
            if (oldKeyToIdx === undefined) {
                oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx) // 有key生成index表
            }
            idxInOld = oldKeyToIdx[newStartVnode.key]
            if (!idxInOld) {
                api.insertBefore(parentElm, createEle(newStartVnode).el, oldStartVnode.el)
                newStartVnode = newCh[++newStartIdx]
            }
            else {
                elmToMove = oldCh[idxInOld]
                if (elmToMove.sel !== newStartVnode.sel) {
                    api.insertBefore(parentElm, createEle(newStartVnode).el, oldStartVnode.el)
                }else {
                    patchVnode(elmToMove, newStartVnode)
                    oldCh[idxInOld] = null
                    api.insertBefore(parentElm, elmToMove.el, oldStartVnode.el)
                }
                newStartVnode = newCh[++newStartIdx]
            }
        }
    }
    if (oldStartIdx > oldEndIdx) {
        before = newCh[newEndIdx + 1] == null ? null : newCh[newEndIdx + 1].el
        addVnodes(parentElm, before, newCh, newStartIdx, newEndIdx)
    }else if (newStartIdx > newEndIdx) {
        removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx)
    }
}

整个代码的逻辑:

  1. 将Vnode的头尾取出跟OldVnode的头尾取出进行四次比较
    取出OldVnode的头尾os ,oe 取出Vnode的头尾vs,ve,比较:
    case 1:if(os=vs){
    说明两个node的头相同,那么将两者的头向后跳一格(即os原本是OlaVnode[0],变 成OldVnode[1])}
    case 2:if(os=ve){
    说明OldVnode的尾应该是新的头,则将ve移到OldVnode的最前面 }
    case 3:if(vs=oe){
    说明OldVnode的头应该是新的尾,则将ve移到OldVnode的最后面 }
    case 4:if(ve=oe){
    说明两个node的尾相同,那么将两者的尾向前跳一格(即oe原本是OldVnode[length-1],变 成OldVnode[length-2])}
    }
    case5:if(无法匹配){
    那么vs/ve 遍历oldVnode每一项
    if(samekey-如果两者Key相同){
    if(samenode){
    将oldvnode匹配到的元素移到对应的vs/ve的位置
    }else{
    说明此元素为新元素,OldVnode添加vs/ve元素,移到对应的vs/ve位置
    }
    }else{ 说明此元素为新元素,OldVnode添加vs/ve元素,移到对应的vs/ve位置
    } }

  2. 示例
    假设OldVnode为a,b,c,d
    Vnode为a,c,d,e,b
    os=a,oe=d,vs=a,vs=b
    第一次遍历:case1为true,则将两者的头向后跳一格,此刻OldVnode为b,c,d , Vnode为c,d,e,b ,os=b,oe=d,vs=c,ve=b
    第二次遍历:case2为true,则将OldVnode的b元素移至最后,此刻OldVnode为c,d,b ,这样子就是case4为true,则将两者的尾向前跳一格。此刻OldVnod为c,d ,Vnode为c,d,e
    第三次遍历:重复上述步骤,经过两次循环后,最终OldVnode先循环结束,Vnode还剩个e,则e为新元素,直接根据自身Index插入OldVnode

  3. 关键点
    key值的作用:在进行比较的时候,如果头尾匹配不到,则会遍历整个OldVnode,进行判断是否存在Key值相同的元素进行复用,最大化的利用了组件
    如果循环中,OldVnode先循环完,Vnode还未循环完,说明Vnode比OldVnode多了未循环的元素,直接插入,反之,说明OldVnode比Vnode多了未循环的元素,直接移除。

PS

这是我一个真前端小白的一些简单的理解,对于源码部分我也只是仔细看了下UpdateChildren函数,还有很多地方需要去理解,这是第一次发表文章,排版有问题请谅解- -如果有大佬发现有问题求指教( •́ .̫ •̀ )