虚拟dom与diff算法笔记自用

123 阅读13分钟

部分图文转载自 【Vue源码】图解 diff算法 与 虚拟DOM-snabbdom-最小量更新原理解析-手写源码-updateChildren_YK菌的博客-CSDN博客_updatechildren

h函数

h函数是用来产生虚拟节点的函数。

2021041216043170.png 每个虚拟节点有以下几个属性

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

手写h函数

这里只写一个低配版的h函数,即只考虑传入三个参数的情况

/**
 * 产生虚拟节点
 * 将传入的参数组合成对象返回
 * @param {string} sel 选择器
 * @param {object} data 属性、样式
 * @param {Array} children 子元素
 * @param {string|number} text 文本内容
 * @param {object} elm 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("请传且只传入三个参数!");
  }
  // 检查第三个参数 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);
    }
    return vnode(sel, data, children, undefined, undefined);
  } else if (typeof c === "object" && c.hasOwnProperty("sel")) {
    // 说明是 ③ h函数 是一个对象(h函数返回值是一个对象)放到children数组中就行了
    let children = [c];
    return vnode(sel, data, children, undefined, undefined);
  } else {
    throw new Error("传入的参数类型不对!");
  }
}
对应的真正的dom节点(对象),undefined表示节点还没有上dom树
 * @returns 
 */
export default function(sel, data, children, text, elm) {
  const key = data.key;
  return { sel, data, children, text, elm, key };
}

Diff算法

在写Diff算法之前,我们首先要明确一个概念:只有是同一个虚拟节点,才会进行精细化比较(往ul中的某个li加入li),如果不是同一个虚拟节点(如进行将li的父元素由ul变成ol),则暴力删除旧的,插入新的
那么如何判断是同一个虚拟节点呢?在这里,我们对同一个虚拟节点的定义是:两个虚拟节点的key相同和sel相同
而且diff算法只会作用于同一层节点,即只会进行同层比较,不会进行跨层比较即便是同一个虚拟节点,若不在同一层,只能进行暴力删除后插入 只进行同层比较,不会进行跨层比较。即使是同一片 虚拟节点,但是跨层了,diff就是暴力删除旧的,然后插入新的只进行同层比较,不会进行跨层比较。即使是同一片 虚拟节点,但是跨层了,diff就是暴力删除旧的,然后插入新的

20210412203424571.png

手写Diff

  1. patch函数 patch函数进行的就是上图的操作,判断传入的节点,并判断是否需要精细化比较,如果不需要,就进行暴力删除后插入的操作
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是存在的 在旧节点之前插入新节点
      oldVnodeElm.parentNode.insertBefore(newVnodeElm, oldVnodeElm);
    }
    // 删除旧节点
    oldVnodeElm.parentNode.removeChild(oldVnodeElm);
  }
}
  1. createElement函数
    createElement函数就是将虚拟节点转化为dom节点,思路就是判断传入的虚拟节点有没有子节点,如果没有,将转化后的dom的innerText设置为虚拟节点的text即可;如果有子节点,即children不为空,那么则需要遍历递归调用
