何为虚拟DOM

虚拟DOM就是普通的js对象,其可以用来描述DOM对象,但是由于不是真正的DOM对象,因此人们把它叫做虚拟DOM。

为何出现虚拟DOM

  • 手动处理DOM

在早期的js应用中,开发人员需要手动处理DOM,手动处理不仅代码繁琐,而且需要适配各种浏览器。

  • jQuery操作DOM

jQuery对DOM处理操作进行了统一封装,由其内部处理浏览器的差异,虽然减轻了开发人员的DOM操作成本,但是开发人员在开发时还是无法避免DOM操作,无法专心处理业务逻辑。

  • mvvm框架屏蔽DOM操作

mvvm框架处理了视图和状态之间的同步问题,让开发人员不再需要进行DOM操作。但是此时不能保存DOM状态,当更新代码时,会整体更新导致DOM状态丢失。

  • 虚拟DOM提升渲染能力

用操作虚拟DOM来代替操作真实DOM,不仅可以保存DOM状态,而且虚拟DOM在更新时只会修改变化的DOM,提升了复杂视图的渲染能力。

Snabbdom

Snabbdom是一个虚拟DOM库,用其官方的话,其体积小,速度快,可扩展。Vue框架虚拟DOM使用的就是Snabbdom(在其基础上进行了修改),下面将通过解析Snabbdom的源码来了解虚拟DOM和diff算法。

基本使用

下面的例子是使用snabbdom在页面上输出hello world。

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>snabbdom-demo</title>
    <script src="https://cdn.bootcss.com/snabbdom/0.7.3/snabbdom.js"></script>
    <script src="https://cdn.bootcss.com/snabbdom/0.7.3/h.js"></script>
</head>

<body>
    <div id="app"></div>
    <script>
        const patch = snabbdom.init([])
        const h = snabbdom.h
        let old = patch(document.querySelector('#app'), h('div.cls', 'hello world'))
        setTimeout(() => {
            patch(old, h('div.cls', 'hello snabbdom'))
        }, 2000);
    </script>
</body>

</html>

从上例可以看出,snabbdom在使用的时候包含如下两个步骤:

  1. 调用init生成patch函数,init支持传入一个数组,数组中可以包含扩展模块。
  2. 调用patch函数对页面dom进行对比更新,patch接受dom元素或者h函数生成的vnodes。

h函数

h函数最早见于hyperscript,用于使用JavaScript创建超文本。在snabbdom中,h函数用于生成vnodes。

其实在使用Vue的项目中,就可以看到h函数:

new Vue({
    router,
    store,
    render: h => h(App)
}).$mount('#app')

此处h函数的作用就是将组件转换为vnodes。

源码

export function h(sel: string): VNode
export function h(sel: string, data: VNodeData | null): VNode
export function h(sel: string, children: VNodeChildren): VNode
export function h(sel: string, data: VNodeData | null, children: VNodeChildren): VNode
export function h(sel: any, b?: any, c?: any): VNode {
    // .....
    // 对传入的参数进行处理,最终调用vnode方法生成vnode对象
    return vnode(sel, data, children, text, undefined)
};


export function vnode(sel: string | undefined,
    data: any | undefined,
    children: Array<VNode | string> | undefined,
    text: string | undefined,
    elm: Element | Text | undefined): VNode {

    const key = data === undefined ? undefined : data.key
    // vnode函数的作用其实就是将多个数据组装成一个固定格式的对象
    return { sel, data, children, text, elm, key }
}

h函数的源码在去除参数判断之后其实非常简单,就是将处理后的用户传参转换为一个vnode对象。

init函数

const patch = snabbdom.init([])

从init函数的使用就可以看出,其是一个高阶函数,因为其返回一个函数。

在snabbdom库中,init函数用于处理模块,指定dom操作api及生成回调函数对象cbs用于后续patch函数使用。

源码

// modules: 模块数组,用于传入扩展模块
// domapi: 定义如何操作dom,通过修改domapi,可以让snabbdom库支持小程序等应用
export function init(modules: Array<Partial<Module>>, domApi?: DOMAPI) {
    let i: number
    let j: number

    // 收集所有的生命周期钩子函数
    const cbs: ModuleHooks = {
        create: [],
        update: [],
        remove: [],
        destroy: [],
        pre: [],
        post: []
    }

    // 为api添加默认值
    const api: DOMAPI = domApi !== undefined ? domApi : htmlDomApi

    // 遍历所有生命周期
    for (i = 0; i < hooks.length; ++i) {
        cbs[hooks[i]] = []
        // 遍历所有模块,如果模块定义了生命周期钩子函数,那么将对应函数添加到cbs中
        for (j = 0; j < modules.length; ++j) {
            const hook = modules[j][hooks[i]]
            if (hook !== undefined) {
                (cbs[hooks[i]] as any[]).push(hook)
            }
        }
    }

    // ...... 省略一些内部函数

    // 返回patch函数
    return function patch(oldVnode: VNode | Element, vnode: VNode): VNode {
        // ......
    }
}

