何为虚拟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。
snabbdom-demo
从上例可以看出,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 | 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>, 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
第四步: 执行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 }
再送一波福利给大家
整理这个的是一些大企业的大佬,认真地和他们学习了很多经验以及获取了很多直观的资料,现在全部拿出来奉献给大家!
想系统学习WEB前端的朋友,可以加这边的交流裙(1146649671)学习资料、面试资料、视频资源,资源给你们拉满,不见不散哦~