双端 Diff 算法原理解析及 snabbdom 简单实现

1,041 阅读17分钟

虚拟DOM和diff算法

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第8天,点击查看活动详情

vue相关原理系列文章

diff算法的作用

我们都知道频繁的操作真实 DOM 节点会极大地耗费性能,所以 vue 为了提高框架的性能用虚拟 DOM 代替真实 DOM,那么就会出现一个问题,当我需要更新 DOM 元素的时候,我怎么知道哪里发生了变化呢?显然将全部旧节点卸载,再重新创建新节点并挂载的策略不可取。结合虚拟 DOM 的特点就产生 diff 算法。diff算法可以进行精细化比对,在虚拟 DOM 树从上至下进行同层比对,如果上层已经不同了,那么下面的DOM全部重新渲染。实现最小量更新。由于 snabbdom 是著名的虚拟 DOM 库,是 diff 算法的鼻祖,Vue 源码借鉴了 snabbdom,所以我们这里就以 snabbdom 库为例

特点

  • 比较只会在同层级进行, 不会跨层级比较
  • 在 diff 比较的过程中,循环从两边向中间比较

diff算法体验心得

  1. key很重要。key是这个节点的唯一标识,告诉diff算法,在更改前后它们是同一个DOM节点,vue才会复用它。

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

  3. 只进行同层比较,不会进行跨层比较。 即使是同一片虚拟节点,但是跨层了精细化比较不 diff 你,而是暴力删除旧的、然后插入新的。

真实DOM

<div class="box">
    <h3>我是一个标题</h3> 
    <ul>
        <li>牛奶</li>
        <li>咖啡</li>
        <li>可乐</li>
	</ul>
</div>

虚拟DOM

{
    "sel": "div",
    "data": {
        "class": { "box": true }
    },
    "children": [ 
        {
            "sel": "h3",
            "data": {},
            "text": "我是一个标题"
         },
        {
            "sel": "ul",
            "data": {},
            "children": [ 
                { "sel": "li", "data": {}, "text": "牛奶" },
                { "sel": "li", "data": {}, "text": "咖啡" },
                { "sel": "li", "data": {}, "text": "可乐" } 
            ] 
        } 
    ] 
}

diff是发生在虚拟 DOM 上的,新虚拟 DOM 和老虚拟 DOM 进行 diff(精细化比较),算出应该如何最小量更新,最后反映到真正的DOM上。

渲染函数

在讲解 diff 算法之前我们需要先实现一个渲染函数,该渲染函数能够将虚拟 DOM 渲染成真实节点,目的是为了更好的理解 diff 算法的原理,如果你只想快速了解 diff 算法原理,可以先跳过本部分内容。

入口文件index.js

import h from './mysc/h.js';
import patch from './mysc/patch.js';
// 测试用例
const myVnode = h('section', {}, '1234')
const myVnode1 = h('ul', {}, [
  h('li', { key: 'A' }, 'A'),
  h('li', { key: 'B' }, 'B'),
  h('li', { key: 'C' }, 'C'),
  h('li', { key: 'D' }, 'D'),
  h('li', { key: 'E' }, 'E')
]);
const myVnode2 = h('ul', {}, [
  h('li', { key: 'Q' }, 'Q'),
  h('li', { key: 'T' }, 'T'),
  h('li', { key: 'A' }, 'A'),
  h('li', { key: 'B' }, 'B'),
  h('li', { key: 'Z' }, 'Z'),
  h('li', { key: 'C' }, 'C'),
  h('li', { key: 'D' }, 'D'),
  h('li', { key: 'E' }, 'E')
]);
const container = document.getElementById('container');
const btn = document.getElementById('click');
patch(container, myVnode1);
btn.onclick = function () {
  patch(myVnode1, myVnode2);
}
console.log(myVnode1)

h函数实现

h函数的作用就是创建虚拟DOM结构,源码中一共有七种用法

