前端入门到弃坑:虚拟dom的实现原理
虚拟 DOM 的实现原理主要包括以下 3 部分:
- 用 JavaScript 对象模拟真实 DOM 树,对真实 DOM 进行抽象;
- diff 算法 — 比较两棵虚拟 DOM 树的差异;
- pach 算法 — 将两个虚拟 DOM 对象的差异应用到真正的 DOM 树。
Virtual DOM【虚拟DOM】库
Vue 内部的虚拟 DOM 是改造了一个开源库:Snabbdom
Snabbdom
Vue 2.x 内部使用的 虚拟 DOM 就是改造的 Snabbdom
通过snabbdom的模块可扩展处理属性/样式/事件功能,
源码使用 TypeScript 开发
最快的 Virtual DOM 之一,大约 200 SLOC(single line of code)
virtual-dom
案例演示 jQuery-demo 和 snabbdom-demo
Snabbdom
1: 创建项目,并安装 parcel (打包工具)
# 创建项目目录
md snabbdom-demo
# 进入项目目录
cd snabbdom-demo
# 创建 package.json
npm init -y
# 本地安装 parcel
npm install parcel-bundler -D
2: 配置 package.json 的 scripts
"scripts": {
"dev": "parcel index.html --open",
"build": "parcel build index.html"
}
3: 创建目录结构
│ index.html
│ package.json
└─src
01-basicusage.js
Snabbdom 的基本使用
1: Snabbdom 文档
文档地址
https://github.com/snabbdom/snabbdom (当前版本 v2.1.0 )
git clone -b v2.1.0 --depth=1 https://github.com/snabbdom/snabbdom.git
(https://codechina.csdn.net/mirrors/snabbdom/snabbdom.git)
## --depth 表示克隆深度;
## 1 表示只克隆最新的版本. 因为如果项目迭代的版本很多, 克隆会很慢
2: 安装 Snabbdom
npm install snabbdom@2.1.0
3: 导入 Snabbdom
1: Snabbdom 2个核心函数 init 和 h()
init() 是一个高阶函数(函数返回函数),返回 patch
h() 返回虚拟节点 VNode,我们在 Vue.js 中有见到过
回顾 Vue 中的 render 函数
new Vue({
router,
store,
render: h => h(App)
}).$mount('#app')
2: 导入方式
文档中导入的方式
import { init } from 'snabbdom/init'
import { h } from 'snabbdom/h'
const patch = init([])
注意:此时运行的话会告诉我们找不到 init / h 模块,因为模块路径并不是 snabbdom/init, 这个路径是在 package.json 中的 exports 字段设置的,而我们使用的打包工具不支持 exports 这个字段,webpack 4 也不支持,webpack 5 支持该字段。该字段在导入 snabbdom/init 的时候会补全路径成 snabbdom/build/package/init.js
"exports": {
"./init": "./build/package/init.js",
"./h": "./build/package/h.js",
"./helpers/attachto": "./build/package/helpers/attachto.js",
"./hooks": "./build/package/hooks.js",
"./htmldomapi": "./build/package/htmldomapi.js",
"./is": "./build/package/is.js",
"./jsx": "./build/package/jsx.js",
"./modules/attributes": "./build/package/modules/attributes.js",
"./modules/class": "./build/package/modules/class.js",
"./modules/dataset": "./build/package/modules/dataset.js",
"./modules/eventlisteners": "./build/package/modules/eventlisteners.js",
"./modules/hero": "./build/package/modules/hero.js",
"./modules/module": "./build/package/modules/module.js",
"./modules/props": "./build/package/modules/props.js",
"./modules/style": "./build/package/modules/style.js",
"./thunk": "./build/package/thunk.js",
"./tovnode": "./build/package/tovnode.js",
"./vnode": "./build/package/vnode.js"
}
实际导入的方式 (parcel / webpack 4 不支持 package.json 中的 exports)
如果使用不支持 package.json 的 exports 字段的打包工具,我们应该把模块的路径写全
查看安装的 snabbdom 的目录结构
import { h } from 'snabbdom/build/package/h'
import { init } from 'snabbdom/build/package/init'
import { classModule } from 'snabbdom/build/package/modules/class'
案例1:
import { init } from 'snabbdom/build/package/init'
import { h } from 'snabbdom/build/package/h'
const patch = init([])
// 第一个参数:标签+选择器
// 第二个参数: 如果是字符串就是文本中的内容
let vnode = h('div#container.cls', {
hook: {
init (vnode) {
console.log(vnode.elm)
},
create (emptyNode, vnode) {
console.log(vnode.elm)
}
}
}, 'hello world')
let app = document.querySelector("#app")
// 第一个参数:旧的VNode 或者 DOM元素
// 第二个参数: 新的VNode
// 对比新旧2个VNode,将差异部分更新到视图中,将新的VNode返回,在下一次调用patch时当作旧的VNode
let oldVNode = patch(app, vnode)
vnode = h('div#container.cls', 'hello i am new')
patch(oldVNode, vnode)
【index.html】
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<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.1-basicusage.js"></script>
</body>
</html>
案例2:
import { init } from 'snabbdom/build/package/init'
import { h } from 'snabbdom/build/package/h'
const patch = init([])
let vnode = h('div#container', [
h('h1', 'hello Snabbdom'),
h('p', '我是一个p')
])
let app = document.querySelector('#app')
let oldVNode = patch(app, vnode)
// 2s 后更新
setTimeout(() => {
vnode = h('div#container', [
h('h1', 'Hello World'),
h('p', 'Hello p')
])
patch(oldVNode, vnode)
}, 2000)
// 4s 清空div中的内容
setTimeout(() => {
patch(oldVNode, h('!'))
}, 4000)
【index.html】
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Snabbdom-demo</title>
</head>
<body>
<div id="app"></div>
<script src="./src/02.1-basicusage.js"></script>
</body>
</html>
4: Snabbdom 中的模块
1: 模块的作用:
a:Snabbdom 中的核心库并不能处理 DOM 元素的属性/样式/事件等,可以通过注册 Snabbdom默认提供的 模块 来实现
b:模块可以用来 扩展 Snabbdom的功能
c: 模块的实现是通过 注册全局的钩子函数 来实现的
d: 我们可以自己添加模块
官方提供了 6 个模块
attributes
设置 DOM 元素的属性,使用 setAttribute()
处理布尔类型的属性
props
和 attributes 模块相似,设置 DOM 元素的属性 element[attr] = value
不处理布尔类型的属性
class
切换类样式
注意:给元素设置类样式是通过 sel 选择器
dataset
设置 data-* 的自定义属性
eventlisteners
注册和移除事件
style
设置行内样式,支持动画
delayed/ remove/ destroy
2: 模块的的使用步骤
导入需要的模块
init () 注册模块
h() 的第二个参数 使用模块
3: 案例
import { init } from 'snabbdom/build/package/init'
import { h } from 'snabbdom/build/package/h'
// 导入模块
import { styleModule } from 'snabbdom/build/package/modules/style'
import { eventListenersModule } from 'snabbdom/build/package/modules/eventlisteners'
// 注册模块
let patch = init([
styleModule,
eventListenersModule
])
// 使用模块:使用h()的第二个参数传入模块中使用的数据(对象)
let vnode = h('div', [
h('h1', { style: { backgroundColor: 'red' } }, 'hello module'),
h('p', { on: { click: eventHandler } }, 'Hello P')
])
function eventHandler () {
console.log('别点我,我疼')
}
let app = document.querySelector("#app")
patch(app, vnode)
Snabbdom 源码解析
Snabbdom 的核心
使用 h() 函数创建 JavaScript 对象(VNode),描述真实DOM
init() 函数 设置模块,创建 patch()
patch() 比较新旧两个 VNode, 把变化的内容更新到真实 DOM 树上
Snabbdom 源码
源码地址:https://github.com/snabbdom/snabbdom (版本:v2.1.0)
克隆代码:git clone -b v2.1.0 --depatch=1 https://github.com/snabbdom/snabbdomhttps://github.com/snabbdom/snabbdom
src目录结构:
├── package
│ ├── helpers
│ │ └── attachto.ts 定义了 vnode.ts 中 AttachData 的数据结构
│ ├── modules
│ │ ├── attributes.ts
│ │ ├── class.ts
│ │ ├── dataset.ts
│ │ ├── eventlisteners.ts
│ │ ├── hero.ts example 中使用到的自定义钩子
│ │ ├── module.ts 定义了模块中用到的钩子函数
│ │ ├── props.ts
│ │ └── style.ts
│ ├── h.ts h() 函数,用来创建 VNode
│ ├── hooks.ts 所有钩子函数的定义
│ ├── htmldomapi.ts 对 DOM API 的包装
│ ├── init.ts 加载 modules、DOMAPI,返回 patch 函数
│ ├── is.ts 判断数组和原始值的函数
│ ├── jsx-global.ts jsx 的类型声明文件
│ ├── jsx.ts 处理 jsx
│ ├── thunk.ts 优化处理,对复杂视图不可变值得优化
│ ├── tovnode.ts DOM 转换成 VNode
│ ├── ts-transform-js-extension.cjs
│ ├── tsconfig.json ts 的编译配置文件
│ └── vnode.ts 虚拟节点定义
1: h() 函数
h() 函数介绍:
作用:h() 函数用来 创建 VNode对象 ,并返回VNode 对象
Vue 中的 h() 函数
new Vue({
router,
store,
render: h => h(App) // h() 创建虚拟 DOM
}).$mount('#app')
h() 函数最早见于 hypeScript ,使用 JavaScript 创建 超文本(html)
函数重载
概念:
参数 个数/类型 不同的函数
JavaScript 中没有重载的概念
TypeScript 中有重载,不过重载的实现还是通过 代码来 调整 参数
重载的示例
// 参数个数
function add (a: number, b: number) {
console.log(a + b)
}
function add (a: number, b: number, c: number) {
console.log(a + b + c)
}
add(1, 2)
add(1, 2, 3)
------------------------------------------------------
// 参数类型
function add (a: number, b: number) {
console.log(a + b)
}
function add (a: number, b: string) {
console.log(a + b)
}
add(1, 2)
add(1, '2')
源码位置:src/package/h.ts
// h 函数的重载
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
// sel: any表示可以是任何类型 b?: any 表示可以是任何值,也可以不传
export function h (sel: any, b?: any, c?: any): VNode {
var data: VNodeData = {}
var children: any
var text: any
var i: number
// 处理参数,实现重载的机制,c不是undefined,证明有三个参数
if (c !== undefined) {
// 处理三个参数的情况
// sel、data、children/text
if (b !== null) {
data = b
}
// c是数组,表示有子元素
if (is.array(c)) {
children = c
} else if (is.primitive(c)) { // c是字符串或者数字
text = c
} else if (c && c.sel) { // c是VNode
children = [c]
}
} else if (b !== undefined && b !== null) {
if (is.array(b)) {
children = b
} else if (is.primitive(b)) {
// 如果 c 是字符串或者数字
text = b
} else if (b && b.sel) {
// 如果 b 是 VNode
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)
}
}
if (
sel[0] === 's' && sel[1] === 'v' && sel[2] === 'g' &&
(sel.length === 3 || sel[3] === '.' || sel[3] === '#')
) {
// 如果是 svg,添加命名空间
addNS(data, children, sel)
}
// 返回 VNode: h函数通过调用vnode方法,创建vnode对象,并返回vnode对象
return vnode(sel, data, children, text, undefined)
};
2: VNode
概念:一个VNode 就是一个虚拟节点,用来描述一个 DOM 元素, 如果这个VNode有 children 就是 Virtual DOM
源码位置:src/package/vnode.ts
export interface VNode {
// 选择器
sel: string | undefined;
// 节点数据:属性/样式/事件等
data: VNodeData | undefined;
// 子节点,和 text 只能互斥
children: Array<VNode | string> | undefined;
// 记录 vnode 对应的真实 DOM
elm: Node | undefined;
// 节点中的内容,和 children 只能互斥
text: string | undefined;
// 优化用
key: Key | undefined;
}
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 }
}
3: init (module, domApi)
功能:init (module, domApi) 返回 patch() 函数(高阶函数)
let patch = init([])
为什么要高阶函数?
patch() 函数在外部会被多次调用,每次调用依赖一些参数,如:module/domApi/cbs;
因为高阶函数让 init() 内部形成闭包,返回的 patch() 可以访问 modules/domApi/cbs,而不需要重新创建
init() 在返回 patch() 之前,首先收集了所有模块中的钩子函数存储到 cbs 对象中
源码位置:src/package/init.ts
const hooks: Array<keyof Module> = ['create', 'update', 'remove', 'destroy', 'pre', 'post']
// modules: 模块数组; domApi:把vnode转化为其他平台API,如果不传,默认是转化为浏览器dom API
export function init (modules: Array<Partial<Module>>, 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
// 把传入的所有模块的钩子方法,统一存储到 cbs 对象中
// 最终构建的 cbs 对象的形式 cbs = [ create: [fn1, fn2], update: [], ... ]
for (i = 0; i < hooks.length; ++i) {
// cbs['create'] = []
cbs[hooks[i]] = []
for (j = 0; j < modules.length; ++j) {
// const hook = modules[0]['create']
const hook = modules[j][hooks[i]]
if (hook !== undefined) {
(cbs[hooks[i]] as any[]).push(hook)
}
}
}
……
return function patch (oldVnode: VNode | Element, vnode: VNode): VNode {
……
}
}
4: patch (oldVNode, newVNode)
作用:传入新旧 VNode ,对比差异,将差异渲染到 DOM,返回新的 VNode,作为下一次执行 patch() 的 oldVNode
执行过程分析
对比新旧 VNode 节点是否相同( 节点的 key 和 sel 相同),调用patchVnode() 找节点的差异,并更新 DOM
如果不是相同节点,删除之前的内容,重新渲染
如果是相同节点,判断新的 VNode 中是否有 text,如果有且和 oldVNode 中 text不同,则直接更新文本内容
如果 oldVnode 是DOM元素, a: 把 DOM 元素转化为 oldVnode; b: 调用createElm()把 vnode 转化为真实DOM,记录到vnode.elm; c: 把刚创建的 DOM 元素插入到 parent 中; d:移除老节点
如果新的 VNode 有 children,判断子节点是否有变化,判断子节点的过程就是diff算法,diff 过程只进行同层级比较
总结:Snabbdom 中的 patch 函数是通过 Snabbdom 的入口函数 init 生成的,init 中初始化 模块 和 DOM 操作的 api,最终返回 patch, 这里的 init 是一个高阶函数,在 init 内部缓存了2个参数, 在返回的 patch 中可以通过 闭包 访问到 init 中 初始化的 模块 和 DOM 操作的 api
源码位置:src/package/init.ts
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]()
// 如果 oldVnode 不是 VNode,创建 VNode 并设置 elm
if (!isVnode(oldVnode)) {
// 把 DOM 元素转换成空的 VNode
oldVnode = emptyNodeAt(oldVnode)
}
// 如果新旧节点是相同节点(key 和 sel 相同)
if (sameVnode(oldVnode, vnode)) {
// 找节点的差异并更新 DOM
patchVnode(oldVnode, vnode, insertedVnodeQueue)
} else {
// 如果新旧节点不同,vnode 创建对应的 DOM
// 获取当前的 DOM 元素
elm = oldVnode.elm!
parent = api.parentNode(elm) as Node
// 触发 init/create 钩子函数,创建 DOM
createElm(vnode, insertedVnodeQueue)
if (parent !== null) {
// 如果父节点不为空,把 vnode 对应的 DOM 插入到文档中
api.insertBefore(parent, vnode.elm!, api.nextSibling(elm))
// 移除老节点
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]()
return vnode
}
5: createElm
功能:createElm(vnode, insertedVnodeQueue) ,创建 vnode 对应 的 DOM 对象,并返回创建的 DOM 对象
注意:没有吧新创建的DOM,挂载到 DOM 树,而是先存储到当前 VNode 对象的 elm 属性中
执行过程
如果选择器是!,创建评论节点
如果选择器是空,创建文本节点
如果选择器不为空
解析选择器,设置标签的 id 和 class 属性
6: patchVnode
功能:patchVnode(oldVnode, vnode, insertedVnodeQueue) ,对比 oldVnode 和 vnode 的差异,把差异渲染到 DOM
注意:对比新旧节点,如果节点本身都没有 text 属性,再去 对比他们的的子节点,如果有 text 属性 ,并且新旧 VNode 的 text 属性不同,就把新节点的 text 属性更新到 DOM 上
7: updateChildren
功能: diff 算法的核心,对比新旧 VNode 节点的 子节点children,更新DOM
要点: 在对比过程中因为DOM 操作的特殊性,同时也为了优化操作,所有只对比 两棵树中的 同一层级的子节点
diff算法
理解:
1.先同级比较,再比较子节点
2.先判断一方有儿子一方没儿子的情况
3.比较都有儿子的情况
4.递归比较子节点
总结:
VDOM原理:因为js的执行速度是非常快的,所以VDOM就是用JS模拟DOM结构,计算出最小的变更(这个对比算法就是DIFF),操作DOM;
DOM结构可以用JSON模拟出来,类似XML;下图需要能写出来
学习VDOM利用 snabbdom
1、DIFF算法例如 v-for 的key为什么必须要;就讲讲DIFF算法
DIFF比较算法
1、只比较同一层级,不跨级比较
2、tag不相同,则这接删掉重建,不再深度比较
3、tag 和 key,两者都相同,则认为是相同节点,不再深度比较
DIFF源码的核心
1、pathVnode(对比vnode 和 oldVnode,把差异渲染到dom)
2、addVnodes , removeVnodes
3、updateChildren(key的重要性)
响应式过程:
DIFF算法
原文链接:https://blog.csdn.net/qq_37833745/article/details/120440897
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· SQL Server 2025 AI相关能力初探
· AI编程工具终极对决:字节Trae VS Cursor,谁才是开发者新宠?
· 开源Multi-agent AI智能体框架aevatar.ai,欢迎大家贡献代码
· Manus重磅发布:全球首款通用AI代理技术深度解析与实战指南