09-简单Diff算法

97 阅读15分钟

简单Diff算法

减少DOM操作的性能开销

在之前我们对两组子节点的更新的时候,采用了一种直接的手段,将全部旧子节点卸载,再将全部新子节点挂载上去

这样确实能够完成更新,但是没有复用任何DOM元素,会产生极大的性能开销

 const oldValue = {
     type: 'div',
     children: [
         {type: 'p', children: '1'},
         {type: 'p', children: '2'},
         {type: 'p', children: '3'}
     ]
 }
 ​
 const newValue = {
     type: 'div',
     children: [
         {type: 'p', children: '4'},
         {type: 'p', children: '5'},
         {type: 'p', children: '6'}
     ]
 }

对于上述代码,我们如果按照之前的做法,必须执行6次DOM操作(3次卸载+3次挂载)

但是上述的代码中,我们可以发现改变的只是他的p标签的值,所以我们可以直接修改旧子节点的文本,这样就只需要3次DOM操作了,性能比第一种方法提高了一倍

实现思路:将每一个子节点的新旧节点一一比较更新即可

 function patchChildren(n1, n2, container){
     if(typeof n2.children === 'string'){
         if(Array.isArray(n1.children)){
             n1.children.forEach(c => unmount(c))
         }
         setElementText(container, n2.children)
     }else if(Array.isArray(n2.children)){
         //判断新子节点是否是一组子节点
         //判断旧子节点是否也是一组子节点
         if(Array.isArray(n1.children)){
             //这里说明新旧子节点都是一组子节点
             //获取新旧节点的子节点
             const oldChildren = n1.children
             const newChildren = n2.children
             //遍历两者,一一对应的更新节点
             for(let i = 0; i < oldChildren.length; i++){
                 //调用patch逐个更新子节点
                 patch(oldChildren[i], newChildren[i])
             }
         }else{
             setElementText(container, '')
             n2.children.forEach(c => patch(null, c, container))
         }
     }else{
         if(Array.isArray(n1.children)){
             n1.children.forEach(c => unmount(c))
         }else if(typeof n1.children === 'string'){
             setElementText(container, '')
         }
     }
 }

上述代码也存在很多不足,我们是通过遍历旧子节点,并假设新子节点的数量与之相同的情况下,这段代码才能正常运行,一旦数量不一致,比如新节点比较少的时候,有些节点就必须卸载

所以现在,我们应该考虑一下两组子节点的长短遍历长度短的那一组,这样才能尽可能多的调用patch函数进行更新,然后,对比新旧两组子节点的长度,如果新的一组子节点更长,那就说明有子节点需要挂载,否则需要卸载

 function patchChildren(n1, n2, container){
     if(typeof n2.children === 'string'){
         if(Array.isArray(n1.children)){
             n1.children.forEach(c => unmount(c))
         }
         setElementText(container, n2.children)
     }else if(Array.isArray(n2.children)){
         //判断新子节点是否是一组子节点
         //判断旧子节点是否也是一组子节点
         if(Array.isArray(n1.children)){
             //这里说明新旧子节点都是一组子节点
             //获取新旧节点的子节点
             const oldChildren = n1.children
             const newChildren = n2.children
             //获取新旧子节点的长度
             const oldLen = oldChildren.length
             const newLen = newChildren.length
             //两组子节点的公共长度,即两者中比较短的那一组子节点的长度
             const commonLength = Math.min(oldLen, newLen)
             //遍历commonLength次
             for(let i = 0; i < commonLength; i++){
                 //调用patch逐个更新子节点
                 patch(oldChildren[i], newChildren[i], container)
             }
             //如果newLen > oldLen,说明有新子节点需要挂载
             if(newLen > oldLen){
                 for(let i = commonLength; i < newLen; i++){
                     patch(null, newChildren[i], container)
                 }
             }else if(newLen < oldLen){
                 //如果newLen < oldLen,说明有旧子节点需要卸载
                 for(let i = commonLength; i < oldLen; i++){
                     unmount(oldChildren[i])
                 }
             }
         }else{
             setElementText(container, '')
             n2.children.forEach(c => patch(null, c, container))
         }
     }else{
         if(Array.isArray(n1.children)){
             n1.children.forEach(c => unmount(c))
         }else if(typeof n1.children === 'string'){
             setElementText(container, '')
         }
     }
 }

