准备:
-
什么是虚拟DOM?
Virtual DOM 其实就是一棵以 JavaScript 对象( VNode 节点)作为基础的树,用对象属性来描述节点,实际上它只是一层对真实 DOM 的抽象。最终可以通过一系列操作使这棵树映射到真实环境上。 www.cnblogs.com/fundebug/p/…
-
虚拟DOM的作用
- 维护视图和状态的关系
- 在复杂视图的情况下提高渲染性能
- 跨平台
- 浏览器平台渲染DOM
- 服务端渲染SSR(nuxt.js、next.js)
- 原生应用(weex、react native)
- 小程序(mpvue、uni-app)
-
相关的虚拟DOM库
- Snabbdom(以下都是使用的snabbdom)
- virtual-dom
创建项目:
md snabbdom-demo
cd snabbdom-demo
npm init -y
npm i parcel-bundler -D
后面使用parcel来进行项目的打包
配置scripts:
"scripts": {
"dev": "parcel index.html --open",
"build": "parcel build index.html"
}
项目目录:
index.html中需要引入src下的js文件
引入snabbdom
安装snabbdom:
npm i snabbdom@2.1.0
引入
import { init } from 'snabbdom/src/package/init'
import { h } from 'snabbdom/src/package/h'
const patch = init([])
init
函数和h
函数是snabbdom中的核心函数
init
函数执行时传入一个数组,返回一个patch
函数,它会将虚拟dom转换成真实的dom并挂载到dom树上
官方示例中的引入是直接引入:github.com/snabbdom/sn…
import { init, classModule, propsModule, styleModule, eventListenersModule, h, } from "snabbdom";
这是因为在webpack5中已经支持在package.json中使用
exports
字段来定义路径以便外部的引用:
但是目前parcel中并不支持这种方式引入,所以还是需要按照路径导入
基本使用
import { init } from 'snabbdom/src/package/init'
import { h } from 'snabbdom/src/package/h'
const patch = init([])
// 第一个参数:标签+选择器
// 第二个参数:如果是字符串那么第二个参数就会被作为标签中的文本内容
let vnode = h('div#container.demo', 'hello')
let app = document.querySelector('#app')
// 第一个参数:旧的vnode,可以为dom元素
// 第二个参数:新的vnode
// 返回新的vnode,一般返回的vnode会作为下次patch时的“旧的vnode”
let oldVnode = patch(app, vnode)
index.html中需要有一个div#app
占位:
完成之后npm run dev
启动项目,可以看到视图和结构中都已经变成了相应的节点:
h
函数中第二个参数还可以传入数组:
let vnode = h('div#container', [
h('h1', 'title'),
h('p', 'pppppppp')
])
如果想要渲染空的节点,可以往h
函数中传入!
:
let vode = h('!')
snabbdom中的模块
作用:
- snabbdom的核心库并不能处理DOM元素的属性、样式、事件等等,可以通过注册snabbdom默认提供的模块来实现
- snabbdom中的模块可以用来扩展snabbdom的功能
- snabbdom中的模块的实现是通过注册全局的钩子函数来实现的
官方提供了这些模块:
- attributes:设置vnode内部对应的属性,使用
setAttribute
实现的 - props:设置vnode内部对应的属性,使用“对象.属性”这种形式实现的,内部不会处理布尔型的属性
- dataset:处理
data-
这样的属性 - class:切换类样式
- style:设置行内样式
- eventListeners:注册、移除事件
使用步骤:
- 导入所需的模块
init
方法中注册模块h
函数中第二个参数位置使用模块
import { init } from "snabbdom/src/package/init";
import { h } from "snabbdom/src/package/h";
// 导入模块
import { styleModule } from "snabbdom/src/package/modules/style";
import { eventListenersModule } from "snabbdom/src/package/modules/eventlisteners";
// 注册模块
const patch = init([
styleModule,
eventListenersModule
])
// 使用模块
let vnode = h('div#container', [
h('h1', { style: { backgroundColor: 'cyan' } }, 'hello'),
h('p', { on: { click: eventHandler } }, 'ppp')
])
function eventHandler() {
console.log(111111111111)
}
let app = document.getElementById('app')
patch(app, vnode)
浏览器中结果也能正常显示,事件也有正常注册:
Snabbdom
h函数
作用:创建vnode对象
分析:
import { vnode, VNode, VNodeData } from './vnode'
import * as is from './is'
export type VNodes = VNode[]
export type VNodeChildElement = VNode | string | number | undefined | null
export type ArrayOrElement<T> = T | T[]
export type VNodeChildren = ArrayOrElement<VNodeChildElement>
function addNS (data: any, children: VNodes | undefined, sel: string | undefined): void {
data.ns = 'http://www.w3.org/2000/svg'
if (sel !== 'foreignObject' && children !== undefined) {
for (let i = 0; i < children.length; ++i) {
const childData = children[i].data
if (childData !== undefined) {
addNS(childData, (children[i] as VNode).children as VNodes, children[i].sel)
}
}
}
}
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 {
var data: VNodeData = {}
var children: any
var text: any
var i: number
// 处理参数,实现重载
if (c !== undefined) {
// 处理三个参数时的情况--self、data、children/text
if (b !== null) {
data = b
}
if (is.array(c)) {
// 判断c是否是数组,是的话将其保存在children中
children = c
} else if (is.primitive(c)) {
// 判断c是否是字符串或者数字,是则将其保存在text中
text = c
} else if (c && c.sel) {
// 判断c是否是vnode对象,是则将其转换成数组并保存在children中
children = [c]
}
} else if (b !== undefined && b !== null) {
// 处理二个参数时的情况--self、data/children
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) {
// 判断children中是否有值
for (i = 0; i < children.length; ++i) {
// 处理children中元素的值为number或者string时的情况--当元素为原始值类型时,用vnode将其创建为文本节点
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)
}
// 创建vnode并返回
return vnode(sel, data, children, text, undefined)
};
vnode函数
作用:返回vnode对象
源码:
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
return { sel, data, children, text, elm, key }
}
patch函数
作用:比较两个vnode的差异,并将差异渲染到页面上,再将新的vnode返回作为下次patch时的oldvnode
源码:
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]()
// 判断oldVNode是否为VNode对象,不是则将其转换成VNode
// isVnode通过判断oldVnode是否有sel这个属性来判断oldVnode是否为vnode对象
if (!isVnode(oldVnode)) {
// emptyNodeAt方法是将元素标签名+id选择器+类选择器拼接起来再将其传入vnode方法将其转换成vnode
// -> vnode(元素标签名+id选择器+类选择器, {}, [], undefined, elm)
oldVnode = emptyNodeAt(oldVnode)
}
// 判断新旧vnode是否为相同节点
// sameVnode通过判断新旧vnode的sel和key属性是否相等来判断vnode是否相同
if (sameVnode(oldVnode, vnode)) {
// 判断vnode中的内容是否有变化
patchVnode(oldVnode, vnode, insertedVnodeQueue)
} else {
// 将旧vnode的dom元素保存在elm上
elm = oldVnode.elm!
// 将旧vnode的dom元素的父元素保存在parent上
// 获取父元素是为了在创建新的节点之后,将其挂载到父元素上
parent = api.parentNode(elm) as Node
// 将新的vnode转换成dom元素,并将dom保存在vnode.elm上
createElm(vnode, insertedVnodeQueue)
if (parent !== null) {
// 如果parent不为null,则将其挂载到dom树上
api.insertBefore(parent, vnode.elm!, api.nextSibling(elm))
// 将oldVnode从dom上移除
removeVnodes(parent, [oldVnode], 0, 0)
}
}
// 触发inserted钩子函数
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]()
return vnode
}
createElm函数
作用:创建dom节点
源码:
function createElm (vnode: VNode, insertedVnodeQueue: VNodeQueue): Node {
// 定义内部变量
let i: any
let data = vnode.data
// 用户定义的init钩子
if (data !== undefined) {
const init = data.hook?.init
// 判断init是否是undefined
if (isDef(init)) {
// init函数处理vnode
init(vnode)
data = vnode.data
}
}
// 将子节点和选择器保存
const children = vnode.children
const sel = vnode.sel
if (sel === '!') {
// sel === '!',创建注释节点
if (isUndef(vnode.text)) {
vnode.text = ''
}
vnode.elm = api.createComment(vnode.text!)
} else if (sel !== undefined) {
// 创建dom
// Parse selector
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
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) {
// 递归调用createElm函数创建dom并挂载到当前元素的elm属性上
api.appendChild(elm, createElm(ch as VNode, insertedVnodeQueue))
}
}
} else if (is.primitive(vnode.text)) {
// 当元素为文本时,直接创建文本节点挂载到elm上
api.appendChild(elm, api.createTextNode(vnode.text))
}
// 触发用户定义的create钩子
const hook = vnode.data!.hook
if (isDef(hook)) {
hook.create?.(emptyNode, vnode)
if (hook.insert) {
// 将vnode的insert钩子函数存入insert钩子队列,会在插入dom树后执行
insertedVnodeQueue.push(vnode)
}
}
} else {
// 创建文本节点
vnode.elm = api.createTextNode(vnode.text!)
}
// 返回当前vnode的elm
return vnode.elm
}
removeVnodes函数
源码:
function removeVnodes (parentElm: Node,
vnodes: VNode[],
startIdx: number,
endIdx: number): void {
// 以开始、结束的索引为条件遍历vnodes数组
for (; startIdx <= endIdx; ++startIdx) {
// 定义一些内部变量
let listeners: number
let rm: () => void
const ch = vnodes[startIdx]
if (ch != null) {
// 当前vnode不为空
// ch有sel属性时,会被当作一个元素节点,否则为文本节点
if (isDef(ch.sel)) {
// 触发destroy钩子函数
invokeDestroyHook(ch)
// 获取cbs中remove钩子函数的个数 + 1
// listeners是为了防止重发删除dom元素
listeners = cbs.remove.length + 1
// createRmCb会返回一个函数,执行该函数就能删除元素
rm = createRmCb(ch.elm!, listeners)
// 遍历remove钩子函数,依次触发remove函数
for (let i = 0; i < cbs.remove.length; ++i) cbs.remove[i](ch, rm)
const removeHook = ch?.data?.hook?.remove
if (isDef(removeHook)) {
removeHook(ch, rm)
} else {
rm()
}
} else { // Text node
// 当前vnode为文本节点时直接调用removeChildren删除这个节点
api.removeChild(parentElm, ch.elm!)
}
}
}
}
其中的createRmCb
函数里面返回的rm
函数执行时会将listenters
先自减1再判断是否为0,若为零,则执行删除操作:
function createRmCb (childElm: Node, listeners: number) {
return function rmCb () {
if (--listeners === 0) {
const parent = api.parentNode(childElm) as Node
api.removeChild(parent, childElm)
}
}
}
patchNode函数
patchNode
函数执行的大致过程:
源码:
function patchVnode (oldVnode: VNode, vnode: VNode, insertedVnodeQueue: VNodeQueue) {
// 触发prePatch和update钩子
const hook = vnode.data?.hook
// 如果有用户传入的prepatch钩子,立即执行
hook?.prepatch?.(oldVnode, vnode)
const elm = vnode.elm = oldVnode.elm!
const oldCh = oldVnode.children as VNode[]
const ch = vnode.children as VNode[]
// 如果是相同节点,结束patch
if (oldVnode === vnode) return
// 执行cbs的update钩子和用户传入的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)
}
// diff新旧vnode
// 判断vnode的text是否为空:为空则说明vnode里面只包含元素节点,否则只包含文本节点
if (isUndef(vnode.text)) {
// 判断新旧节点是否有子节点
if (isDef(oldCh) && isDef(ch)) {
// 都有子节点且子节点不相同时调用updateChildren函数
// updateChildren函数会对比所有子节点,并更新dom
if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue)
} else if (isDef(ch)) {
// 只有新节点有子节点
// 如果oldVnode有文本节点,清空
if (isDef(oldVnode.text)) api.setTextContent(elm, '')
// 调用addVnodes将新节点插入老节点
addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue)
} else if (isDef(oldCh)) {
// 只有老节点有子节点
// 移除老节点里面的子节点
removeVnodes(elm, oldCh, 0, oldCh.length - 1)
} else if (isDef(oldVnode.text)) {
// 如果老节点里面有text属性,清空
api.setTextContent(elm, '')
}
} else if (oldVnode.text !== vnode.text) {
// 新旧节点的text不相等
// 判断老节点是否有子节点,若有则将子节点删除
if (isDef(oldCh)) {
removeVnodes(elm, oldCh, 0, oldCh.length - 1)
}
// 替换文本
api.setTextContent(elm, vnode.text!)
}
// 触发postPatch钩子
hook?.postpatch?.(oldVnode, vnode)
}
updateChildren函数
当新旧节点都有子节点时会触发updateChildren
函数,这个函数中使用了diff算法
diff算法:
snabbdom中的diff算法只对同级node进行dif
diff的过程中,会对比4种情况:
- 旧开始节点与新开始节点的比较
- 旧结束节点与旧结束节点的比较
- 旧开始节点与新结束节点的比较
- 旧结束节点与新开始节点的比较
-
新旧开始节点的之间的比较
从新旧节点的开始位置开始比较,如果新旧开始节点是
sameVnode
,调用patchNode
对比节点更新差异然后把oldStartIndex/newStartIndex都自加1
-
新旧结束节点之间的比较
与新旧开始节点相反,它是从结束位置开始比较
-
旧开始节点与新结束节点
将旧的开始节点与新的结束节点比较,如果是相同节点,则调用
patchVnode
函数对比节点更新差异然后将oldStartIndex对应的节点移动到右边,更新索引
-
旧结束节点与新开始节点
与3相反
-
如果都不满足以上四种情况
遍历新的开始节点,看其中有无与旧节点里有相同key值的节点:
- 如果没有找到那么以这个vnode创建一个新的节点,并将其插入旧节点最前面的位置
- 如果找到了,那么对比key值相同的两个节点的sel属性,如果sel属性不相同,那么将创建新的dom节点将其插入最前面的位置;如果sel属性相同,将该节点保存到
elmToMove
这个变量上,然后调用patchVnode
对比更新这两个节点的差异,然后将elmToMove
移动到最前面
这样遍历结束后,如果:
-
老节点先遍历完:
说明新节点有剩余,这时候会调用
addVnodes
把剩余的新节点批量插到右边 -
新节点先遍历完
说明老节点有剩余,调用
removeVnodes
把多的给删除
源码:
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] // Vnode might have been moved left
} else if (oldEndVnode == null) {
oldEndVnode = oldCh[--oldEndIdx]
} else if (newStartVnode == null) {
newStartVnode = newCh[++newStartIdx]
} else if (newEndVnode == null) {
newEndVnode = newCh[--newEndIdx]
// 处理结束
// 如果旧起始节点===新起始节点
} else if (sameVnode(oldStartVnode, newStartVnode)) {
// 调用patchVnode函数比较更新节点
patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue)
// 新旧节点索引都自加1,同时保存新的新旧起始节点
oldStartVnode = oldCh[++oldStartIdx]
newStartVnode = newCh[++newStartIdx]
// 如果旧结束节点===新结束节点
} else if (sameVnode(oldEndVnode, newEndVnode)) {
// 调用patchVnode函数比较更新节点
patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue)
// 新旧节点索引都自减1,同时保存新的新旧结束节点
oldEndVnode = oldCh[--oldEndIdx]
newEndVnode = newCh[--newEndIdx]
// 如果旧的起始节点===新结束节点
} else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
// 调用patchVnode函数比较更新节点
patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue)
// 将旧起始节点移动到旧结束节点后面
api.insertBefore(parentElm, oldStartVnode.elm!, api.nextSibling(oldEndVnode.elm!))
// 旧起始节点++,新结束节点--
oldStartVnode = oldCh[++oldStartIdx]
newEndVnode = newCh[--newEndIdx]
// 旧结束节点===新起始节点
} 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]
} else {
// oldKeyToIdx是一个对象,对象中的键对应的是老节点的key,值是老节点的索引
// 作用是方便根据新节点的key,找到对应在老节点中的索引
if (oldKeyToIdx === undefined) {
// 利用createKeyToOldIdx初始化这个对象,传入旧节点、旧节点开始索引、旧节点结束索引
oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
}
// oldKeyToIdx[以新起始节点的key]
idxInOld = oldKeyToIdx[newStartVnode.key as string]
if (isUndef(idxInOld)) { // New element
// 如果新节点在老节点中不存在,则新创建元素
api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm!)
} else {
// 将新节点的key在旧节点中对应的元素保存到elmToMove上
elmToMove = oldCh[idxInOld]
if (elmToMove.sel !== newStartVnode.sel) {
// 若sel属性不相同,则将新节点创建出来并插入旧起始节点之前
api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm!)
} else {
// 相同的话调用patchVnode方法比较更新节点
patchVnode(elmToMove, newStartVnode, insertedVnodeQueue)
// 将旧节点数组中的该节点变成undefined
oldCh[idxInOld] = undefined as any
// 将elmToMove更新到旧开始节点之前
api.insertBefore(parentElm, elmToMove.elm!, oldStartVnode.elm!)
}
}
// 新开始节点++
newStartVnode = newCh[++newStartIdx]
}
}
// 收尾工作
if (oldStartIdx <= oldEndIdx || newStartIdx <= newEndIdx) {
if (oldStartIdx > oldEndIdx) {
// 老节点遍历完,新节点有剩余
// before是参考元素,插入的元素插在before之前
before = newCh[newEndIdx + 1] == null ? null : newCh[newEndIdx + 1].elm
addVnodes(parentElm, before, newCh, newStartIdx, newEndIdx, insertedVnodeQueue)
} else {
// 新节点遍历完,老节点有剩余
// 直接删除
removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx)
}
}
}