当浏览器获取了一个 HTML 文档后,浏览器内核 webkit 的 WebCore 层中的 HTML 引擎会将该文档解析为 DOM 树,解析出的 DOM 树会交给同处在 WebCore 层的 DOM 模块负责管理,这里的 DOM 树就是真实 DOM。
我们在前端的刀耕火种的时代,都是直接使用 JavaScript 直接操作 DOM,但 Dom 树实际是由 DOM 模块负责管理的,浏览器内核中单独有一块内存来存储 DOM 树,但 JavaScript 引擎和这块内存并无关系,也就是它并不能直接的操作真实的 DOM 树。
为了给予 JavaScript 操作 DOM 的能力,浏览器在全局对象 window 上为 JavaScript 封装了一个 document 对象,在这个对象上提供了大部分 DOM 操作 API(接口由 C++ 实现)。
虚拟DOM:是由普通 JS 对象 来描述 DOM 对象 ,因为不是真实的 DOM,所以叫做虚拟 DOM。
创建/操作真实 DOM ,资源开销是特别大的,而仅仅使用普通对象来模拟虚拟 DOM,能够有效减少对真实 DOM 的操作,从而提升网页性能。
当我们通过这些接口操作 DOM 时, JavaScript 并没有直接和 DOM 模块交互,它调用浏览器的 DOM API后,是由浏览器来操作 DOM 模块,再把操作结果返回给 JavaScript 引擎的,而正是由于 JavaScript 需要借助 DOM 接口才能操作真实 DOM,所以操作真实 DOM 的代价是巨大的:
- JavaScript 直接调用的是 C++ 实现的接口;
- 真实 DOM 树的节点较多,体积较为庞大,真实 DOM 操作过程资源开销成本很大;
- 修改真实 DOM 经常导致页面重绘,操作 DOM 越多,网页性能越差;
创建/操作真实 DOM ,资源开销是特别大的,因此:
问题思考:使用 虚拟 DOM 一定 比不使用 虚拟DOM 性能好吗??
1: 使用虚拟DOM,在首次渲染的时候会影响性能,因为要创建额外的对象来描述真实DOM
2: 在更新少量标签的时候,也不会有性能上的提升,在DOM 结构复杂的时候更新 DOM,才会有明显的性能提升,因为仅仅将 前后两次 DOM 树 的差异 更新到真实DOM
Snabbdom
- Vue 2.x 内部使用的 虚拟 DOM 就是改造的 Snabbdom
- 通过snabbdom的模块可扩展处理属性/样式/事件功能,
- 源码使用 TypeScript 开发
- 最快的 Virtual DOM 之一,大约 200 SLOC(single line of code)
virtual-dom
案例演示 jQuery-demo 和 snabbdom-demo
项目代码链接
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
文档地址
- 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 表示只克隆最新的版本. 因为如果项目迭代的版本很多, 克隆会很慢
npm install snabbdom@2.1.0
- init() 是一个高阶函数(函数返回函数),返回 patch
- h() 返回虚拟节点 VNode,我们在 Vue.js 中有见到过
- 回顾 Vue 中的 render 函数
new Vue({ router, store, render: h => h(App) }).$mount('#app')
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" }
如果使用不支持 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'
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】
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Snabbdom-demotitle>
head>
<body>
<div id="app">div>
<script src="./src/01.1-basicusage.js">script>
body>
html>
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】
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Snabbdom-demotitle>
head>
<body>
<div id="app">div>
<script src="./src/02.1-basicusage.js">script>
body>
html>
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
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)
- 使用 h() 函数创建 JavaScript 对象(VNode),描述真实DOM
- init() 函数 设置模块,创建 patch()
- patch() 比较新旧两个 VNode, 把变化的内容更新到真实 DOM 树上
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 虚拟节点定义
new Vue({
router,
store,
render: h => h(App) // h() 创建虚拟 DOM
}).$mount('#app')
参数 个数/类型 不同的函数
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')
// 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)
};
export interface VNode {
// 选择器
sel: string | undefined;
// 节点数据:属性/样式/事件等
data: VNodeData | undefined;
// 子节点,和 text 只能互斥
children: Array | undefined;
// 记录 vnode 对应的真实 DOM
elm: Node | undefined;
// 节点中的内容,和 children 只能互斥
text: string | undefined;
// 优化用
key: Key | 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
return { sel, data, children, text, elm, key }
}
let patch = init([])
patch() 函数在外部会被多次调用,每次调用依赖一些参数,如:module/domApi/cbs;
因为高阶函数让 init() 内部形成闭包,返回的 patch() 可以访问 modules/domApi/cbs,而不需要重新创建
init() 在返回 patch() 之前,首先收集了所有模块中的钩子函数存储到 cbs 对象中
const hooks: Array = ['create', 'update', 'remove', 'destroy', 'pre', 'post']
// modules: 模块数组; domApi:把vnode转化为其他平台API,如果不传,默认是转化为浏览器dom API
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
// 把传入的所有模块的钩子方法,统一存储到 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 {
……
}
}
- 对比新旧 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
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
}
如果选择器是!,创建评论节点
如果选择器是空,创建文本节点
如果选择器不为空
解析选择器,设置标签的 id 和 class 属性
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的重要性)