diff算法vue2/3

129 阅读13分钟

diff 基础

虚拟 dom 基础结构

// vnode.js
/**
 * 产生虚拟节点
 * 将传入的参数组合成对象返回
 * @param {string} sel 选择器
 * @param {object} data 属性、样式
 * @param {Array} children 子元素
 * @param {string|number} text 文本内容
 * @param {object} elm 对应的真正的dom节点(对象),undefined表示节点还没有上dom树
 * @returns 
 */
// 将5个参数组合成对象返回
export default function (sel, data, children, text, elm) {
    const key = data.key;
    return { sel, data, children, text, elm, key };
}
// h.js

import vnode from "./vnode.js";

// 低配 h 函数,必须接收3个参数
// 类似重载
// h('div', {}, '文字')
// h('div', {}, [])
// h('div', {}, h())
export default function (sel, data, c) {
    // 检查参数个数
    if (arguments.length != 3) {
        throw new Error('必须传入3个参数');
    }
    // 检查 c 的类型
    if (typeof c === 'string' || typeof c === 'number') {
        // 说明调用第一种
        return vnode(sel, data, undefined, c, undefined);
    } else if (Array.isArray(c)) {
        // 说明调用第二种
        let children = [];
        // 遍历 c,判断 c 的项是否合法
        for (let i = 0; i < c.length; i++) {
            if (!(typeof c[i] === 'object' && c[i].hasOwnProperty('sel'))) {
                throw new Error('传入的数组参数某项不是h函数');
            }
            children.push(c[i]);
        }
        return vnode(sel, data, children, undefined, undefined);
    } else if (typeof c === 'object' && c.hasOwnProperty('sel')) {
        // 说明调用第三种
        //唯一的children
        return vnode(sel, data, [c], undefined, undefined);
    } else {
        throw new Error('传入3个参数类型不对')
    }
}

判断 oldVnode 是虚拟节点还是真实的 dom 节点

// 判断第一个参数是虚拟节点还是真实的 dom 节点
if (oldVnode.sel === '' || oldVnode.sel === undefined) {
    // 如果是真实的 dom 节点,则包装成虚拟节点
    oldVnode = vnode(oldVnode.tagName.toLowerCase(), {}, [], undefined, oldVnode);
}

判断同一个节点的规则

  • 根据 key 和选择器 sel 唯一标识判断是否同一个节点
  • 同一虚拟节点才进行精细比较
  • 跨级不比较,直接删除,插入新的
// 判断 oldVnode 和 newVnode 是否为同一个节点
if (oldVnode.key === newVnode.key && oldVnode.sel === newVnode.sel) {
    console.log('同一个节点');
    patchVnode(oldVnode, newVnode);
} else {
    console.log('不是同一个节点先插入,再删');
    let newVnodeElm = createElement(newVnode);
    // 插入到老节点之前
    if (oldVnode.elm.parentNode && newVnodeElm) {
        oldVnode.elm.parentNode.insertBefore(newVnodeElm, oldVnode.elm);
    }
    // 删除老节点
    oldVnode.elm.parentNode.removeChild(oldVnode.elm);
}

image.png

diff 算法流程

  1. 判断新节点是不是文本节点,是:则直接替换旧的节点;否:下一阶段;
  2. 判断旧节点是不是文本节点,是:则用新节点的 children 直接替换;否:下一阶段;
  3. 新旧节点都有 children,则进行子节点判断; image.png

patchVnode 函数

import createElement from "./createElement";
import updateChildren from "./updateChildren";

