Diff

300 阅读5分钟

引言

在如今的面试中,对候选人源码原理层面的考察,越来越重视。其中 diff 算法,问的尤为高频。在拜读了多位大佬的分析文章并对照源码,写一些自己对 diff 的理解,希望对您有所帮助。

VirtualDOM

个人的理解,virtual dom就是真实dom的一种抽象,是 JavaScript 对真实dom的描述。现在主流前端框架几乎都有 virtual dom这个概念的原因,无非有以下这几点。

  • 可以通过diff算法,找出最小差异,然后进行更新操作,更有效率。
  • 应用跨平台成为需求,通过vdom 抽象真实 dom,更加适合于跨平台,只需在不同平台,将vdom生成相应真实dom即可。
  • 为前后端同构提供了可能性。

diff 的方式

在采取diff算法比较新旧节点的时候,比较只会在同层级进行, 不会跨层级比较。

隔壁到来的图

何时调用

当改变响应式属性时,会调用属性的 set 方法, 进而调用 dep.notify(); 依赖于此响应式属性的Watcher就会通过patch比对oldVnode、vnode,得出最小差异,更新视图。

patch

// src/core/vdom/patch.js
// 代码均为删除了非主要代码,保留重要流程逻辑的代码
// 英文注释,为vue2.6 源码自带, 中文注释为本人添加
function patch(oldVnode, vnode, hydrating, removeOnly) {
  if (!isRealElement && sameVnode(oldVnode, vnode)) { 
    // 比较是否是 sameVnode
    // patch existing root node
    patchVnode(oldVnode, vnode);
  } else {
    // 如果不是 sameVnode 直接获取 oldVnode 的 parent,删除 oldVnode,再添加 vnode
    // replacing existing element
    const oldElm = oldVnode.elm; // Vnode 中elm属性保存真实的 element
    const parentElm = nodeOps.parentNode(oldElm); // 获取 oldVnode 的父节点
    createElm(vnode);
    // destroy old node
    if (isDef(parentElm)) {
      removeVnodes(parentElm, [oldVnode], 0, 0);
    } 
  }
  return vnode.elm;
};

sameVnode

// 比较 oldVnode vnode 的 key, tag, isComment, data是否相同
// 如果是input标签,还需比较 type 是否相同
// 因为有些浏览器不支持动态修改 input 输入框的 type 值
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)
      ) 
    )
  )
}

patchVnode

function patchVnode(oldVnode, vnode) {
  if (oldVnode === vnode) {
    return; // 相等直接返回
  }

  const elm = (vnode.elm = oldVnode.elm);
  // reuse element for static trees.
  // note we only do this if the vnode is cloned -
  // if the new node is not cloned it means the render functions have been
  // reset by the hot-reload-api and we need to do a proper re-render.
  if (
    isTrue(vnode.isStatic) &&
    isTrue(oldVnode.isStatic) &&
    vnode.key === oldVnode.key &&
    (isTrue(vnode.isCloned) || isTrue(vnode.isOnce))
  ) {
    vnode.componentInstance = oldVnode.componentInstance;
    return;
  }

  let i;
  // 获取各自子节点
  const oldCh = oldVnode.children;
  const ch = vnode.children;

  if (isUndef(vnode.text)) {
    if (isDef(oldCh) && isDef(ch)) {
      if (oldCh !== ch)
        updateChildren(elm, oldCh, ch);
    } else if (isDef(ch)) {
      if (process.env.NODE_ENV !== 'production') {
        checkDuplicateKeys(ch); // 此处可说明,key 不可重复
      }
      if (isDef(oldVnode.text)) nodeOps.setTextContent(elm, '');
      addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue);
    } else if (isDef(oldCh)) {
      removeVnodes(elm, oldCh, 0, oldCh.length - 1); // 如果oldCh 存在, ch不存在,则清空 oldCh
    } else if (isDef(oldVnode.text)) {
      // vnode 无 文本节点, oldVnode 有文本节点,则清空oldVnode 文本节点
      nodeOps.setTextContent(elm, '');
    }
  } else if (oldVnode.text !== vnode.text) {
    nodeOps.setTextContent(elm, vnode.text);
  }
}