DOM复用与key的作用

上述方式已经可以减少DOM操作次数,但是还有优化空间

 //旧子节点
 [
     {type: 'p'},
     {type: 'div'},
     {type: 'span'}
 ]
 //新子节点
 [
     {type: 'span'},
     {type: 'p'},
     {type: 'div'}
 ]

如果按照上一节的方式去更新DOM元素,那么需要6次DOM操作

实际上,这些DOM元素只是顺序不同,所以应该通过DOM的移动来完成子节点的更新,这比不断执行子节点的卸载和挂载性能更好

但是,这个方法有一个前提:新旧子节点中要存在可复用的节点

所以现在的问题就变成:应该如何确定新的子节点是否出现在旧的子结点中

解决方式一:通过vnode.type来判断,只要相同就认为是相同的节点,但是实际上,这种方式并不可靠

 //旧子节点
 [
     {type: 'p', children: '1'},
     {type: 'p', children: '2'},
     {type: 'p', children: '3'}
 ]
 //新子节点
 [
     {type: 'p', children: '3'},
     {type: 'p', children: '1'},
     {type: 'p', children: '2'}
 ]

如果是这段代码的话,我们就无法确定新旧子节点中节点的对应关系,所以也无法得知怎么进行DOM移动才能完成更新

解决方式二:引入一个额外的key来作为vnode的标识

key就像虚拟节点的身份证,只要两个虚拟子节点的type属性值和key属性值都相同那么就认为他们是相同的,可以进行DOM的复用

 //旧子节点
 [
     {type: 'p', children: '1', key: 1},
     {type: 'p', children: '2', key: 2},
     {type: 'p', children: '3', key: 3}
 ]
 //新子节点
 [
     {type: 'p', children: '3', key: 3},
     {type: 'p', children: '1', key: 1},
     {type: 'p', children: '2', key: 2}
 ]

注意,DOM可复用并不是代表着不需要更新,可能需要更新文本节点等

现在,要完成新旧元素的更新一共有两步:打补丁操作和移动元素操作

首先,我们要对虚拟节点进行打补丁,因为新的虚拟节点的文本子节点可能已经改变

 function patchChildren(n1, n2, container){
     if(typeof n2.children === 'string'){
         if(Array.isArray(n1.children)){
             n1.children.forEach(c => unmount(c))
         }
         setElementText(container, n2.children)
     }else if(Array.isArray(n2.children)){
         //这里说明新旧子节点都是一组子节点
         //获取新旧节点的子节点
         const oldChildren = n1.children
         const newChildren = n2.children
         //遍历新的children
         for(let i = 0; i < newChildren.length; i++){
             //拿要将要更新的新子节点
             const newVNode = newChildren[i]
             //遍历旧的children
             for(let j = 0; j < oldChildren.length; j++){
                 //获取旧的子节点
                 const oldVNode = oldChildren[j]
                 //如果找到了具有相同的key值的两个节点,则说明可以复用
                 if(newVNode.key === oldVNode.key){
                     //当仍然需要patch函数来更新
                     patch(oldVNode, newVNode, container)
                     break
                 }
             }
         }
     }else{
         setElementText(container, '')
         n2.children.forEach(c => patch(null, c, container))
     }
     }else{
         if(Array.isArray(n1.children)){
             n1.children.forEach(c => unmount(c))
         }else if(typeof n1.children === 'string'){
             setElementText(container, '')
         }
     }
 }

现在,所有节点对应的真实DOM元素已经更新完毕了,但是依旧保持着旧子节点的顺序,因此我们需要通过移动节点来完成真实DOM顺序的更新

找到需要移动的元素

我们现在已经可以通过key值找到可复用的节点了,但是顺序还是原来的顺序,所以接下来我们需要考虑,判断一个节点是否需要移动,以及如何移动

现在我们先来思考一下如何判断一个节点需不需要移动,在这里,我们可以通过逆向思维的方式想想在啥情况下节点不需要移动,很明显,当新旧两组子节点顺序不变时,就不需要移动

这里考虑一种最理想的情况