export default function patchVnode (oldVnode, newVnode) {
    // 判断新旧 vnode 是否是同一个对象
    if (oldVnode === newVnode) {
        return;
    }
    // 判断新 vnode 有没有 text 属性
    if (newVnode.text !== undefined && (newVnode.children === undefined || newVnode.children.length === 0)) {
        // 新vnode有text属性
        console.log('新vnode有text属性');
        if (newVnode.text !== oldVnode.text) {
            // 如果新旧虚拟节点 text 不同,直接将新的 text 值写入旧的 elm中,即使旧的节点是 children 也会被覆盖
            oldVnode.elm.innerText = newVnode.text
        }
    } else {
        // 新vnode没有text属性,有children
        console.log('新vnode没有text属性');
        // 判断旧的有没有 children
        if (oldVnode.children !== undefined && oldVnode.children.length > 0) {
            // 旧的有 children,新旧都有 children 情况最复杂
            updateChildren(oldVnode.elm, oldVnode.children, newVnode.children);
        } else {
            // 旧的没有,新的有
            // 清空老节点内容
            oldVnode.elm.innerHTML = '';
            for (let i = 0; i < newVnode.children.length; i++) {
                let dom = createElement(newVnode.children[i]);
                oldVnode.elm.appendChild(dom);
            }
        }
    }
}

createElement 函数

// 真正创建节点,创建真正的 dom,是孤儿节点,暂不进行插入
export default function createElement (vnode) {
    // console.log('目的把虚拟节点插入', vnode, '变为真正的 dom 但不插入');
    // 创建一个 dom 节点,此节点目前还是孤儿节点
    let domNode = document.createElement(vnode.sel);
    // 判断是子节点还是文本
    if (vnode.text !== '' && (vnode.children === undefined || vnode.children.length === 0)) {
        // 是文本
        domNode.innerText = vnode.text;
    } else if (Array.isArray(vnode.children) && vnode.children.length > 0) {
        // 内部是子节点,要递归创建子节点
        for (let i = 0; i < vnode.children.length; i++) {
            // 得到 children
            let ch = vnode.children[i];
            // 创建真实 dom
            let chDom = createElement(ch);
            // 将子节点添加进父节点
            domNode.appendChild(chDom);
        }
    }
    // 创建的真实 dom 对象指向新的 vnode 的 elm 属性
    vnode.elm = domNode;

    // 返回真实 dom 对象
    return vnode.elm;
}

updateChildren() 函数

import createElement from "./createElement";
import patchVnode from "./patchVnode";

// 判断是否为同一个虚拟节点
function checkSameVnode (a, b) {
    return a.sel === b.sel && a.key === b.key;
}

