从原理到手写,深入理解diff算法

1,677 阅读14分钟

简要说明

内容较长,作者也是学习挺久才编写完成😼,可以慢慢品尝~

diff算法

diff->最简单的介绍,就是进行找不同,就如同平日里玩的找茬游戏一般。
比如你进行房子装修,建好之后想要进行修改,难不成全部拆掉重建嘛?!太耗时耗力!
当然是精确化进行修改,这就好比diff算法存在的价值。

snabbdom

想要充分理解diff算法,可以去参考一下一个虚拟dom和diff算法的鼻祖==> snabbdom,一个开源的库,这是瑞典语,翻译名为:速度。而 diff,发生在虚拟DOM上

虚拟DOM

就是用js来描述DOM结构:

image.png

那说到虚拟DOM,就又得先看一看h函数了。

h函数=>产生虚拟节点

比如:

// 调用h函数
h('a', { props: { href: 'http://baidu.com' }}, 'hzc')

// 得到虚拟节点
{"sel": "a", "data": { props: {href: "http://baidu.com"}}, "text": "hzc"}

// 它表示的真正的DOM节点:
<a href="http://baidu.com">hzc</a>

虚拟节点vnode属性

{
	children: undefined// 子元素 数组
	data: {} // 属性、样式、key
	elm: undefined // 对应的真正的dom节点(对象),undefined表示节点还没有上dom树
	key: // 唯一标识
	sel: "" // 选择器
	text: "" // 文本内容
}

这样的话,当我们调用h函数生成一个虚拟节点后,使用patch函数让虚拟节点上树,就可以在页面中看到展示的内容。

// 创建patch函数
const patch = init([
  classModule,
  propsModule,
  styleModule,
  eventListenersModule,
]);

// 创建虚拟节点
var myVnode1 = h(
  "a",
  { props: { href: "https://baidu.com", target: "_blank" } },
  "cc"
);

// 让虚拟节点上树
let container = document.getElementById("container");
patch(container, myVnode1);

当然,h函数更厉害的地方在于:嵌套使用:

// 比如:
h('ul',{},[
    h('li',{},'牛奶'),
    h('li',{},'咖啡'),
    h('li',{},'可乐'),
])

// 将得到这样的虚拟DOM树
{
    "sel": "ul",
    "data":{},
    "children":[
        {"sel":"li","text":"牛奶"},
        {"sel":"li","text":"咖啡"},
        {"sel":"li","text":"可乐"},
    ]
}

实现一个简易 h函数

为了更深刻的理解 h函数,尝试一下去实现它:

//  vnode.js 文件

/**
 * 产生虚拟节点
 * 将传入的参数组合成对象返回
 * @param {string} sel 选择器
 * @param {object} data 属性、样式
 * @param {Array} children 子元素
 * @param {string|number} text 文本内容
 * @param {object} elm 对应的真正的dom节点(对象),undefined表示节点还没有上dom树
 * @returns 
 */
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";
/**
 * 产生虚拟DOM树,返回的一个对象
 * 低配版本的h函数,这个函数必须接受三个参数,缺一不可
 * @param {*} sel
 * @param {*} data
 * @param {*} c
 * 调用只有三种形态 文字、数组、h函数
 * ① h('div', {}, '文字')
 * ② h('div', {}, [])
 * ③ h('div', {}, h())
 */
export default function (sel, data, c) {
  // 检查参数个数
  if (arguments.length !== 3) {
    throw new Error("sorry,请只传入三个参数,我这是个简易版~");
  }
  // 检查第三个参数 c 的类型
  if (typeof c === "string" || typeof c === "number") {
    // 说明现在是 ① 类型
    return vnode(sel, data, undefined, c, undefined);
  } else if (Array.isArray(c)) {
    // 说明是 ② 类型
    let children = [];
    // 遍历 c 数组
    for (let item of c) {
      if (!(typeof item === "object" && item.hasOwnProperty("sel"))) {
        throw new Error("传入的数组参数中,有项不是h函数");
      }
      // 不用执行item, 因为你的测试语句中已经有了执行
      children.push(item);
    }
    // 循环结束,就说明children收集完了,此时就可以返回虚拟节点了,它有children属性
    return vnode(sel, data, children, undefined, undefined);
  } else if (typeof c === "object" && c.hasOwnProperty("sel")) {
    // 说明是 ③ h函数 
    // 即,传入的c 是唯一的children,不用执行c,因为测试语句已经执行了
    let children = [c];
    return vnode(sel, data, children, undefined, undefined);
  } else {
    throw new Error("传入的参数类型不对!");
  }
}