新旧节点顺序不变.jpg 如图所示,新旧两组节点的顺序没有发生变化,如果我们采用上一节的更新算法,会发生什么:

  • 第一步:取新数子节点的第一个节点p-1key1,尝试在旧子节点中找到具有相同的key值,能够发现,旧节点中的p-1可以复用,索引值为0
  • 第二步:取新数子节点的第二个节点p-2key2,尝试在旧子节点中找到具有相同的key值,能够发现,旧节点中的p-2可以复用,索引值为1
  • 第三步:取新数子节点的第三个节点p-3key3,尝试在旧子节点中找到具有相同的key值,能够发现,旧节点中的p-3可以复用,索引值为2

可以发现,我们按照这些旧子节点的位置索引值的先后顺序排序,可以得到一个索引递增的顺序1,2,3,所以在这种情况下是不需要移动任何节点的

再来考虑一下另一种情况:

新旧节点顺序改变.jpg

现在新旧两组节点的顺序已经发生变化了,我们再来采用原来的更新算法分析一下:

  • 第一步:取新子节点的第一个节点p-3key3,然后再旧子节点中找具有相同key,能够发现,旧子节点中的p-3可以复用,索引值为2

  • 第二步:取新子节点的第一个节点p-1key1,然后再旧子节点中找具有相同key,能够发现,旧子节点中的p-1可以复用,索引值为0

    在这里,我们可以发现,我们想要的递增序列已经被打破,这说明节点p-1在旧children中排在节点p-3前面,但在新children中,他排在p-3后面,故节点p-1对应真实的DOM需要移动

  • 第三步:取新子节点的第三个节点p-2key2,然后再旧子节点中找具有相同key,能够发现,旧子节点中的p-2可以复用,索引值为1

    在这里,我们可以发现,节点p-2在旧children中索引为1,小于节点三p-3在旧children上的索引2,说明在旧children中,p-2排在p-3前面,但是在新children中,排在节点p-3后面,因此需要移动p-2对应的真实DOM

所以,最后我们得出了p-1p-2都要移动的结论

这是因为他们在旧children中索引小于p-3在旧children中的索引,按照先后顺序记录在寻找节点过程中所遇到位置的索引,可以得到:2, 0, 1并不具有递增序列

综上,我们可以将节点p-3在旧节点中的索引定义为:在旧children中寻找具有相同key值节点过程中,遇到的最大索引值如果在后续寻找过程中,存在索引值比当前最大索引值小的节点,则意味着该节点需要移动(即上面第二三步)

 function patchChildren(n1, n2, container){
     if(typeof n2.children === 'string'){
         if(Array.isArray(n1.children)){
             n1.children.forEach(c => unmount(c))
         }
         setElementText(container, n2.children)
     }else if(Array.isArray(n2.children)){
         //这里说明新旧子节点都是一组子节点
         //获取新旧节点的子节点
         const oldChildren = n1.children
         const newChildren = n2.children
         //存储寻找过程中遇到的最大索引值
         let lastIndex = 0
         //遍历新的children
         for(let i = 0; i < newChildren.length; i++){
             //拿要将要更新的新子节点
             const newVNode = newChildren[i]
             //遍历旧的children
             for(let j = 0; j < oldChildren.length; j++){
                 //获取旧的子节点
                 const oldVNode = oldChildren[j]
                 //如果找到了具有相同的key值的两个节点,则说明可以复用
                 if(newVNode.key === oldVNode.key){
                     //当仍然需要patch函数来更新
                     patch(oldVNode, newVNode, container)
                     if(j < lastIndex){
                         //如果当前找到的节点在旧children中的索引小于最大索引值lastIndex
                         //说明该节点对应真实的DOM需要移动
                     }else{
                         //如果当前找到的节点在旧children中的索引不小于最大索引值,则更新lastIndex
                         lastIndex = j
                     }
                     //此处需要break
                     break
                 }
             }
         }
     }else{
         setElementText(container, '')
         n2.children.forEach(c => patch(null, c, container))
     }
     }else{
         if(Array.isArray(n1.children)){
             n1.children.forEach(c => unmount(c))
         }else if(typeof n1.children === 'string'){
             setElementText(container, '')
         }
     }
 }

如何移动元素

在上一节中我们已经可以找到需要移动的节点了,这一节中,我们需要实现移动节点

移动节点指的是,移动一个虚拟节点所对应的真实DOM节点,而不是移动虚拟节点本身,所以我们要取得虚拟节点的引用