export default function updateChildren (parentElm, oldCh, newCh) {
    console.log(oldCh, newCh)
    // 旧前
    let oldStartIdx = 0;
    // 旧后
    let oldEndIdx = oldCh.length - 1;
    // 新前
    let newStartIdx = 0;
    // 新后
    let newEndIdx = newCh.length - 1;
    // 旧前节点
    let oldStartVnode = oldCh[0];
    // 旧后节点
    let oldEndVnode = oldCh[oldEndIdx];
    // 新前节点
    let newStartVnode = newCh[0];
    // 新后节点
    let newEndVnode = newCh[newEndIdx];

    let keyMap = null;

    while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
        // 首先应该不是判断四种命中,而是略过已经加了undefined标记的项
        if (oldStartVnode === null || oldCh[oldStartIdx] === undefined) {
            oldStartVnode = oldCh[++oldStartIdx];
        } else if (oldEndVnode === null || oldCh[oldEndIdx] === undefined) {
            oldEndVnode = oldCh[--oldEndIdx];
        } else if (newStartVnode === null || newCh[newStartIdx] === undefined) {
            newStartVnode = newCh[++newStartIdx];
        } else if (newEndVnode === null || newCh[newEndIdx] === undefined) {
            newEndVnode = newCh[--newEndIdx];
        } else if (checkSameVnode(oldStartVnode, newStartVnode)) {
            console.log('一 旧前新前');
            // 旧前新前
            patchVnode(oldStartVnode, newStartVnode);
            oldStartVnode = oldCh[++oldStartIdx];
            newStartVnode = newCh[++newStartIdx];
        } else if (checkSameVnode(oldEndVnode, newEndVnode)) {
            console.log('二 旧后新后');
            // 旧后新后
            patchVnode(oldEndVnode, newEndVnode);
            oldEndVnode = oldCh[--oldEndIdx];
            newEndVnode = newCh[--newEndIdx];
        } else if (checkSameVnode(oldStartVnode, newEndVnode)) {
            console.log('三 旧前新后');
            // 旧前新后
            patchVnode(oldStartVnode, newEndVnode);
            // 此种情况命中后,要移动真实 dom,将 新后 指向节点移动到 旧后 指向节点后面
            // 移动节点原理:插入已经在 dom 树上的节点,它就会被移动
            parentElm.insertBefore(oldStartVnode.elm, oldEndVnode.elm.nextSibling); 

            oldStartVnode = oldCh[++oldStartIdx];
            newEndVnode = newCh[--newEndIdx];
        } else if (checkSameVnode(oldEndVnode, newStartVnode)) {
            console.log('四 旧后新前');
            // 旧后新前
            patchVnode(oldEndVnode, newStartVnode);
            // 此种情况命中后,要移动真实 dom,将 新前(旧后) 指向节点移动到 旧前 指向节点前面
            // 移动节点原理:插入已经在 dom 树上的节点,它就会被移动
            parentElm.insertBefore(oldEndVnode.elm, oldStartVnode.elm);

            oldEndVnode = oldCh[--oldEndIdx];
            newStartVnode = newCh[++newStartIdx];
        } else {
            // 4种都没有命中
            if (!keyMap) {
                keyMap = {};
                for (let i = oldStartIdx; i <= oldEndIdx; i++) {
                    const key = oldCh[i].key;
                    if (key !== undefined) {
                        keyMap[key] = i
                    }
                }
            }
            // 寻找 newStartIdx 这项在 keyMap 中的映射位置虚序号
            const idxInOld = keyMap[newStartVnode.key];
            if (idxInOld === undefined) {
                // 则表示是全新的项
                parentElm.insertBefore(createElement(newStartVnode), oldStartVnode.elm);
            } else {
                // 如果不是undefind,则要移动
                const elmToMove = oldCh[idxInOld];
                patchVnode(elmToMove, newStartVnode);
                // 把这项设置为 undefind,表示已处理
                oldCh[idxInOld] = undefined;
                // 移动,调用 insertBefore实现移动
                parentElm.insertBefore(elmToMove.elm, oldStartVnode.elm);
            }
            // 指针下移,只移动新的头
            newStartVnode = newCh[++newStartIdx];
        }
    }

    // 继续处理剩余节点
    if (newStartIdx <= newEndIdx) {
        console.log('newCh 剩余节点没有处理完');
        // 遍历新的newnewCh,添加到老的未处理之前
        for (let i = newStartIdx; i <= newEndIdx; i++) {
            // insertBefore 方法可以自动识别 null,如果是 null 自动插入队尾
            parentElm.insertBefore(createElement(newCh[i]), oldCh[oldStartIdx].elm);
        }
    } else if (oldStartIdx <= oldEndIdx) {
        console.log('oldCh 剩余节点没有处理完');
        // 批量删除 oldStart 和 oldEnd 之间的项
        for (let i = oldStartIdx; i <= oldEndIdx; i++) {
            if (oldCh[i]) {
                parentElm.removeChild(oldCh[i].elm);
            }
        }
    }
}

vue2

2和3的主要区别从 updateChildren 开始,在对比同一层子节点做了优化,vue2的判断规则是(旧新):头头、尾尾、前后、后前。指针名称:oldStart、oldEnd、newStart、newEnd

前前

  1. 比较旧前 oldStart 与新前 newStart;若命中,patch之后就移动头指针 ++oldStart、++newStart image.png
if (checkSameVnode(oldStartVnode, newStartVnode)) {
    console.log('一 旧前新前');
    // 旧前新前
    patchVnode(oldStartVnode, newStartVnode);
    oldStartVnode = oldCh[++oldStartIdx];
    newStartVnode = newCh[++newStartIdx];
}

后后

  1. 比较旧后 oldEnd 与新后 newEnd;若命中,patch之后就移动头指针 --oldEnd、--newEnd image.png
if (checkSameVnode(oldEndVnode, newEndVnode)) {
    console.log('二 旧后新后');
    // 旧后新后
    patchVnode(oldEndVnode, newEndVnode);
    oldEndVnode = oldCh[--oldEndIdx];
    newEndVnode = newCh[--newEndIdx];
}

