面试里夹杂的diff算法

187 阅读4分钟

前因是因为面试了几家公司都提到了diff算法相关的知识

诸如:怎么比较两颗dom树的不同

其实在vue和react怎么实现虚拟DOM和真实DOM同步的diff算法可以体现

先大概有个概念,diff算法的整体策略是深度优先遍历,同级比较

直接开始看源码吧,下面是我在GitHub上找到的相关源码

diff算法的实现,patchNode和update Children这两个函数是比较关键的函数

本文仅限于过一遍diff算法的原理实现,有些细节没能照顾到,见谅哈~

patch

function patch (oldVnode, vnode) {
  //利用sameVnode判断新旧接节点是否值得比较
	if (sameVnode(oldVnode, vnode)) {
    //新旧节点一样,值得比较
		patchVnode(oldVnode, vnode)
	} else {
    //获取就节点挂载的对象
		const oEl = oldVnode.el
    //获取节点的父子节点,parentEle是真实的dom
		let parentEle = api.parentNode(oEl)
    //创建新节点真实的dom
		createEle(vnode)
		if (parentEle !== null) {
      //给真实dom的父节点插入新的dom(vnode),同时移除旧的dom
			api.insertBefore(parentEle, vnode.el, api.nextSibling(oEl))
			api.removeChild(parentEle, oldVnode.el)
			oldVnode = null
		}
	}
	return vnode //重点:返回的一个真实dom
}

var oldVnode = patch (oldVnode, vnode)

patch的过程其实是将虚拟dom和真实的dom对比后的结果反映为真实的dom,实现更新

过程

  • 通过sameNode判断两个节点的是否需要比较
  • 若值得比较,则调用patchNode进行比较;若不值得则获取真实的父子节点,同时创建新的节点的真实dom,并把新的节点的真实dom插入到为真实dom的父子节点parentEle

以下是怎么判断两个节点是否一致的sameVnode方法

function sameVnode (a, b) {
  return (
    a.key === b.key && (
      (
        a.tag === b.tag &&
        a.isComment === b.isComment &&
        isDef(a.data) === isDef(b.data) &&
        sameInputType(a, b)
      ) || (
        isTrue(a.isAsyncPlaceholder) &&
        a.asyncFactory === b.asyncFactory &&
        isUndef(b.asyncFactory.error)
      )
    )
  )
}

只有节点上所有的key,标签tag,数据data完全一致,才值得比较节点情况

patchNode

对源码有删改,只留下了关键的部分

function patchVnode(
    oldVnode,  //旧节点
    vnode,     //新节点
    insertedVnodeQueue,   //增加新节点的队列
) {
    //比较新旧节点 节点引用相同 直接返回
    if (oldVnode === vnode) {
        return
    }
      
 		//diff算法 新旧节点的孩子
    const oldCh = oldVnode.children
    const ch = vnode.children
    //找到真实dom => el
    const elm = vnode.elm = oldVnode.elm
    
    //新旧节点有文本节点
    if (isUndef(vnode.text)) {
        //新旧节点都有子节点
        if (isDef(oldCh) && isDef(ch)) {
            //子节点不相同,调用updateChildren方法
            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, '')
        }
        //新旧节点的文本节点不一样,给elm添加新的文本节点替换
    } else if (oldVnode.text !== vnode.text) {
        nodeOps.setTextContent(elm, vnode.text)
    }
}

过程总结一下:

  1. 获取真实的dom ,为el
  2. oldVnode === vnode新旧节点的引用相同,直接返回
  3. 新旧节点都有文本节点,不过不相等,则将el的文本节点设置为新节点的文本节点
  4. 新节点有子节点,旧节点没有,则将新节点的子节点真实化后添加到el
  5. 旧节点有子节点,新节点没有,则删除el的子节点
  6. 当两者都有子节点,则调用updateChildren方法比较子节点

updateChildren

	//updateChildren方法以下是方法的关于四种比较
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)
          	//把旧头插入到旧尾的位置 oldEndVnode.el
            api.insertBefore(parentElm, oldStartVnode.el, api.nextSibling(oldEndVnode.el))
            oldStartVnode = oldCh[++oldStartIdx]
            newEndVnode = newCh[--newEndIdx]
          //新头和旧尾比较
        }else if (sameVnode(oldEndVnode, newStartVnode)) {
            patchVnode(oldEndVnode, newStartVnode)
          	//把旧尾插入到旧头的位置 oldStartVnode.el
            api.insertBefore(parentElm, oldEndVnode.el, oldStartVnode.el)
            oldEndVnode = oldCh[--oldEndIdx]
            newStartVnode = newCh[++newStartIdx]
        }

这个方法是人为设计出了四种比较方法,新节点的每一个子节点都首先用着四种方法比较,如果两个节点符合比较资格,则继续调用patchNode进行更深层的比较,循环进行到最后一个子节点

具体是怎么实现的,结合图片来理解

下面是自己对源码实现具体比较的过程的一些解读,updateChildren是diff算法实现高效性的操作,情况考虑的很多,也是有些复杂,不知道自己的理解是否有错误的点,欢迎批评指正,感谢!

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, idxInOld, vnodeToMove, refElm
    const canMove = !removeOnly
			...
    while (oldStartIdx <= oldEndIdx && newStartIdx <= 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)
          	//把旧头插入到旧尾的位置 oldEndVnode.el
            api.insertBefore(parentElm, oldStartVnode.el, api.nextSibling(oldEndVnode.el))
            oldStartVnode = oldCh[++oldStartIdx]
            newEndVnode = newCh[--newEndIdx]
          //新头和旧尾比较
        }else if (sameVnode(oldEndVnode, newStartVnode)) {
            patchVnode(oldEndVnode, newStartVnode)
          	//把旧尾插入到旧头的位置 oldStartVnode.el
            api.insertBefore(parentElm, oldEndVnode.el, oldStartVnode.el)
            oldEndVnode = oldCh[--oldEndIdx]
            newStartVnode = newCh[++newStartIdx]
        }else {
           // 不符合四种比较的情况,使用key时作为index的比较
            if (oldKeyToIdx === undefined) {
              //首先根据旧节点的所有子节点生成index
                oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
            }
            idxInOld = oldKeyToIdx[newStartVnode.key]
            if (!idxInOld) {
              //新节点的key和旧节点的key没有匹配的,则直接插入到oldStartVnode的位置
                api.insertBefore(parentElm, createEle(newStartVnode).el, oldStartVnode.el)
                newStartVnode = newCh[++newStartIdx]
            }
            else {
              //有匹配,进行移动的操作,idxInOld是指移动到具体的索引值
                elmToMove = oldCh[idxInOld]
              //要移动的节点和新节点的css选择器不一致,则直接插入到oldStart的位置
                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]
            }
        }
    }
//遍历结束的位置,当startindex大于endindex,说明遍历完了
    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)
    }
}

源码是在github的vuejs/src/core/patch.js扒下来的

参考:

详解vue的diff算法

解析vue2.0的diff算法