这部分可能你看的有点懵,别急~ 看完下面这个给你解释:

//  index.js 文件

import h from "./my_snabbdom/h";

const myVnode1 = h("div", {}, [
  h("p", {}, "嘻嘻"),
  h("p", {}, "哈哈"),
  h("p", {}, "呵呵"),
  h("p", {}, "么么哒")
]);
console.log(myVnode1);

image.png 上面在判断为数组时候,children里的子项并未操作执行,其实是因为下面测试语句中,已经去执行了 h() ,这样你会发现,好像自己莫名其妙的就写了个类似递归的东东🤷‍♂️出来??? 不,这是嵌套,和递归不同,巧妙就巧妙在这个地方!
看到现在,可能你不知道我在说些什么,别急,这些都是必要前缀,慢慢来~

diff算法理解

一、认识diff

首先是最小量更新,如何理解呢?

image.png

请看,首先将前一个节点上树,然后点击按钮时候替换前一个节点,此时未设置key,为了更直观显示,在浏览器中修改内容,再点击按钮,有如下效果:

image.png

image.png

你会发现,前面的节点其实根本没有改变,只是对比新增了最后一个。
但是,如果此时改变vnode2:

image.png

再次改变内容后,进行点击效果却是这样:

image.png

image.png

这玩意直接给你全拆了!重新来,哦吼😏你肯定说,这玩意还高效?对不起,是我没加 key

image.png

image.png

此时的结果,是不是符合了你的预期😏 这就是最小量更新了,记住,key很重要

其次,只有是同一个虚拟节点,才进行精细化比较,否则就是暴力删除、插入新的

那么,什么是同一个虚拟节点呢?==>选择器相同,且key相同

image.png

这样的两个节点,一个是li,另一个是ol,所以会直接删除、重新插入

最后,同层比较,跨层不diff你

怎么说呢?上例子吧🤷‍♂️🤷‍♂️:

image.png

可以看到,vnode2多了一层:section,其他都没变,但是diff依旧不鸟它,直接暴力删除!

二、深入diff

极其重要的patch函数

一张流程图搞定👌👌:

image.png

这就是源码中,patch做的工作,具体呢,里面又含有一个sameVnode的函数,它定义了“同一个节点”如何判定:

image.png

即:旧节点的key要和新节点的key相同,且,旧节点的选择器要和新节点选择器相同。

包括,创建新节点时,所有子节点需要递归进行创建:

image.png image.png

ok,那我们尝试实现一下patch函数,建议结合流程图理解代码

patch 暴力删除部分
//  patch.js

import vnode from "./vnode";
import createElement from "./createElement";

export default function (oldVnode, newVnode) {
  // 判断传入的第一个参数是 DOM节点 还是 虚拟节点
  if (oldVnode.sel == "" || oldVnode.sel === undefined) {
    // 说明oldVnode是DOM节点,此时要包装成虚拟节点
    oldVnode = vnode(
      oldVnode.tagName.toLowerCase(), // sel
      {}, // data
      [], // children
      undefined, // text
      oldVnode // elm
    );
  }
  // 判断 oldVnode 和 newVnode 是不是同一个节点
  if (oldVnode.key === newVnode.key && oldVnode.sel === newVnode.sel) {
    console.log("是同一个节点,需要精细化比较");
    // 后面再实现~~
  } else {
    console.log("不是同一个节点,暴力 插入新节点,删除旧节点");
    // 创建 新虚拟节点 为 DOM节点
    // 要操作DOM,所以都要转换成 DOM节点
    let newVnodeElm = createElement(newVnode);
    let oldVnodeElm = oldVnode.elm;
    // 插入 新节点 到 旧节点 之前
    if (newVnodeElm) {
      // 判断newVnodeElm是存在的 在旧节点之前插入新节点
      // insertBefore 需要 父级 调用
      oldVnodeElm.parentNode.insertBefore(newVnodeElm, oldVnodeElm);
    }
    // 删除旧节点
    oldVnodeElm.parentNode.removeChild(oldVnodeElm);
  }
}
//  createElement.js