在此之前,我们已经将每一个虚拟节点对应的真实DOM元素存储在他的vnode.el属性中了,所以我们可以通过旧子节点的vnode.el属性取得它对应的真实DOM节点

当更新操作发生时,渲染器会调用patchElement函数在新旧虚拟节点之间进行打补丁,以下是patchElement函数:

function patchElement(n1, n2){
    const el = n2.el = n1.el
    //省略部分代码
}

可以看到在我们之前的代码中,我们将旧节点的n1.el属性赋值给新节点的n2.el属性,这个赋值语句的真正含义其实就是DOM元素的复用,让新节点也持有对真实DOM的引用

更新节点步骤(采用上一节第二个例子):

  • 第一步:取新一组子节点中的第一个节点p-3,通过key在旧的一组子节点中找到具有相同key值得可复用节点,该可复用节点在旧的一组子节点中的索引为2,此时变量lastIndex的值为0,由于2不小于0,所以只要更新lastIndex的值即可,不用移动DOM元素

  • 第二步:取新的一组子节点中的第二个节点p-1,通过key在旧的一组子节点中找到具有相同key值的可复用节点,该可复用节点在旧的一组子节点中的索引为0,此时变量lastIndex的值为2,由于0小于2,所以p-1对应的真实DOM元素需要移动

    到这里,我们发现p-1对应的真实DOM需要移动,移动到节点p-3所对应的真实DOM后面

    因为children的顺序其实就是更新后真实DOM节点应有的顺序,所以节点p-1在新children中的位置就代表了真实DOM更新后的位置,即要将其放在p-3对应的真实DOM后面

    现在的真实DOM顺序为p-2、p-3、p-1

  • 第三步:取新的一组子节点中的第三个节点p-2,通过key在旧的一组子节点中找到具有相同key值的可复用节点,该可复用节点在旧的一组子节点中的索引为1,此时变量lastIndex的值为2,由于1小于2,所以p-2对应的真实DOM元素需要移动

    移动的位置分析与p-1类似,此时p-2就需要移动到节点p-1对应的真实DOM的后面了

上述分析过程的图例:

移动节点图示.jpg

实现思路:

  • 先获取当前元素在新子节点中的前一个元素prevVNode
  • 如果不存在前一个元素的话则不需要移动
  • 如果存在的话就要找到prevVNode的实际DOM元素的下一个兄弟节点
  • insert方法将新节点对应的真实DOM插入到上面找到的兄弟节点前面,也就是prevVNode对应的真实DOM后面

代码实现其移动过程:

function patchChildren(n1, n2, container){
    if(typeof n2.children === 'string'){
        if(Array.isArray(n1.children)){
            n1.children.forEach(c => unmount(c))
        }
        setElementText(container, n2.children)
    }else if(Array.isArray(n2.children)){
        const oldChildren = n1.children
        const newChildren = n2.children
        let lastIndex = 0
        for(let i = 0; i < newChildren.length; i++){
            const newVNode = newChildren[i]
            for(let j = 0; j < oldChildren.length; j++){
                const oldVNode = oldChildren[j]
                if(newVNode.key === oldVNode.key){
                    patch(oldVNode, newVNode, container)
                    if(j < lastIndex){
                        //如果当前找到的节点在旧children中的索引小于最大索引值lastIndex
                        //说明该节点对应真实的DOM需要移动
                        //需要先获取newVNode的前一个vnode
                        const prevVNode = newChildren[i - 1]
                        //如果prevVNode不存在,则说明newVNode是第一个节点,不需要移动
                        if(prevVNode){
                            //由于我们要将newVNode对应的真实DOM移动到prevVNode所对应的真实DOM后面
                            //所以需要获取prevVNode所对应的真实DOM的下一个兄弟节点,并将其作为锚点
                            const anchor = prevVNode.el.nextSibling
                            //调用insert方法将newVNode对应的真实DOM插入到锚点元素之前
                            // 也就是prevVNode对应真实DOM后面
                            insert(newVNode.el, container, anchor)
                        }
                    }else{
                        //如果当前找到的节点在旧children中的索引不小于最大索引值,则更新lastIndex
                        lastIndex = j
                    }
                    //此处需要break
                    break
                }
            }
        }
    }else{
        setElementText(container, '')
        n2.children.forEach(c => patch(null, c, container))
    }
    }else{
        if(Array.isArray(n1.children)){
            n1.children.forEach(c => unmount(c))
        }else if(typeof n1.children === 'string'){
            setElementText(container, '')
        }
    }
}

