从浅入深diff算法

760 阅读5分钟

一、什么是diff算法?

开始之前先来玩个装修游戏,如果你是装修师傅,要将下面的左图变为右图,你要怎么装修?

image.png

1)将左边的房子全部拆掉,重新装修; 2)找出两个房子的不同,只做局部改造 很明显,正常人为了减少消耗,都会选择第二种,那么这时候就要对两者进行层层比较,找出不同,再开始更新。

这就是diff算法的思想:精细化对比,最小量更新

二、为什么要使用diff算法?

众所周知,渲染真实DOM的开销非常大,当数据进行修改时,我们如果直接渲染到真实DOM上会引起整个dom树的重绘和重排,那如果我们只是想更新我们修改的那一小块dom呢,这时候diff算法就能帮助到我们。

三、当数据发生变化时,vue是怎么更新节点的?

我们首先会根据真实的DOM生成一棵virtual DOM(虚拟DOM),当virtual DOM某个节点的数据改变后会生成一个新的Vnode,然后Vnode和oldVnode作对比,发现有不一样的地方就直接修改在真实的DOM上,然后使oldVnode的值为Vnode。diff的过程就是调用名为patch的函数,比较新旧节点,一边比较一边给真实的DOM打补丁。 注意:只有同一个虚拟节点,才能进行精细化比较,否则就是暴力删除旧的、插入新的。 那如何定义同一个虚拟节点?就是选择器相同且key相同,这也是为什么我们在写v-for循环时为什么要加key的原因。

四、virtual DOM和真实DOM的区别

virtual DOM是将真实的DOM的数据抽取出来,以对象的形式模拟树形结构。 真实DOM:

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

virtual DOM:

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

五、diff的比较方式

image.png

相信大家很多地方都看到这张图,diff算法只会进行同层的比较不会进行跨层比较,即使是同一片虚拟节点,如果跨层比较,精细化对比就不会进行比较,只会暴力拆除,然后插入新的节点。

六、patch函数

1.patch函数被调用的过程

image1.png

2.patch核心方法改写

export function patch(oldVnode, newVnode) {
    const isRealElement = oldVnode.nodeType;
    if (isRealElement) {
        // oldVnode是真实dom元素 就代表初次渲染
  } else {
    // oldVnode是虚拟dom 就是更新过程 使用diff算法

    //判断oldVnode和newVnode是不是同一个节点
    if(oldVnode.key== newVnode.key && oldVnode.tag == newVnode.tag){
        //是同一个节点,进行精细化比较
        //判断oldVnode和newVnode是否是同一个对象
        if(newVnode === oldVnode) return

        //判断新vnode有没有text属性
        if(newVnode.text != undefined && (newVnode.children == undefined || newVnode.children.length == 0)){
            //vnode有text属性
            if(newVnode.text != oldVnode.text){
                //如果newVnode中的text和oldVnode的text不同,直接让新的text写入老的elm中,如果老得elm中是children,也会立即消失掉
                oldVnode.elm.innerText = vnode.text 
            }
        }else{
            //vnode没有text属性

            //判断oldVnode有没有children
            if(oldVnode.children != undefined && oldVnode.children.length > 0){
                //oldVnode和newVnode都有子节点

                //更新子节点
                updateChildren(oldVnode.elm, oldVnode.children, newVnode.children);
            }else{
                //oldVnode没有子节点,newVnode有子节点

                //清空老节点的内容
                oldVnode.elm.innerHTML = '';
                //遍历newVnode的子节点,创建DOM上树
                for(let i = 0; i < newVnode.children.length ;i++){
                    let dom = createElement(newVnode.children[i]);
                    oldVnode.elm.appendChild(dom)
                }
            }
        }
    }
    else{
        //不是同一个节点暴力插入新的节点
        let newNodeElm =  createElement(newVnode)
        if(oldVnode.elm.parentNode&& newNodeElm){
            oldVnode.elm.parentNode.insetBefore(newNodeElm,oldVnode.elm)
        }
        //删除老节点
        oldVnode.elm.parentNode.removeChild(oldVnode.elm)

    }
}
}

3.diff算法子节点更新方式

首先我们要知道diff算法里面提供了四种命中查找方法:1)新前与旧前、2)新后与旧后、3)新后与旧前、4)新前与旧后; 按顺序循环命中,一个节点只要命中一个策略就再进行命中判断;如果都没有命中,就需要用循环来命中!

首先我们要理解一个概念是:无论新还是旧,前>后,那就表示这个子节点循环完毕; 1)如果旧节点先循环完毕,新节点中还有剩余节点,说明他们是要新增的节点; 2)如果新节点先循环完毕,老节点中还有剩余节点,说明他们是要被删除的节点; 3)当情况3命中时,此时要移动节点,把新前指向的节点移动到旧后之后; 4)当情况4命中时,此时要移动节点,把新前指向的节点移动到旧前之前;