/**
 * 创建节点。将vnode虚拟节点创建为DOM节点
 * 是孤儿节点,不进行插入操作
 * @param {object} vnode
 */
export default function createElement(vnode) {
  // 根据虚拟节点sel选择器属性 创建一个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 ch of vnode.children) {
      // 递归调用 创建出它的DOM,一旦调用createElement意味着创建出DOM了;
      // 并且它的elm属性指向了创建出的dom,但是没有上树,是一个孤儿节点。
      let chDOM = createElement(ch); 
      // 得到 子节点 表示的 DOM节点 递归最后返回的一定是文本节点
      console.log(ch);
      domNode.appendChild(chDOM); // 文本节点 上domNode树
    }
  }
  // 补充虚拟节点的elm属性
  vnode.elm = domNode;
 // 返回elm,elm属性是一个纯DOM对象
 // return vnode.elm; 和下面是一样的
  return domNode;
}

这里需要注意的是:不给createElement.js太多的压力,让它去承担太多功能,如果包括上树的部分也写在createElement.js,那么进行递归部分就会显得复杂,所以将上树操作放在patch.js这也是,如何让 虚拟DOM 通过 diff 变为 真实DOM 的操作。

patch 精细化比较部分

老规矩,先上流程图分析:

image.png

可以发现,相比上面的暴力删除,精细化是比较复杂的,尤其是流程图中“五角星”的内容,实现起来更是需要考虑不少,而且这是基于上面已经暂定的简易h函数, 基于此,进行实现:

//  patch.js

// 判断 oldVnode 和 newVnode 是不是同一个节点
if (oldVnode.key === newVnode.key && oldVnode.sel === newVnode.sel) {
  console.log("是同一个节点,需要精细化比较");
  patchVnode(oldVnode, newVnode);
}
//  patchVnode.js

export default function patchVnode(oldVnode, newVnode) {
  // 1. 判断新旧 vnode 是否是同一个对象
  if (oldVnode === newVnode) return;
  // 2. 判断 newVndoe 有没有 text 属性
  if (
    newVnode.text !== undefined &&
    (newVnode.children === undefined || newVnode.children.length === 0)
  ) {
    // newVnode 有 text 属性
    // 2.1 判断 newVnode 与 oldVnode 的 text 属性是否相同
    if (newVnode.text !== oldVnode.text) {
      // 如果newVnode中的text和oldVnode的text不同,那么直接让新text写入老elm中即可。
      // 如果oldVnode中是children,也会立即消失
      oldVnode.elm.innerText = newVnode.text;
    }
  } else {
    // newVnode 没有text属性 有children属性
    // 2.2 判断 oldVnode 有没有 children 属性
    if (oldVnode.children !== undefined && oldVnode.children.length > 0) {
      // oldVnode有children属性 最复杂的情况,新老节点都有children
 
    } else {
      // oldVnode没有children属性 说明有text;  newVnode有children属性
      // 清空oldVnode的内容
      oldVnode.elm.innerHTML = "";
      // 遍历新的vnode虚拟节点的子节点,创建DOM,上树
      for (let ch of newVnode.children) {
        let chDOM = createElement(ch);
        oldVnode.elm.appendChild(chDOM);
      }
    }
  }
}

到这里,可以发现,除了“五角星”尚未实现,其他都已经有头有脸了。

十分复杂的 新老节点都有children的情况!!!

  • 首先,是单纯的 新增情况:

image.png

为什么是插入未处理前呢?是因为如果后续插入,顺序将会出错,于是,先来看一下简单的处理流程:

image.png 但现在,仅仅只是实现了在老节点的前提上,插入新的,但是,当新节点相对于老节点进行了顺序改变的时候,我们就发现,还得去进行判断,看是否移动了节点,包括还未提及的删除、更新🤷‍♂️🤷‍♂️你可能想到了暴力求解,哈哈哈,达咩!这样太累了!So,看了看源码的方式,再想想自己这些日子刷的小算法,浅浅试一下:

  • 四种命中查找(经典diff算法优化策略)
    1. 新前与旧前
    2. 新后与旧后
    3. 新后与旧前
    4. 新前与旧后