其中使用了insert函数,我们之前已经定义过了

insert(el, parent, anchor = null) {
    //其中要传入锚点元素anchor,代表在这个元素之前插入
    parent.insertBefore(el, anchor)
}

添加新元素

在之前的例子中,我们的新节点都能找到对于的旧节点,而现在我们考虑一种新的情况:

新的子节点不能在旧子节点中找到对应的节点,也就是添加新的节点到原本的DOM树上,所以我们在更新的时候应该正确的将他挂载,主要分为两步:

  • 想办法找到新增节点
  • 将新增节点挂载到正确位置

同样的,我们采用一个例子来深入理解一下:

添加新元素.jpg

现在我们来模拟一下简单Diff算法的更新逻辑:

  • 第一步:取新子节点的第一个节点p-3,它的key值为3,尝试在旧的一组子节点中寻找可复用的节点,能够找到对应的节点,该结点在旧的一组子节点中的索引值为2,而此时变量lastIndex的值为0,索引值2不小于0,所以不需要移动p-3的真实DOM元素,只需要将变量lastIndex更新为2即可
  • 第二步:取新的一组子节点中的第二个节点p-1,它的key值为1,在旧的一组子结点中寻找可复用的节点,可以找到对应的旧子节点,索引值为0,因为比当前的lastIndex的值小,所以需要移动该结点的位置,移动到节点p-3对应的真实DOM后面,经过这一步移动操作后,真实DOM的状态为p-2p-3p-1
  • 第三步:取新的一组子节点中的第三个节点p-4,他的key值为4在旧的一组子结点中找不到可以复用的节点,所以就应该把节点p-4作为新增节点并挂载它,而挂载位置就应该挂载在目前节点p-1的后面,也就是挂载在p-1对应的真实DOM后面,经过这一步移动操作,真实DOM的状态为p-2p-3p-1p-4
  • 第四步:取新的一组子节点中的第四个节点p-2,它的key值为2,在旧的一组子结点中寻找可复用的节点,可以找到对应的旧子节点,索引值为1,因为比当前的lastIndex的值小,所以需要移动该结点的位置,移动到节点p-4对应的真实DOM后面,经过这一步移动操作后,真实DOM的状态为p-2p-3p-1p-4p-2

目前的状态:

添加新元素后的DOM状态.jpg

代码实现:

function patchChildren(n1, n2, container){
    if(typeof n2.children === 'string'){
        if(Array.isArray(n1.children)){
            n1.children.forEach(c => unmount(c))
        }
        setElementText(container, n2.children)
    }else if(Array.isArray(n2.children)){
        const oldChildren = n1.children
        const newChildren = n2.children
        let lastIndex = 0
        for(let i = 0; i < newChildren.length; i++){
            const newVNode = newChildren[i]
            //在第一层循环中定义变量find,代表是否在旧的一组子节点中找到可复用的节点
            let find = false
            for(let j = 0; j < oldChildren.length; j++){
                const oldVNode = oldChildren[j]
                if(newVNode.key === oldVNode.key){
                    //找到可复用的节点就将变量改为true
                    find = true
                    patch(oldVNode, newVNode, container)
                    if(j < lastIndex){
                        const prevVNode = newChildren[i - 1]
                        if(prevVNode){
                            const anchor = prevVNode.el.nextSibling
                            insert(newVNode.el, container, anchor)
                        }
                    }else{
                        lastIndex = j
                    }
                    break
                }
            }
            //代码运行到这里则find仍然为false,则说明newVNode没有在旧的一组子节点中找到可复用的节点
            //也就是说newVNode是新增节点,需要挂载
            if(!find){
                //为了将节点挂载到正确位置,我们需要先获取锚点元素
                //首先获取当前newVNode的前一个vnode节点
                const prevVNode = newChildren[i - 1]
                let anchor = null
                if(prevVNode){
                    //如果存在前一个vnode节点,则说明他的下一个兄弟元素作为锚点元素
                    anchor = prevVNode.el.nextSibling
                }else{
                    //如果不存在前一个vnode节点,则说明即将挂载的新节点是第一个子节点
                    anchor = container.firstChild
                }
                //挂载newVNode
                patch(null, newVNode, container, anchor)
            }
        }
    }else{
        setElementText(container, '')
        n2.children.forEach(c => patch(null, c, container))
    }
    }else{
        if(Array.isArray(n1.children)){
            n1.children.forEach(c => unmount(c))
        }else if(typeof n1.children === 'string'){
            setElementText(container, '')
        }
    }
}

