Skip to content

diff 算法

在 Vue 里面每个组件都有一个更新函数(patch),通过对新旧虚拟节点(vnode)的对比,来更新真实 DOM,为了提高性能,增加 DOM 的复用性,提出了 diff 算法。

TIP

  1. 只比较同一层,不做跨级比较
  2. 通过 tag(标签名)&& key 是否相同,来判断是否是同一个节点
    • 是:复用老节点,将属性更新到老节点,然后更新儿子节点;
    • 不是:删除老的的创建新的

Vue2

比较节点属性

遍历旧的属性,如果新的属性上没有,就操作 DOM 从当前节点将属性移除,再遍历新节点,一次将属性设置在 DOM 上

javascript
// 更新节点属性
function updateProperties() {
  // 比对样式 如果老的有新的没有,就删除这个属性
  for (let key in oldStyle) {
    if (!newStyle[key]) {
      el.style[key] = '';
    }
  }
  // 删除多余属性
  for (let key in oldProps) {
    if (!newProps[key]) {
      el.removeAttribute(key);
    }
  }
  // 遍历 newProps 设置新的属性
  for (let key in newProps) {
    if (key === 'style') {
      for (let styleName in newProps.style) {
        el.style[styleName] = newProps.style[styleName];
      }
    } else if (key === 'class') {
      el.className = newProps.class;
    } else {
      el.setAttribute(key, newProps[key]);
    }
  }
}

比较孩子节点:

旧(有子节点)新 (有子节点)处理
双端对比算法更新子节点
直接清空
遍历增加新的儿子的节点

双端对比算法

首先为新、旧两个数组创建头尾双指针

  1. 头头比较
  2. 尾尾比较
  3. 交叉比较:新头 & 老尾
  4. 交叉比较:新尾 & 旧头
  5. 乱序比对
双端对比算法 code
javascript
// 比较儿子节点
function updateChildren(el, oldChildren, newChildren) {
  // 我们操作列表 经常会是有  push shift pop unshift reverse sort这些方法  (针对这些情况做一个优化)
  // vue2中采用双指针的方式 比较两个节点
  let oldStartIndex = 0;
  let newStartIndex = 0;
  let oldEndIndex = oldChildren.length - 1;
  let newEndIndex = newChildren.length - 1;

  let oldStartVnode = oldChildren[0];
  let newStartVnode = newChildren[0];

  let oldEndVnode = oldChildren[oldEndIndex];
  let newEndVnode = newChildren[newEndIndex];

  function makeIndexByKey(children) {
    let map = {};
    children.forEach((child, index) => {
      map[child.key] = index;
    });
    return map;
  }

  let map = makeIndexByKey(oldChildren);

  while (oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex) {
    // 双方有一方头指针,大于尾部指针则停止循环
    if (!oldStartVnode) {
      oldStartVnode = oldChildren[++oldStartIndex];
    } else if (!oldEndVnode) {
      oldEndVnode = oldChildren[--oldEndIndex];
    } else if (isSameVnode(oldStartVnode, newStartVnode)) {
      // 头头比较
      patchVnode(oldStartVnode, newStartVnode); // 如果是相同节点 则递归比较子节点
      oldStartVnode = oldChildren[++oldStartIndex];
      newStartVnode = newChildren[++newStartIndex];
      // 比较开头节点
    } else if (isSameVnode(oldEndVnode, newEndVnode)) {
      // 尾尾比较
      patchVnode(oldEndVnode, newEndVnode);
      oldEndVnode = oldChildren[--oldEndIndex];
      newEndVnode = newChildren[--newEndIndex];
    } else if (isSameVnode(oldEndVnode, newStartVnode)) {
      // 交叉比较:新头 & 老尾
      patchVnode(oldEndVnode, newStartVnode);
      // insertBefore 具备移动性 会将原来的元素移动走
      el.insertBefore(oldEndVnode.el, oldStartVnode.el); // 将老的尾巴移动到老的前面去
      oldEndVnode = oldChildren[--oldEndIndex];
      newStartVnode = newChildren[++newStartIndex];
    } else if (isSameVnode(oldStartVnode, newEndVnode)) {
      // 交叉比较:新尾 & 旧头
      patchVnode(oldStartVnode, newEndVnode);
      // insertBefore 具备移动性 会将原来的元素移动走
      el.insertBefore(oldStartVnode.el, oldEndVnode.el.nextSibling); // 将老的尾巴移动到老的前面去
      oldStartVnode = oldChildren[++oldStartIndex];
      newEndVnode = newChildren[--newEndIndex];
    } else {
      // 在给动态列表添加key的时候 要尽量避免用索引,因为索引前后都是从0 开始 , 可能会发生错误复用
      // 乱序比对
      // 根据老的列表做一个映射关系 ,用新的去找,找到则移动,找不到则添加,最后多余的就删除
      let moveIndex = map[newStartVnode.key]; // 如果拿到则说明是我要移动的索引
      if (moveIndex !== undefined) {
        let moveVnode = oldChildren[moveIndex]; // 找到对应的虚拟节点 复用
        el.insertBefore(moveVnode.el, oldStartVnode.el);
        oldChildren[moveIndex] = undefined; // 表示这个节点已经移动走了
        patchVnode(moveVnode, newStartVnode); // 比对属性和子节点
      } else {
        el.insertBefore(createElm(newStartVnode), oldStartVnode.el);
      }
      newStartVnode = newChildren[++newStartIndex];
    }
  }
  if (newStartIndex <= newEndIndex) {
    // 新的多了 多余的就插入进去
    for (let i = newStartIndex; i <= newEndIndex; i++) {
      let childEl = createElm(newChildren[i]);
      // 这里可能是像后追加 ,还有可能是向前追加
      let anchor = newChildren[newEndIndex + 1]
        ? newChildren[newEndIndex + 1].el
        : null; // 获取下一个元素
      // el.appendChild(childEl);
      el.insertBefore(childEl, anchor); // anchor 为null的时候则会认为是appendChild
    }
  }

  if (oldStartIndex <= oldEndIndex) {
    // 老的多了,需要删除老的
    for (let i = oldStartIndex; i <= oldEndIndex; i++) {
      if (oldChildren[i]) {
        let childEl = oldChildren[i].el;
        el.removeChild(childEl);
      }
    }
  }
}

