何为虚构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): VNodeexport function h(sel: string, data: VNodeData | null): VNodeexport function h(sel: string, children: VNodeChildren): VNodeexport function h(sel: string, data: VNodeData | null, children: VNodeChildren): VNodeexport 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。

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 = 0let oldEndIdx = oldCh.length - 1let oldStartVnode = oldCh[0]let oldEndVnode = oldCh[oldEndIdx]let newStartIdx = 0let newEndIdx = newCh.length - 1let 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 }