在研究Vue的源码的时候,了解了一下Virtual DOM,以及Virtual DOM的作用。下面分享一下什么是Virtual DOM,以及它的作用。和一个比较常见的Virtual DOM库的使用与源码分析。
什么是Virtual DOM
- Virtual DOM(虚拟DOM),是由js对象来描述DOM对象,因为不是真是的DOM,所以叫作虚拟DOM。
- Virtual DOM的格式一般如下:
{
sel: "div",
data: {},
children: undefined,
text: "Hello Virtual DOM",
elm: undefined,
key: undefined
}
为什么使用Virtual DOM
- 手动操作DOM比较麻烦,虽然有JQuery等库简化DOM操作,但是随着项目的复杂性提升,DOM操作越来越难
- 为了简化视图的操作我们可以使用模板引擎,但是模板引擎没有解决跟踪状态变化的问题
- Virtual DOM的好处是可以不用立即更新DOM,当数据改变时,只需创建一个虚拟树来描述DOM,可以跟踪记录上一次的状态,通过比较前后两次的状态差异来更新真实的DOM,在复杂项目中可以提升渲染的性能
- 除了渲染DOM外,还可以实现SSR渲染(nuxt.js/next.js),原生应用(weex/React Native),小程序等
Snabbdom(Virtual DOM库)
- snabbdom文档
- Vue 2.x 内部使用的 Virtual DOM 就是改造的 Snabbdom
- 大约 200 SLOC(single line of code)
- 通过模块可扩展,源码使用 TypeScript 开发,最快的 Virtual DOM 之一
Snabbdom的基本使用
- 我使用的打包工具是parcel,不用webpack,选这个的原因,是可以零配置运行一个简单的demo
# 创建项目目录
md snabbdom-demo
# 进入项目目录
cd snabbdom-demo
# 创建 package.json
npm init -y
# 本地安装 parcel
npm install parcel-bundler -D
# 本地安装 snabbdom 安装snabbdom要注意版本问题,最新的版本有问题,建议安装0.7.4版本
npm install snabbdom@0.7.4 -S
- 创建对应的目录结构
- index.html
- package.json
└─ src
index.js
- 配置 package.json 的 scripts
"scripts": {
"dev": "parcel index.html --open",
"build": "parcel build index.html"
}
- Snabbdom的导入
// 因为snabbdom是commonJs语法,在项目中要是想用ES6模块的语法,要注意导入的方式
// commonJs
let snabbdom = require('snabbdom')
// ES6
import * as snabbdom from 'snabbdom' 或者
import { h, init, thunk} from 'snabbdom'
Snabbdom的使用
先简单的介绍一下常用的几个方法的功能
- 使用 h() 函数创建 JavaScript 对象(VNode)描述真实 DOM
- init() 设置模块,创建 patch()
- patch() 比较新旧两个 VNode,它有两个参数,第一个参数可以是真实的DOM或者VNode,第二个参数是新的VNode
- 把变化的内容更新到真实 DOM 树上 开始上代码
- 先简单的写一个Hello World的例子。
在很简单的项目中是没必要使用Virtual DOM的,这里只是为了展示学习
<!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/index.js"></script>
</body>
</html>
import * as snabbdom from 'snabbdom'
let patch = snabbdom.init([])
let vnode = snabbdom.h('div#app.app', 'Hello World')
let app = document.querySelector('#app')
let oldVnode = patch(app, vnode) // 保留上一次的vnode
setTimeout(() => { // 过两秒更新一下DOM
vnode = snabbdom.h('div#app.app', 'Hello World1')
oldVnode = patch(oldVnode, vnode)
}, 2000);
setTimeout(() => {
vnode = snabbdom.h('div#app.app', 'Hello World2')
oldVnode = patch(oldVnode, vnode)
}, 4000);
- 来一个包含子节点的例子
import * as snabbdom from 'snabbdom'
// 下面是常用模块,还有一些在官网
import { classModule } from 'snabbdom/modules/class' // 可以根据变量动态切换的class
import { propsModule } from 'snabbdom/modules/props' // 设置不为布尔值的属性
import { attributesModule } from 'snabbdom/modules/attributes' // 设置不为布尔值的属性
import { datasetModule } from 'snabbdom/modules/dataset' // 设置data-*的属性
import { styleModule } from 'snabbdom/modules/style' // 设置内联样式
import { eventListenersModule } from 'snabbdom/modules/eventlisteners' // 注册和移除事件
let patch = snabbdom.init([
classModule,
propsModule,
attributesModule,
datasetModule,
styleModule,
eventListenersModule
])
function handleClick(num) {
console.log('点击了' + num)
}
let app = document.querySelector('#app')
let vnode = snabbdom.h('div#app', {
style: {
color: 'pink'
},
dataset: {
id: 1
},
props: {
title: 'hello world'
},
on: {
click: [handleClick, 1] // 如果不想传参数可以写成 handleClick
}
},[
snabbdom.h('p', '你好啊')
])
let oldVnode = patch(app, vnode)
- 上一个可以排序,添加,删除的列表
<!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>
<style>
*{
margin: 0;
padding: 0;
}
html,body{
height: 100%;
background-color: #000000;
color: #ffffff;
font-size: 14px;
}
#app{
width: 600px;
padding-top: 50px;
margin-left: 100px;
}
.h1{
margin-bottom: 15px;
}
.options_box{
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 15px;
}
.options_box .left_btn {
margin-right: 10px;
}
.options_box .left_btn,
.options_box .right_btn{
cursor: pointer;
padding: 5px 10px;
background-color: #1d1717;
}
.options_box .left_btn.active,
.options_box .right_btn.active{
background-color: #524e4e;
}
.list_box .list{
background-color: #524e4e;
margin-bottom: 10px;
}
.list_box .list .btn_box{
overflow: hidden;
}
.list_box .list .btn_box .btn{
cursor: pointer;
float: right;
padding: 5px 10px;
}
.list_box .list .row{
display: flex;
align-items: center;
padding: 0 15px 15px;
}
.list_box .list .row .rank{
width: 30px;
}
.list_box .list .row .title{
flex: 1;
}
.list_box .list .row .desc{
margin-left: 20px;
flex: 3;
}
</style>
</head>
<body>
<div id="app"></div>
<script src="./src/index.js"></script>
</body>
</html>
import {h, init} from 'snabbdom'
// 下面是常用模块,还有一些在官网
import { classModule } from 'snabbdom/modules/class' // 可以根据变量动态切换的class
import { propsModule } from 'snabbdom/modules/props' // 设置不为布尔值的属性
import { styleModule } from 'snabbdom/modules/style' // 设置内联样式
import { eventListenersModule } from 'snabbdom/modules/eventlisteners' // 注册和移除事件
let patch = init([
classModule,
propsModule,
styleModule,
eventListenersModule
])
let oldVnode = null // 旧节点
let types = '' // 当前排序的条件
let tableData = [
{ rank: 3, title: 'The Godfather: Part II', desc: 'The early life and career of Vito Lake Tahoe, Nevada to pre-revolution 1958 Cuba.', elmHeight: 0 },
{ rank: 1, title: 'The Shawshank Redemption', desc: 'Two imprisoned men bond over a ', elmHeight: 0 },
{ rank: 7, title: '12 Angry Men', desc: 'A dissenting juror in a murder trial slowly ma as it seemed in court.', elmHeight: 0 },
{ rank: 4, title: 'The Dark Knight', desc: 'When the menace known as the Joker wreaks h ability to fight injustice.', elmHeight: 0 },
{ rank: 9, title: 'The Lord of the Rings: The Return of the King', desc: 'Gandalf and A from Frodo and Sam aDoom with the One Ring.', elmHeight: 0 },
{ rank: 6, title: 'Schindler\'s List', desc: 'In Poland during World War II, Oskar Schinessing their persecution by the Nazis.', elmHeight: 0 },
{ rank: 2, title: 'The Godfather', desc: 'The aging patriarch of an organized crime son.', elmHeight: 0 },
{ rank: 8, title: 'The Good, the Bad and the Ugly', desc: 'A bounty hunting scam joins rtune in gold buried in a remote cemetery.', elmHeight: 0 },
{ rank: 5, title: 'Pulp Fiction', desc: 'The lives of two mob hit men, a boxer, a gangs violence and redemption.', elmHeight: 0 },
{ rank: 10, title: 'Fight Club', desc: 'An insomniac office worker looking for a way t they form an into something much, much more...', elmHeight: 0 },
]
let rankId = tableData.length // 最后一个rank的值
let app = document.querySelector('#app')
// 删除某一行
function handleRemove(row, index) {
tableData.splice(index, 1)
oldVnode = patch(oldVnode, returnVnode(tableData))
}
// 添加一行
function addRow() {
let data = {
rank: ++rankId,
title: `我是添加的第条${rankId - 10}数据`,
desc: `我是添加的第条${rankId - 10}数据`,
elmHeight: 0
}
tableData.push(data)
oldVnode = patch(oldVnode, returnVnode(tableData))
}
// 创建表的每一行
function tableRow(row, index) {
return h('div.list', [
h('p.btn_box', [
h('span.btn', {
on: {
click: [handleRemove, row, index]
}
}, 'x')
]),
h('div.row', [
h('div.rank', row.rank),
h('div.title', row.title),
h('div.desc', row.desc),
])
])
}
// 排序
function changeSort(type) {
types = type
tableData.sort((a, b) => {
if(typeof a[type] === 'number') {
return a[type] - b[type]
}else {
return a[type].localeCompare(b[type])
}
})
oldVnode = patch(oldVnode, returnVnode(tableData))
}
// 返回vnode
function returnVnode(data) {
return h('div#app', [
h('h1.h1', 'Top 10 movies'),
h('div.options_box', [
h('div.left', [
h('span.left_btn', {
class: {active: types === 'rank'},
on: {
click: [changeSort, 'rank']
}
}, 'rank'),
h('span.left_btn', {
class: {active: types === 'title'},
on: {
click: [changeSort, 'title']
}
}, 'title'),
]),
h('span.right_btn',{
on: {
click: addRow
}
}, 'add')
]),
h('div.list_box', data.map(tableRow))
])
}
oldVnode = patch(app, returnVnode(tableData))
Snabbdom的源码分析
Snabbdom的核心
- 使用 h() 函数创建 JavaScript 对象(VNode)描述真实 DOM
- init() 设置模块,创建 patch()
- patch() 比较新旧两个 VNode,它有两个参数,第一个参数可以是真实的DOM或者VNode,第二个参数是新的VNode
- 把变化的内容更新到真实 DOM 树上
Snabbdom的源码
- 源码地址 snabbdom
- src目录结构
│ h.ts h() 函数,用来创建 VNode
│ hooks.ts 所有钩子函数的定义
│ htmldomapi.ts 对 DOM API 的包装
│ is.ts 判断数组和原始值的函数
│ jsx-global.d.ts jsx 的类型声明文件
│ jsx.ts 处理 jsx
│ snabbdom.bundle.ts 入口,已经注册了模块
│ snabbdom.ts 初始化,返回 init/h/thunk
│ thunk.ts 优化处理,对复杂视图不可变值得优化
│ tovnode.ts DOM 转换成 VNode
│ vnode.ts 虚拟节点定义
│
├─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 函数
- h函数在使用vue的时候是经常见到的
new Vue({
router,
store,
render: h => h(App)
}).$mount('#app')
- snabbdom的h函数是用来创建vnode的,它利用了函数重载的思想,根据传入的参数个数或类型的不同,执行不同函数。
// 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;
export function h(sel: any, b?: any, c?: any): VNode {
var data: VNodeData = {}, children: any, text: any, 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 是字符串或者数字
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);
}
}
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);
};
// 导出模块
export default h;
VNode
一个VNode就是一个虚拟节点,用来描述一个DOM元素,下面分析一下snabbdom的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 {
let key = data === undefined ? undefined : data.key;
return {sel, data, children, text, elm, key};
}
export default vnode;
Snabbdom.ts
分析一下snabbdom.ts文件
- patch(oldVnode, newVnode)
- 打补丁,把新节点中变化的内容渲染到真实 DOM,最后返回新节点作为下一次处理的旧节点
- 对比新旧 VNode 是否相同节点(节点的 key 和 sel 相同)
- 如果不是相同节点,删除之前的内容,重新渲染
- 如果是相同节点,再判断新的 VNode 是否有 text,如果有并且和 oldVnode 的 text 不同,直接更新文本内容
- 如果新的 VNode 有 children,判断子节点是否有变化,判断子节点的过程使用的就是 diff 算法
- diff 过程只进行同层级比较
init 函数
- init(modules, domApi),返回 patch() 函数(高阶函数)。modules是一个数组,数组里面是各种扩展插件,domApi是一个包含dom操作的对象
patch 函数
- 功能
- 传入新旧 VNode,对比差异,把差异渲染到 DOM
- 返回新的 VNode,作为下一次 patch() 的 oldVnode
- 执行过程
- 首先执行模块中的钩子函数 pre
- 如果 oldVnode 和 vnode 相同(key 和 sel 相同),调用 patchVnode(),找节点的差异并更新 DOM
- 如果 oldVnode 是 DOM 元素。① 把 DOM 元素转换成 oldVnode ② 调用 createElm() 把 vnode 转换为真实 DOM,记录到 vnode.elm ③ 把刚创建的 DOM 元素插入到 parent 中 ④ 移除老节点,触发用户设置的 create 钩子函数
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);
// 触发 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]();
// 返回 vnode
return vnode;
};
createElm 函数
- 功能:createElm(vnode, insertedVnodeQueue),返回创建 vnode 对应的 DOM 元素
- 执行过程
- 首先触发用户设置的 init 钩子函数
- 如果选择器是!,创建注释节点;如果选择器为空,创建文本节点
- 如果选择器不为空。① 解析选择器,设置标签的 id 和 class 属性 ② 执行模块的 create 钩子函数 ③ 如果 vnode 有 children,创建子 vnode 对应的 DOM,追加到 DOM 树 ④ 如果 vnode 的 text 值是 string/number,创建文本节点并追击到 DOM 树 ⑤ 执行用户设置的 create 钩子函数 ⑥ 如果有用户设置的 insert 钩子函数,把 vnode 添加到队列中
patchVnode 函数
- 功能:patchVnode(oldVnode, vnode, insertedVnodeQueue),对比 oldVnode 和 vnode 的差异,把差异渲染到 DOM
- 执行过程
- 首先执行用户设置的 prepatch 钩子函数
- 执行模块的 create 钩子函数,再执行用户设置的 create 钩子函数
- 如果设置了 vnode.text 并且和 oldVnode.text 不相等。如果老节点有子节点,全部移除,设置 DOM 元素的 textContent 为 vnode.text
- 如果 vnode.text 未定义
- 如果 oldVnode.children 和 vnode.children 都有值。调用 updateChildren(),使用 diff 算法对比子节点,更新子节点
- 如果 vnode.children 有值。 oldVnode.children 无值。清空 DOM 元素,调用 addVnodes() ,批量添加子节点
- 如果 oldVnode.children 有值, vnode.children 无值。调用 removeVnodes() ,批量移除子节点
- 如果 oldVnode.text 有值。清空 DOM 元素的内容
function patchVnode(oldVnode: VNode, vnode: VNode, insertedVnodeQueue:VNodeQueue) {
const hook = vnode.data?.hook;
// 首先执行用户设置的 prepatch 钩子函数
hook?.prepatch?.(oldVnode, vnode);
const elm = vnode.elm = oldVnode.elm!;
let oldCh = oldVnode.children as VNode[];
let ch = vnode.children as VNode[];
// 如果新老 vnode 相同返回
if (oldVnode === vnode) return;
if (vnode.data !== undefined) {
// 执行模块的 update 钩子函数
for (let i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode,
vnode);
// 执行用户设置的 update 钩子函数
vnode.data.hook?.update?.(oldVnode, vnode);
}
// 如果 vnode.text 未定义
if (isUndef(vnode.text)) {
// 如果新老节点都有 children
if (isDef(oldCh) && isDef(ch)) {
// 使用 diff 算法对比子节点,更新子节点
if (oldCh !== ch) updateChildren(elm, oldCh, ch,
insertedVnodeQueue);
} else if (isDef(ch)) {
// 如果新节点有 children,老节点没有 children
// 如果老节点有text,清空dom 元素的内容
if (isDef(oldVnode.text)) api.setTextContent(elm, '');
// 批量添加子节点
addVnodes(elm, null, ch, 0, ch.length - 1, insertedVnodeQueue);
} else if (isDef(oldCh)) {
// 如果老节点有children,新节点没有children
// 批量移除子节点
removeVnodes(elm, oldCh, 0, oldCh.length - 1);
} else if (isDef(oldVnode.text)) {
// 如果老节点有 text,清空 DOM 元素
api.setTextContent(elm, '');
}
} else if (oldVnode.text !== vnode.text) {
// 如果没有设置 vnode.text
if (isDef(oldCh)) {
// 如果老节点有 children,移除
removeVnodes(elm, oldCh, 0, oldCh.length - 1);
}
// 设置 DOM 元素的 textContent 为 vnode.text
api.setTextContent(elm, vnode.text!);
}
// 最后执行用户设置的 postpatch 钩子函数
hook?.postpatch?.(oldVnode, vnode);
}
updateChildren 函数
- diff 算法的核心,对比新旧节点的 children,更新 DOM
- 通过调试 updateChildren,我们发现不带 key 的情况需要进行两次 DOM 操作,带 key 的情况只需要更新一次 DOM 操作(移动 DOM 项),所以带 key 的情况可以减少 DOM 的操作,如果子项比较多,更能体现出带 key 的优势。
简述 Diff 算法的执行过程
diff算法是一种通过同层的树节点进行比较的高效算法,避免了对树进行逐层搜索遍历,所以时间复杂度只有 O(n)。
diff算法有两个比较显著的特点:
1、比较只会在同层级进行, 不会跨层级比较。
2、在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]之间的所有节点都删除