vdom

# vdom

用一段 JS 代码来模拟真实 DOM

  • 真实 DOM
<ul id="list">
  <li class="item">哈哈</li>
  <li class="item">呵呵</li>
  <li class="item">嘿嘿</li>
</ul>
  • 虚拟 DOM
let oldVDOM = {
  // 旧虚拟DOM
  tagName: "ul", // 标签名
  props: {
    // 标签属性
    id: "list",
  },
  children: [
    // 标签子节点
    {
      tagName: "li",
      props: { class: "item" },
      children: ["哈哈"],
    },
    {
      tagName: "li",
      props: { class: "item" },
      children: ["呵呵"],
    },
    {
      tagName: "li",
      props: { class: "item" },
      children: ["嘿嘿"],
    },
  ],
};

由下图可知,方式 1 有三个步骤,方式二有 2 个步骤,所以仅有虚拟 DOM 性能不一定比直接操作真实 DOM 好,这个时候就需要配合一下 diff 算法。

vdom

# diff 算法

# 简介

Diff 算法是一种对比算法。对比两者是旧虚拟 DOM 和新虚拟 DOM,对比出是哪个虚拟节点更改了,找出这个虚拟节点,并只更新这个虚拟节点所对应的真实节点,而不用更新其他数据没发生改变的节点,实现精准地更新真实 DOM,进而提高效率。

  • 使用虚拟 DOM 算法的损耗计算: 总损耗 = 虚拟 DOM 增删改+(与 Diff 算法效率有关)真实 DOM 差异增删改+(较少的节点)排版与重绘

  • 直接操作真实 DOM 的损耗计算: 总损耗 = 真实 DOM 完全增删改+(可能较多的节点)排版与重绘

# 原理

对比两棵树,并且还要重新生成一个树,时间复杂度为 O(n3),但 diff 只需要 O(n)

  1. 只比较同一层级,不跨级比较;

  2. tag 不相同,则直接删掉重建,不再深度比较

  3. tag 和 key,两者都相同,则认为是相同的节点,不再深度比较;

# 对比流程

当数据改变时,会触发 setter,并且通过 Dep.notify 去通知所有订阅者 Watcher,订阅者们就会调用 patch 方法,给真实 DOM 打补丁,更新相应的视图。

diff

# patch

这个方法作用就是,对比当前同层的虚拟节点是否为同一种类型(sameVnode)的标签:

  • 是:继续执行 patchVnode 方法进行深层比对
  • 否:没必要比对了,直接整个节点替换成新虚拟节点
function patch(oldVnode, newVnode) {
  // 比较是否为一个类型的节点
  if (sameVnode(oldVnode, newVnode)) {
    // 是:继续进行深层比较
    patchVnode(oldVnode, newVnode);
  } else {
    // 否
    const oldEl = oldVnode.el; // 旧虚拟节点的真实DOM节点
    const parentEle = api.parentNode(oldEl); // 获取父节点
    createEle(newVnode); // 创建新虚拟节点对应的真实DOM节点
    if (parentEle !== null) {
      api.insertBefore(parentEle, vnode.el, api.nextSibling(oEl)); // 将新元素添加进父元素
      api.removeChild(parentEle, oldVnode.el); // 移除以前的旧元素节点
      // 设置null,释放内存
      oldVnode = null;
    }
  }

  return newVnode;
}

# sameVnode

function sameVnode(oldVnode, newVnode) {
  return (
    oldVnode.key === newVnode.key && // key值是否一样
    oldVnode.tagName === newVnode.tagName && // 标签名是否一样
    oldVnode.isComment === newVnode.isComment && // 是否都为注释节点
    isDef(oldVnode.data) === isDef(newVnode.data) && // 是否都定义了data
    sameInputType(oldVnode, newVnode) // 当标签为input时,type必须是否相同
  );
}

# patchVnode

  1. 找到对应的真实 DOM,称为 el
  2. 判断 newVnode 和 oldVnode 是否指向同一个对象,如果是,那么直接 return
  3. 如果他们都有文本节点并且不相等,那么将 el 的文本节点设置为 newVnode 的文本节点。
  4. 如果 oldVnode 有子节点而 newVnode 没有,则删除 el 的子节点
  5. 如果 oldVnode 没有子节点而 newVnode 有,则将 newVnode 的子节点真实化之后添加到 el
  6. 如果两者都有子节点,则执行updateChildren函数比较子节点,这一步很重要