工具函数

在讲解patch函数之前,需要了解一些工具函数:

isVnode

用于判断一个js数据是否是vnode,只需要判断对象是否包含sel属性(在使用h函数生成的vnode对象均包含sel属性),

function isVnode (vnode: any): vnode is VNode {
  return vnode.sel !== undefined
}

sameVnode

用于判断两个vnode是否相同,在snabbdom中,如果两个vnode的key和sel属性相同,那么就认为这两个vnode相同。

function sameVnode (vnode1: VNode, vnode2: VNode): boolean {
  return vnode1.key === vnode2.key && vnode1.sel === vnode2.sel
}

patch函数

patch函数是snabbdom库的核心函数,在其内部执行新旧Vnode之间的对比,并根据对比的差异更新真实DOM,执行完成后,会返回新的Vnode作为下次对比的旧Vnode。

patch的执行过程分为如下几步:

第一步: 执行所有的pre生命钩子函数。

第二步: 用isVnode函数判断传入的oldVnode是否是一个Vnode对象。

  • 不是Vnode对象,将oldVnode转换成空的Vnode对象。

第三步: 用sameVnode函数判断oldVnode和newVnode是否相同。

  • 如果相同,调用patchVnode进行对比更新。
  • 如果不相同,直接删除oldVnode,将newVnode渲染成dom添加到页面上。

第四步: 执行insert和post生命钩子函数。

function patch(oldVnode: VNode | Element, vnode: VNode): VNode {
    let i: number, elm: Node, parent: Node
    const insertedVnodeQueue: VNodeQueue = []
    // 1. 执行pre钩子函数
    for (i = 0; i < cbs.pre.length; ++i) cbs.pre[i]()

    // 2. 判断oldVnode是否是Vnode
    if (!isVnode(oldVnode)) {
        oldVnode = emptyNodeAt(oldVnode)
    }

    // 3. 判断oldVnode和newVnode是否相同
    if (sameVnode(oldVnode, vnode)) {
        // 如果相同就对比更新
        patchVnode(oldVnode, vnode, insertedVnodeQueue)
    } else {
        // 如果不同就替换
        elm = oldVnode.elm!
        parent = api.parentNode(elm) as Node
        // 根据Vnode创建dom
        createElm(vnode, insertedVnodeQueue)

        if (parent !== null) {
            api.insertBefore(parent, vnode.elm!, api.nextSibling(elm))
            removeVnodes(parent, [oldVnode], 0, 0)
        }
    }

    // 4. 执行insert和post生命钩子函数
    for (i = 0; i < insertedVnodeQueue.length; ++i) {
        insertedVnodeQueue[i].data!.hook!.insert!(insertedVnodeQueue[i])
    }
    for (i = 0; i < cbs.post.length; ++i) cbs.post[i]()
    return vnode
}

patchVnode

patchVnode函数的作用是对比新旧Vnode之间的差异,并根据差异操作真实dom。

image.png

patchVnode函数的执行过程可以分为以下4步:

第一步: 执行用户设置的prepatch钩子函数。

第二步: 执行update钩子函数,先执行模块的update函数,再执行用户设置的update函数。

第三步: 判断newVnode的text属性是否被设置。

  • 设置了text属性,如果oldVnode和newVnode的text属性值不相同,那么首先删除oldVnode的所有子节点,然后修改oldVnode对应的ele元素的文本内容。
  • 未设置text属性
  1. 如果 oldVnode.children 和 newVnode.children 都有值
    调用 updateChildren()
    使用 diff 算法对比子节点,更新子节点
  2. 如果 newVnode.children 有值, oldVnode.children 无值
    清空 DOM 元素
    调用 addVnodes() ,批量添加子节点
  3. 如果 oldVnode.children 有值, newVnode.children 无值
    调用 removeVnodes() ,批量移除子节点
  4. 如果 oldVnode.text 有值
    清空 DOM 元素的内容

第四步: 执行用户设置的postpatch钩子函数。