updateChildren更新子节点--diff算法核心方法

// 判断是否是同一个节点
function isSameVnode(oldVnode, newVnode) {
  return oldVnode.tag === newVnode.tag && oldVnode.key === newVnode.key
}

export default function updateChildren(parentElm, oldCh, newCh) {
  // 旧前
  const oldStartIdx = 0
  // 新前
  const newStartIdx = 0
  // 旧后
  const oldEndIdx = oldCh.length - 1
  // 新后
  const newEndIdx = newCh.length - 1

  // 旧前节点
  const oldStartVnode = oldCh[0]
  // 旧后节点
  const oldEndVnode = oldCh[oldEndIdx]
  // 新前节点
  const newStartVnode = newCh[0]
  // 新后节点
  const newEndVnode = newCh[newEndIdx]

  // 根据key来创建老的儿子的index映射表  类似 {'a':0,'b':1} 代表key为'a'的节点在第一个位置 key为'b'的节点在第二个位置
  //这样就不用每次都遍历老对象了
  function makeIndexByKey(children) {
    const map = {}
    children.forEach((item, index) => {
      map[item.key] = index
    })
    return map
  }
  // 生成的映射表
  const map = makeIndexByKey(oldCh)

  while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
    // 首先判断节点是否已经被标记过
    if (!oldStartVnode) {
      oldStartVnode = oldCh[++oldStartIndex]
    } else if (!oldEndVnode) {
      oldEndVnode = oldCh[--oldEndIndex]
    } else if (isSameVnode(oldStartIdx, newStartVnode)) {
      // 新前和旧前
      patch(oldStartVnode, newStartVnode)
      oldStartVnode = oldCh[++oldStartIdx]
      newStartVnode = newCh[++newStartIdx]
    } else if (isSameVnode(oldEndVnode, newEndVnode)) {
      // 新后与旧后
      patch(oldEndVnode, newEndVnode)
      oldEndVnode = oldCh[--oldEndIdx]
      newEndVnode = newCh[--newEndIdx]
    } else if (isSameVnode(newEndVnode, oldStartVnode)) {
      // 新后与旧前
      patch(newEndVnode, oldStartVnode)
      // 当新后与旧前命中时,此时要移动节点,把新前指向的节点移动到旧后之后
      parent.insertBefore(oldStartVnode.elm, oldEndVnode.elm.nextSibling)
      newEndVnode = newCh[--newEndIdx]
      oldStartVnode = oldCh[++oldStartIdx]
    } else if (isSameVnode(newStartVnode, oldEndVnode)) {
      // 新前与旧后
      patch(newStartVnode, oldEndVnode)
      // 当新前与旧后命中时,此时要移动节点,把新前指向的节点移动到旧前之前!
      parent.insertBefore(oldEndVnode.elm, oldStartVnode.elm)
      oldEndVnode = oldCh[--oldEndIdx]
      newEndVnode = newCh[--newEndIdx]
    } else {
      // 4种情况都没有命中
      // 寻找当前这项在keyMap中的映射的位置序号
      const idxInOld = map[newStartVnode.key]
      // 如果idxInOld是undefined表示是全新的项,需要插入
      if (!idxInOld) {
        parentElm.insertBefore(createElement(newStartVnode), oldStartVnode.elm)
      } else {
        // 如果存在 表示节点需要移动
        const moveVnode = oldCh[idxInOld] // 找得到就拿到老的节点
        patch(moveVnode, newStartVnode)
        oldCh[idxInOld] = undefined // 这个是占位操作 表示已处理完 避免数组塌陷  防止老节点移动走了之后破坏了初始的映射表位置
        parent.insertBefore(moveVnode.el, oldStartVnode.el) // 把找到的节点移动到最前面
        newStartVnode = newCh[++newEndIdx]
      }
    }
  }

  // 循环结束看有没有剩余节点
  if (newStartIdx <= newEndIdx) {
    // 新节点还有剩余节点 需要插入
    const before =
      newCh[newEndIdx + 1] == null ? null : newCh[newEndIdx + 1].elm
    // insertBefore方法可以自动识别null,如果是null就会自动排到队尾去,相当于applendChild方法
    for (let i = newStartIdx; i <= newEndIdx; i++) {
      // newCh[i]现在还没有真正的dom,需要创建
      parentElm.insertBefore(createElement(newCh[i], before))
    }
  } else if (oldStartIdx <= oldEndIdx) {
    // 旧节点还有剩余节点 需要删除
    for (let i = oldStartIdx; i <= oldEndIdx; i++) {
      parentElm.removeChild(oldCh[i].elm)
    }
  }
}