/**
 * 创建节点。将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树
      domNode.appendChild(chDOM);
    }
  }
  // 补充虚拟节点的elm属性
  vnode.elm = domNode;
  // 返回domNode DOM对象
  return domNode;
}

20210414151721159.png 然后就要处理newVnode和oldVnode是同一个节点的情况,逻辑如上图

patchVnode函数

patchVnode函数处理需要精细化比较两个节点的逻辑
思路:

  1. 首先,因为进入了patchVnode,所以传入的新旧节点的sel和key肯定是相同的,那么先判断新旧节点是否指向同一个节点(===),如果是,无需操作,如果不是,进入下一个逻辑判断
  2. 判断newVnode中有没有text属性(注意我们低配版h函数的定义,newVnode不存在既有text又有children的情况),如果有,则判断newVnode和oldVnode的text是否相同,如果不同,则直接将oldVnode.elm.innerText替换为newVnode中的text,这样即使oldVnode有children也会被直接覆盖.
  3. 进入判断oldVnode有没有children的逻辑,说明newVnode没有text属性而有children属性,那么我们需要判断oldVnode是否有children属性,如果有,则进入4;如果没有,则进行两步操作:第一步将oldVnode中的text清空,第二步遍历newVnode的children将其转化为dom并上树。
  4. 此时新旧节点都有children,进入updateChildren函数
    patchVnode:
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);
      }
    }
  }
}

updateElement函数

updateElement函数就是处理oldVnode和newVnode都有children的时候,需要对他们的children进行比较,逻辑如下图.

20210414191540816.png 指针与节点定义

// 四个指针
  // 旧前
  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];

checkSameVnode函数

// 判断是否是同一个节点
function checkSameVnode(a, b) { return a.sel === b.sel && a.key === b.key; }
  1. 新前与旧前
    如果命中1,则patch之后oldStartIdx和newStartIdx ++
if (checkSameVnode(oldStartVnode, newStartVnode)) {
    // 新前与旧前
    console.log(" ①1 新前与旧前 命中");
    // 精细化比较两个节点 oldStartVnode现在和newStartVnode一样了
    patchVnode(oldStartVnode, newStartVnode);
    // 移动指针,改变指针指向的节点,这表示这两个节点都处理(比较)完了
    oldStartVnode = oldCh[++oldStartIdx];
    newStartVnode = newCh[++newStartIdx];
}
  1. 新后与旧后
    如果命中2,则patch之后oldEndIdx和newEndIdx --
if (checkSameVnode(oldEndVnode, newEndVnode)) {
    patchVnode(oldEndVnode, newEndVnode);
    oldEndVnode = oldCh[--oldEndIdx];
    newEndVnode = newCh[--newEndIdx];
}
  1. **新后与旧前
if (checkSameVnode(oldStartVnode, newEndVnode)) {
    // 新后与旧前
    console.log(" ③3 新后与旧前 命中");
    patchVnode(oldStartVnode, newEndVnode);
    // 当③新后与旧前命中的时候,此时要移动节点。移动 新后(旧前) 指向的这个节点到老节点的 旧后的后面
    // 移动节点:只要插入一个已经在DOM树上 的节点,就会被移动
    parentElm.insertBefore(oldStartVnode.elm, oldEndVnode.elm.nextSibling);
    oldStartVnode = oldCh[++oldStartIdx];
    newEndVnode = newCh[--newEndIdx];
}
  1. 新前与旧后
if (checkSameVnode(oldEndVnode, newStartVnode)) {
    // 新前与旧后
    console.log(" ④4 新前与旧后 命中");
    patchVnode(oldEndVnode, newStartVnode);
    // 当④新前与旧后命中的时候,此时要移动节点。移动 新前(旧后) 指向的这个节点到老节点的 旧前的前面
    // 移动节点:只要插入一个已经在DOM树上的节点,就会被移动
    parentElm.insertBefore(oldEndVnode.elm, oldStartVnode.elm);
    oldEndVnode = oldCh[--oldEndIdx];
    newStartVnode = newCh[++newStartIdx];
}
  1. 当四种都没有命中,进入循环 在oldVnode中遍历未处理的key,如果有,则将这个节点插入到oldStart前并newStart++,如果没有找到,说明这个节点是新节点,则直接插入到所有未处理节点之前。
// 四种都没有匹配到,都没有命中
console.log("四种都没有命中");
// 寻找 keyMap 一个映射对象, 就不用每次都遍历old对象了
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;
  // 移动,调用insertBefoe也可以实现移动。
  parentElm.insertBefore(elmToMove.elm, oldStartVnode.elm);
}

// newStartIdx++;
newStartVnode = newCh[++newStartIdx];
  1. 循环结束后(即不满足oldStartIdx<=oldEndIdx && newStartIdx<=newEndIdx时),如果newVnode还有剩余,将newVnode剩余的节点全部插入到oldStart后或oldEnd前;如果oldVnode还有剩余,则将剩余节点全部删除。
// 循环结束
if (newStartIdx <= newEndIdx) {
  // 说明newVndoe还有剩余节点没有处理,所以要添加这些节点
  for (let i = newStartIdx; i <= newEndIdx; i++) {
    // insertBefore方法可以自动识别null,如果是null就会自动排到队尾,和appendChild一致
    parentElm.insertBefore(createElement(newCh[i]), oldCh[oldStartIdx].elm);
  }
} else if (oldStartIdx <= oldEndIdx) {
  // 说明oldVnode还有剩余节点没有处理,所以要删除这些节点
  for (let i = oldStartIdx; i <= oldEndIdx; i++) {
    if (oldCh[i]) {
      parentElm.removeChild(oldCh[i].elm);
    }
  }
}

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对象了
      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);
      }

      // newStartIdx++;
      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一致
      parentElm.insertBefore(createElement(newCh[i]), oldCh[oldStartIdx].elm);
    }
  } 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;
}

20210415222737884.png

总结一下

  • 当数据发生改变,触发数据的set,dep数组会通知watcher,订阅者watcher就会调用patch给真实DOM打补丁
  • patch是一个判断是否为同一个虚拟节点的函数,因为只有是相同的虚拟节点(即key与sel标签相同),才会进行精细化比较,不是相同虚拟节点的会直接暴力删除后插入节点
  • 精细化比较的思路比较newVNode和oldVNode, 就是首先判断他们是不是同一个对象,如果是则返回,如果不是就看newVnode有没有text属性,如果有,就直接将oldVnode的innerhtml属性替换为newVnode的text属性,如果没有,说明newVnode有children属性,如果oldVnode也有children,则对他们的children进行diff算法,如果oldVnode没有children,则将oldVnode的elm(也就是它的真实DOM)的innertext属性清空,然后遍历newVnode的children,把节点转化为真实DOM后插入到oldVnode(的elm也就是真实DOM)中。
  • Diff算法部分,也就是比较oldVnode和newVnode都有children子节点时作用,首先要定义新旧节点的头尾指针以及这些指针对应的虚拟节点,然后按照新前旧前,新后旧后,新后旧前,新前旧后的顺序进行比较,如果命中了,即是同一个虚拟节点,则需要进行精细化比较(patchVnode),如果是新前旧后、新后旧前命中,还需将当前DOM节点移动到对应的旧节点位置。
  • 如果四种算法不满足,就分成两种情况:从以旧的Vnode为key,index序列为value的map中,查找当前newVnode的key一致的Vnode节点。第一种,找到了,就进行patchVnode然后把这个真实DOM移动到旧前对应的真实DOM前;第二种,没找到,调用createElm创建一个新的dom节点放到当前newStartIdx位置。