h('div')
h('div', '文字')
h('div', [h()...])
h('div', h())
h('div', {}, '文字')
h('div', {}, [h()...])
h('div', {}, h())

h函数最终返回的是一个虚拟DOM,其实虚拟DOM节点很简单,接收五个参数并返回,下面给出了我们实现的简易版,源码中还做了一些数据类型验证。

实现创建虚拟节点

vnode函数

export default function (sel, data, children, text, elm) {
  return { sel, data, children, text, elm };
}

编写一个低配版本的h函数

这个函数必须接受3个参数,缺一不可,相当于它的重载功能较弱。也就是说,调用的时候形态必须是下面的三种之一:

  1. 形态① h('div', {}, '文字')
  2. 形态② h('div', {}, [])
  3. 形态③ h('div', {}, h())

为什么只写包含三个参数的情况呢?

因为前四种情况最复杂的就是参数之间的判断,转化,并不是我们学习的重点,所以我们应该做适当的简化。

h函数

import vnode from './vnode.js'
export default function (sel, data, temp) {
  // 检查参数的个数,不足三个参数抛出错误
  if (arguments.length !== 3) {
    throw new Error('对不起,h函数必须传入3个参数,我们是低配版h函数');
  }
    // 检查参数temp的类型
    // 命中第一种形态
  if (typeof temp === 'string' || typeof temp === 'number') {
    return vnode(sel, data, undefined, temp, undefined);
   // 命中第二中形态
  } else if (Array.isArray(temp)) {
    let children = [];
    // 遍历,收集children
    temp.forEach(item => {
      if (typeof item !== 'object' || !item.hasOwnProperty('sel')) {
        throw new Error('传入的数组参数中有项不是h函数')
      }
      children.push(item);
    })
      // 循环结束了,就说明children收集完毕了,此时可以返回虚拟节点了,它有children属性的
    return vnode(sel, data, children, undefined, undefined);
   // 命中第三种形态
  } else if (typeof item !== 'object') {
    // 即,传入的temp是唯一的children。
    return vnode(sel, data, [temp], undefined, undefined);
  } else {
    throw new Error('传入的第三个参数类型不对');
  }
}

难点

  1. 为什么这里只需要搜集item呢?传入的数组不是[h(),h()...]吗?难道不需要在循环中调用h()吗?
temp.forEach(item => {
  if (typeof item !== 'object' || !item.hasOwnProperty('sel')) {
    throw new Error('传入的数组参数中有项不是h函数')
  }
  children.push(item);
})

答案是不用,因为在进入index.js文件里外层h函数调用时,内层也就调用了就已经调用了,所以temp存放的就是一个个的虚拟节点,在循环里只需要进行搜集就好了。

实现diff算法

diff算法的入口是patch函数,它主要处理两种情况:

  • 新旧节点不可复用,那么就不需要继续深入比较它们的子节点、文本等精细化比较
  • 新旧节点可复用,那么就需要进行精细化比较,直接创建出所有新节点放在原来旧节点位置,并删除所有旧结点。

image.png 我这里判断标准就是:新旧节点的标签和key值是否相同

vue 中 diff 判断标准就是:新旧节点的标签、key值是否相同,是否是是注释节点,是否都绑定了 Data,如果是 input ,类型是否相同。

在处理以上两种情况前需要先判断老节点是否是虚拟节点,若不是需要先将其包装成虚拟节点,再处理以上两种情况。什么情况下会出现老节点并不是虚拟节点的情况呢?就是在虚拟节点第一次上树时,此时传入的老节点是container 容器。

index.js文件中第一次上树部分内容

// 得到盒子和按钮
const container = document.getElementById('container');
// 第一次上树
patch(container, myVnode1);

patch函数

import createEle from "./createElements";
import patchVnode from "./patchVnode";
import vnode from "./vnode";