image.png

比如说,上图中,进行判断时,发现旧前与新前same,即命中 1 ,此时就不会再去命中 2。

命中一种,就不会再进行命中判断了

image.png 命中 1 之后,对应指针会移动,然后再次进行命中判断。

前指针向下移,后指针向上移,循环条件:while(新前<=新后 && 旧前<=旧后)

image.png

此时循环结束,最巧妙的地方就在于此!即:旧节点先循环完毕,说明新节点中有要插入的节点,新节点中剩余节点,就是要被插入的节点

同理!删除时:新节点先循环完毕,即旧节点中剩余的节点,就是要被删除的节点

除此之外,可能已经有人发现bug了,对于多删除的情况,四种情况都不会命中!

image.png

如果四种情况都未命中,不意味着崩,而是需要循环进行寻找了

当然,在源码中的处理,是当作移动位置,把 D 放到旧前指针前,同时将之前的 D 标记为 undefined,然后结束循环,此时在旧节点中的待删除节点,就只有C、E,因为之前的 D 已经是 undefined 了。

  • 命中3与4的特殊

命中3:新后与旧前命中,此时要移动节点,移动新后指向的节点,到旧后的后面; 命中4:新前与旧后命中,此时要移动节点,移动新前指向的节点,到旧前的前面

举个例子:

image.png

此时正是命中3,则会变为:

image.png

随后都是命中3,即,下一次在旧后的后面放入B,直到结束循环,此时最后变为:E->D->C->B->A

最后附上源码中方法定义:需要看源码的老哥,可以直接去看源码,但是温馨提示:它可没有代码备注😏

image.png

  • 精细化尝试实现:
//  patchVnode.js  (补全一下)

if (oldVnode.children !== undefined && oldVnode.children.length > 0) {
      // oldVnode有children属性 最复杂的情况,新老节点都有children
      updateChildren(oldVnode.elm, oldVnode.children, newVnode.children);
    }
//  updateChildren.js


import createElement from "./createElement";
import patchVnode from "./patchVnode";
/**
 * 
 * @param {object} parentElm Dom节点
 * @param {Array} oldCh oldVnode的子节点数组
 * @param {Array} newCh newVnode的子节点数组
 */
