Vue2中的虚拟DOM实现改造了了第三方库snabbdom
Snabbdom 是一个虚拟 DOM 库,专注提供简单、模块性的体验,以及强大的功能和性能。
使用起来也很简单package.json
{
"name": "parcel-test",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"dev": "parcel index.html --open",
"build": "parcel build index.html"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"parcel-bundler": "^1.12.5"
},
"dependencies": {
"snabbdom": "^2.1.0"
}
}
index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Snabbdom-demo</title>
</head>
<body>
<div id="app"></div>
<!-- <script src="./src/01-test.js"></script> -->
<script src="./src/02-test.js"></script>
</body>
</html>
01-test.js
中测试了单个元素的替换
import {init} from 'snabbdom/build/package/init'; // parcel这里要导入全路径,不然会报错
import {h} from 'snabbdom/build/package/h'
const patch = init([])
// 第一个参数:标签+选择器
// 第二个参数:如果是字符串就是标签中的文本内容
let vnode = h('div#container.cls','hello world')
let app = document.querySelector('#app')
// 第一个参数:旧的vnode,可以是DOM元素
// 第二个参数:新的vnode
// 返回新的vnode
// 对比两个vnode,比较差异,更新到DOM
let oldVnode = patch(app,vnode)
vnode = h('div#container.another','snabbdom test')
patch(oldVnode,vnode)
02-test.js
中测试了多个标签的替换,还有异步更新和清空
import { init } from "snabbdom/build/package/init";
import { h } from "snabbdom/build/package/h";
const patch = init([]);
// 多个子元素
let vnode = h("div#container", [h("h1", "snabbdom"), h("p", "p tip")]);
let app = document.querySelector("#app");
let oldVnode = patch(app, vnode);
setTimeout(() => {
vnode = h("div#container", [
h("h1", "timeout snabbdom"),
h("p", "tmeout p tip"),
]);
patch(oldVnode, vnode); // 3秒后更新
patch(oldVnode, h("!")); // 清除div中的内容,创建空的注释节点
}, 3000);
Snabbdom模块作用
- Snabbdom的核心课并不能处理DOM元素的属性/样式/事件等,可以通过注册Snabbdom默认提供的模块来实现
- Snabbdom中的模块可以用来扩展Snabbdo的功能
- Snabbdom中的模块的实现是通过注册全局钩子函数来实现的
模块
- attributes 设置DOM的属性,通过setAttribute()方法。处理布尔类型的属性。
- props 处理非布尔类型的属性。
- class 切换样式
- dataset 设置自定义data-*属性。
- eventlisteners
- style 设置行内样式
模块使用
- 导入模块。如
import { styleModule } from "snabbdom/build/package/modules/style";
- init()函数注册模块,参数是个数组
使用h()函数创建VNode时,在第二个参数中传入对象。
模块导入注入样式,添加点击事件案例import { init } from "snabbdom/build/package/init"; import { h } from "snabbdom/build/package/h"; // 1导入模块 import { styleModule } from "snabbdom/build/package/modules/style"; import { eventListenersModule } from "snabbdom/build/package/modules/eventlisteners"; // 2注册模块 const patch = init([styleModule, eventListenersModule]); // 3使用h()函数的第二个参数传入模块中使用的数据(对象) let vnode = h("div", [ h("h1", { style: { backgroundColor: "skyblue" } }, "hello world"), h("p", { on: { click: eventHandler } }, "p click"), ]); function eventHandler() { console.log("p click...."); } let app = document.querySelector("#app"); patch(app, vnode);
Snabbdom核心
版本2.1.0
init()设置模块,创建patch()函数
使用h()函数创建JavaScript对象(VNode)描述真实DOM(即一个JavaScript对象)
patch()比较新旧两个Vnode,patch函数如果第一个参数为真实DOM,会先转化为VNode。
把变化的内容更新到真实DOM树
snabbdom中h函数的源码
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) {
// 处理三个参数的情况
// sel,data,children/text
if (b !== null) {
data = b
}
if (is.array(c)) {
children = c
// 如果c是字符串或者数字
} else if (is.primitive(c)) {
text = c
// 如果c是VNode
} else if (c && c.sel) {
children = [c]
}
} else if (b !== undefined && b !== null) {
// 处理是两个参数的情况
// 如果b是数组
if (is.array(b)) {
children = b
// 如果b是字符串或者数字,primitive方法判断是否是字符串或者数字
} else if (is.primitive(b)) {
text = b
// 如果b是VNode
} else if (b && b.sel) {
children = [b]
} else { data = b }
}
if (children !== undefined) {
// 处理children中原始值(string/number)
for (i = 0; i < children.length; ++i) {
// 如果child是string/number创建文本节点
if (is.primitive(children[i])) children[i] = vnode(undefined, undefined, undefined, children[i], undefined) // 转换为vnode对象
}
}
if (
sel[0] === 's' && sel[1] === 'v' && sel[2] === 'g' &&
(sel.length === 3 || sel[3] === '.' || sel[3] === '#')
) {
// 如果是svg,添加命名空间
addNS(data, children, sel)
}
// 返回vnode
return vnode(sel, data, children, text, undefined)
};
patch整体过程分析
- patch(oldVnode, newVnode)
- 把新节点中变化的内容渲染到真实DOM,最后返回新节点作为下一次处理的旧节点
- 对比新旧 VNode 是否相同节点(节点的key 和sel 相同)
- 如果不是相同节点,删除之前的内容,重新渲染
- 如果是相同节点,再判断新的 VNode 是否有text,如果有并且和oldVnode 的 text 不同,直接更新文本内容
- 如果新的 VNode 有 children,判断子节点是否有变化,会依次对比节点
patch函数在init.ts
中实现
const hooks: Array<keyof Module> = ['create', 'update', 'remove', 'destroy', 'pre', 'post']
init (modules: Array<Partial<Module>>, domApi?: DOMAPI){}
钩子函数在module遍历时被挂载
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执行完数据结构 --> { create: [fn1,fn2],update:[fn1,fn2]...}
(cbs[hooks[i]] as any[]).push(hook)
}
}
}
patch函数源码
return function patch (oldVnode: VNode | Element, vnode: VNode): VNode {
let i: number, elm: Node, parent: Node
const insertedVnodeQueue: VNodeQueue = [] // 新插入节点队列,为了触发新插入节点insert函数
for (i = 0; i < cbs.pre.length; ++i) cbs.pre[i]()
if (!isVnode(oldVnode)) {
oldVnode = emptyNodeAt(oldVnode) // DOM对象转化为VNode对象,处理id,类样式
}
if (sameVnode(oldVnode, vnode)) { // 判断是否是相同节点,比较key属性和sel(选择器)是否都相同
patchVnode(oldVnode, vnode, insertedVnodeQueue)
} else { // 不是相同节点
elm = oldVnode.elm! // !是typescript语法,表示一定有值
parent = api.parentNode(elm) as Node // 获取父元素,方便新元素挂载到父元素
createElm(vnode, insertedVnodeQueue) // 创建VNode节点对应的DOM元素,并且把新插入的队列作为参数传递给createElm,函数内部会触发一些函数
if (parent !== null) {
api.insertBefore(parent, vnode.elm!, api.nextSibling(elm)) // 父元素插入新元素,api.nextSibling(elm)代表老节点对应DOM元素的下一个兄弟节点
removeVnodes(parent, [oldVnode], 0, 0) // 老节点对应的DOM元素从parent中移除
}
}
for (i = 0; i < insertedVnodeQueue.length; ++i) {
insertedVnodeQueue[i].data!.hook!.insert!(insertedVnodeQueue[i]) // insertedVnodeQueue存储的是具有insert钩子函数的新的VNode节点,队列中元素是在createElm中添加
}
for (i = 0; i < cbs.post.length; ++i) cbs.post[i]() // 触发cbs中post钩子函数
return vnode
}
patch方法中调用了createElm方法,用于创建DOM元素
function createElm (vnode: VNode, insertedVnodeQueue: VNodeQueue): Node {
// 过程1:执行用户设置的init钩子函数
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 // vnode子节点
const sel = vnode.sel // sel为选择器
// 过程2:把vnode转换成真实DOM对象(没有渲染到页面)
if (sel === '!') { // 选择器是!,创建注释节点
if (isUndef(vnode.text)) {
vnode.text = ''
}
vnode.elm = api.createComment(vnode.text!) // 调用html API,创建注释节点
} 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) // data 和 data.ns是否有值
? api.createElementNS(i, tag)
: api.createElement(tag)
// 判断是否有id和class选择器,有就添加id和类样式
if (hash < dot) elm.setAttribute('id', sel.slice(hash + 1, dot))
if (dotIdx > 0) elm.setAttribute('class', sel.slice(dot + 1).replace(/\./g, ' '))
for (i = 0; i < cbs.create.length; ++i) cbs.create[i](emptyNode, vnode) // 触发create钩子函数
if (is.array(children)) {
for (i = 0; i < children.length; ++i) { // 遍历子节点,可能会递归调用createElm
const ch = children[i]
if (ch != null) {
api.appendChild(elm, createElm(ch as VNode, insertedVnodeQueue))
}
}
} else if (is.primitive(vnode.text)) { // 判断参数是否为string或者number
api.appendChild(elm, api.createTextNode(vnode.text))
}
const hook = vnode.data!.hook
if (isDef(hook)) {
hook.create?.(emptyNode, vnode)
if (hook.insert) {
insertedVnodeQueue.push(vnode)
}
}
} else { // sel为空,创建文本节点
vnode.elm = api.createTextNode(vnode.text!)
}
//
return vnode.elm
}
patch中patchVnode函数
// 对比新旧两个vnode节点,找到差异,更新到DOM上
function patchVnode (oldVnode: VNode, vnode: VNode, insertedVnodeQueue: VNodeQueue) {
// 第一个过程:触发prepatch和update钩子函数
const hook = vnode.data?.hook // 用户传入的钩子函数
hook?.prepatch?.(oldVnode, vnode)
const elm = vnode.elm = oldVnode.elm! // 旧节点ele属性赋值给新节点ele
const oldCh = oldVnode.children as VNode[]
const ch = vnode.children as VNode[]
if (oldVnode === vnode) return
if (vnode.data !== undefined) {
for (let i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode) // cbs中的钩子函数
vnode.data.hook?.update?.(oldVnode, vnode) // 用户传入的钩子函数
}
// 第二个过程:真正对比新旧vnode差异的地方
if (isUndef(vnode.text)) { // 判断新节点是否有text属性
if (isDef(oldCh) && isDef(ch)) { // 判断新旧节点是否都有子节点
if (oldCh !== ch) updateChildren(elm, oldCh, ch, insertedVnodeQueue) // 都有子节点并且不相同,对比新旧节点子节点并更新DOM
} else if (isDef(ch)) { // 新节点是否有子节点
if (isDef(oldVnode.text)) api.setTextContent(elm, '') // 老节点是否有text属性,有就清空DOM元素文本内容,并更新到elm
addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue) // 插入到elm
} else if (isDef(oldCh)) { // 老节点有子节点
removeVnodes(elm, oldCh, 0, oldCh.length - 1) // 老节点对应的子节点从DOM树移除
} else if (isDef(oldVnode.text)) { // 判断老节点是否有text属性
api.setTextContent(elm, '') // 清空DOM元素对应文本内容
}
} else if (oldVnode.text !== vnode.text) { // oldVnode.text有值并且新旧节点text不相等
if (isDef(oldCh)) { // 老节点有子节点,remove
removeVnodes(elm, oldCh, 0, oldCh.length - 1)
}
api.setTextContent(elm, vnode.text!) // 更新text
}
// 第三个过程:触发postpatch钩子函数
hook?.postpatch?.(oldVnode, vnode)
}
patchVnode中新旧节点都有子节点,并且不相同时会调用updateChildren,是整个diff算法的核心。
diff算法是一种通过同层的树节点进行比较的高效算法,避免了对树进行逐层搜索遍历,所以时间复杂度只有 O(n)。
diff算法有两个比较显著的特点:
- 比较只会在同层级进行, 不会跨层级比较。
- 在diff比较的过程中,循环从两边向中间收拢。
diff流程:
1 、首先定义 oldStartIdx、newStartIdx、oldEndIdx 以及 newEndIdx 分别是新老两个 VNode 的两边的索引。
2、接下来是一个 while 循环,在这过程中,oldStartIdx、newStartIdx、oldEndIdx 以及 newEndIdx 会逐渐向中间靠拢。while 循环的退出条件是直到老节点或者新节点的开始位置大于结束位置。
while 循环中会遇到四种情况:
情形一:当新老 VNode 节点的 start 是同一节点时,直接 patchVnode 即可,同时新老 VNode 节点的开始索引都加 1。
情形二:当新老 VNode 节点的 end 是同一节点时,直接 patchVnode 即可,同时新老 VNode 节点的结束索引都减 1。
情形三:当老 VNode 节点的 start 和新 VNode 节点的 end 是同一节点时,这说明这次数据更新后 oldStartVnode 已经跑到了 oldEndVnode 后面去了。这时候在 patchVnode 后,还需要将当前真实 dom 节点移动到 oldEndVnode 的后面,同时老 VNode 节点开始索引加 1,新 VNode 节点的结束索引减 1。
情形四:当老 VNode 节点的 end 和新 VNode 节点的 start 是同一节点时,这说明这次数据更新后 oldEndVnode 跑到了 oldStartVnode 的前面去了。这时候在 patchVnode 后,还需要将当前真实 dom 节点移动到 oldStartVnode 的前面,同时老 VNode 节点结束索引减 1,新 VNode 节点的开始索引加 1。
3、while 循环的退出条件是直到老节点或者新节点的开始位置大于结束位置。
情形一:如果在循环中,oldStartIdx大于oldEndIdx了,那就表示oldChildren比newChildren先循环完毕,那么newChildren里面剩余的节点都是需要新增的节点,把[newStartIdx, newEndIdx]之间的所有节点都插入到DOM中
情形二:如果在循环中,newStartIdx大于newEndIdx了,那就表示newChildren比oldChildren先循环完毕,那么oldChildren里面剩余的节点都是需要删除的节点,把[oldStartIdx, oldEndIdx]之间的所有节点都删除
function updateChildren (parentElm: Node, // 父DOM元素
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 // 存储一个对象,键是老节点对应的key,值是老节点的索引
let idxInOld: number
let elmToMove: VNode
let before: any
// 同级别比较
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) { // 旧节点开始索引小于等于旧节点结束所以呢,并且新节点开始索引小于等于新结束节点索引
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]
// 比较开始和结束的4种情况
} 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]
} else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue)
api.insertBefore(parentElm, oldStartVnode.elm!, api.nextSibling(oldEndVnode.elm!)) // 旧开始节点对应DOM元素移动到旧结束节点对应DOM元素之后
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!) // 旧结束节点对应DOM元素移动到旧开始节点对应DOM元素之后
oldEndVnode = oldCh[--oldEndIdx]
newStartVnode = newCh[++newStartIdx]
} else {
// 开始和结尾比较结束
if (oldKeyToIdx === undefined) {
oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx)
}
idxInOld = oldKeyToIdx[newStartVnode.key as string] // 新节点的key在oldKeyToIdx中找到老节点索引存储到idxInOld
if (isUndef(idxInOld)) { // New element 找不到,就是新元素
api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm!)
} else {
elmToMove = oldCh[idxInOld]
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]
}
}
// 循环结束收尾工作
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)
}
}
}
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。