前后

  1. 比较旧前 oldStrat 与新后 newEnd;若命中,将新后 newEnd 指向的节点移动到 旧后 oldEnd 指向节点之后,移动指针 --oldStart、++newEnd image.png
if (checkSameVnode(oldStartVnode, newEndVnode)) {
    console.log('三 旧前新后');
    // 旧前新后
    patchVnode(oldStartVnode, newEndVnode);
    // 此种情况命中后,要移动真实 dom,将 新后 指向节点移动到 旧后 指向节点后面
    // 移动节点原理:插入已经在 dom 树上的节点,它就会被移动
    parentElm.insertBefore(oldStartVnode.elm, oldEndVnode.elm.nextSibling); 

    oldStartVnode = oldCh[++oldStartIdx];
    newEndVnode = newCh[--newEndIdx];
}

后前

  1. 比较旧后 oldEnd 与新前 newStart;若命中,新前 newStart 指向的节点,移动到旧前 oldStart 之前,移动指针 --oldEnd、++newStart image.png
if (checkSameVnode(oldEndVnode, newStartVnode)) {
    console.log('四 旧后新前');
    // 旧后新前
    patchVnode(oldEndVnode, newStartVnode);
    // 此种情况命中后,要移动真实 dom,将 新前(旧后) 指向节点移动到 旧前 指向节点前面
    // 移动节点原理:插入已经在 dom 树上的节点,它就会被移动
    parentElm.insertBefore(oldEndVnode.elm, oldStartVnode.elm);

    oldEndVnode = oldCh[--oldEndIdx];
    newStartVnode = newCh[++newStartIdx];
}

旧元素做 Map

  1. 前四种判断都没命种,则将旧元素做成 Map 结构,用 Map 判断当前 newStartVnode 是移动还是新增
if (!keyMap) {
  keyMap = {};
  for (let i = oldStartIdx; i <= oldEndIdx; i++) {
    const key = oldCh[i].key;
     if (key !== undefined) {
       keyMap[key] = i
     }
  }
}
// 寻找 newStartIdx 这项在 keyMap 中的映射位置虚序号
const idxInOld = keyMap[newStartVnode.key];
if (idxInOld === undefined) {
  // 则表示是全新的项
  parentElm.insertBefore(createElement(newStartVnode), oldStartVnode.elm);
} else {
  // 如果不是undefind,则要移动
  const elmToMove = oldCh[idxInOld];
  patchVnode(elmToMove, newStartVnode);
  // 把这项设置为 undefind,表示已处理
  oldCh[idxInOld] = undefined;
  // 移动,调用 insertBefore实现移动
  parentElm.insertBefore(elmToMove.elm, oldStartVnode.elm);
}
// 指针下移,只移动新的头
newStartVnode = newCh[++newStartIdx];

处理剩余节点

旧节点列表可能有剩余节点:oldStartIdx <= oldEndIdx

 if (oldStartIdx <= oldEndIdx) {
    console.log('oldCh 剩余节点没有处理完');
    // 批量删除 oldStart 和 oldEnd 之间的项
    for (let i = oldStartIdx; i <= oldEndIdx; i++) {
        if (oldCh[i]) {
            parentElm.removeChild(oldCh[i].elm);
        }
    }
}

新节点列表可能有剩余节点:newStartIdx <= newEndIdx

if (newStartIdx <= newEndIdx) {
    console.log('newCh 剩余节点没有处理完');
    // 遍历新的newnewCh,添加到老的未处理之前
    for (let i = newStartIdx; i <= newEndIdx; i++) {
        // insertBefore 方法可以自动识别 null,如果是 null 自动插入队尾
        parentElm.insertBefore(createElement(newCh[i]), oldCh[oldStartIdx].elm);
    }
}

image.png

涉及函数

Node.insertBefore()

var insertedNode = parentNode.insertBefore(newNode, referenceNode);
insertNode:用于插入的节点(newNode)
parentNode:新插入节点的父节点
newNode:用于插入的节点
referenceNode :newNode 将要插在这个节点之前,如果此节点为 null,则直接插入最后
如果插入的节点已存在于父节点,则移动此节点到指定位置