Vue3

旧孩子新孩子处理
数组文本删除老儿子,设置文本内容
文本文本更新文本
数组数组diff 算法
文本数组清空文本,创建新儿子挂载
数组清空儿子

diff 全量更新 patchKeyedChildren()

  • 老的 children:[ a, b, c, d, e, f, g ]
  • 新的 children:[ a, b, f, c, d, e, h, g ]
  1. 先进行头和头比,发现不同就结束循环,得到 [ a, b ]
  2. 再进行尾和尾比,发现不同就结束循环,得到 [ g ]
  3. 再保存没有比较过的节点 [ f, c, d, e, h ],并通过 newIndexToOldIndexMap 拿到在数组里对应的下标,生成数组 [ 5, 2, 3, 4, -1 ],-1 是老数组里没有的就说明是新增
  4. 然后再拿取出数组里的最长递增子序列,也就是 [ 2, 3, 4 ] 对应的节点 [ c, d, e ]
  5. 然后只需要把其他剩余的节点,基于 [ c, d, e ] 的位置进行移动/新增/删除就可以了
javascript
/**
 * diff 更新vnode -> dom
 * @param c1  oldChildren
 * @param c2  newChildren
 * @param el
 */
const patchKeyedChildren = (c1, c2, el) => {
  let i = 0; // 两个子节点的头指针
  let e1 = c1.length - 1;
  let e2 = c2.length - 1;
  /** more */
};

从前往后比 相同的节点直接复用,不同的时候退出循环

javascript
// 任意一方头尾指针相同,结束循环
while (i <= e1 && i <= e2) {
  const n1 = c1[i];
  const n2 = c2[i];
  if (isSameVnode(n1, n2)) {
    patch(n1, n2, el); // 同一个dom节点 复用 递归比较属性和子节点了
  } else {
    // 不同 则退出循环 后面的节点默认都是不同的 前面的都是相同节点
    break;
  }
  i++;
}

从后开始往前比

javascript
while (i <= e1 && i <= e2) {
  const n1 = c1[e1];
  const n2 = c2[e2];
  if (isSameVnode(n1, n2)) {
    patch(n1, n2, el);
  } else break;
  e1--;
  e2--;
}

同序列的情况下,对上面的两步进行创建或者卸载

