Virtual DOM 是一种编程概念,在更新state或props时,render 返回一颗虚拟dom树,而非直接修改真实dom

Diffing算法 用与对比两棵虚拟dom树,将旧dom更新为新dom,如果用传统的dfs算法转换需要O(n3),而diff只需要O(n)
目前 Virtual DOM 的主流 diff算法 基本一致,Vue整合了snabbdom库,React使用了reconcilation

snabbdom中是如何实现的?
1. h函数 => 创建 VNode
snabbdom中使用 h() 函数创建虚拟节点称为VNode,因此Vue中使用 h 作为 createElement 的别名
h函数接口如下
export declare function h(sel: string): VNode;
export declare function h(sel: string, data: VNodeData | null): VNode;
export declare function h(sel: string, children: VNodeChildren): VNode;
export declare function h(sel: string, data: VNodeData | null, children: VNodeChildren): VNode;
其中,VNode和VNodeData接口如下,VNodeChildren表示一个子VNode或子VNode数组
export interface VNode {
sel: string | undefined; // 选择器,即html标签
data: VNodeData | undefined;
children: Array<VNode | string> | undefined; // 当真实dom节点包含子节点时,存为children
elm: Node | undefined; // 对真实dom的封装,能反应出真实dom的信息
text: string | undefined; // 当真实dom节点是文本时,存为text
key: Key | undefined;
}
export interface VNodeData {
props?: Props;
attrs?: Attrs;
class?: Classes;
style?: VNodeStyle;
dataset?: Dataset;
on?: On;
attachData?: AttachData;
hook?: Hooks;
key?: Key;
ns?: string;
fn?: () => VNode;
args?: any[];
is?: string;
[key: string]: any;
}
测试
import {
init,
classModule,
propsModule,
styleModule,
eventListenersModule,
h,
} from "snabbdom";
// 此函数用于将vnode挂载到真实node
const patch = init([classModule, propsModule, styleModule, eventListenersModule]);
const vnode1 = h('a', {
props: {
href: 'http://www.bilibili.com',
target: '_blank'
}
}, '哔哩哔哩');
const vnode2 = h('ul', h('li', '科技区'));
const vnode3 = h('ul', [h('li', '知识区'), h('li', '番剧区')])
console.log('vnode1', vnode1);
console.log('vnode2', vnode2);
console.log('vnode3', vnode3);
const node1 = document.getElementById('node1');
const node2 = document.getElementById('node2');
const node3 = document.getElementById('node3');
patch(node1, vnode1);
patch(node2, vnode2);
patch(node3, vnode3);

