本文主要讲解什么是 Virtual DOM 以及 Virtual DOM 的作用,通过一个 Virtual DOM 库 Snabbdom 真正了解什么是 Virtual DOM ,以及 Diff 算法的实现和 key 的作用。
Virtual DOM 和 DOM
DOM(Document Object Model——文档对象模型)是用来呈现以及与任意 HTML 或 XML文档交互的API。DOM 是载入到浏览器中的文档模型,以节点树的形式来表现文档,每个节点代表文档的构成部分(例如:页面元素、字符串或注释等等)。
什么是 Virtual DOM
Virtual DOM(虚拟 DOM),是由普通的的 JS 对象来描述 DOM 对象,因为不是真实的 DOM 对象,所以叫 Virtual DOM 。
- 真实 DOM 成员
1 |
|
打印结果:
通过打印结果得知,一个DOM对象的成员有很多,从而创建一个DOM成本是非常高的。
- 可以使用 Virtual DOM 来描述真实 DOM
1 | { |
对比真实DOM创建VNode成本比真实DOM小很多。
为什么使用 Virtual DOM
- 手动操作 DOM 比较麻烦,还需要考虑浏览器兼容性问题,虽然有 jQuery 等库简化 DOM 操作,但是随着项目的复杂 DOM 操作复杂提升
- 为了简化 DOM 的复杂操作于是出现了各种 MVVM 框架,MVVM 框架解决了视图和状态的同步问题
- 为了简化视图的操作我们可以使用模板引擎,但是模板引擎没有解决跟踪状态变化的问题(数据发生变化时无法获取上次状态),于是 Virtual DOM 出现了
- Virtual DOM 的好处是当状态改变时不需要立即更新 DOM,只需要创建一个虚拟树来描述 DOM, Virtual DOM 内部将弄清楚如何有效(diff)的更新 DOM
- 参考github上 virtual-dom 的描述
- 虚拟 DOM 可以维护程序的状态,跟踪上一次的状态
- 通过比较前后两次状态的差异更新真实 DOM
虚拟 DOM 的作用
- 使用VNode可以记录上次状态的变化,只更新发生变化的部分
- 维护视图和状态的关系
- 复杂视图情况下提升渲染性能,简单视图首次渲染并不能提高性能
- 除了渲染 DOM 以外,还可以实现 SSR(Nuxt.js/Next.js)、原生应用(Weex/React Native)、小程序(mpvue/uni-app)等
Snabbdom
Vue 2.x 内部使用的Virtual DOM就是改造的Snabbdom,Snabbdom内部使用TypeScript开发,是最快的Virtual DOM 之一
Snabbdom基本使用
需求:实现一个如下html结构的web ui,其中div的text部分hello是会在[hello、visual、world]三者当中随机选择一个显示的(演示dom更新):
1 | <div> |
1 | <!-- index.html --> |
dom实现(原生)
1 | // index.js |
virtual dom实现(snabbdom)
项目中引入snabbdom可以通过script标签和npm形式引入,此案例使用npm方式
script方式引入:
1
2<script src="https://cdn.bootcss.com/snabbdom/0.7.4/snabbdom.js"></script>
<script src="https://cdn.bootcdn.net/ajax/libs/snabbdom/0.7.4/h.js"></script>npm方式引入:
1
2$ yarn add parcel-bundler # 本地安装 parcel 打包工具
$ yarn add snabbdom # snabbdom
配置 package.json 的 scripts
1
2
3
4
5
6{
"scripts": {
"dev": "parcel index.html --open",
"build": "parcel build index.html"
}
}index.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49// exports是在nodejs12中中开始支持,webpack4和parcel是不支持的,webpack5中开始支持exports,所以此处不能直接使用官方文档提供的写法,需要导入完整的路径
import { init } from 'snabbdom/build/package/init'
import { h } from 'snabbdom/build/package/h'
let app = document.getElementById("app");
/** -- init() 使用 init() 函数创建 patch()
* 参 数: 数组,将来可以传入模块,处理属性/样式/事件等
* 返回值:patch函数
*/
const patch = init([])
function createDivVNode() {
function createLiVNode(sel, text) {
return h(sel, text);
}
/** -- h() 函数创建 vnode
* 第一个参数:标签 + 选择器
* 第二个参数:若是字符串,则表示标签中的内容
* 若是数组, 则表示创建标签中的子元素
*/
const vnode = h("div", [
["hello", "visual", "dom"][Date.now()%3],
// h('!') 是创建注释
h("!", "this is a notes node"),
createLiVNode("li", "first li"),
createLiVNode("li", "second li"),
]);
return vnode;
}
function render(action) {
switch (action) {
case "init":
/** -- patch() 对比两个vnode的差异,更新到真实DOM
* 第一个参数:旧VNode 可以是 DOM元素,则内部会把DOM元素转换成VNode;也可以是 VNode
* 第二个参数:新VNode
* 返回值:新的 VNode
*/
app = patch(app, createDivVNode());
break;
case "update":
app = patch(app, createDivVNode());
break;
}
}
render("init");
setInterval(() => {
render("update");
}, 1000);
上述示例中可以看到:DOM 方式实现是使用 DOM 提供的 API 编写渲染 DOM 结构即可。那么对于 virtual dom 库 snbbdom 是如何实现的 dom 渲染?通过代码可以看到,使用 snabbdom 的 init() 函数创建 patch(),通过h函数创建虚拟节点vnode,patch 函数用于把 vnode 渲染到 app 的位置,更新时再把新的虚拟节点渲染在老的虚拟节点位置上,后面我们将通过snabbdom源码来研究下内部实现原理的。
需要注意,查看官方文档是可以看到案例是通过
import { init } from 'snabbdom/init'
方式引入,此时运行的话会告诉我们找不到 init / h 模块,查看源码得知模块路径并不是 snabbdom/int,这个路径是在 package.json 中的 exports 字段设置的,而我们使用的parcel打包工具不支持 exports 这个字段,webpack 4 也不支持,webpack 5 支持该字段,该字段在导入 snabbdom/init 的时候会补全路径成 snabbdom/build/package/init.js,如果使用不支持 package.json 的 exports 字段的打包工具,我们应该把模块的路径写全既import { init } from 'snabbdom/build/package/init'
内置模块
Snabbdom 的核心库并不能处理DOM元素的属性/样式/事件等,如果需要处理的话,可以使用snabbdom默认提供的模块来实现,snabbdom中模块的实现是通过注册全局的钩子函数来实现。
官方提供了 6 个模块
- attributes
- 设置 DOM 元素的属性,使用 setAttribute()
- 处理布尔类型的属性
- props
- 和 attributes 模块相似,设置 DOM 元素的属性 element[attr] = value
- 不处理布尔类型的属性
- class
- 切换类样式
- 注意:给元素设置类样式是通过 sel 选择器
- dataset
- 设置 data-* 的自定义属性
- eventlisteners
- 注册和移除事件
- style
- 设置行内样式,支持动画
- delayed/remove/destroy
模块使用
- 模块使用步骤:
- 导入需要的模块
- init() 中注册模块
- 使用 h() 函数创建 VNode 的时候,可以把第二个参数设置为对象,其他参数往后移
1 | import { init} from 'snabbdom/build/package/init' |
snabbdom源码分析
Snabbdom 的核心
- 使用 h() 函数创建 JavaScript 对象(VNode)描述真实 DOM
- init() 设置模块,创建 patch()
- patch() 比较新旧两个 VNode
- 把变化的内容更新到真实 DOM 树上
snabbdom目录结构:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31|—— .vscode --------------------------- 开发工具的配置文件
|—— examples -------------------------- 官方提示示例
├── hero ---------------------------- 演示自定义模块
├── reorder-animation --------------- 演示过度动画的列表
|—— perf ------------------------------ 性能测试
|—— 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 ------------------- 声明周期用到的所有钩子函数的定义,类似于vue生命周期钩子函数
│ ├── htmldomapi.ts -------------- 对 DOM API 的包装(元素的增删改查)
│ ├── init.ts -------------------- 加载 modules、DOMAPI,返回 patch 函数
│ ├── is.ts ---------------------- 判断数组和原始值的函数
│ ├── jsx-global.ts -------------- jsx 的类型声明文件
│ ├── jsx.ts --------------------- 处理 jsx
│ ├── thunk.ts ------------------- 优化处理,对复杂视图不可变值得优化
│ ├── tovnode.ts ----------------- 提供一个函数把DOM 转换成 VNode(patch函数内部使用这个函数转换)
│ ├── ts-transform-js-extension.cjs
│ ├── tsconfig.json -------------- ts 的编译配置文件
│ └── vnode.ts ------------------- 虚拟节点定义
|── test --------------------------- 单元测试
snabbdom如何通过h函数创建Virtual Node?
h() 函数
在使用 Vue 的时候见过 h() 函数,Vue中增强了 h 函数,实现了组件的机制
1
2
3
4
5new Vue({
router,
store,
render: h => h(App)
}).$mount('#app')h() 函数最早见于 hyperscript,使用 JavaScript 创建超文本,也就是 html 字符串
- Snabbdom 中的 h() 函数不是用来创建超文本,而是创建 VNode
重载
- 参数个数或类型不同的函数
- JavaScript 中没有重载的概念
- TypeScript 中有重载,不过重载的实现还是通过代码调整参数
在源码中,snabbdom通过函数重载方式提供给用户四种调用h函数的方式,而后定义了一个h函数处理参数,并且调用vnode函数创建一个VNode对象返回
源码位置:src/package/h.ts
1 | // h函数重载 (前四个) |
对于h函数的具体函数逻辑则在第五个h函数当中,它主要做了三件事情:
- 首先,解析参数输入实现函数重载
- 其次,进行输入预处理,先是子节点的输入分为两种类型(为的是加快效率),即单文本children节点和children节点数组,而后为svg添加命名空间。
- 最后,转给vnode函数取出data中的key后构造一个vNode节点对象
VNode
h函数是通过调用 VNode 函数创建了一个 VNode 对象,下面具体看下 VNode 对象包含什么
源码位置:src/package/vnode.ts
1 | // key 用来表示唯一标识当前的vnode对象 |
snabbdom如何通过patch函数渲染Virtual Node?
patch 整体执行过程
- patch(oldVnode, newVnode) —— snabbdom的核心
- 打补丁,把新节点中变化的内容渲染到真实 DOM,最后返回新节点作为下一次处理的旧节点
- 对比新旧 VNode 是否相同节点(节点的 key(节点唯一标识) 和 sel(节点选择器) 相同)
- 如果不是相同节点,删除之前的内容,重新渲染
- 如果是相同节点,再判断新的 VNode 是否有 text,如果有并且和 oldVnode 的 text 不同,直接更新文本内容
- 如果新的 VNode 有 children,判断子节点是否有变化,判断子节点的过程使用的就是 diff 算法
- diff 过程只进行同层级比较
VNode中为什么要使用Diff算法?
渲染真实DOM的开销很大,DOM操作会引起浏览器的重拍和重绘,即浏览器重新渲染,性能消耗比较大
diff 算法核心:当数据变化后,不直接操作DOM,用js对象描述真实DOM,当数据变化后会先比较js对象是否发生变化,找到所有变化的位置,最小化的更新变化的位置,从而提高性能
接下来我们看下patch函数是如果创建的?
从之前的示例中我们可以得知,patch函数是通过调用init函数得到的,我们先来探讨一下init函数。
init函数
init函数负责接收用户传入的模块数组参数和渲染平台domApi参数,通过闭包的方式返回一个构造好外部环境的用于渲染Virtual Node的patch函数。
init(modules, domApi),返回 patch() 函数(高阶函数)
为什么要使用高阶函数?
1.因为 patch() 函数在外部会调用多次,每次调用依赖一些参数,比如:modules/domApi/cbs
2.通过高阶函数让 init() 内部形成闭包,返回的 patch() 可以访问到 modules/domApi/cbs,而不需要重新创建
源码位置:src/package/init.ts
1 | // 存储了钩子函数的名字 |
init函数的输入参数有:模块数组modules和平台的domApi作为参数。
- 模块数组参数modules通过钩子机制使得snabbdom具备了可扩展性(snabbdom核心部分只具备解析id、class以及key属性的功能,其它属性以及事件机制都在钩子模块中实现)
- domApi参数使得snabbdom具备了跨平台渲染的能力,默认值为htmlDomApi
除了modules和domApi之外,init函数的函数体中还为patch函数定义了一些工具变量和函数以便patch函数调用,下面我们看下patch函数内部的实现
patch函数
patch函数的职责是接收一个老的虚拟node对象和一个新的虚拟node对象,然后用新的虚拟node对象的信息去更新老的虚拟node对象,更新完成后返回新的虚拟node对象以作为下一次更新的老的虚拟node对象。
执行过程:
- 首先执行模块中的钩子函数 pre
- 如果 oldVnode 和 vnode 相同(key 和 sel 相同)
- 调用 patchVnode(),找节点的差异并更新 DOM
- 如果 oldVnode 是 DOM 元素
- 把 DOM 元素转换成 oldVnode
- 调用 createElm() 把 vnode 转换为真实 DOM,记录到 vnode.elm
- 把刚创建的 DOM 元素插入到 parent 中
- 移除老节点
- 触发用户设置的 create 钩子函数
源码位置:src/package/init.ts
1 | // init 内部返回 patch 函数,把vnode渲染成真实 dom,并返回vnode(高阶函数,在一个函数内部返回一个函数) |
snabbdom如何通过createElm函数把Virtual Node转为Dom Node?
createElm函数
createElm函数以虚拟node对象作为输入,经过一定的逻辑转换后返回输出一个dom节点。需要注意的是,在前面说到,需要创建的dom节点的类型可以分为四种,即文本节点、注释节点、属性节点和元素节点。
用户想要创建哪一种节点类型在调用h函数创建虚拟node节点时已经告诉了snabbdom,但是虚拟node对象却不区分这些类型,所以在转换过程中,我们要把用户希望创建的节点类型给解析回来,然后创建对应的dom类型节点。
执行过程:
- 首先触发用户设置的 init 钩子函数
- 把 vnode 转换成 DOM 对象,存储到 vnode.elm 中
- 如果选择器是!,创建注释节点
- 如果选择器为空,创建文本节点
- 如果选择器不为空
- 创建对应的 DOM 对象:解析选择器,设置标签的 id 和 class 属性
- 执行模块的 create 钩子函数
- 如果 vnode 有 children,创建子 vnode 对应的 DOM,追加到 DOM 树
- 如果 vnode 的 text 值是 string/number,创建文本节点并追击到 DOM 树
- 执行用户设置的 create 钩子函数
- 如果有用户设置的 insert 钩子函数,把 vnode 添加到队列中
- 返回 vnode.elm
源码位置:src/package/init.ts
1 | // 作用:把 VNode 转换成对应的 DOM 元素,但是并不会把 DOM 渲染到页面中 |
snabbdom如何通过patchVnode函数和updateChildren函数实现Virtual Node更新(Dom diff算法)?
实现Virtual Node更新也就是以生成新虚拟node对应的dom结构为目标,通过最大限度的从现有的dom树中寻找并复用已有的dom节点的方式减少dom节点的创建和渲染,加快UI渲染速度。
从算法角度来说,就是实现树节点的差异更新,也就是diff算法。
patchVnode
patchNode函数负责在新旧节点相同后,把新虚拟节点渲染在旧虚拟节点对应的dom节点位置上
执行过程:
- 首先执行用户设置的 prepatch 钩子函数
- 执行 create 钩子函数
- 首先执行模块的 create 钩子函数
- 然后执行用户设置的 create 钩子函数
- 如果 vnode.text 未定义
- 如果 oldVnode.children 和 vnode.children 都有值
- 调用 updateChildren()
- 使用 diff 算法对比子节点,更新子节点
- 如果 vnode.children 有值,oldVnode.children 无值
- 清空 DOM 元素
- 调用 addVnodes(),批量添加子节点
- 如果 oldVnode.children 有值,vnode.children 无值
- 调用 removeVnodes(),批量移除子节点
- 如果 oldVnode.text 有值
- 清空 DOM 元素的内容
- 如果 oldVnode.children 和 vnode.children 都有值
- 如果设置了 vnode.text 并且和和 oldVnode.text 不等
- 如果老节点有子节点,全部移除
- 设置 DOM 元素的 textContent 为 vnode.text
- 最后执行用户设置的 postpatch 钩子函数
源码位置:src/package/init.ts
1 | function patchVnode(oldVnode: VNode, vnode: VNode, insertedVnodeQueue: VNodeQueue) { |
updateChildren
updateChildren函数负责在新旧节点相同后,把新虚拟节点的子节点与老虚拟节点的子结点数组进行比较后差异更新(diff 算法的核心,对比新旧节点的 children,更新 DOM)
执行过程:
- 要对比两棵树的差异,我们可以取第一棵树的每一个节点依次和第二课树的每一个节点比较,但是这样的时间复杂度为 O(n^3)(传统的diff)
传统的diff算法是:将两棵树中所有节点一一对比需要O(n^2),在加上更新(移动,创建,删除)时需要遍历一次,所以是O(n^3) 因此只需要找同级别的子节点依次比较,然后再找下一级别的节点比较,这样算法的时间复杂度为 O(n)
snbbdom根据DOM的特点对传统的diff算法做了优化,DOM操作时候会很少跨级别操作节点,值对比同级别的节点,n个节点需要对比n次,时间复杂度为O(n)在进行同级别节点比较的时候,首先会对新老节点数组的开始和结尾节点设置标记索引,遍历的过程中移动索引
在对开始和结束节点比较的时候,总共有四种情况
- oldStartVnode / newStartVnode (旧开始节点 / 新开始节点)
- oldEndVnode / newEndVnode (旧结束节点 / 新结束节点)
- oldStartVnode / oldEndVnode (旧开始节点 / 新结束节点)
- oldEndVnode / newStartVnode (旧结束节点 / 新开始节点)
开始节点和结束节点比较,这两种情况类似
- oldStartVnode / newStartVnode (旧开始节点 / 新开始节点)
- oldEndVnode / newEndVnode (旧结束节点 / 新结束节点)
如果 oldStartVnode 和 newStartVnode 是 sameVnode (key 和 sel 相同)
- 调用 patchVnode() 对比和更新节点
- 把旧开始和新开始索引往后移动 oldStartIdx++ / oldEndIdx++
oldStartVnode / newEndVnode (旧开始节点 / 新结束节点) 相同
- 调用 patchVnode() 对比和更新节点
- 把 oldStartVnode 对应的 DOM 元素,移动到右边
- 更新索引
oldEndVnode / newStartVnode (旧结束节点 / 新开始节点) 相同
- 调用 patchVnode() 对比和更新节点
- 把 oldEndVnode 对应的 DOM 元素,移动到左边
- 更新索引
如果不是以上四种情况
- 遍历新节点,使用 newStartNode 的 key 在老节点数组中找相同节点
- 如果没有找到,说明 newStartNode 是新节点
- 创建新节点对应的 DOM 元素,插入到 DOM 树中
- 如果找到了
- 判断新节点和找到的老节点的 sel 选择器是否相同
- 如果不相同,说明节点被修改了
- 重新创建对应的 DOM 元素,插入到 DOM 树中
- 如果相同,把 elmToMove 对应的 DOM 元素,移动到左边
循环结束
- 当老节点的所有子节点先遍历完 (oldStartIdx > oldEndIdx),循环结束
- 新节点的所有子节点先遍历完 (newStartIdx > newEndIdx),循环结束
如果老节点的数组先遍历完(oldStartIdx > oldEndIdx),说明新节点有剩余,把剩余节点批量插入到右边
如果新节点的数组先遍历完(newStartIdx > newEndIdx),说明老节点有剩余,把剩余节点批量删除
这里有一个视频通过动画的形式讲解了Diff算法:图解 Vue.js Virtual DOM 的 Diff 算法核心
源码位置:src/package/init.ts
1 | // VNode 的核心 |
通过源码发现,在通常情况下,snabbdom的做法和我们上面说的一样,用新节点数组中的节点一一去匹配老节点数组中的节点,有则移动或更新(注意老节点只复用一次),无则创建并插入。
节点设置key与不设置key有什么不同?
- 当选择器的sel都相同时,未设置key,新旧节点会被认为是相同的节点,只会更改老节点的text或者children属性从而最大限度的重用现有DOM
- 当选择器的sel都相同时,设置key时,他会对比在diff中用来比较是否是相同的节点,如果不同会移动老节点的DOM元素来匹配新数据项的顺序,从而重用和重排现有元素,减少DOM操作
Hooks 函数 和 模块 Modules
- snabbdom核心: patch() -> patchVnode() -> updateChildren()
- Snabbdom 为了保证核心库的精简,把处理元素的属性/事件/样式等工作,放置到模块中
- 模块可以按照需要引入
- 模块的使用可以查看官方文档
- 模块实现的核心是基于 Hooks
Hooks 函数
预定义的钩子函数的名称
源码位置:src/package/hooks.ts
1 | export interface Hooks { |
模块 Modules
Snabbdom 提供的所有模块在:src/package/modules
文件夹下,主要模块有:
modules目录结构:1
2
3
4
5
6
7
8|—— attributes.ts --------------------------- 使用 setAttribute/removeAttribute 操作属性,能够处理 boolean 类型的属性
|—— class.ts -------------------------------- 切换类样式
|—— dataset.ts ------------------------------ 操作元素的 data-* 属性
|—— eventlisteners.ts ---------------------- 注册和移除事件
|—— module.ts ------------------------------ 定义模块遵守的钩子函数
|—— props.ts ------------------------------- 和 attributes.ts 类似,但是是使用 elm[attrName] = value 的方式操作属性
|—— style.ts ------------------------------- 操作行内样式,可以使动画更平滑
|—— hero.ts -------------------------------- 自定义的模块,examples/hero 示例中使用
attributes.ts
- 模块到出成员
1 | export const attributesModule = { |
updateAttrs 函数功能
1.更新节点属性
2.如果节点属性值是 true 设置空置
3.如果节点属性值是 false 移除属性updateAttrs 实现
1 | function updateAttrs(oldVnode: VNode, vnode: VNode): void { |