何为虚拟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): 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 | 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
进行对比更新。 - 如果不相同,直接删除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 = 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个变量分别存储:
旧数组:开始索引,开始节点,结束索引,结束节点。
新数组:开始索引,开始节点,结束索引,结束节点。
- 设置循环执行条件
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 }