export default function updateChildren(parentElm, oldCh, newCh) {
  console.log("updateChildren()");
  console.log(oldCh, newCh);

  // 四个指针
  // 旧前
  let oldStartIdx = 0;
  // 新前
  let newStartIdx = 0;
  // 旧后
  let oldEndIdx = oldCh.length - 1;
  // 新后
  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) {
    console.log("**循环中**");
    // 首先应该不是判断四种命中,而是略过已经加了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(" ①1 新前与旧前 命中");
      // 精细化比较两个节点 oldStartVnode现在和newStartVnode一样了
      patchVnode(oldStartVnode, newStartVnode);
      // 移动指针,改变指针指向的节点,这表示这两个节点都处理(比较)完了
      oldStartVnode = oldCh[++oldStartIdx];
      newStartVnode = newCh[++newStartIdx];
    } else if (checkSameVnode(oldEndVnode, newEndVnode)) {
      // 新后与旧后
      console.log(" ②2 新后与旧后 命中");
      patchVnode(oldEndVnode, newEndVnode);
      oldEndVnode = oldCh[--oldEndIdx];
      newEndVnode = newCh[--newEndIdx];
    } else if (checkSameVnode(oldStartVnode, newEndVnode)) {
      // 新后与旧前
      console.log(" ③3 新后与旧前 命中");
      patchVnode(oldStartVnode, newEndVnode);
      // 当③新后与旧前命中的时候,此时要移动节点。移动 新后(旧前) 指向的这个节点到老节点的 旧后的后面
      // 移动节点:只要插入一个已经在DOM树上的节点,它就会被移动
      parentElm.insertBefore(oldStartVnode.elm, oldEndVnode.elm.nextSibling);
      oldStartVnode = oldCh[++oldStartIdx];
      newEndVnode = newCh[--newEndIdx];
    } else if (checkSameVnode(oldEndVnode, newStartVnode)) {
      // 新前与旧后
      console.log(" ④4 新前与旧后 命中");
      patchVnode(oldEndVnode, newStartVnode);
      // 当④新前与旧后命中的时候,此时要移动节点。移动 新前(旧后) 指向的这个节点到老节点的 旧前的前面
      // 移动节点:只要插入一个已经在DOM树上的节点,就会被移动
      parentElm.insertBefore(oldEndVnode.elm, oldStartVnode.elm);
      oldEndVnode = oldCh[--oldEndIdx];
      newStartVnode = newCh[++newStartIdx];
    } else {
      // 四种都没有匹配到,都没有命中
      console.log("四种都没有命中");
      // 寻找 keyMap 一个映射对象, 就不用每次都遍历old对象了
      // 源码中是使用Map对象
      if (!keyMap) {
        keyMap = {};
        // 记录oldVnode中的节点出现的key
        // 从oldStartIdx开始到oldEndIdx结束,创建keyMap
        for (let i = oldStartIdx; i <= oldEndIdx; i++) {
          const key = oldCh[i].key;
          if (key !== undefined) {
            keyMap[key] = i;
          }
        }
      }
      console.log(keyMap);
      // 寻找当前项(newStartIdx)在keyMap中映射的序号
      const idxInOld = keyMap[newStartVnode.key];
      if (idxInOld === undefined) {
        // 如果 idxInOld 是 undefined 说明是全新的项,要插入
        // 被加入的项(就是newStartVnode这项)现不是真正的DOM节点
        parentElm.insertBefore(createElement(newStartVnode), oldStartVnode.elm);
      } else {
        // 说明不是全新的项,要移动
        const elmToMove = oldCh[idxInOld]; // 需要移动的
        patchVnode(elmToMove, newStartVnode);
        // 把这项设置为undefined,表示我已经处理完这项了
        oldCh[idxInOld] = undefined;
        // 移动,调用insertBefore也可以实现移动。
        parentElm.insertBefore(elmToMove.elm, oldStartVnode.elm);
      }
      
      // 指针下移,只移新的头
      newStartVnode = newCh[++newStartIdx];
    }
  }
  // 循环结束
  if (newStartIdx <= newEndIdx) {
    // 说明newVndoe还有剩余节点没有处理,所以要添加这些节点
    // // 插入的标杆
    // const before =
    //   newCh[newEndIdx + 1] === null ? null : newCh[newEndIdx + 1].elm;
    for (let i = newStartIdx; i <= newEndIdx; i++) {
      // insertBefore方法可以自动识别null,如果是null就会自动排到队尾,和appendChild一致
      // newCh[i]现在还没有变为真正的DOM,所以要调用createElement()函数变为DOM
      parentElm.insertBefore(createElement(newCh[i]), oldCh[oldStartIdx].elm);
      // parentElm.insertBefore(createElement(newCh[i]), before); 
      // 本文这里还是有问题的,因为是阉割版实现,和源库缺少了一些逻辑,
      // 导致newCh[newEndIdx + 1].elm是undefined
      // 但是,oldCh[oldStartIdx].elm 在插入时候还是有一些错误,目前自测解决办法
      // before=before.nextSibling
    }
  } else if (oldStartIdx <= oldEndIdx) {
    // 说明oldVnode还有剩余节点没有处理,所以要删除这些节点
    for (let i = oldStartIdx; i <= oldEndIdx; i++) {
      if (oldCh[i]) {
        parentElm.removeChild(oldCh[i].elm);
      }
    }
  }
}

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

代码可以结合备注和前面例子来理解~

完结撒花

这里特别乱!写的时候错了好多好多次😵‍💫😵‍💫,特别晕,期间难得实现还感动了半天🤣,千万别跳,可以把代码中变量更换为自己熟悉的叫法,方便理解!包括代码中删除了很多log,自己可以去多打印看看算法运行的步骤,也许你的某个错误就会让你的电脑发光发热(死循环~)🤣 慢慢来,毕竟这篇文章我都编辑了好几天...🤷‍♂️终于可以发布了!!!

如有 bug 或好的建议,希望各位大佬们批评指出😁 可以的话,点个赞哦~