javascript
// 同序列 common sequence + mount
// i 比 e1 大 则 i 和 e2 之间的节点都是新增节点(可能没有)
// 上面的从前往后和从后往前两种情况都可以通过下面代码来处理
if (i > e1) {
  // 循环创建新增节点
  while (i <= e2) {
    /**
     * 从前往后、从后往前两种情况需要区分新增的节点是要插在最后(类似 push)呢还是插在最前面(unshift)
     * 我们可以狠巧妙的发现,无论哪种情况 其实都是看后面的节点是否存在
     * 因为 insertBefore 有个特性 如果传入的是真实节点就插入到其前面,如果是 null 就追加在后面(类似appendChild)
     */
    const nextPos = e2 + 1;
    // 插入指定兄弟节点之前
    const anchor = c2[nextPos]?.el;
    patch(null, c2[i++], el, anchor);
  }
}
// 同序列卸载
else if (i > e2) {
  while (i <= e1) {
    unmount(c1[i++]);
  }
}

乱序比对:

  1. 将新的 vnode 创建 <key, index> 的 Map 对象
  2. 根据新的节点的个数创建标记 newIndexToOldIndex, 新的索引对应老索引 如果后面遍历一次之后 位置上的值还是 0,说明这个节点是新增的
  3. 倒序插入(通过最长递增子序列进行优化)
javascript
let s1 = i,
  s2 = i;
// 将新的vnode做成map比较 `{e:2, c:3, d:4, h:5}`
const keyToNewIndexMap = new Map();
for (let i = s2; i <= e2; i++) {
  keyToNewIndexMap.set(c2[i].key, i);
}
// 应该移动的节点个数
let toBePatched = e2 - s2 + 1;
/**
 * 标记被patch过的节点 也就是复用的节点 用于后面倒序插入时判断是新增的还是patch的
 * 如果后面遍历一次之后,位置上的值还是0,说明这个节点是新增的
 */
const newIndexToOldIndex = new Array(toBePatched).fill(0);
/**
 * 循环老的节点 看新的有没有 有就比较差异 没有就卸载节点
 * 注意 这里只是将节点给做了 patch 和卸载,并没有将其移动到正确的位置
 */
for (let i = s1; i <= e1; i++) {
  const oldChild = c1[i];
  const existIndex = keyToNewIndexMap.get(oldChild.key);
  if (!existIndex) {
    // 卸载
    unmount(oldChild);
  } else {
    // 比较节点差异 新的索引对应老索引
    newIndexToOldIndex[existIndex - s2] = i + 1;
    patch(oldChild, c2[existIndex], el);
  }
}
// 移动节点位置 从后往前插入
// 最长递增子序列 优化
// 获取最长递增子序列
const increment = getSequence(newIndexToOldIndex);
let j = increment.length - 1;
for (let i = toBePatched - 1; i >= 0; i--) {
  // 计算出节点的真实索引
  let index = s2 + i;
  const current = c2[index]; // 找到最后一个节点
  const anchor = c2[index + 1]?.el; // 参照物 插入该节点前
  // 是新增 还是移动复用节点
  if (newIndexToOldIndex[i] === 0) {
    // 需要创建
    patch(null, current, el, anchor);
  } else {
    // 不相等 就插入节点 相同 就跳过
    if (i !== increment[j]) {
      // 比对过 直接移动节点即可复用
      hostInsert(current.el, el, anchor);
    } else {
      j--;
    }
  }
}

key 的作用

key 主要是为了更高效的更新 DOM

vue 的 patch 方法中,会通过标签名和 key 来判断是否是相同节点,相同的执行更新,不同的则会删除重建

不能用 index 作为 key

  1. 会影响性能,在插入节点操作时,如果用 index 作为 key,那么插入的节点的后面所有节点都会认为不是同一个(因为 index 有变化),都会重新创建 DOM,极大的浪费了性能。
  2. 删除操作时,可能会导致数组错位(常见于 input 输入框)

vue3 相比 vue2 的优点

  1. 增加了 PatchFlags 在编译的时候(创建 render 函数)将静态节点提升,将动态节点标记出哪些属性是动态的,使 vue 在运行时做 diff 更加高效。

静态提升 hoist

将一些静态的节点提升到 render 函数外,每次 render 函数树执行直接引用提升了的静态节点即可,不需要反复创建。

  1. cacheHandlers 事件侦听器缓存

事件更新时需要先卸载之前的在换上新的,很耗费性能,所以 vue3 增加了事件缓存,diff 时只修改旧事件函数的地址

cacheHandlers

  1. 给绑定事件的 el 增加 _vei(vue event invoker)
  2. addEventListener(el, name, invoker, options) 绑定的回调设置为 invoker
  3. 用户绑定的事件设置在 invoker 上,invoker[rawName] = createInvoker(xxx)
  4. diff 的时候修改 invoker 对象上对应方法名的地址
  1. 使用最长递增子序列优化乱序比对,可以最大程度的减少对 DOM 的操作

参考