diff 算法

670 阅读7分钟

这是我参与8月更文挑战的第16天,活动详情查看:8月更文挑战

diff 算法

Vue的diff算法是基于snabbdom改造过来的,仅在同级的vnode间做diff,递归地进行同级vnode的diff,最终实现整个DOM树的更新。在vue里面 diff 算法是通过patch函数来完成的,所以有的时候也叫patch算法

如何理解Diff算法

在js中,渲染真实DOM的开销是非常大的, 比如我们修改了某个数据,如果直接渲染到真实DOM, 会引起整个dom树的重绘和重排。Diff算法先根据真实dom生成虚拟dom, 当虚拟dom某个节点的数据改变后会生成有一个新的Vnode, 然后新的Vnode和旧的Vnode作比较,发现有不一样的地方就直接修改在真实DOM上,然后使新的Vnode的值为旧的Vnode

diff的过程就是调用patch函数,比较新旧节点,一边比较一边给真实的DOM打补丁。在采取diff算法比较新旧节点的时候,比较只会在同层级进行。 在patch方法中,首先进行树级别的比较 new Vnode不存在就删除 old Vnode old Vnode 不存在就增加新的Vnode 都存在就执行diff更新 当确定需要执行diff算法时,比较两个Vnode,包括三种类型操作:属性更新,文本更新,子节点更新 新老节点均有子节点,则对子节点进行diff操作,调用updatechidren 如果老节点没有子节点而新节点有子节点,先清空老节点的文本内容,然后为其新增子节点 如果新节点没有子节点,而老节点有子节点的时候,则移除该节点的所有子节点 老新老节点都没有子节点的时候,进行文本的替换

updateChildrenVnode的子节点Vch和oldVnode的子节点oldCh提取出来。 oldCh和vCh各有两个头尾的变量StartIdx和EndIdx,它们的2个变量相互比较,一共有4种比较方式。如果4种比较都没匹配,如果设置了key,就会用key进行比较,在比较的过程中,变量会往中间靠,一旦StartIdx>EndIdx表明oldCh和vCh至少有一个已经遍历完了,就会结束比较。

Diff算法是一种对比算法。对比旧虚拟DOM和新虚拟DOM,对比出是哪个虚拟节点更改了,找出这个虚拟节点,并只更新这个虚拟节点所对应的真实节点,而不用更新其他数据没发生改变的节点,实现精准地更新真实DOM,进而提高效率

diff的策略

同层策略:

  • 按tree层级diff(level by level)

    因为跨层级的操作是非常少的,所以Diff算法是:同层比较、深度优先算法。 时间复杂度:O(n)

  • 按类型进行diff

    不论virtual dom对应的是原生DOm节点还是vue组件,不同类型的节点子树间的差异明显。因此为了提高效率,只diff相同类型的节点,当新旧节点类型发生变化,直接创建新类型的virtual dom替换旧节点。

  • 在列表diff中,给每个diff绑定key,可以提升diff的效率。

diff 算法的步骤:

  • 用 JavaScript 对象结构表示 DOM 树的结构;然后用这个树构建一个真正的 DOM 树,插到文档当中
  • 当状态变更的时候,重新构造一棵新的对象树。然后用新的树和旧的树进行比较,记录两棵树差异
  • 把所记录的差异应用到所构建的真正的DOM树上,视图就更新了

当数据改变时,会触发setter,并且通过Dep.notify去通知所有订阅者Watcher,订阅者们就会调用patch方法,给真实DOM打补丁,更新相应的视图。

diff 算法的实现过程

patch方法

这个方法的作用是,使用sameVnode(oldVnode, newVnode)方法判断新旧虚拟Dom节点,是否为同一类型的的标签:

  • 是:继续执行patchVnode(oldVnode, newVnode)方法进行深层比对
  • 否:将整个旧节点替换成新虚拟节点

下面是patch的核心代码:

function patch(oldVnode, newVnode) {
  // 比较是否为一个类型的节点
  if (sameVnode(oldVnode, newVnode)) {
    // 是:继续进行深层比较
    patchVnode(oldVnode, newVnode)
  } else {
    // 否
    const oldEl = oldVnode.el // 旧虚拟节点的真实DOM节点
    const parentEle = api.parentNode(oldEl) // 获取父节点
    createEle(newVnode) // 创建新虚拟节点对应的真实DOM节点
    if (parentEle !== null) {
      api.insertBefore(parentEle, vnode.el, api.nextSibling(oEl)) // 将新元素添加进父元素
      api.removeChild(parentEle, oldVnode.el)  // 移除以前的旧元素节点
      // 设置null,释放内存
      oldVnode = null
    }
  }
​
  return newVnode
}

