virtual DOM的广泛运用革新了技术框架,带来了更多的可能。本文了解virtual DOM的本质及对snabbdom库的源码进行分析。
virtual DOM
什么是virtual DOM
virtual DOM的本质是javaScript Object,是对浏览器真实DOM的一种关系映射。用js的对象描述真实DOM的内容,在页面需要重绘时只修改局部内容。从而提高渲染性能。
virtual DOM优缺点
优点:
- 保存VNode,在需要重新渲染页面内容时候,只需修改局部内容,也就是通过diff提高性能
- 成功的打破了跨平台,可以在没有document对象的地方(node)进行伪DOM操作,如ssr应用的产生
- 针对大量的DOM操作,浏览器渲染引擎是很消耗性能的,可以用过VNode提高性能
缺点:
- 首次渲染更消耗性能,因为需要先转到VNode在转回来或把真实DOM转为v-dom
- 关于svelte框架不使用v-dom的看法
virtual DOM场景
一个单独的v-dom库很少直接运行,都是作为框架的一部分运行,如vue,和react框架。都是用到了v-dom。
snabbdom
简介
snabbdom一个简单,高效的virtual DOM库。在vue框架中,v-dom的核心也是基于本库进行二次开发的。
地址:https://github.com/snabbdom/snabbdom
由于整个库的源码还是很多,本文只讨论核心部分的几个函数。
例子
snabbdom用起来很简单,通过init生成patch函数进行DOM的渲染对比,通过h函数生成vNode,init接受一个数组,可以使用官方提供的模块,也可以自定义模块。
import { h } from "snabbdom/build/package/h"
import { init } from "snabbdom/build/package/init"
import { styleModule } from "snabbdom/build/package/modules/style";
import { eventListenersModule } from "snabbdom/build/package/modules/eventlisteners";
const patch = init([
styleModule,
eventListenersModule
])
let vNode = h('div#container', [
h('h1', {
style: { backgroundColor: 'red' }
}, 'h1 texht'),
h('p', { on: { click: clickEventHandle } }, 'this p tag ')
])
function clickEventHandle() {
console.log('我被点了')
}
// 需要存在#app根节点作为入口
const app = document.querySelector('#app')
// 通过oldNode和newNode渲染页面
patch(app, vNode)
如上创造了一个简单的内容,并添加点击事件。更多内容见文档。
源码结构
src/package
文件名 | 作用 |
---|---|
h.ts | 生成vNode |
hooks.ts | 整个vNode生命周期钩子函数 |
htmldomapi.ts | 封装生成dom的原生api |
init.ts | 项目入口,处理vNode的关键 |
is.ts | 判断类型工具函数 |
jsx-global.ts | jsx声明文件 |
jsx.ts | jsx解析文件 |
thunk.ts | 优化处理,对复杂视图不可变化的处理 |
tovnode.ts | 真实DOM转vNode工具函数 |
vnode.ts | 定义vnode的类型 |
helpers/attachto.ts | 定义了 vnode.ts 中 AttachData 的数据结构 |
modules/attributes.ts | 操作DOM的属性 |
modules/class.ts | 切换类样式 |
modules/dataset.ts | 处理data-自定义属性 |
modules/eventlisteners.ts | 注册和移除事件 |
modules/hero.ts | 自定义钩子函数 |
modules/module.ts | 导出各种模块 |
modules/props.ts | 通过对象属性来设置,不处理布尔属性 |
modules/style.ts | 设置行内样式及动画 |
VNode结构
一个VNode节点完整的数据结构如下。
export type Key = string | number
/**
* @description 定义Vnode数据的接口类型
* @param sel 元素选择器
* @param data 节点数据:属性/样式/事件等
* @param children 子节点,和 text 只能互斥
* @param elm 记录 vnode 对应的真实 DOM
* @param text 节点中的内容,和 children 只能互斥
* @param key 数据的key对比dom时候用
*
*/
export interface VNode {
sel: string | undefined
data: VNodeData | undefined
children: Array<VNode | string> | undefined
elm: Node | undefined
text: string | undefined
key: Key | undefined
}
h函数
h函数的作用是创建VNode节点并返回创建节点。在snabbdom中,h函数是基于ts的重载的定义的函数,参数具有不确定性。
- 第一个参数是sel,可以是tag name(如:h1) 也可以是tag name联合标签选择器(如:div#container)。
- b参数是元素attributes集合,具有不确定性,可以选择性传入
- c参数是textConent或者子元素
为了更好理解运行原理,这里采用ts被编译后的js函数作为演示。
export function h(sel, b, c) {
var data = {};
var children;
var text;
var i;
if (c !== undefined) {
if (b !== null) {
data = b;
}
if (is.array(c)) {
children = c;
}
// typeof s === 'string' || typeof s === 'number';
else if (is.primitive(c)) {
text = c;
}
else if (c && c.sel) {
children = [c];
}
}
else if (b !== undefined && b !== null) {
if (is.array(b)) {
children = b;
}
else if (is.primitive(b)) {
text = b;
}
else if (b && b.sel) {
children = [b];
}
else {
data = b;
}
}
if (children !== undefined) {
for (i = 0; i < children.length; ++i) {
if (is.primitive(children[i]))
children[i] = vnode(undefined, undefined, undefined, children[i], undefined);
}
}
// 针对svg元素特殊处理
if (sel[0] === 's' && sel[1] === 'v' && sel[2] === 'g' &&
(sel.length === 3 || sel[3] === '.' || sel[3] === '#')) {
addNS(data, children, sel);
}
return vnode(sel, data, children, text, undefined);
}
init函数/patch(内部)
init函数是最核心的函数是一个高阶函数,会返回一个patch函数。用于渲染对比VNode变成真实DOM。
patch函数流程
- 调用pre钩子
- 如果不是Vnode,就创建一个空的vnode节点
- 先判是否和旧的是用一个节点(判断key和sel)
- 获取是否存在父节点(方便后续插入)
- 调用createElm函数
- 得到elm元素后,判断父节点是否存在,存在就插入到该父节点中
- 调用removeVnodes函数移除掉旧的节点
- 循环调用insert钩子函数
- 循环调用post构造函数
- 结束patch并返回vNode(下一次的oldNode)
export function init (modules: Array<Partial<Module>>, domApi?: DOMAPI) {
// 初始化patch函数
let i: number
let j: number
// 初始化hooks回调
const cbs: ModuleHooks = {
create: [],
update: [],
remove: [],
destroy: [],
pre: [],
post: []
}
const api: DOMAPI = domApi !== undefined ? domApi : htmlDomApi
// 依次存储传入的hooks供后面调用
for (i = 0; i < hooks.length; ++i) {
cbs[hooks[i]] = []
for (j = 0; j < modules.length; ++j) {
const hook = modules[j][hooks[i]]
if (hook !== undefined) {
(cbs[hooks[i]] as any[]).push(hook)
}
}
}
// 创建空vNode的函数
function emptyNodeAt (elm: Element) {
const id = elm.id ? '#' + elm.id : ''
const c = elm.className ? '.' + elm.className.split(' ').join('.') : ''
return vnode(api.tagName(elm).toLowerCase() + id + c, {}, [], undefined, elm)
}
/**
* @description /一个高阶函数,返回一个移除节点的函数
* @param childElm 子元素
* @param listeners 移除的key,确定移除顺序
*/
function createRmCb (childElm: Node, listeners: number) {
return function rmCb () {
if (--listeners === 0) {
const parent = api.parentNode(childElm) as Node
api.removeChild(parent, childElm)
}
}
}
/**
* @description 添加vNode函数
* @param parentElm 父元素
* @param before 插入前的第一个元素
* @param vnodes
* @param startIdx
* @param endIdx
* @param insertedVnodeQueue
*/
function addVnodes (
parentElm: Node,
before: Node | null,
vnodes: VNode[],
startIdx: number,
endIdx: number,
insertedVnodeQueue: VNodeQueue
) {
for (; startIdx <= endIdx; ++startIdx) {
const ch = vnodes[startIdx]
if (ch != null) {
api.insertBefore(parentElm, createElm(ch, insertedVnodeQueue), before)
}
}
}
function invokeDestroyHook (vnode: VNode) {
const data = vnode.data
if (data !== undefined) {
//移除节点的destroy钩子回调
data?.hook?.destroy?.(vnode)
for (let i = 0; i < cbs.destroy.length; ++i) cbs.destroy[i](vnode)
if (vnode.children !== undefined) {
for (let j = 0; j < vnode.children.length; ++j) {
const child = vnode.children[j]
if (child != null && typeof child !== 'string') {
invokeDestroyHook(child)
}
}
}
}
}
/**
* @description 返回一个patch函数,参数是旧节点 - 新节点
*/
return function patch (oldVnode: VNode | Element, vnode: VNode): VNode {
let i: number, elm: Node, parent: Node
const insertedVnodeQueue: VNodeQueue = []
// 调用pre钩子
for (i = 0; i < cbs.pre.length; ++i) cbs.pre[i]()
// 如果不是Vnode,就创建一个空的vnode节点
if (!isVnode(oldVnode)) {
oldVnode = emptyNodeAt(oldVnode)
}
// 先判是否和旧的是用一个节点(判断key和sel
if (sameVnode(oldVnode, vnode)) {
patchVnode(oldVnode, vnode, insertedVnodeQueue)
} else {
elm = oldVnode.elm!
// 获取是否存在父节点(方便后续插入)
parent = api.parentNode(elm) as Node
// 创建新的节点
createElm(vnode, insertedVnodeQueue)
// ,判断父节点是否存在,存在就插入到该父节点中
if (parent !== null) {
api.insertBefore(parent, vnode.elm!, api.nextSibling(elm))
// 调用removeVnodes函数移除掉旧的节点
removeVnodes(parent, [oldVnode], 0, 0)
}
}
// 循环调用insert钩子函数
for (i = 0; i < insertedVnodeQueue.length; ++i) {
insertedVnodeQueue[i].data!.hook!.insert!(insertedVnodeQueue[i])
}
// 循环调用post构造函数
for (i = 0; i < cbs.post.length; ++i) cbs.post[i]()
// 结束patch并返回vNode(下一次的oldNode)
return vnode
}
}
patchVnode函数
patchVnode是对比节点函数的具体实现。
- 首先触发preptach钩子
- 触发update钩子函数
- 判断是否为文本节点,如果和旧的一样就不处理,如果是不一样的就设置新节点的textConent
- 如果有chilcren,就对比新旧子元素是否相等,通过updateChildren函数,并更新差异
- 如果新节点有childeren,就节点没有,那么就清空textConent,然后添加子节点进去
- 如果老节点有children属性,新节点没有,那么直接移除老节点的内容
- 判断老节点是否存在text属性,有的话直接清空
- 触发postpatch钩子函数
/**
* @description 对比节点函数
* @param oldVnode 旧节点
* @param vnode 新节点
* @param insertedVnodeQueue
*/
function patchVnode (oldVnode: VNode, vnode: VNode, insertedVnodeQueue: VNodeQueue) {
const hook = vnode.data?.hook
// 首先触发preptach钩子
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
// 执行update钩子函数
if (vnode.data !== undefined) {
for (let i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode)
vnode.data.hook?.update?.(oldVnode, vnode)
}
// 判断是否为文本节点,如果和旧的一样就不处理,如果是不一样的就设置新节点的textConent
if (isUndef(vnode.text)) {
if (isDef(oldCh) && isDef(ch)) {
// 如果有chilcren,就对比新旧子元素是否相等,通过updateChildren函数对象,并更新差异 diff核心
if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue)
} else if (isDef(ch)) {
// 如果新节点有childeren,就节点没有,那么就清空textConent,然后添加子节点进去
if (isDef(oldVnode.text)) api.setTextContent(elm, '')
addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
} else if (isDef(oldCh)) {
// 如果老节点有children属性,新节点没有,那么直接移除老节点的内容
removeVnodes(elm, oldCh, 0, oldCh.length - 1)
} else if (isDef(oldVnode.text)) {
// 判断老节点是否存在text属性,有的话直接清空
api.setTextContent(elm, '')
}
// 如果新旧节都是文本节点,且不一致,那么就移除旧节点的内容,并设置新的
} else if (oldVnode.text !== vnode.text) {
if (isDef(oldCh)) {
removeVnodes(elm, oldCh, 0, oldCh.length - 1)
}
api.setTextContent(elm, vnode.text!)
}
// 触发postpatch钩子函数
hook?.postpatch?.(oldVnode, vnode)
}
createElm函数
- 先判断是否为注释节点,是就直接渲染
- 如果是DOM节点,先保存原节点的样式属性
- 然后调用原生document.createElement创建标签,然后设置相关的id和class到该标签
- 依次执行create钩子函数
- 如果有子节点就递归调用该该函数,创建子节点
- 没有子节点,判断该节点是否为文本节点,如果是就插入到当前vNode中
- 最后返回该Vnode的elm元素
/**
* @description 创建新的元素节点
* @param vnode
* @param insertedVnodeQueue
*/
function createElm (vnode: VNode, insertedVnodeQueue: VNodeQueue): Node {
let i: any
let data = vnode.data
if (data !== undefined) {
const init = data.hook?.init
if (isDef(init)) {
init(vnode)
data = vnode.data
}
}
const children = vnode.children
const sel = vnode.sel
// 如果是注释节点,直接创建一个空的注释节点
if (sel === '!') {
if (isUndef(vnode.text)) {
vnode.text = ''
}
vnode.elm = api.createComment(vnode.text!)
} else if (sel !== undefined) {
// 是非空节点
// 先保存原节点的属性
const hashIdx = sel.indexOf('#')
const dotIdx = sel.indexOf('.', hashIdx)
const hash = hashIdx > 0 ? hashIdx : sel.length
const dot = dotIdx > 0 ? dotIdx : sel.length
const tag = hashIdx !== -1 || dotIdx !== -1 ? sel.slice(0, Math.min(hash, dot)) : sel
// createElementNS一般创建svg标签
const elm = vnode.elm = isDef(data) && isDef(i = data.ns)
? api.createElementNS(i, tag)
: api.createElement(tag)
if (hash < dot) elm.setAttribute('id', sel.slice(hash + 1, dot))
if (dotIdx > 0) elm.setAttribute('class', sel.slice(dot + 1).replace(/\./g, ' '))
// 调用create钩子函数
for (i = 0; i < cbs.create.length; ++i) cbs.create[i](emptyNode, vnode)
// 如果存在子节点,递归调用该函数创建节点
if (is.array(children)) {
for (i = 0; i < children.length; ++i) {
const ch = children[i]
if (ch != null) {
api.appendChild(elm, createElm(ch as VNode, insertedVnodeQueue))
}
}
//
} else if (is.primitive(vnode.text)) {
api.appendChild(elm, api.createTextNode(vnode.text))
}
const hook = vnode.data!.hook
if (isDef(hook)) {
hook.create?.(emptyNode, vnode)
// 如果存在insert钩子,添加到插入vNode的队列中
if (hook.insert) {
insertedVnodeQueue.push(vnode)
}
}
} else {
// 没有子节点,判断该节点是否为文本节点,如果是就插入到当前vNode中
vnode.elm = api.createTextNode(vnode.text!)
}
// 返回该Vnode的elm元素
return vnode.elm
}
removeVnodes函数
- 循环处理要删除的节点内容
- 如果是文本节点,直接通过removeChild方式移除
- 如果是常规节点,先调用传入的destroy钩子函数(可选)
- 把remove钩子函数的数量添加
- 然后调用createRmCb高阶函数,并通过listeners来确定唯一的dom防止重复删除
- 循环调用remove函数
- 调用外部传入的remove钩子(可选)
- 最后调用前面高阶函数返回的函数,真正执行删除
/**
* @description 移除Vnode函数
* @param parentElm
* @param vnodes
* @param startIdx
* @param endIdx
*/
function removeVnodes (parentElm: Node,
vnodes: VNode[],
startIdx: number,
endIdx: number): void {
// 循环要删除的节点内容
for (; startIdx <= endIdx; ++startIdx) {
let listeners: number
let rm: () => void
const ch = vnodes[startIdx]
if (ch != null) {
if (isDef(ch.sel)) {
// 如果是常规节点,先调用传入的destroy钩子函数(可选)
invokeDestroyHook(ch)
// remove钩子函数的数量添加
listeners = cbs.remove.length + 1
// rm是真实执行删除操作的函数
rm = createRmCb(ch.elm!, listeners)
// 循环执行remove的hooks钩子函数
for (let i = 0; i < cbs.remove.length; ++i) cbs.remove[i](ch, rm)
// 调用外部传入的remove钩子(可选)
const removeHook = ch?.data?.hook?.remove
if (isDef(removeHook)) {
removeHook(ch, rm)
} else {
// 真正执行删除
rm()
}
} else {
// 如果是文本节点,直接通过removeChild方式移除
api.removeChild(parentElm, ch.elm!)
}
}
}
}
updateChildren函数(diff核心)
updateChildren是init内部的函数,也就是核心的diff算法。
- oldStartIdx(旧节点开始索引)
- newStartIdx(新节点开始索引)
- oldEndIdx(旧节点结束索引)
- newEndIdx(新节点结束索引)
-
先初始化索引和头尾节点的值
-
分别判断新头/新,旧头/尾 是否为null,如果是那么就在节点队列里面创建一个/减少一个元素
-
对比旧头和新头,如果是一个节点就处理内部的变化,并更新DOM
-
对比旧尾和新尾,如果是一个节点就处理内部的变化,并更新DOM
-
对比旧头和新尾,比较内部差异,把更新的内容移动到插入到旧节点的最后
-
对比旧尾和新头,然后比较内部差异,把更新的内容移动到插入到旧节点的最前
-
如果不是以上情况,说明开始节点 是一个全新的节点,而非对比的节点
- 定义一个方便通过新节点的key找到旧节点数组的索引
- 然后 用新节点的key 找到老节点的索引
- 如果没有key,那么就创建dom,并插入到最前方
- 如果有key。判断sel属性是否相同,如果不相同就创建新的dom,如果相同则代表是相同节点
- 插入完成后,索引增加
-
如果新节点/旧节点还有剩余
- 如果是老节点的子节点先遍历完成,那么就把剩下的新节点元素,都插入到旧节点的后面
- 如果新节点先完成,那么剩下的就是老节点需要移除的节点
/**
*
* @param parentElm 父亲元素
* @param oldCh 旧节点元素
* @param newCh 新节点元素
* @param insertedVnodeQueue 插入vNode的队列
*
* 通过四个索引地址来进行diff
*
*/
function updateChildren (parentElm: Node,
oldCh: VNode[],
newCh: VNode[],
insertedVnodeQueue: VNodeQueue) {
// 旧开始节点
let oldStartIdx = 0
// 新开始节点
let newStartIdx = 0
// 旧结束节点
let oldEndIdx = oldCh.length - 1
let oldStartVnode = oldCh[0]
let oldEndVnode = oldCh[oldEndIdx]
// 新结束节点
let newEndIdx = newCh.length - 1
let newStartVnode = newCh[0]
let newEndVnode = newCh[newEndIdx]
let oldKeyToIdx: KeyToIndexMap | undefined
let idxInOld: number
let elmToMove: VNode
let before: any
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
/**
* 如果四个对比节点其中有为null的,那么就重新赋值,并且为元素数组中 添加/减少 一位
*/
if (oldStartVnode == null) {
oldStartVnode = oldCh[++oldStartIdx]
} else if (oldEndVnode == null) {
oldEndVnode = oldCh[--oldEndIdx]
} else if (newStartVnode == null) {
newStartVnode = newCh[++newStartIdx]
} else if (newEndVnode == null) {
newEndVnode = newCh[--newEndIdx]
/**
* 如果是同一个元素,就对比新旧节点内部的变化,然后修改dom
* 并且把Vnode内容分别添加一项(索引后移)
*/
} else if (sameVnode(oldStartVnode, newStartVnode)) {
patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue)
oldStartVnode = oldCh[++oldStartIdx]
newStartVnode = newCh[++newStartIdx]
// 道理同前者
} else if (sameVnode(oldEndVnode, newEndVnode)) {
patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue)
oldEndVnode = oldCh[--oldEndIdx]
newEndVnode = newCh[--newEndIdx]
/**
* 对比旧开始和新结束节点,并更新dom
* 然后比较内部差异,把更新的内容移动到插入到旧节点的最后
* 旧的索引向后移动 新的索引向前移动
*/
} else 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]
/**
* 对比旧结束和新开始节点,并更新dom
* 然后比较内部差异,把更新的内容移动到插入到旧节点的最前
* 旧的索引向前移动 新的索引向后移动
*/
} else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left
patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue)
api.insertBefore(parentElm, oldEndVnode.elm!, oldStartVnode.elm!)
oldEndVnode = oldCh[--oldEndIdx]
newStartVnode = newCh[++newStartIdx]
/**
* 如果不是以上情况,说明开始节点 是一个全新的节点,而非对比的节点
* 如果没有key,那么就创建dom,并插入到最前方(1)
* 如果有key。判断sel属性是否相同,如果不相同就创建新的dom,如果相同则代表是相同节点(2)
*/
} else {
// 方便通过新节点的key找到旧节点数组的索引
if (oldKeyToIdx === undefined) {
oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
}
// 用新节点的key 找到老节点的索引
idxInOld = oldKeyToIdx[newStartVnode.key as string]
// (1)新节点
if (isUndef(idxInOld)) {
api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm!)
} else {
// (2) 旧节点
// 取出就节点
elmToMove = oldCh[idxInOld]
// 被修改过的元素,直接创建一个新的插入
if (elmToMove.sel !== newStartVnode.sel) {
api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm!)
} else {
// 没有修改过,同patchVnode内部差异并更新
patchVnode(elmToMove, newStartVnode, insertedVnodeQueue)
// 把旧节点相应位置的元素设置为undefined
oldCh[idxInOld] = undefined as any
// 把修改过的元素移动到盗猎的元素之前
api.insertBefore(parentElm, elmToMove.elm!, oldStartVnode.elm!)
}
}
// 插入完成后,索引增加
newStartVnode = newCh[++newStartIdx]
}
}
// 老节点的子节点先遍历完成。或者新节点的子节点先遍历完成
if (oldStartIdx <= oldEndIdx || newStartIdx <= newEndIdx) {
if (oldStartIdx > oldEndIdx) {
// 如果是老节点的子节点先遍历完成,那么就把剩下的新节点元素,都插入到旧节点的后面
// before是需要插入的参考位置
before = newCh[newEndIdx + 1] == null ? null : newCh[newEndIdx + 1].elm
addVnodes(parentElm, before, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)
} else {
// 新节点先完成,那么剩下的就是老节点需要移除的节点
removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx)
}
}
}
常见疑问
为什么要设置key
通过阅读diff算法(updateChildren函数)源码我们可以知道,判断两个节点是否相同是通过sameVnode函数。
function sameVnode(vnode1, vnode2) {
return vnode1.key === vnode2.key && vnode1.sel === vnode2.sel;
}
如上所示sameVnode是通过key和sel来区分两个节点是否相同,如果没有设置key那么两个VNode的key都是undefined,只需要sel相同即可,那么就会造成数据渲染错误的问题。
在一定程度上设置key的过程更消耗性能,但是在页面渲染和diff过程中。key是不可或缺的。