export default function (oldVnode, newVnode) {
  // 判断老节点是否是虚拟节点
  if (oldVnode.sel === '' || oldVnode.sel === undefined) {
      // 传入的第一个参数是DOM节点,此时要包装为虚拟节点
    oldVnode = vnode(oldVnode.tagName.toLowerCase(), {}, [], undefined, oldVnode);
  }
    // 判断oldVnode和newVnode是不是同一个节点
  if (oldVnode.key === newVnode.key && oldVnode.sel === newVnode.sel) {
    // 新旧节点一样
    patchVnode(oldVnode, newVnode);
  } else {//老节点新节点不是同一个节点
     // 先创建
    const newNode = createEle(newVnode);
  // 插入到老节点之前
   if (oldVnode.elm.parentNode && newVnodeElm) {
         oldVnode.elm.parentNode.insertBefore(newNode, oldVnode.elm);
    }
   // 删除老节点
    oldVnode.elm.parentNode.removeChild(oldVnode.elm);
  }
}

新旧节点不可复用

那么就不需要继续深入比较它们的子节点、文本等精细化比较,直接创建真实的新的DOM结点并替代旧节点。

这里提到要创建真实的DOM结点,那么就有会有两种情况:

  1. 当前虚拟节点内部是文字,那么只需要创建出携带文本的结点即可。
  2. 当前虚拟节点内部又是子虚拟节点,那么就需要先创建所有子节点并放到以当前虚拟结点创建的DOM结点上。

由于创建子节点时也需要判断上面的两种情况,操作完全一样,所以这里使用递归创建真是节点最好,因为递归可以很好创建具有一定结构的DOM结点,且创建父子结点的操作完全一致,就更符合递归的特性了。

createEle函数

// 根据虚拟节点创建真实节点
export default function createEle(vnode) {
    // 创建一个DOM节点,这个节点现在还是孤儿节点
  let Node = document.createElement(vnode.sel);
    // 有子节点还是有文本??
  if (vnode.text !== '' && (vnode.children === undefined || vnode.children.length === 0)) {
       // 它内部是文字
    Node.innerText = vnode.text;
  } else if (Array.isArray(vnode.children) && vnode.children.length > 0) {
    // 遍历子虚拟节点
    vnode.children.forEach(element => {
     // 递归创建出它的DOM,一旦调用createElement意味着:创建出DOM了,并且它的elm属性指向了创建出的DOM,但是还没有上树,是一个孤儿节点。
      const childNode = createEle(element);
        // 上树
      Node.appendChild(childNode);
    });
  }
   // 补充elm属性
  vnode.elm = Node;
  // 返回elm,elm属性是一个纯DOM对象
  return vnode.elm;
}

新旧节点是同一个节点,精细化比较

精细化比较调用的是patchVnode方法,那么此时有三种情况:

  1. 新旧节点是同一个对象,说明节点没有做任何改变
  2. 新节点有 text 属性,那么此时又有两种情况
    1. 新旧节点的 text 属性相同,那么就不需要做处理
    2. 新旧节点的 text 属性不同,那么就把旧真实节点的 innerText 改为新节点的 text 内容。
  3. 新节点没有 text 属性但有 children 属性,此时又有两种情况:
    1. 旧节点没有 children,那么就将旧节点的 innerText 属性清空,再将新节点的子节点添加到旧节点上。
    2. 旧节点有 children 属性,那么此时就面临这最复杂的情况,继续深入比较子节点。

image.png

patchVnode函数