从染陌大佬处copy, 总结很到位

  1. 如果新旧VNode都是静态的,同时它们的key相同(代表同一节点),并且新的VNode是clone或者是标记了once(标记v-once属性,只渲染一次),那么只需要替换elm以及componentInstance即可。
  2. 新老节点均有children节点,则对子节点进行diff操作,调用updateChildren,这个updateChildren也是diff的核心。
  3. 如果老节点没有子节点而新节点存在子节点,先清空老节点DOM的文本内容,然后为当前DOM节点加入子节点。
  4. 当新节点没有子节点而老节点有子节点的时候,则移除该DOM节点的所有子节点。
  5. 当新老节点都无子节点的时候,只是文本的替换。

updateChildren

千呼万唤始出来,兜兜转转许久,终于见到了 updateChildren 函数,其就是 diff 的核心了。

function updateChildren (parentElm, oldCh, newCh) {
    let oldStartIdx = 0
    let 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

    if (process.env.NODE_ENV !== 'production') {
      checkDuplicateKeys(newCh)
    }

    while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
      if (isUndef(oldStartVnode)) {
        oldStartVnode = oldCh[++oldStartIdx] // Vnode has been moved left
      } else if (isUndef(oldEndVnode)) {
        oldEndVnode = oldCh[--oldEndIdx]
      } else if (sameVnode(oldStartVnode, newStartVnode)) { // 新老vnode 互相比较首位两个节点,总共四种方式
        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)) { // Vnode moved right
        patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue, newCh, newEndIdx)
        canMove && nodeOps.insertBefore(parentElm, oldStartVnode.elm)
        oldStartVnode = oldCh[++oldStartIdx]
        newEndVnode = newCh[--newEndIdx]
      } else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left
        patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
        canMove && nodeOps.insertBefore(parentElm, oldEndVnode.elm, oldStartVnode.elm)
        oldEndVnode = oldCh[--oldEndIdx]
        newStartVnode = newCh[++newStartIdx]
      } else {
        // 如果首尾两两都不满足 sameVnode
        if (isUndef(oldKeyToIdx)) oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx) // 将oldVnode 的 key 生成一张hash map 
        idxInOld = isDef(newStartVnode.key)
          ? oldKeyToIdx[newStartVnode.key]
          : findIdxInOld(newStartVnode, oldCh, oldStartIdx, oldEndIdx)
        if (isUndef(idxInOld)) { // New element 不存在,则新建element
          createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
        } else {// 在oldVnode 中有相同 key 的节点
          vnodeToMove = oldCh[idxInOld]
          if (sameVnode(vnodeToMove, newStartVnode)) { 
            // 比较是否是 sameVnode 如果是,就可以复用。否则不行
            // key 的作用是增加节点的复用性,而不只单单比较首尾两个节点。避免新建节点,增加开销。
            patchVnode(vnodeToMove, newStartVnode, insertedVnodeQueue, newCh, newStartIdx)
            oldCh[idxInOld] = undefined
            canMove && nodeOps.insertBefore(parentElm, vnodeToMove.elm, oldStartVnode.elm)
          } else {
            // same key but different element. treat as new element
            createElm(newStartVnode, insertedVnodeQueue, parentElm, oldStartVnode.elm, false, newCh, newStartIdx)
          }
        }
        newStartVnode = newCh[++newStartIdx]
      }
    }
    if (oldStartIdx > oldEndIdx) { // 说明 oldVnode 比 vnode少, 则新建vnode剩下的节点,添加进parentElm
      refElm = isUndef(newCh[newEndIdx + 1]) ? null : newCh[newEndIdx + 1].elm
      addVnodes(parentElm, refElm, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)
    } else if (newStartIdx > newEndIdx) { 说明 oldVnode 比 vnode多,清空剩余所有oldVnode
      removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx)
    }
}

createKeyToOldIdx

// 将children的key 生成一张hash表
function createKeyToOldIdx (children, beginIdx, endIdx) {
  let i, key
  const map = {}
  for (i = beginIdx; i <= endIdx; ++i) {
    key = children[i].key
    if (isDef(key)) map[key] = i
  }
  return map
}

总结

以上就是个人对 diff 的理解,为便于阅读,代码有所删减。尽量抓大放小,抓住主要流程,理清执行过程,有助于读懂源码。希望能对大家有所帮助。如果有错误或不严谨的地方,欢迎批评指正,如果喜欢,欢迎点赞。

参考