sameVnode方法

作用是判断新旧节点是不是同一类型:

function sameVnode(oldVnode, newVnode) {
  return (
    oldVnode.key === newVnode.key && // key值是否一样
    oldVnode.tagName === newVnode.tagName && // 标签名是否一样
    oldVnode.isComment === newVnode.isComment && // 是否都为注释节点
    isDef(oldVnode.data) === isDef(newVnode.data) && // 是否都定义了data
    sameInputType(oldVnode, newVnode) // 当标签为input时,type必须是否相同
  )
}

patchVnode方法

这个方法的原理分析:

  • 找到对应的真实DOM,称为el
  • 判断newVnodeoldVnode是否指向同一个对象,如果是,那么直接return
  • 如果他们都有文本节点并且不相等,那么将el的文本节点设置为newVnode的文本节点。
  • 如果oldVnode有子节点而newVnode没有,则删除el的子节点
  • 如果oldVnode没有子节点而newVnode有,则将newVnode的子节点真实化之后添加到el
  • 如果两者都有子节点,则执行updateChildren函数比较子节点
unction patchVnode(oldVnode, newVnode) {
  const el = newVnode.el = oldVnode.el // 获取真实DOM对象
  // 获取新旧虚拟节点的子节点数组
  const oldCh = oldVnode.children, newCh = newVnode.children
  // 如果新旧虚拟节点是同一个对象,则终止
  if (oldVnode === newVnode) return
  // 如果新旧虚拟节点是文本节点,且文本不一样
  if (oldVnode.text !== null && newVnode.text !== null && oldVnode.text !== newVnode.text) {
    // 则直接将真实DOM中文本更新为新虚拟节点的文本
    api.setTextContent(el, newVnode.text)
  } else {
    // 否则
​
    if (oldCh && newCh && oldCh !== newCh) {
      // 新旧虚拟节点都有子节点,且子节点不一样
​
      // 对比子节点,并更新
      updateChildren(el, oldCh, newCh)
    } else if (newCh) {
      // 新虚拟节点有子节点,旧虚拟节点没有
​
      // 创建新虚拟节点的子节点,并更新到真实DOM上去
      createEle(newVnode)
    } else if (oldCh) {
      // 旧虚拟节点有子节点,新虚拟节点没有
​
      //直接删除真实DOM里对应的子节点
      api.removeChild(el)
    }
  }
}

updateChildren方法

作用: 新旧虚拟节点的子节点对比

对比方法: 首尾指针法

对比流程:

旧节点有两个指针:旧首指针、旧尾指针

新节点有两个指针:新首指针、新尾指针

  • 1.旧首指针 与 新首指针 对比, 若相等, 旧首指针 与 新首指针向前(右)移动,回到第一步开始对比;若不相等,执行第二步
  • 2.旧尾指针 与 新尾指针 对比, 若相等, 旧尾指针 与 新尾指针向后(左)移动,回到第一步开始对比;若不相等,执行第三步
  • 3.旧首指针 与 新尾指针 对比(斜方向\), 若相等,将旧首指针指向的真实dom节点移动至旧尾指针指向的节点之后, 旧首指针向前移动新尾指针向后移动,回到第一步开始对比;若不相等,执行第四步
  • 4.旧尾指针 与 新首指针 对比(反斜方向/), 若相等,将旧尾指针指向的真实dom节点移动至旧首指针指向的节点之前, 旧尾指针向后移动 新首指针向前移动,回到第一步开始对比;若不相等,执行第五步
  • 5.遍历旧首指针、旧尾指针之间的所有节点,寻找是否有与新首指针指向节点相等的节点,若找得到,则将这个节点移动到旧首指针指向的节点之前,回到第一步开始对比;若找不到,则新创建一个与新首指针指向节点相等的节点,放在旧首指针指向的节点之前,旧尾指针向后移动;

当新尾指针 < 新首指针,则结束对比,并删除掉多余节点:旧首指针、旧尾指针之间的节点。

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) {
      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)
  }
}

为什么 Vue 中v-for循环渲染的时候不要用 index 作为 key?

可以参考juejin.cn/post/699495…中的用index做key

参考

链接:juejin.cn/post/684490…

链接:www.cnblogs.com/fundebug/p/…

juejin.cn/post/698942…

juejin.cn/post/699495…