vue的diff算法详解

10 篇文章 0 订阅
为什么需要虚拟dom?

虚拟dom只是一个普通的js对象。

由于每次渲染视图都是先创建vnode,然后用它创建真实DOM插入到页面中,所以可以将上一次渲染视图所创建的vnode缓存起来。之后重新渲染视图,就可以对比oldVnode和vnode,基于新旧差异来更新DOM了。这样可以提升性能。(《深入浅出Vue.js》P55)

源码专有术语一览
  • 新旧虚拟节点在源码里的变量名分别为:vnode和oldVnode。我们以vnode为基准,目标是把dom修改成vnode的样子,并且最小化dom操作次数。

  • 如果vnode有text属性,意味着是“文本节点”,则无论旧节点的子节点是什么,都可以直接调用api.setTextContent。具体地说,是api.setTextContent(el, vnode.text)eloldVnode对应的真实dom节点。

  • sameVnode:判定两个虚拟dom是否相同。

  •   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必须相同
        )
      }
    
  • elvnode对应的真实dom节点。

太长不看的总结版本
  • 优化点:dom操作尽量少、updateChildren尽量避免线性查找、同层级比较
  • patch用sameVnode判定是否有必要进行更细致的比较。如果不需要,直接更新dom,过程结束。否则调用patchVnode。
  • dom插入操作的插入位置:任何情况下(包括线性查找,和patch函数的情况),插入位置一定是在“待处理区间”之外
  • patchVnode判定是否需要进行children的比较和更新。以下情况是不需要进行children比较的:vnode有text属性(即它是文本节点)且和oldVnode的不相同;vnode和oldVnode有至少一方没children,或都有children但children地址相同。需要进行children比较,才进入updateChildren。
  • updateChildren是启发式算法,《深入浅出Vue.js》把它叫做“快捷查找”,可以大大减少线性查找的次数。该算法维护旧children和新children的待比较区间。当两个区间都不空,我们判定oldStartIdxnewStartIdx等4种情况的相同性(用sameVnode判定)。
  1. 如有满足,则不用进行线性查找了,递归调用patchVnode、更新dom即可。然后缩小待比较区间。
  2. 否则需要线性查找。若没找到,新建节点即可。若找到,则需要先递归调用patchVnode,再把旧children数组对应位置的节点移动到oldStartVnode.el之前。情况2,总是只需要以newStartIdx对应的虚拟节点为基准,且缩小待比较区间的操作是++newStartIdx(只考虑newEndIdx也行,二选一)。
  3. 移动操作需要oldCh[idxInOld] = null,表示该位置已废弃。而后续待比较区间的下标还有可能经过idxInOld,所以需要4个if语句判定当前下标是null,并跳过。
  4. updateChildren的循环结束后,如果是旧待比较区间先变为空,则需要插入操作;否则需要删除操作。
跟着函数走一遍

如果我们自己构思diff,多半直接一个dfs完事。但vue的diff算法是间接递归的。patch只需要在根处使用(原因将在下文解释),它调用了patchVnode;patchVnode调用了updateChildren;updateChildren递归调用了patchVnode。

patch

patch的一部分代码

function patch (oldVnode, vnode) {
    // some code
    if (sameVnode(oldVnode, vnode)) {
        patchVnode(oldVnode, vnode)
    } else {
        const oEl = oldVnode.el // 当前oldVnode对应的真实元素节点
        let parentEle = api.parentNode(oEl)  // 父元素
        createEle(vnode)  // 根据Vnode生成新元素
        if (parentEle !== null) {
            api.insertBefore(parentEle, vnode.el, api.nextSibling(oEl)) // 将新元素添加进父元素
            api.removeChild(parentEle, oldVnode.el)  // 移除以前的旧元素节点
            oldVnode = null
        }
    }
    // some code 
    return vnode
}

值得注意的是,vue的diff算法是在同层级比较节点的相同性的。所以如果当前的两个虚拟dom不相同(sameVnode为false),就没必要再dfs下去了。用vnode建出dom,插到父亲的children里,再把旧的el删掉。

由此也可以得到,从根节点到vnode的树上唯一路径,和从旧根节点到oldVnode的树上唯一路径,它们的每个节点都满足sameVnode为true。所以同层级比较,是通过树上唯一路径的每个点都满足sameVnode为true,来实现的。