import updateChildren from "./updateChildren";
export default function patchVnode(oldVnode, newVnode) {
  // 情况1:判断新旧vnode是否是同一个对象
  if (newVnode === oldVnode) return;
  // 情况2: 新节点有text,旧节点没有
  if (newVnode.text !== '' && (newVnode.children === undefined || newVnode.children.length === 0)) {
    if (newVnode.text !== oldVnode.text) {
  	// 如果新虚拟节点中的text和老的虚拟节点的text不同,那么直接让新的text写入老的elm中即可。如果老的elm中是children,那么也会立即消失掉。
      oldVnode.elm.innerText = newVnode.text;
    }
  } else {// 情况3:新vnode没有text属性,有children
    // 老的有children,新的也有children,此时就是最复杂的情况。
    if (oldVnode.children !== undefined && oldVnode.children.length > 0 ) {
      updateChildren(oldVnode.elm, newVnode.children, oldVnode.children);
    } else {// 老的没有children,新的有children
     // 清空老的节点的内容
      oldVnode.elm.innerText = undefined;
      // 遍历新的vnode的子节点,创建DOM,上树
      newVnode.forEach(element => {
        oldVnode.elm.appendChild(createEle(element));
      });
    }
  }
}

接下来我们就来聊聊diff算法的精髓部分也是最难的部分

当新旧节点都有子节点是怎么比较呢?若使用双重嵌套循环遍历一个一个比那么效率就太慢了,源码给出的方法是定义四个指针分别指向新旧子节点数组的第一个节点和最后一个节点。这里就简称为新前、旧前、新后、旧后,因此就会有五种情况:

  1. 新前与旧前是同一个节点,将新节点内容更新到旧节点。
  2. 新后与旧后是同一个节点,将新节点内容更新到旧节点。
  3. 新后与旧前是同一个节点,将新节点内容更新到旧节点后,将旧节点移动到对应新节点的对应位置。
  4. 新前与旧后是同一个节点,将新节点内容更新到旧节点后,将旧节点移动到对应新节点的对应位置。
  5. 前四种情况都没有命中,那么就需要循环遍历匹配了,此时又有两种情况:
    • 该新节点是全新的节点,旧节点中没有该节点
    • 该节点不是全新的,更新旧节点后移动位置

情况3命中如何移动旧节点?

新后和旧后后边的节点是已经处理过的节点,而新后就是新子节点中未处理节点的最后一个,因为是新后与旧前相同,所以也应该将旧前指向的节点移动到旧后后边。此时新后指针向前移动一个位置,那么移动前的新后指向的节点就成了移动后新后指针后边处理过节点的第一个节点,而他对应的旧节点也成了旧后后边处理过节点的第一个,两节点位置成功对应。

情况4命中如何移动旧节点?

新前和旧前前边的节点是已经处理过的节点,而新前就是新子节点中未处理节点的最第一个,因为是新前与旧后相同,所以也应该将旧后指向的节点移动到旧前前边。此时新前指针向后移动一个位置,那么移动前的新前指向的节点就成了移动后新前指针前边处理过节点的最后一个节点,而他对应的旧节点也成了旧前前边处理过节点的最后一个,两节点位置成功对应。

情况5命中且新节点为全新节点,该怎么处理?

这里有一个妙解,使用 for 循环和对象创建一个旧子节点数组中未处理节点的 key 值与下标的映射

for (let i = oldStartIndex; i <= oldEndIndex; i++) {
  // 从oldStartIdx开始,到oldEndIdx结束,创建keyMap映射对象
  const key = oldCh[i].data.key;
  if (key != undefined) {
    keyMap[key] = i;
  }
}

这样就可以使用下面代码直接从旧子节点数组中直接找到与新节点key值相同的旧节点。注意:第5种情况是找新前节点对应旧节点

const index = keyMap[newStartVnode.key];

当然若找不到,就说明该新节点是全新节点,需要重新创建,并插入到旧前前边,原因与情况四类似。

情况5命中且该节点不是全新节点,该怎么移动?

若下面 index 不为 undefined,说明不是全新节点,就调用 patchVnode 方法更新旧节点后,同时把原旧虚拟节点标记为 undefined 后移动到旧前前边,原因与情况四类似。

const index = keyMap[newStartVnode.key];

image.png

精细化比较完成,新或旧子节点数组中还有未处理节点