function patchVnode(oldVnode: VNode, vnode: VNode, insertedVnodeQueue: VNodeQueue) {
    const hook = vnode.data?.hook
    // 1. 执行用户设置的prepatch钩子函数
    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
    if (vnode.data !== undefined) {
        // 2. 执行update钩子函数
        for (let i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode)
        vnode.data.hook?.update?.(oldVnode, vnode)
    }
    // 3. 判断vnode的text属性是否被定义
    if (isUndef(vnode.text)) {
        // 二者都有children
        if (isDef(oldCh) && isDef(ch)) {
            if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue)
        }
        // vnode有children, oldVnode没有 
        else if (isDef(ch)) {
            if (isDef(oldVnode.text)) api.setTextContent(elm, '')
            addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
        }
        // oldVnode有children, vnode没有 
        else if (isDef(oldCh)) {
            removeVnodes(elm, oldCh, 0, oldCh.length - 1)
        }
        // 都没有children,oldVnode有text
        else if (isDef(oldVnode.text)) {
            api.setTextContent(elm, '')
        }
    }
    // vnode的text属性被定义并且和oldVnode的text属性值不同
    else if (oldVnode.text !== vnode.text) {
        // 如果oldVnode包含子元素
        if (isDef(oldCh)) {
            removeVnodes(elm, oldCh, 0, oldCh.length - 1)
        }
        // 设置文本节点内容
        api.setTextContent(elm, vnode.text!)
    }
    // 4. 执行用户设置的postpatch钩子函数
    hook?.postpatch?.(oldVnode, vnode)
}

updateChildren

diff算法的核心方法,同层比较新旧节点children之间的差异并更新dom。

普通对比vs同层对比

对比节点差异就是对比两个dom树之间的差异。

  • 普通方式

获取第一颗树的每个节点和第二棵树的每一个节点进行比对,这样比对的时间复杂度为O(n^l),l表示树节点的层级数, 如果一棵树的层级是3,那么复杂度就是O(n^3)

  • 同层对比

由于在操作dom的时候,很少会将一棵dom树的父节点移动更新到其子节点。因此,在对比时,只需要找两棵dom树的同级别子节点依次对比,比对完成后然后再找下一级别的节点进行比对,这样的时间复杂度为O(n)。

diff过程

由于虚拟Dom的diff算法是同层对比两棵dom树,因此探究diff算法过程,也就是探究某一层级两组节点数组之间的对比过程。

diff算法可以大致分成两个步骤,以下面的两个数组为例:

let oldNodes = ['A', 'B', 'C', 'D', 'E', 'F', 'G']
let newNodes = ['A', 'H', 'F', 'B', 'G']

步骤一 重新排列旧的数组

此步骤目的是根据新的数组,将旧的数组重新排列,新数组中的每一项在旧数组中无外乎两种情况:

  1. 旧数组中存在,调整其到正确位置

如newNodes数组中的B项在a数组中存在,但是oldNodes数组中其位置不正确,依据newNodes数组的结构,B应该在FG之间,因此需要调整B的位置。

  1. 旧数组不存在,在正确位置插入

如newNodes数组中的H在oldNodes数组中不存在,所以需要将其插入到oldNodes数组中,那么插入到哪个位置?根据newNodes的结构,应该将H插入到A的后面。

调整完成之后,oldNodes应该变成如下:

let oldNodes = ['A', 'H', 'C', 'D', 'E', 'F', 'B', 'G']

步骤一实现逻辑

整个步骤一通过一个循环就可以完成,将两个数组的开始、结束索引作为循环变量,每调整一次数组(移动或者新增),就修改变量指向的索引,直到某一个数组的开始变量大于结束变量,那么说明此数组中的每一个元素都被遍历过,循环结束。

  1. 设置循环开始的指示变量
let oldStartIdx = 0
let oldEndIdx = oldCh.length - 1
let oldStartVnode = oldCh[0]
let oldEndVnode = oldCh[oldEndIdx]

let newStartIdx = 0
let newEndIdx = newCh.length - 1
let newStartVnode = newCh[0]
let newEndVnode = newCh[newEndIdx]

用8个变量分别存储:
旧数组:开始索引,开始节点,结束索引,结束节点。
新数组:开始索引,开始节点,结束索引,结束节点。

  1. 设置循环执行条件
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) { 
    // 调整逻辑
}

当某个数组的开始索引变量大于结束索引变量时,循环结束。

  1. 调整逻辑

将旧数组的开始结束节点和新数组的开始结束节点进行对比,会出现以下5种情况:

  • 新旧数组的开始节点相同

此种情况下,说明旧数组中当前开始节点变量存储的开始节点位置是正确的,不需要移动。

if (sameVnode(oldStartVnode, newStartVnode)) {
    // 递归diff处理子节点
    patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue)
    // 将起始节点,起始索引向后移动一位
    oldStartVnode = oldCh[++oldStartIdx]
    newStartVnode = newCh[++newStartIdx]
}
  • 新旧数组的结束节点相同