patchVnode
function patchVnode (oldVnode, vnode) {
    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)
        }
    }
}

如果oldVnodevnode指向同一个对象,就没必要更新(但不知道这种情况有没有可能发生)。

vnode有text属性,意味着它是文本节点,则可以考虑直接api.setTextContent

接下来,如果oldVnodevnode不是都有children(children自然也是虚拟dom节点),则也没必要进行复杂的updateChildren

  1. 如果vnode的ch不空(此时旧的为空),则需要新建节点(整个子树都随之新建)
  2. 如果oldVnode的ch不空(此时新的为空),则需要删除el

如果新旧虚拟节点都有children,且不相等,则需要进行updateChildren的对比。

updateChildren
function 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)
    }
}

重头戏!代码很长,但其实是纸老虎!

parentElm, oldCh, newCh分别表示vnode对应的el和旧、新children。

先看局部变量

    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

前8个变量,分别是旧children的待比较起始、结束下标、新children的待比较起始、结束下标,和它们对应的虚拟dom节点(oldStartVnode等只是为了方便书写)。[oldStartIdx,oldEndIdx][newStartIdx,newEndIdx]分别表示旧、新children的待比较区间,左闭右闭。

if (oldKeyToIdx === undefined) {
  oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx) // 由key生成index表
}
idxInOld = oldKeyToIdx[newStartVnode.key]

oldKeyToIdx表示旧children只取key属性、待比较区间的部分,生成的数组。idxInOld则表示newStartVnode.key在旧children的什么地方(所以key属性很重要啊)。

后面4个变量,只会在while循环的else部分用到。

updateChildren的主体是一个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]
}

后面的代码会移动线性查找所找到的节点,移动前的位置作废了,但指针可能还会经过这里,所以需要设为null并跳过。

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]
}

这里是一个启发式的方法(《深入浅出Vue.js》把这个方法称为“快捷查找”,据说,这种方式能大大减少线性查找的耗时操作的次数),即所谓的人类经验(类似的有,并查集按秩合并size小的合并到size大的,复杂度能达到nlogn,避免了n^2)。

  • 如果头和头相等,或尾和尾相等,只递归调用patchVnode即可,不用进行dom操作。

    为什么不递归调用patch?回去看一下patch代码发现,调用patch会直接进入patchNode,只是徒增递归深度。因为此时已知这两个节点是值得比较的。这就是patch只需要在根处调用的原因了。
  • 如果头和尾相等,或尾和头相等,则在patchVnode完成后,进行dom的插入,然后缩小区间。

insertBefore怎么用:document.getElementById("myList").insertBefore(newItem,existingItem);

  • sameVnode(oldStartVnode, newEndVnode),则把oldStartVnode.el插到oldEndVnode.el之后(它的nextSibling之前,也就是它之后)。
  • sameVnode(oldEndVnode, newStartVnode),则把oldEndVnode.el插到oldStartVnode.el之前。

插入位置的推导,在《深入浅出Vue.js》P80有讲述。我们只需要记住:任何情况下(包括下面的线性查找,和patch函数的情况),插入位置一定是在“待处理区间”之外

如果以上“人类经验”的情况不满足,则只好进行线性查找的耗时操作了。

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]
    }
}

这一段代码,只考虑newStartIdx对应的虚拟dom节点要放到什么位置即可(只考虑newEndIdx也行,二选一即可)。

回顾一下变量定义:oldKeyToIdx表示旧children只取key属性、待比较区间的部分,生成的数组(它只生成1次)。idxInOld则表示newStartVnode.key在旧children的下标。idxInOld为0,表示newStartVnode.key是新产生的,就插到oldStartVnode.el之前。否则:

  • sel属性不知道是个啥,不管啦!
  • sel相等,则需要先用patchVnode比较elmToMove和newStartVnode这两个子节点,然后把elmToMove.el插到oldStartVnode.el之前。oldCh[idxInOld] = null则是删除操作,因为已经移动完成了,所以后续指针再次经过这里的时候应该跳过!
循环结束后的收尾工作
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)
}

都是很自然的推导结果。

  • 如果旧的区间先空了,就把新的区间的元素都插入
  • 如果新的区间先空了,就把旧的区间的元素都删除

参考:https://www.cnblogs.com/wind-lanyan/p/9061684.html

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值