若循环结束则说明必有一个数组遍历结束,所以又有两种情况:

  1. 旧子节点遍历完成,新子节点还有未处理节点
  2. 新子节点遍历完成,旧子节点还有未处理节点

针对第一种情况,从新前遍历到新后,每一个都需要调用 createEle 函数创建真正的DOM节点,并且把节点插入到旧前之前。为什么要插入到旧前之前呢?其实和情况四很类似,可以这样想,循环是从新前开始的,所以每次新前指向的节点都应该在移动后变为旧前前面处理过节点的最后一个,尽管此时旧子节点已经全部处理完了,所以直接将节点插入新前之前就可以达到目的。

针对第二种情况,从旧前开始遍历到旧后,一个一个的删除旧节点即可。

updateChildren函数

import patchVnode from "./patchVnode";
import createEle from "./createElements";
// 判断是否是同一个虚拟节点
function checkSameVnode(a, b) {
  return a.sel === b.sel && a.data.key === b.data.key;
};
export default function (parentNode, newCh, oldCh) {
  // 旧前
  let oldStartIndex = 0;
  // 新前
  let newStartIndex = 0;
  // 旧后
  let oldEndIndex = oldCh.length - 1;
  // 新后
  let newEndIndex = newCh.length - 1;
  // 旧前节点
  let oldStartVnode = oldCh[oldStartIndex];
  // 旧后节点
  let oldEndVnode = oldCh[oldEndIndex];
  // 新前节点
  let newStartVnode = newCh[newStartIndex];
  // 新后节点
  let newEndVnode = newCh[newEndIndex];
  // 在以上四种情况都为命中时处理用到的结构
  let keyMap = null;
  // 循环遍历处理节点
  while (oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex) {
    // 首先不是判断前四种命中,而是要略过已经加undefined标记的东西
    if (oldStartVnode === undefined) {
      oldStartVnode = oldCh[++oldStartIndex];
    } else if (oldEndVnode === undefined) {
      oldEndVnode = oldCh[--oldEndIndex];
    } else if (newStartVnode === undefined) {
      newStartVnode = newCh[++newStartIndex];
    } else if (newEndVnode === undefined) {
      newEndVnode = newCh[--newEndIndex];
    } else if (checkSameVnode(oldStartVnode, newStartVnode)) {
      // 新前和旧前
      patchVnode(oldStartVnode, newStartVnode);
      oldStartVnode = oldCh[++oldStartIndex];
      newStartVnode= newCh[++newStartIndex];
    } else if (checkSameVnode(oldEndVnode, newEndVnode)) {
      // 新后和旧后命中
      patchVnode(oldEndVnode, newEndVnode);
      oldEndVnode = oldCh[--oldEndIndex];
      newEndVnode = newCh[--newEndIndex];
    } else if (checkSameVnode(oldStartVnode, newEndVnode)) {
      // 新后和旧前命中
      patchVnode(oldStartVnode, newEndVnode);
      // 当新后与旧前命中的时候,此时要移动节点。移动新前指向的这个节点到老节点的旧后的后面
	  // 如何移动节点??只要你插入一个已经在DOM树上的节点,它就会被移动
      parentNode.insertBefore(oldStartVnode.elm, oldEndVnode.elm.nextSibling);
      oldStartVnode = oldCh[++oldStartIndex];
      newEndVnode = newCh[--newEndIndex];
    } else if (checkSameVnode(oldEndVnode, newStartVnode)) {
      // 新前和旧后命中
      patchVnode(oldEndVnode, newStartVnode);
      // 当新前和旧后命中的时候,此时要移动节点。移动新前指向的这个节点到老节点的旧前的前面
      parentNode.insertBefore(oldEndVnode.elm, oldStartVnode.elm);
      oldEndVnode = oldCh[--oldEndIndex];
      newStartVnode = newCh[++newStartIndex];
    } else {
      // 四种命中都没有命中
      // 制作keyMap一个映射对象,这样就不用每次都遍历老对象了。
      console.log(oldEndVnode, newEndVnode)
      if (!keyMap) {
        keyMap = {};
        // 从oldStartIdx开始,到oldEndIdx结束,创建keyMap映射对象
        for (let i = oldStartIndex; i <= oldEndIndex; i++) {
          // 从oldStartIdx开始,到oldEndIdx结束,创建keyMap映射对象
          const key = oldCh[i].data.key;
          if (key != undefined) {
            keyMap[key] = i;
          }
        }
      }
      // 寻找当前这项(newStartIdx)这项在keyMap中的映射的位置序号
      const index = keyMap[newStartVnode.key];
      if (index === undefined) {
        // 判断,如果idxInOld是undefined表示它是全新的项
      	// 被加入的项(就是newStartVnode这项)现不是真正的DOM节点
        parentNode.insertBefore(createEle(newStartVnode), oldStartVnode.elm)
      } else {
        // 如果不是undefined,不是全新的项,而是要移动
        const eleToMove = old[index];
        patchVnode(eleToMove, newStartVnode);
        // 把这项设置为undefined,表示我已经处理完这项了
        oldCh[index] = undefined;
        // 移动,调用insertBefore也可以实现移动。
        parentNode.insertBefore(eleToMove.elm, oldStartVnode.elm);
      }
        // 指针下移,只移动新的头
      newStartVnode = newCh[++newStartIndex]
    }
    
  }
  // 继续看看有没有剩余的。循环结束了start还是比old小
  if (newStartIndex <= newEndIndex) {
    // 遍历新的newCh,添加到老的没有处理的之前
    for (let i = newStartIndex; i <= newEndIndex; i++) {
      // insertBefore方法可以自动识别null,如果是null就会自动排到队尾去。和appendChild是一致了。
      // newCh[i]现在还没有真正的DOM,所以要调用createElement()函数变为DOM
      parentNode.insertBefore(createEle(newCh[i]), oldCh[oldStartIndex].elm);
    }
  } else if (oldStartIndex <= oldEndIndex) {
    // 批量删除oldStart和oldEnd指针之间的项
    for (let i = oldStartIndex; i <= oldEndIndex; i++) {
      if (oldCh[i]) {
        parentNode.removeChild(oldCh[i].elm);
      }
    }
  }
}

