虚拟DOM简介
Virtual Dom可以看做一棵模拟了DOM树的JavaScript对象树,其主要是通过vnode,实现一个无状态的组件,当组件状态发生更新时,然后触发Virtual Dom数据的变化,然后通过Virtual Dom和真实DOM的比对,再对真实DOM更新。虚拟DOM其实就是一种模拟DOM的JavaScript数据结构。
像SnabbDOM这种库的虚拟DOM是如下的数据结构:
- sel 元素选择器
- data 元素属性 ●
- children 元素子节点 ●
- text 元素文本 ●
- elm 对应dom元素 ●
- key
SnabbDOM源码概述
说到SnabbDOM可能大家不太知道,但是大名鼎鼎的VUE就是使用SnabbDOM来提供虚拟DOM。SnabbDOM中的VNode结构如下:
export interface VNodeData { props?: Props; attrs?: Attrs; class?: Classes; style?: VNodeStyle; dataset?: Dataset; on?: On; hero?: Hero; attachData?: AttachData; hook?: Hooks; key?: Key; ns?: string; // for SVGs fn?: () => VNode; // for thunks args?: Array<any>; // for thunks [key: string]: any; // for any other 3rd party module } export function vnode(sel: string | undefined, data: any | undefined, children: Array<VNode | string> | undefined, text: string | undefined, elm: Element | Text | undefined): VNode { let key = data === undefined ? undefined : data.key; return {sel: sel, data: data, children: children, text: text, elm: elm, key: key}; } export default vnode;
但是这里并没有直接提供对外接口,而是提供了h方法来创建VNode:
View Code
那么在具体使用场景如下:
View Code
其中跟虚拟DOM各个周期中需要使用的hook方法都放在Hook.ts这个文件下:
View Code
具体的钩子方法则是放在src/modules目录下的各个文件中。
其中核心代码,包括整个Diff算法都放在snabbdom.ts文件中:
View Code
Diff算法
SnabbDOM的diff算法主要有两个特点:
- 同级比较
- 就近复用
同级比较的意思是,对于两颗DOM树,只会比较同一层级的节点,如果节点类型不同直接干掉旧的节点,而不是继续比较。那么就近复用意味着如果节点类型相同就会对这个节点进行改造,而不是严格的比较各个属性书否相同。那么这里涉及snabbdom中的两个主要函数:
function patch(oldVnode: VNode | Element, vnode: VNode): VNode { let i: number, elm: Node, parent: Node; const insertedVnodeQueue: VNodeQueue = []; for (i = 0; i < cbs.pre.length; ++i) cbs.pre[i]();// 调用所有模块的pre钩子方法 if (!isVnode(oldVnode)) { // 非虚拟dom直接清空 oldVnode = emptyNodeAt(oldVnode); } if (sameVnode(oldVnode, vnode)) { // 两个虚拟dom树的根节点完全一样才会进行打补丁 patchVnode(oldVnode, vnode, insertedVnodeQueue); } else { // 如果跟节点不同直接干掉旧的根节点,重新创建dom元素插入到dom树中 elm = oldVnode.elm as Node; parent = api.parentNode(elm); createElm(vnode, insertedVnodeQueue); if (parent !== null) { api.insertBefore(parent, vnode.elm as Node, api.nextSibling(elm)); removeVnodes(parent, [oldVnode], 0, 0); } } // 对每个插入的元素使用相应模块中的insert钩子方法进行更新 for (i = 0; i < insertedVnodeQueue.length; ++i) { (((insertedVnodeQueue[i].data as VNodeData).hook as Hooks).insert as any)(insertedVnodeQueue[i]); } for (i = 0; i < cbs.post.length; ++i) cbs.post[i](); // 调用所有钩子模块的post方法 return vnode; };
function patchVnode(oldVnode: VNode, vnode: VNode, insertedVnodeQueue: VNodeQueue) { let i: any, hook: any; // 调用prepatch钩子 if (isDef(i = vnode.data) && isDef(hook = i.hook) && isDef(i = hook.prepatch)) { i(oldVnode, vnode); } const elm = vnode.elm = (oldVnode.elm as Node); let oldCh = oldVnode.children; let ch = vnode.children; if (oldVnode === vnode) return;// 没有变化直接返回 if (vnode.data !== undefined) { // 对两个虚拟dom树的根节点进行更新 for (i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode); i = vnode.data.hook; if (isDef(i) && isDef(i = i.update)) i(oldVnode, vnode); } if (isUndef(vnode.text)) { // 是否有文本节点 if (isDef(oldCh) && isDef(ch)) { // 都有子节点对子节点进行diff算法 if (oldCh !== ch) updateChildren(elm, oldCh as Array<VNode>, ch as Array<VNode>, insertedVnodeQueue); } else if (isDef(ch)) { // 新虚拟dom有子节点旧的没有,则把新的子节点挂在dom上 if (isDef(oldVnode.text)) api.setTextContent(elm, ''); addVnodes(elm, null, ch as Array<VNode>, 0, (ch as Array<VNode>).length - 1, insertedVnodeQueue); } else if (isDef(oldCh)) {// 老的里面有子节点,新的没有直接干掉老的 removeVnodes(elm, oldCh as Array<VNode>, 0, (oldCh as Array<VNode>).length - 1); } else if (isDef(oldVnode.text)) { // 老的有文本信息直接设置为空字符串 api.setTextContent(elm, ''); } } else if (oldVnode.text !== vnode.text) {// 使用新的文本信息替换旧的 api.setTextContent(elm, vnode.text as string); } if (isDef(hook) && isDef(i = hook.postpatch)) { // 调用postpatch方法 i(oldVnode, vnode); } }
其中diff的核心算法就在updateChildren中
function updateChildren(parentElm: Node, oldCh: Array<VNode>, newCh: Array<VNode>, insertedVnodeQueue: VNodeQueue) { // 声明指针 let oldStartIdx = 0, 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: any; 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); // 将oldStart指向的节点插入到oldEnd指向节点前面 api.insertBefore(parentElm, oldStartVnode.elm as Node, api.nextSibling(oldEndVnode.elm as Node)); oldStartVnode = oldCh[++oldStartIdx]; newEndVnode = newCh[--newEndIdx]; } else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left 尾头相同 patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue); // 将oldEnd指向节点插入到oldStart之前 api.insertBefore(parentElm, oldEndVnode.elm as Node, oldStartVnode.elm as Node); oldEndVnode = oldCh[--oldEndIdx]; newStartVnode = newCh[++newStartIdx]; } else { if (oldKeyToIdx === undefined) { // 制作key-index的哈西集合 oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx); } idxInOld = oldKeyToIdx[newStartVnode.key as string]; if (isUndef(idxInOld)) { // New element // 新节点则插入到oldStart指向的dom节点之前 api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm as Node); newStartVnode = newCh[++newStartIdx]; } else { elmToMove = oldCh[idxInOld]; if (elmToMove.sel !== newStartVnode.sel) { api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm as Node); } else { // 打补丁 // 对旧的位置坐标记设为undefined // 将更新后的节点移动到oldStart之前 patchVnode(elmToMove, newStartVnode, insertedVnodeQueue); oldCh[idxInOld] = undefined as any; api.insertBefore(parentElm, (elmToMove.elm as Node), oldStartVnode.elm as Node); } newStartVnode = newCh[++newStartIdx]; } } } if (oldStartIdx > oldEndIdx) {// 新的有剩余则需要将这些节点插入到dom树中 before = newCh[newEndIdx+1] == null ? null : newCh[newEndIdx+1].elm; addVnodes(parentElm, before, newCh, newStartIdx, newEndIdx, insertedVnodeQueue); } else if (newStartIdx > newEndIdx) {// 老的有节点则应该移除 removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx); } }
这个方法有些复杂,下面文字描述未必那么清晰,大家可以看我录制的视频——虚拟DOM详解。
下图中白色图表代表目前实际的DOM节点,oldStart和oldEnd指向之前的虚拟DOM树,newStart和newEnd分别指向新的虚拟DOM节点。
现在二者进行比较,首先处理头头、尾尾相同的节点,如果头尾尾头相同则同时移动新旧的指针
接下来处理头尾尾头相同的节点,把newStartIdx和oldEndIdx相同的节点插入到oldStartIdx指向节点之前,newStartIdx向后移动,oldEndIdx向前移动;把newEndIdx和oldStartIdx相同的节点插入到oldEndIdx指向的节点之后,同时oldStartIdx向后移动,newEndIdx向前移动。
处理完毕后的指针状态:
接下来需要处理newStartIdx指向的节点11,那么这时候先去oldStartIdx和oldEndIdx的区间内寻找有没有这个节点,如果没有那么这个节点属于插入节点,这个节点会被插入到oldStartIdx指向的节点的前面,同时newStartIdx向后移动
处理完11后,newStartIdx指向4,这个时候从oldStartIdx和oldEndIdx中能够找到这个节点,这说明它的位置被移动了,那么这时候只需要移动这个节点,把它移动到oldStartIdx所指向的节点之前,同时对就的虚拟DOM节点的位置进行标记,这里是设置为undefined。继续移动newStartIdx。
接下来对7、 5、 6 都进行相同操作,这时候newStartIdx指向3的位置
那么这个时候又变成了头头相同的情况,只需要同时将newStartIdx和oldStartIdx向后移动。
那么这时候newStartIdx越过了newEndIdx,到这里循环结束,这时候oldStartIdx和oldEndIdx中剩下的节点都是需要删除的节点。因为之前都已经打上了标记,所以这里只要节点8是需要删除的。当然也有时候会遇到oldStartIdx和oldEndIdx先相遇,这时候在newStartIdx和newEndIdx中的节点都是需要插入的。
至此整个比较算法结束。