此种情况下,说明旧数组中当前结束节点变量存储结束节点位置是正确的,不需要移动。

if (sameVnode(oldEndVnode, newEndVnode)) {
    patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue)
    // 将结束节点,结束索引向前移动一位
    oldEndVnode = oldCh[--oldEndIdx]
    newEndVnode = newCh[--newEndIdx]
}
  • 旧数组的开始节点和新数组的结束节点相同

此种情况下,说明旧数组中的当前开始节点变量存储的开始节点位置不正确,应该调整到当前旧数组结束节点变量存储的结束节点之后。

也可以用下面的例子表示此种情况:

let oldNodes = ['A', 'B', 'C']
let newNodes = ['D', 'A']

oldNodes数组中的开始节点A和newNodes的结束节点A相同,此时,如果依据newNodes来调整oldNodes,需要将A移动到C的后面。移动完成后,oldNodes应该变成:['B', 'C', 'A']

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]
}
  • 旧数组的结束节点和新数组的开始节点相同

此种情况下,说明旧数组中当前的结束节点变量存储的结束节点位置不正确,应该将其移动到旧数组当前开始节点变量存储的开始节点之前。

也就是:

let oldNodes = ['A', 'B', 'C']
let newNodes = ['C', 'D']

将oldNodes调整为['C', 'A', 'B']

if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left
    patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue)
    // 将结束节点移动到开始节点之前
    api.insertBefore(parentElm, oldEndVnode.elm!, oldStartVnode.elm!)
    // 旧数组中的结束节点指向前一位
    oldEndVnode = oldCh[--oldEndIdx]
    // 新数组中的开始节点指向后一位
    newStartVnode = newCh[++newStartIdx]
}
  • 以上情况均不符合

在这种情况下,只需要判断新数组初始节点在旧数组中是否存在,如果不存在,就在旧数组开始节点之前插入新数组的开始节点。如果存在,就将对应的节点移动到旧数组开始节点之前。

如:

let oldNodes = ['A', 'B', 'D', 'C']
let newNodes = ['D', 'E']

newNodes的开始节点为D,其在oldNodes数组中存在,所以将D移动到A节点之前,变为:['D', 'A', 'B', 'C']

if (oldKeyToIdx === undefined) {
    // 创建key与索引值的结构数组,方便查找
    oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
}
// 根据新数组的初始节点在旧数组中查找
idxInOld = oldKeyToIdx[newStartVnode.key as string]
// 不存在
if (isUndef(idxInOld)) { 
    // 创建一个和新数组开始节点相同的dom节点,插入到旧数组开始节点之前
    api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm!)
} 
// 存在
else {
    // 获取旧数组中当前需要移动的节点
    elmToMove = oldCh[idxInOld]
    // 如果sel不一样,同样认为不同,和不存在的处理逻辑一样
    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]

步骤二 处理重新排列后的数组

经过步骤一的处理,可能会出现两种情况:

  1. 新数组被遍历完,旧数组没有遍历完。

如下例:

let oldNodes = ['A', 'B', 'C']
let newNodes = ['A', 'E']

步骤一完成之后,oldNodes数组变为 ['A', 'E', 'B', 'C'],此时BC没有被遍历到。

在这种情况下,说明BC在新数组中不存在,直接删掉即可。

  1. 旧数组被遍历完,新数组没有遍历完。

如下例:

let oldNodes = ['A']
let newNodes = ['A', 'D', 'E']

步骤一完成后,oldNodes数组依然为['A'],此时newNodes数组中的DE还没有被遍历到。

在这种情况下,说明DE是新增的元素,在旧数组中肯定没有,直接将两个元素增加到相应位置即可。

步骤二实现逻辑
// oldStartIdx <= oldEndIdx 代表旧数组中有元素没有被遍历到
// newStartIdx <= newEndIdx 代表新数组中有元素没有被遍历到
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)
    }
}

模块

snabbdom为了保证框架的简洁高效,将样式、属性、事件等交给模块处理。

模块的定义非常简单,常见的就是在create 和update生命周期钩子函数中根据用户输入修改dom节点。

updateAttrs模块为例:

function updateAttrs (oldVnode: VNode, vnode: VNode): void {
   // 对比修改两个vnode对应ele的attrs
   // ......
}

// 导出一个包含create,update钩子函数的对象,在init的时候,会将钩子函数添加到cbs中。
export const attributesModule: Module = { create: updateAttrs, update: updateAttrs }

carry
58 声望7 粉丝

学无止境