本文是关于虚拟 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) 会发现“七里不香”依旧没变。
- 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 {
// 不同...
}
// ...忽略部分代码
};
根据源码得到如下流程图:
手写第一次上树
新建 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(函数):向目标节点的指定位置添加节点。