全篇是基于snabbdom进行的相关介绍。大家可以参考开源地址:github.com/snabbdom/sn…
本篇也是笔者重学虚拟dom和diff算法的过程,感觉有了一些不一样的认识,虽然还是之前学过的内容,但是感觉比第一次要领会得更加深刻,笔者是一个喜欢通过博客来学习的一些知识的人,但是有时候我会发现有的知识并不是那么好理解,所以就会采用视频的方式去学习,本篇的内容就是看了视频学习的,再此非常感谢尚硅谷提供的关于虚拟DOM与diff算法的视频。由于笔者的经验不是特别的多,所以关于本篇的内容,可能存在很多缺陷,甚至错误,希望各位小伙伴在阅读的过程中要多加甄别,也非常欢迎小伙伴们与我沟通相关问题。
1. 虚拟DOM
虚拟DOM是一个javascript对象,是对原始的DOM进行的抽象;由于是一个javascript对象,所以可以运行在服务端和浏览器端,实现跨端功能,同时结合diff算法,实现了DOM的局部更新,避免了大量的回流重绘,在一定程度上提高了性能。
创建虚拟节点(vnode)
一个vnode对象包含的内容有:
// 这里面定义一个vnode函数,虚拟节点
/**
* @param {string} sel 元素标签名
* @param {object} data 标签属性
* @param {array|string|object} children 子节点
* @param {object} elm 真实的DOM对象
* @param {string} text 字符串节点
* @return {object}
*
*/
export default function vnode(sel, data, children, elm, text) {
const key = data === undefined ? undefined : data.key;
return { sel, data, children, elm, text, key };
}
使用h函数创建虚拟DOM:
const vnode = h('ul', {}, [
h('li', {}, '测试1'),
h('li', {}, '测试2'),
h('li', {}, '测试3')
]);
// h函数的内部回去调用vnode函数,生成对应的vnode
2. diff算法
diff 算法本质上是一个 对比的方法。其核心就是在: “旧 DOM 组”更新为“新 DOM 组”时,如何更新才能效率更高。因为diff算法主要实在patch函数中执行的,所以下面介绍它的基本流程
2.1 patch函数执行过程
2.2 精细化比较
2.3 diff核心
在新旧节点中,都有children元素时,就要开始比较子元素节点是否相同,如果相同就继续进行patchVnode操作;
在进行比较时,分别采用头尾指针(在新旧节点中都有头尾指针)的方式进行比较,那么根据这四个不同的指针,就出现了下面这四种不同的比较(如下图);当然也存在四个比较都没有命中时,需要进行额外处理的情况;那么在进行遍历比较时,这里使用循环遍历,遍历的条件就是:
oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx下面依次列举一些情况来进一步理解diff算法
- 新增情况
- 删除
- 移动
- 复杂比较
当之前的四种比较都无法满足条件时,就进行复杂比较
3. diff算法代码实现
通过render函数创建出虚拟DOM之后,就要开始进行新旧虚拟DOM的比较(patch);
众所周知,这个比较过程是在patch函数中实现的,内容也是进行了很多处理,并最终生成一个更新后的真实DOM;
涉及到的主要方法有:
- sameVnode: 判断是否为相同的虚拟节点,这是同层级的比较
- createElm: 根据虚拟DOM创建真实的DOM元素
- patchVnode:在同层级节点相同的前提下,进行下一步比较
- updateChildren:在新旧节点都有子元素时,进行的比较更新,这也是diff算法的核心过程
sameVnode
主要比较四个地方:
- 元素标签
- 元素的key属性
- 如果是自定义元素,那么就有一个is属性,使用这个属性进行比较
- 如果前面都能满足,那么就可以比较一下text类型
function sameVnode(vnode1: VNode, vnode2: VNode): boolean {
const isSameKey = vnode1.key === vnode2.key; // key值
// https://developer.mozilla.org/zh-CN/docs/Web/API/Web_components
const isSameIs = vnode1.data?.is === vnode2.data?.is; // for custom elements v1, 在vue中,那个component动态组件,就有一个is类型
const isSameSel = vnode1.sel === vnode2.sel; // 元素标签
// 单纯文本节点比较
const isSameTextOrFragment =
!vnode1.sel && vnode1.sel === vnode2.sel
? typeof vnode1.text === typeof vnode2.text
: true;
return isSameSel && isSameKey && isSameIs && isSameTextOrFragment;
}
createElm
根据虚拟DOM创建真实的DOM元素,一般要创建真实DOM的直接原因就是新旧虚拟节点不一致;
在这个过程中存在递归创建的过程(如果节点有子元素)
下述内容省略了部分代码
function createElm(vnode: VNode, insertedVnodeQueue: VNodeQueue): Node {
let i: any;
let data = vnode.data;
if (data !== undefined) {
const init = data.hook?.init;
if (isDef(init)) {
init(vnode);
data = vnode.data;
}
}
const children = vnode.children;
const sel = vnode.sel;
if (sel === "!") {
if (isUndef(vnode.text)) {
vnode.text = "";
}
vnode.elm = api.createComment(vnode.text!); // 创建注释节点
} else if (sel !== undefined) {
/** Parse selector */
if (is.array(children)) {
for (i = 0; i < children.length; ++i) {
const ch = children[i];
if (ch != null) {
api.appendChild(elm, createElm(ch as VNode, insertedVnodeQueue));
}
}
} else if (is.primitive(vnode.text)) {
api.appendChild(elm, api.createTextNode(vnode.text));
}
/** some handle */
} else if (vnode.children) {
vnode.elm = (
api.createDocumentFragment ?? documentFragmentIsNotSupported
)();
for (i = 0; i < cbs.create.length; ++i) cbs.create[i](emptyNode, vnode);
// 循环递归创建元素
for (i = 0; i < vnode.children.length; ++i) {
const ch = vnode.children[i];
if (ch != null) {
api.appendChild(
vnode.elm,
createElm(ch as VNode, insertedVnodeQueue)
);
}
}
} else {
vnode.elm = api.createTextNode(vnode.text!);
}
return vnode.elm;
}
patchVnode
在同层比较时,如果节点相同,那么就进行更加精细的比较;
在snabbdom源码中,还是进行了很完善的处理
function patchVnode(
oldVnode: VNode,
vnode: VNode,
insertedVnodeQueue: VNodeQueue
) {
const hook = vnode.data?.hook;
hook?.prepatch?.(oldVnode, vnode);
const elm = (vnode.elm = oldVnode.elm)!; // 变量后面跟!表示变量不会是undefined 或 null
// 如果新旧节点相同(对象地址相同),无须比较
if (oldVnode === vnode) return;
if (
vnode.data !== undefined ||
(isDef(vnode.text) && vnode.text !== oldVnode.text)
) {
vnode.data ??= {};
oldVnode.data ??= {};
for (let i = 0; i < cbs.update.length; ++i)
cbs.update[i](oldVnode, vnode);
vnode.data?.hook?.update?.(oldVnode, vnode);
}
const oldCh = oldVnode.children as VNode[];
const ch = vnode.children as VNode[];
if (isUndef(vnode.text)) {
if (isDef(oldCh) && isDef(ch)) {
// 都有子结点是,进行复杂比较
if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue);
} else if (isDef(ch)) { // 仅有新节点中有子节点
if (isDef(oldVnode.text)) api.setTextContent(elm, ""); // 如果旧节点中有text, 则置空
addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue); // 将新节点中的子节点直接加入
} else if (isDef(oldCh)) { // 只有旧节点中有,就直接删除旧节点中的子节点
removeVnodes(elm, oldCh, 0, oldCh.length - 1);
} else if (isDef(oldVnode.text)) {
api.setTextContent(elm, ""); // 新节点中什么都没有,置空旧节点
}
} else if (oldVnode.text !== vnode.text) {
if (isDef(oldCh)) {
removeVnodes(elm, oldCh, 0, oldCh.length - 1); // 新旧节点text不相同,直接删除旧节点中的所有
}
api.setTextContent(elm, vnode.text!);// 重置旧节点中的text
}
hook?.postpatch?.(oldVnode, vnode);
}
updateChildren
diff算法核心
function updateChildren(
parentElm: Node,
oldCh: VNode[],
newCh: VNode[],
insertedVnodeQueue: VNodeQueue
) {
let oldStartIdx = 0; // 旧开始索引
let newStartIdx = 0; // 新开始索引
let oldEndIdx = oldCh.length - 1; // 旧结束索引
let oldStartVnode = oldCh[0]; // 旧开始指针
let oldEndVnode = oldCh[oldEndIdx]; // 旧结束指针
let newEndIdx = newCh.length - 1; // 新结束索引
let newStartVnode = newCh[0]; // 新开始指针
let newEndVnode = newCh[newEndIdx]; // 新结束指针
let oldKeyToIdx: KeyToIndexMap | undefined; // 键值和索引的映射; key --> index
let idxInOld: number;
let elmToMove: VNode;
let before: any;
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
// 两种节点的两端可能存在已经处理过的,所以需要进行一次过滤
if (oldStartVnode == null) {
oldStartVnode = oldCh[++oldStartIdx]; // Vnode might have been moved left
} else if (oldEndVnode == null) {
oldEndVnode = oldCh[--oldEndIdx];
} else if (newStartVnode == null) {
newStartVnode = newCh[++newStartIdx];
} else if (newEndVnode == null) {
newEndVnode = newCh[--newEndIdx];
} else if (sameVnode(oldStartVnode, newStartVnode)) {
patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue);
oldStartVnode = oldCh[++oldStartIdx];
newStartVnode = newCh[++newStartIdx];
} else if (sameVnode(oldEndVnode, newEndVnode)) {
patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue);
oldEndVnode = oldCh[--oldEndIdx];
newEndVnode = newCh[--newEndIdx];
} else if (sameVnode(oldStartVnode, newEndVnode)) {
// Vnode moved right
patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue);
api.insertBefore(
parentElm,
oldStartVnode.elm!,
api.nextSibling(oldEndVnode.elm!)
);
oldStartVnode = oldCh[++oldStartIdx];
newEndVnode = newCh[--newEndIdx];
} else if (sameVnode(oldEndVnode, newStartVnode)) {
// Vnode moved left
patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue);
api.insertBefore(parentElm, oldEndVnode.elm!, oldStartVnode.elm!);
oldEndVnode = oldCh[--oldEndIdx];
newStartVnode = newCh[++newStartIdx];
} else {
// 创键key --> index 的映射关系
if (oldKeyToIdx === undefined) {
oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx);
}
idxInOld = oldKeyToIdx[newStartVnode.key as string]; // 在old节点中查询新节点的key 对应old节点的索引
if (isUndef(idxInOld)) {
// 没有找到对应的节点,在oldStartVnode前面补一个节点
// New element
api.insertBefore(
parentElm,
createElm(newStartVnode, insertedVnodeQueue),
oldStartVnode.elm!
);
} else {
// 此时是两个首部指针位置,对应的内容不一样,然后开始在旧节点中遍历查询和当前newStartVnode一样的key的节点;
// 如果查询到就直接移动节点到oldStartVnode前面
elmToMove = oldCh[idxInOld];
if (elmToMove.sel !== newStartVnode.sel) {
// 如果不是相同元素就直接新增
api.insertBefore(
parentElm,
createElm(newStartVnode, insertedVnodeQueue), // 新建一个新的节点
oldStartVnode.elm!
);
} else {
// 如果是相同的元素就行精细化比较
patchVnode(elmToMove, newStartVnode, insertedVnodeQueue);
oldCh[idxInOld] = undefined as any; // 置位undefined
// 直接移动到指定的位置
api.insertBefore(parentElm, elmToMove.elm!, oldStartVnode.elm!);
}
}
newStartVnode = newCh[++newStartIdx]; // 向前移动新节点的指针
}
}
if (newStartIdx <= newEndIdx) {
//说明新节点中有新增元素
// 注意这里写的是newEndIdx
before = newCh[newEndIdx + 1] == null ? null : newCh[newEndIdx + 1].elm;
addVnodes(
parentElm,
before,
newCh,
newStartIdx,
newEndIdx,
insertedVnodeQueue
);
}
if (oldStartIdx <= oldEndIdx) {
// 删除新节点中没有的多余元素
removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx);
}
}
patch
同层比较,内部递归
return function patch(
oldVnode: VNode | Element | DocumentFragment,
vnode: VNode
): VNode {
let i: number, elm: Node, parent: Node;
const insertedVnodeQueue: VNodeQueue = [];
for (i = 0; i < cbs.pre.length; ++i) cbs.pre[i]();
if (isElement(api, oldVnode)) {
oldVnode = emptyNodeAt(oldVnode);
} else if (isDocumentFragment(api, oldVnode)) {
oldVnode = emptyDocumentFragmentAt(oldVnode);
}
// 判断是否为相同vnode
if (sameVnode(oldVnode, vnode)) {
patchVnode(oldVnode, vnode, insertedVnodeQueue);
} else {
elm = oldVnode.elm!;
parent = api.parentNode(elm) as Node;
// 元素不同时,直接创建新DOM
createElm(vnode, insertedVnodeQueue);
if (parent !== null) {
// 插入页面DOM结构中
api.insertBefore(parent, vnode.elm!, api.nextSibling(elm));
// 删除旧DOM
removeVnodes(parent, [oldVnode], 0, 0);
}
}
/** some handle */
return vnode;
};
到底啦~👏 非常感谢您能看到这里,您的支持就是我最大的动力。🌈