Node.appendChild()

parentNode.appendChild(childNode)
parentNode:容器节点
childNode:要插入节点
如果插入的节点已存在于父节点,则移动此节点到最后

Node.removeChild()

let delNode = node.removeChild(child)

delNode:被删除节点
child:要删除节点

vue3

原理

因为 vue 是同一层级对比,以数组对比为例: 旧:[a, b, c, d, e, f, g];新:[a, b, e, c, d, h, f, g]

基本步骤

  1. 首先对比头部,相同的则复用节点,并以指针记录。上述数组 a、b 可以复用,指针指向第三个不同的元素,记录下标为 3;
  2. 对比尾部,因为数组长度不一致需要双指针记录,f、g 可以复用,同理,旧元素的指针记录下标为 4,新元素为 5;
  3. 对比完头尾后,会有3种情况出现
    3.1 老元素没有节点,新元素还有节点,要新增节点;
    3.2 老元素还有节点,新元素没有节点,要删除节点;
    3.3 新老节点都有节点,如上述,旧:[c, d, e];新:[e, c, d, h]
  4. 处理 3.3 情况是 vue3 diff的核心
    4.1 先是用 key-value 的数据结构保存新元素,在旧元素中找到可复用的旧元素(用pathc打补丁);
    4.2 同时用一个数组对需要移动的元素做标记,标记数组的下标是新元素的相对下标:[e, c, d, h] 从0开始,值是老元素下标+1,e在老元素下标为4,即5,所以标记数组为[5,3,4,0];
    4.3 然后用标记数组得到最长递增子序列路径数组,最后进行移动和新增

代码解释

以下代码是伪代码,获取最长子序列的算法还没弄懂

// vdom 虚拟dom
// old 老节点
// new 新节点
// old array [a, b, c, d, e, f, g]
// new array [a, b, e, c. d, h, f, g]
// 节点结构 node = { key: a }

// mountElement 新增元素
// patch 复用元素 a b c d e f g  注:源码无论 新增 还是 复用 元素都是用 patch
// unmount 删除元素
// todo
// move 元素移动

function diffArray(c1, c2, { mountElement, patch, unmount, move }) {
  function isSameVnodeType(n1, n2) {
    return n1.key === n2.key; // && n1.type === n2.type
  }

  let i = 0; // 头部记录指针
  const l1 = c1.length;
  const l2 = c2.length;
  let e1 = l1 - 1; // 新元素尾部记录指针
  let e2 = l2 - 1; // 旧元素尾部记录指针

  // 1、从左边往右边遍历,如果节点可以复用就继续 go,反之停止
  while (i <= e1 && i <= e2) {
    const n1 = c1[i];
    const n2 = c2[i];
    if (isSameVnodeType(n1, n2)) {
      patch(n1.key);
    } else {
      break;
    }
    i++;
  }

  // 2、从右边往左边遍历,如果节点可以复用就继续 go,反之停止
  while (i <= e1 && i <= e2) {
    const n1 = c1[e1];
    const n2 = c2[e2];
    if (isSameVnodeType(n1, n2)) {
      patch(n1.key);
    } else {
      break;
    }
    e1--;
    e2--;
  }

  // 3.1、老节点没了,新节点还有
  if (i > e1) {
    while (i <= e2) {
      const n2 = c2[i];
      mountElement(n2.key);
      i++;
    }
  }
  // 3.2、老节点还有,新节点没了
  else if (i > e2) {
    while (i <= e1) {
      const n1 = c1[i];
      unmount(n1.key);
      i++;
    }
  }

  // 4、新老节点都有,但是顺序不固定
  else {
    // 把新元素做成 Map,key:value(index),用于判断旧元素有没有复用 注:源码的 value 不是下标
    const s1 = i;
    const s2 = i;
    const keyToNewIndexMap = new Map();
    for (i = s2; i < e2; i++) {
      const nextChild = c2[i];
      keyToNewIndexMap.set(nextChild.key, i);
    }

    const toBePatched = e2 - s2 + 1; // 做完前后对比后,可以从新元素下标获取 需要更新的总节点数量
    let patched = 0;
    let moved = false; // 调用最长递增子序列算法标志
    let maxNewIndexSofar = 0;

    // 记录复用元素用于判断移动还是新增,下标是新元素的相对下标,初始值是0,如果节点复用了,值是老元素下标 +1
    const newIndexToOldIndexMap = new Array(toBePatched).fill(0);

    // 遍历老元素,判断老元素是否被复用,并且删除
    for (i = s1; i < e1; i++) {
      const prevChild = c1[i];

      if (patched >= toBePatched) {
        //
        unmount(prevChild.key); // 删除元素
        continue;
      }

      const newIndex = keyToNewIndexMap.get(prevChild.key);

      if (newIndex === undefined) {
        // 节点没有复用
        unmount(prevChild.key);
      } else {
        if (newIndex >= maxNewIndexSofar) {
          // 判断是否需要获取 最长递增子序列 路径
          maxNewIndexSofar = newIndex;
        } else {
          moved = true;
        }
        newIndexToOldIndexMap[newIndex - s2] = i + 1;
        patch(prevChild.key);
        patched++;
      }
    }

    // 处理移动或者是新增元素:move mount
    const increasingNewIndexSequence = moved
      ? getSequence(newIndexToOldIndexMap)
      : [];
    const lastIndex = increasingNewIndexSequence.length - 1;
    for (i = toBePatched - 1; i >= 0; i--) {
      const nextChildIndex = s2 + i;
      const nextChild = c2[nextChildIndex];

      // 判断节点是不是 mount
      if (newIndexToOldIndexMap[i] === 0) {
        mountElement(nextChild.key);
      } else {
        if (lastIndex < 0 || i !== increasingNewIndexSequence[lastIndex]) {
          move(nextChild.key);
        } else {
          lastIndex--;
        }
      }
    }
  }
}