h函数的实现
export function h(sel: any, b?: any, c?: any): VNode { // 注意:用来实现重载的函数,参数都用any
let data: VNodeData = {};
let children: any;
let text: any;
let i: number;
if (c !== undefined) { // 如果有三个参数
if (b !== null) { // 如果第二个参数不为空
data = b; // 把第二个参数作为data
}
if (Array.isArray(c)) { // 如果第三个参数是数组
children = c; // 把第三个参数作为children
} else if (typeof c === "string" || typeof c === "number") { // 如果第三个参数是string或number
text = c; // 把第三个参数作为text
} else if (c && c.sel) { // 如果第三个参数是单个的vnode对象
children = [c]; // 把第三个参数作为children
}
} else if (b !== undefined && b !== null) { // 如果只有两个参数
if (Array.isArray(b)) {
children = b;
} else if (typeof b === "string" || typeof c === "number") {
text = b;
} else if (b && b.sel) {
children = [b];
} else {
data = b;
}
}
if (children !== undefined) {
for (i = 0; i < children.length; i++) {
// 如果children数组中的当前元素为string或number类型,则转为vnode,且作为vnode中的text
if (typeof children[i] === "string" || typeof children[i] === "number") {
children[i] = vnode (undefined, undefined, undefined, children[i], undefined);
}
}
}
return vnode(sel, data, children, text, undefined);
}
2. 旧VNode vs 新VNode
init.ts中的 sameVnode() 方法明确了如何判断新旧节点是同一节点
function sameVnode(vnode1: VNode, vnode2: VNode): boolean {
const isSameKey = vnode1.key === vnode2.key; // 1.两个节点要有相同的key
const isSameIs = vnode1.data?.is === vnode2.data?.is; // 2.两个节点如果都存在data,则is属性要相同
const isSameSel = vnode1.sel === vnode2.sel; // 3.两个节点要有相同的选择器
return isSameSel && isSameKey && isSameIs;
}
init.ts中主函数返回了一个 patch() 方法,利用这个方法对比新旧vnode,从而在diff过程中将真实dom进行差异化更新,同时也说明了 ** 虚拟dom => 真实dom 的过程包含在diff算法中**,patch方法如下:
return function patch(oldVnode: VNode | Element, vnode: VNode): VNode {
let i: number, elm: Node, parent: Node;
const insertedVnodeQueue: VNodeQueue = [];
if (!isVnode(oldVnode)) { // 如果老节点不是vnode类型
oldVnode = emptyNodeAt(oldVnode); // 将老节点转成vnode类型
}
if (sameVnode(oldVnode, vnode)) { // 如果两个节点是同一节点
patchVnode(oldVnode, vnode, insertedVnodeQueue); // 执行对比操作
} else { // 如果两个节点不是同一节点
elm = oldVnode.elm!; // 拿到旧节点的dom信息
parent = api.parentNode(elm) as Node; // 拿到旧节点的父节点dom
createElm(vnode, insertedVnodeQueue); // 创建新节点dom
if (parent !== null) { // 如果父节点dom不为空
api.insertBefore(parent, vnode.elm!, api.nextSibling(elm)); // 将新节点dom插入到旧节点dom的下一兄弟节点dom前
removeVnodes(parent, [oldVnode], 0, 0); // 移除旧节点dom
}
}
return vnode;
};
由patch的源码可知,patch主要进行了两件事情:
- 新旧节点是同一节点,则执行
patchVnode()方法 - 新旧节点不是同一节点,则 创建新节点dom -> 插入新节点dom -> 删除旧节点dom
其中创建新节点dom的方法 createElm() ,使用递归遍历了所有节点中的text和children,从而创建出真实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
const hashIdx = sel.indexOf("#");
const dotIdx = sel.indexOf(".", hashIdx);
const hash = hashIdx > 0 ? hashIdx : sel.length;
const dot = dotIdx > 0 ? dotIdx : sel.length;
const tag =
hashIdx !== -1 || dotIdx !== -1
? sel.slice(0, Math.min(hash, dot))
: sel;
const elm = (vnode.elm =
isDef(data) && isDef((i = data.ns))
? api.createElementNS(i, tag, data)
: api.createElement(tag, data));
if (hash < dot) elm.setAttribute("id", sel.slice(hash + 1, dot));
if (dotIdx > 0)
elm.setAttribute("class", sel.slice(dot + 1).replace(/\./g, " "));
for (i = 0; i < cbs.create.length; ++i) cbs.create[i](emptyNode, vnode);
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)); // 递归追加节点dom
}
}
} else if (is.primitive(vnode.text)) {
api.appendChild(elm, api.createTextNode(vnode.text));
}
const hook = vnode.data!.hook;
if (isDef(hook)) {
hook.create?.(emptyNode, vnode);
if (hook.insert) {
insertedVnodeQueue.push(vnode);
}
}
} else {
vnode.elm = api.createTextNode(vnode.text!);
}
return vnode.elm;
}
同一节点时的 patchVnode() 方法如下
function patchVnode(
oldVnode: VNode,
vnode: VNode,
insertedVnodeQueue: VNodeQueue
) {
const hook = vnode.data?.hook;
hook?.prepatch?.(oldVnode, vnode);
const elm = (vnode.elm = oldVnode.elm)!;
const oldCh = oldVnode.children as VNode[];
const ch = vnode.children as VNode[];
if (oldVnode === vnode) return; // 如果新旧节点完全是同一个对象,则直接return
if (vnode.data !== undefined) {
for (let i = 0; i < cbs.update.length; ++i)
cbs.update[i](oldVnode, vnode);
vnode.data.hook?.update?.(oldVnode, vnode);
}
if (isUndef(vnode.text)) { // 如果新节点不是text类型的
if (isDef(oldCh) && isDef(ch)) { // 如果新老节点都有children
if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue); // 如果新老节点children不同,则更新children
} else if (isDef(ch)) { // 如果新节点是children,老节点是text
if (isDef(oldVnode.text)) api.setTextContent(elm, ""); // 清除老节点文本内容
addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue); // 插入children到老节点dom下
} else if (isDef(oldCh)) { // 如果只有老节点,且只是children
removeVnodes(elm, oldCh, 0, oldCh.length - 1); // 删除老节点dom
} else if (isDef(oldVnode.text)) { // 如果只有老节点,且还是text
api.setTextContent(elm, ""); //清除老节点文本内容
}
} else if (oldVnode.text !== vnode.text) { // 如果新节点是text类型的
if (isDef(oldCh)) { // 如果老节点是children
removeVnodes(elm, oldCh, 0, oldCh.length - 1); //删除老节点dom
}
api.setTextContent(elm, vnode.text!); // 更新文本dom
}
hook?.postpatch?.(oldVnode, vnode);
}
最后,patchVnode中的 updateChildren() 方法,源码如下:
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;
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 {
if (oldKeyToIdx === undefined) {
oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx);
}
idxInOld = oldKeyToIdx[newStartVnode.key as string];
if (isUndef(idxInOld)) {
// New element
api.insertBefore(
parentElm,
createElm(newStartVnode, insertedVnodeQueue),
oldStartVnode.elm!
);
} else {
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;
api.insertBefore(parentElm, elmToMove.elm!, oldStartVnode.elm!);
}
}
newStartVnode = newCh[++newStartIdx];
}
}
if (oldStartIdx <= oldEndIdx || newStartIdx <= newEndIdx) {
if (oldStartIdx > oldEndIdx) {
before = newCh[newEndIdx + 1] == null ? null : newCh[newEndIdx + 1].elm;
addVnodes(
parentElm,
before,
newCh,
newStartIdx,
newEndIdx,
insertedVnodeQueue
);
} else {
removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx);
}
}
}
可以看到,源码中首先为新旧节点定义了4个索引,并在循环中根据情况使start和end索引向中间移动
![[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-EGc4akto-1626772840720)(https://i.loli.net/2021/07/20/xThPgXnNBcOwWzI.png)]](https://i-blog.csdnimg.cn/blog_migrate/8a52c91d22688acd366eb7ea0585d413.png)
4个索引对应的节点,两两对比,共存在5种情况:
- sameVnode(oldStartVnode, newStartVnode)
- sameVnode(oldEndVnode, newEndVnode)
- sameVnode(oldStartVnode, newEndVnode)
- sameVnode(oldEndVnode, newStartVnode)
- 都不相同
这篇文章详细讲解了snabbdom库中Virtual DOM的工作原理,包括h函数的用法、旧VNode与新VNode的比较以及patch方法的内部逻辑。通过实例演示了VNode的创建、对比和差异化更新过程,展示了虚拟DOM如何提高性能和DOM操作的效率。
647

被折叠的 条评论
为什么被折叠?