总结

diff 算法来比对虚拟节点,来达到最小化更新,大大提高了效率。当然 diff 算法本身也是很优秀的,采用创建新节点,如何设置传入参数即返回值都是值得思考的问题,而它的精髓比对新旧子节点数组里的节点更是让人耳目一新,使用四个指针,五种情况就可以解决绝大部分更新问题,而且处理过程条理清晰。

总结一下比较流程:

  1. sameVnode 首先比较是否是同一节点,若不是同一节点则全面新建新节点替换旧节点,若是进行下一步比较。
    • key值
    • 标签名
    • 是否是注释节点
    • 是否都绑定了data
    • 如果是input类型是否相同
  2. patchVnode
    • 是否是同一对象,是就复用节点
    • 旧节点是否是文本节点,若是判断新节点是否一样是文本节点并且文本是否一样。
    • 判断是否有子节点,若新节点有旧节点无,新建子节点,若旧节点有新节点无删除所有旧子节点。若都有下一步比较
  3. updateChildren 比较子节点
    • 四个指针新头新尾,旧头旧尾,四次比较
    • 四次比较都未命中
      • 若都有key值,会根据节点列表的key值生成hashmap,匹配新头所指节点的key值
      • 若不满足都有key值,则会遍历旧虚拟节点观察是否有与新前一样的节点可以复用

image.png

我是孤城浪人,一名正在前端路上摸爬滚打的菜鸟,本项目已开源到 github ,欢迎你的关注。

image.png