在上述代码中,我们使用了patch函数来挂载新节点,而现在的patch函数不支持传递第四个参数,所以需要调整一下patch代码

function patch(n1, n2, container, anchor){
    //省略部分代码
    if(typeof type === 'string'){
        //首次挂载,直接递归调用mountElement函数
        if (!n1) {
            //挂载时将锚点元素作为第三个参数传递给mountElement函数
            mountElement(n2, container, anchor)
        } else {
            //更新
            patchElement(n1, n2)
        }
    }
    //省略部分代码
}
//此处由于给挂载函数也传递了第三个参数,我们也要对其进行相应的调整
function mountElement(vnode, container, anchor) {
    //省略部分代码
    //再插入节点时,将锚点元素传给insert
    insert(el, container, anchor)
}

移除不存在的元素

上一节中,我们探讨了遇到新增元素的情况,不仅如此,我们还会遇到元素被删除的情况

同样,我们来分析一个例子:

移除不存在的元素.jpg

  • 第一步:取新的子结点中的第一个节点p-3key值为3,能够在旧子节点中找到对应的节点,在旧子节点中的索引为2,此时变量lastIndex的值为0,由于2不小于0,所以不需要移动,只需要更新lastIndex变量

  • 第二步:取新的子结点中的第二个节点p-1key值为1,能够在旧子节点中找到对应的节点,在旧子节点中的索引为0,此时变量lastIndex的值为2,由于0小于2,所以需要移动,将节点p-1对应的真实DOM移动到节点p-3对应的真实DOM后面

  • 至此,更新已经结束,但是我们发现,节点p-2对应真实DOM还存在,所以需要增加额外的逻辑来删除遗留节点

    思路:当更新结束的时候,需要遍历旧的一组子节点,然后取新的一组子结点中寻找相同key值得节点,如果找不到则需要删除该节点

    function patchChildren(n1, n2, container){
        if(typeof n2.children === 'string'){
            if(Array.isArray(n1.children)){
                n1.children.forEach(c => unmount(c))
            }
            setElementText(container, n2.children)
        }else if(Array.isArray(n2.children)){
            const oldChildren = n1.children
            const newChildren = n2.children
            let lastIndex = 0
            for(let i = 0; i < newChildren.length; i++){
                const newVNode = newChildren[i]
                let find = false
                for(let j = 0; j < oldChildren.length; j++){
                    const oldVNode = oldChildren[j]
                    if(newVNode.key === oldVNode.key){
                        find = true
                        patch(oldVNode, newVNode, container)
                        if(j < lastIndex){
                            const prevVNode = newChildren[i - 1]
                            if(prevVNode){
                                const anchor = prevVNode.el.nextSibling
                                insert(newVNode.el, container, anchor)
                            }
                        }else{
                            lastIndex = j
                        }
                        break
                    }
                }
                if(!find){
                    const prevVNode = newChildren[i - 1]
                    let anchor = null
                    if(prevVNode){
                        anchor = prevVNode.el.nextSibling
                    }else{
                        anchor = container.firstChild
                    }
                    patch(null, newVNode, container, anchor)
                }
            }
            //上一步更新操作完成后,需要遍历一次旧的子节点
            for(let i = 0; i < oldChildren.length; i++){
                const oldVNode = oldChildren[i]
                //拿旧子节点oldVNode去新的一组子结点中寻找具有相同key值得节点
                const has = newChildren.find(
                    vnode => vnode.key === oldVNode.key
                )
                //如果没有找到则说明需要删除该节点,调用unmount将其卸载
                if(!has) unmount(oldVNode)
            }
        }else{
            setElementText(container, '')
            n2.children.forEach(c => patch(null, c, container))
        }
    }else{
        if(Array.isArray(n1.children)){
            n1.children.forEach(c => unmount(c))
        }else if(typeof n1.children === 'string'){
            setElementText(container, '')
        }
    }
    }