何为虚构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在应用的时候蕴含如下两个步骤:
- 调用init生成patch函数,init反对传入一个数组,数组中能够蕴含扩大模块。
- 调用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属性
- 如果 oldVnode.children 和 newVnode.children 都有值
调用 updateChildren()
应用 diff 算法比照子节点,更新子节点 - 如果 newVnode.children 有值, oldVnode.children 无值
清空 DOM 元素
调用 addVnodes() ,批量增加子节点 - 如果 oldVnode.children 有值, newVnode.children 无值
调用 removeVnodes() ,批量移除子节点 - 如果 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']
步骤一 重新排列旧的数组
此步骤目标是依据新的数组,将旧的数组重新排列,新数组中的每一项在旧数组中无外乎两种状况:
- 旧数组中存在,调整其到正确地位
如newNodes数组中的B
项在a数组中存在,然而oldNodes数组中其地位不正确,根据newNodes数组的构造,B
应该在F
与G
之间,因而须要调整B
的地位。
- 旧数组不存在,在正确地位插入
如newNodes数组中的H
在oldNodes数组中不存在,所以须要将其插入到oldNodes数组中,那么插入到哪个地位?依据newNodes的构造,应该将H
插入到A
的前面。
调整实现之后,oldNodes应该变成如下:
let oldNodes = ['A', 'H', 'C', 'D', 'E', 'F', 'B', 'G']
步骤一实现逻辑
整个步骤一通过一个循环就能够实现,将两个数组的开始、完结索引作为循环变量,每调整一次数组(挪动或者新增),就批改变量指向的索引,直到某一个数组的开始变量大于完结变量,那么阐明此数组中的每一个元素都被遍历过,循环完结。
- 设置循环开始的批示变量
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个变量别离存储:
旧数组:开始索引,开始节点,完结索引,完结节点。
新数组:开始索引,开始节点,完结索引,完结节点。
- 设置循环执行条件
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) { // 调整逻辑}
当某个数组的开始索引变量大于完结索引变量时,循环完结。
- 调整逻辑
将旧数组的开始完结节点和新数组的开始完结节点进行比照,会呈现以下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]
步骤二 解决重新排列后的数组
通过步骤一的解决,可能会呈现两种状况:
- 新数组被遍历完,旧数组没有遍历完。
如下例:
let oldNodes = ['A', 'B', 'C']let newNodes = ['A', 'E']
步骤一实现之后,oldNodes数组变为 ['A', 'E', 'B', 'C']
,此时B
和C
没有被遍历到。
在这种状况下,阐明B
和C
在新数组中不存在,间接删掉即可。
- 旧数组被遍历完,新数组没有遍历完。
如下例:
let oldNodes = ['A']let newNodes = ['A', 'D', 'E']
步骤一实现后,oldNodes数组仍然为['A']
,此时newNodes数组中的D
和E
还没有被遍历到。
在这种状况下,阐明D
和E
是新增的元素,在旧数组中必定没有,间接将两个元素减少到相应地位即可。
步骤二实现逻辑
// 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 }