虚拟 DOM 和 diff 算法 -02

838 阅读4分钟

本文是关于虚拟 DOM 和 diff 算法的学习笔记,目的在于更好的理解 Vue 的底层原理,篇幅较长,故而拆分为几篇,今后将陆续更新。

上一篇《虚拟 DOM 和 diff 算法 -01》我们手写实现了 h 函数,本篇着重介绍 diff 算法。

diff 算法的特点

  • 如果是往数组的最后面添加节点,那么前面的节点不会改动
    比如有如下新(vnode2) 旧(vnode1) 两个节点,那么执行 patch(vnode1, vnode2) 会发现浏览器仅仅只是追加了一个节点 <div>东风破</div>, 不会改变前两个。
const vnode1 = h("div", {}, [
  h("div", "七里香"),
  h("div", "东风破")
])
const vnode2 = h("div", {}, [
  h("div", "七里香"),
  h("div", "东风破"),
  h("div", "兰亭序")
])

可以通过在浏览器调试工具里直接将“七里香”改成“七里不香”,然后通过点击按钮执行 patch(vnode1, vnode2) 会发现“七里不香”依旧没变。

gif5新文件.gif

  • key 很重要,key 作为节点的标识,告诉 diff 算法在更改前后节点是否为同一个
    如果是往数组的开头添加节点,则所有的节点都会被改动,想要做到最小化更新,需要给每个节点添加 key 属性,这样 <div>七里香</div><div>东风破</div> 两个节点就不会被改动了:
const vnode1 = h("div", {}, [
  h("div", { key: 1 }, "七里香"),
  h("div", { key: 2 }, "东风破")
])
const vnode2 = h("div", {}, [
  h("div", { key: 3 }, "兰亭序"),
  h("div", { key: 1 }, "七里香"),
  h("div", { key: 2 }, "东风破")
])
  • 只有是同一个虚拟节点,才进行精细化比较,否则直接删除旧节点,插入新节点
    判断两个节点是否为同一个,是根据比较选择器,也就是 sel 的值和 key 的值是否都相同,都相等则判断为同一个虚拟节点。
  • 只进行同层比较
    新旧节点的层级要相同,比如下面的例子里新节点比旧节点多了层 div,则不会进行精细化比较,直接删除旧节点插入新节点:
const vnode2 = h("div", {}, [
  h('div', [
    h("div", { key: 3 }, "兰亭序"),
    h("div", { key: 1 }, "七里香"),
    h("div", { key: 2 }, "东风破")
  ])
])

手写 patch 函数

diff 算法是通过 patch 函数实现的,在开始手写之前,我们先来理清 patch 函数做了什么。

函数功能分析

可以通过之前下载到 node_modules 里的 snabbdom 查看源码,patch 函数被定义在了 snabbdom 下的 src 目录下的 init.ts 里

// init.ts
return function patch(oldVnode: VNode | Element, vnode: VNode): VNode {
    // ...忽略部分代码
    // 通过 isVnode 函数判断旧节点是否为虚拟节点
    if (!isVnode(oldVnode)) {
      oldVnode = emptyNodeAt(oldVnode); // 不是则通过 emptyNodeAt 包装为虚拟节点
    }
    // 通过 sameVnode 函数判断新旧节点是否为为同一个节点
    if (sameVnode(oldVnode, vnode)) {
      // 相同...
    } else {
      // 不同...
    }
    // ...忽略部分代码
  };

根据源码得到如下流程图:

yuque_diagram.jpg

手写第一次上树

新建 patch.js 文件,引入 vnode 函数用于将非虚拟节点的 oldVnode 包装为虚拟节点

// patch.js
import vnode from './vnode.js'
import creatElement from './creatElement.js'

export default (oldVnode, newVnode) => {
  // 判断 oldVnode 是否为虚拟节点
  if (oldVnode.sel === undefined) {
    // oldVnode 不是虚拟节点,则包装成虚拟节点
    oldVnode = vnode(oldVnode.tagName.toLowerCase, {}, [], undefined, oldVnode)
  } 
  // 判断 oldVnode, newVnode 是否为同一节点
  if (oldVnode.sel === newVnode.sel && oldVnode.key === newVnode.key) {
    // 同一节点
  } else {
    // 不是同一节点
    const domNode = creatElement(newVnode)
    // 将新节点上树
    oldVnode.elm.parentNode?.insertBefore(domNode, oldVnode.elm)
    // 删除旧节点
    oldVnode.elm.parentNode?.removeChild(oldVnode.elm)
  }
}

新建 creatElement.js 并在 patch.js 引入 creatElement 函数,用于创建新节点,并将对应的虚拟节点的 elm 属性赋值为创建出的新节点

/**
 * creatElement.js 
 * 将 vnode 创建为真正的 DOM 节点(但是没上树的孤儿节点)
 */
export default function createElement (vnode) {
  const domNode = document.createElement(vnode.sel)
  vnode.elm = domNode
  // 判断 vnode 有子节点(children)还是文本(text)
  if (vnode.children !== undefined && vnode.children.length && vnode.text === undefined) {
    // 有子节点
    vnode.children.forEach(item => {
      // 调用 createElement 意味着创建出了 DOM,并且将该虚拟节点的 elm 属性指向了这个 DOM,
      // 但这个 DOM 是个孤儿节点,还没上树
      const childNode = createElement(item)
      item.elm = childNode
      domNode.appendChild(childNode)
    })
  } else {
    // 内部为文本
    domNode.innerText = vnode.text
    vnode.elm = domNode
  }
  return domNode
}

至此,我们已经完成了上面 patch 函数流程图中除了“精细化比较”之外的内容。接下来就开始着手当 oldVnode 和 newVnode 是同一节点的情况下的精细化比较的内容,这部分将有较多的图示,写在本篇难免会导致页面过长,我将在下篇继续分享~

One More Thing

本文有用到一些插入节点的方法,现在就此做一个扩展总结:

  • innerHTML(属性):获取标签内部的HTML内容;
  • outerHTML(属性):获取包括目标标签在内,以及内部HTML的内容;
  • appendChild(函数):向目标标签末尾添加子节点,返回参数节点;
  • insertBefore(函数):向目标节点的第二个参数位置添加第一个参数为子节点,返回第一个参数;
  • insertAdjacentHTML(函数):向目标节点的指定位置添加节点。

感谢.gif

点赞.png