// 获取最长递增子序列的路径,算法还没懂
// 依据例子传进来的 arr:[5, 3, 4, 0],返回的是3和4的下标[1, 2]
function getSequence(arr) {
  // 返回LIS路径
  const lis = [0];
  const len = arr.length;
  const record = arr.splice();

  for (let i = 0; i < len; i++) {
    const arrI = arr[i];

    if (arrI !== 0) {
      const last = lis[lis.length - 1];

      if (arr[last] < arrI) {
        record[i] = last;
        lis.push(i);
        continue;
      }

      // 二分插入
      let left = 0,
        right = lis.length - 1;
      while (left < right) {
        const mid = (left + right) >> 1;
        if (arr[lis[mid]] < arrI) {
          // 在右边
          left = mid + 1;
        } else {
          right = mid;
        }
      }

      // 从lis里找比arrI大的最小元素,并且替换
      if (arrI < arr[lis[left]]) {
        if (left > 0) {
          record[i] = lis[left - 1];
        }
        lis[left] = i;
      }
    }
  }

  let i = lis.length;
  let last = record[last];

  while (i-- > 0) {
    lis[i] = last;
    last = record[last];
  }

  return lis;
}

优化方向文字描述

  • 编译时优化:在模版编译阶段做静态提升和分析,将不会变的节点做静态标记,每次更新渲染时可以将节点提升至创建函数外,避免重新创建,减少虚拟 DOM 创建和销毁、及节点比较次数;
  • 补丁标记:对于文本节点的内容发生变化,为对应的虚拟 DOM 节点添加文本内容变化的补丁标记。在 diff 过程中,可以直接根据这个标记找到需要更新文本内容的节点,而不需要对整个组件的虚拟 DOM 树进行全面的比较;
  • 双端比较优化:Vue3继续使用了双端比较算法,但是只做头头、尾尾比较,剩余以最长递增子序列做增删改。
    后面两点不是很理解
  • 动态节点处理的优化:支持了动态插槽,可以更好地处理动态节点,使得在处理包含动态节点的组件时,性能更好。
  • 内存管理的优化:采用了更高效的虚拟DOM实现,使用 WeakMap 存储节点信息,减少了对DOM的操作,降低了内存占用。