function patchVnode(oldVnode, newVnode) {
  const el = (newVnode.el = oldVnode.el); // 获取真实DOM对象
  // 获取新旧虚拟节点的子节点数组
  const oldCh = oldVnode.children,
    newCh = newVnode.children;
  // 如果新旧虚拟节点是同一个对象,则终止
  if (oldVnode === newVnode) return;
  // 如果新旧虚拟节点是文本节点,且文本不一样
  if (
    oldVnode.text !== null &&
    newVnode.text !== null &&
    oldVnode.text !== newVnode.text
  ) {
    // 则直接将真实DOM中文本更新为新虚拟节点的文本
    api.setTextContent(el, newVnode.text);
  } else {
    // 否则

    if (oldCh && newCh && oldCh !== newCh) {
      // 新旧虚拟节点都有子节点,且子节点不一样

      // 对比子节点,并更新
      updateChildren(el, oldCh, newCh);
    } else if (newCh) {
      // 新虚拟节点有子节点,旧虚拟节点没有

      // 创建新虚拟节点的子节点,并更新到真实DOM上去
      createEle(newVnode);
    } else if (oldCh) {
      // 旧虚拟节点有子节点,新虚拟节点没有

      //直接删除真实DOM里对应的子节点
      api.removeChild(el);
    }
  }
}

# updateChildren

首尾指针进行新旧子节点集合的比对

<ul>
  <li>a</li>
  <li>b</li>
  <li>c</li>
</ul>

修改数据后

<ul>
  <li>b</li>
  <li>c</li>
  <li>e</li>
  <li>a</li>
</ul>
diff
  1. oldS 和 newS 使用 sameVnode 方法进行比较,sameVnode(oldS, newS)
  2. oldS 和 newE 使用 sameVnode 方法进行比较,sameVnode(oldS, newE)
  3. oldE 和 newS 使用 sameVnode 方法进行比较,sameVnode(oldE, newS)
  4. oldE 和 newE 使用 sameVnode 方法进行比较,sameVnode(oldE, newE)
  5. 如果以上逻辑都匹配不到,再把所有旧子节点的 key 做一个映射到旧节点下标的 key -> index 表,然后用新 vnode 的 key 去找出在旧节点中可以复用的位置。
  • 第一步
(oldS = a), (oldE = c);
(newS = b), (newE = a);

比较结果:oldS 和 newE 相等,需要把节点 a 移动到 newE 所对应的位置,也就是末尾,同时 oldS++,newE--

diff
  • 第二步
(oldS = b), (oldE = c);
(newS = b), (newE = e);

比较结果:oldS 和 newS 相等,需要把节点 b 移动到 newS 所对应的位置,同时 oldS++,newS++

  • 第三步
(oldS = c), (oldE = c);
(newS = c), (newE = e);

比较结果:oldS、oldE 和 newS 相等,需要把节点 c 移动到 newS 所对应的位置,同时 oldS++,newS++

diff
  • 第四步

oldS > oldE,则 oldCh 先遍历完成了,而 newCh 还没遍历完,说明 newCh 比 oldCh 多,所以需要将多出来的节点,插入到真实 DOM 上对应的位置上

diff

# index 做 key 的弊端

<ul>
    <li key="0">a</li>
    <li key="1">b</li>
    <li key="2">c</li>
</ul>

<ul>
    <li key="0">e</li>
    <li key="1">a</li>
    <li key="2">b</li>
    <li key="3">c</li>
</ul>
  1. 没有必要的性能消耗

最理想的结果是:只插入一个 li 标签新节点,其他都不动,确保操作 DOM 效率最高。

实际情况是:在进行子节点的 diff 算法过程中,会进行旧首节点和新首节点的 sameNode 对比,这一步命中了逻辑,因为现在新旧两次首部节点的 key 都是 0 了,同理,key 为 1 和 2 的也是命中了逻辑,导致相同 key 的节点会去进行 patchVnode 更新文本,而原本就有的 c 节点,却因为之前没有 key 为 4 的节点,而被当做了新节点,所以很搞笑,使用 index 做 key,最后新增的居然是本来就已有的 c 节点。所以前三个都进行 patchVnode 更新文本,最后一个进行了新增,那就解释了为什么所有 li 标签都更新了。

  1. 内部子节点的错误归属

如果结构中还包含输入类的 DOM,会产生错误 DOM 更新 ==> 界面有问题。

  1. 如果不存在对数据的逆序添加、逆序删除等破坏顺序操作,仅用于渲染列表用于展示,使用 index 作为 key 是没有问题的。