使用vnode的优点
- 手动操作dom比较复杂,还需要考虑浏览器的兼容性问题,虽然有jquery等库简化dom操作,但是随着项目越来越复杂,dom操作复杂提升
- Virtual DOM的好处是当状态改变时不需要立即更新dom,只需要创建一个虚拟树来描述dom,Virtual DOM内部将弄清楚如何有效(diff)的更新dom。
Snabbdom基本用法
为什么选择snabbdom呢,因为Snabbdom对于vnode的实现的非常精简,源代码实现不到200行,比较有利于读者对源码的理解和解析。同时 VueJS 的 virtual dom 部分基于snabbdom改造,理解了snabbdom有利于理解VueJS。
安装snabbdom
- 安装snabbdom
yarn add snabbdom
导入 snabbdom
- snabbdom的官网demo中导入使用的是commonjs模块化语法,我们使用更流行的es6模块化的语法 import
- 关于模块化的语法请参考阮一峰老师的Modules的语法
- ES6模块与CommonJS模块的差异
最简单的入门示例:
import { init, h, thunk} from 'snabbdom'
let patch = init([])
let vnode = h('div#container', [
h('h1', 'Hello Snabbdom'),
h('p', '这是一个p标签'),
]);
let app = document.querySelector('#app');
patch(app, vnode);
模块
snabbdom的核心并不能处理元素的属性/样式/事件等,如果需要处理的话,可以使用模块
常用模块
官方提供了6个模块
1、 attribute
- 设置dom元素的属性,使用setAttribute()
- 处理布尔类型的属性
2、 props
- 和attribute模块相似,设置dom属性
element[attr] = value - 不处理布尔类型的属性
3、 class
- 切换类样式
- 注意:给元素设置类样式是通过sel选择器
4、 dataset
- 设置'data-*'的自定义属性
5、 eventlisteners
- 注册和移除事件
6、 style
- 设置行内样式,支持动画
- delayed/remove/destory
模块使用
导入模块 init()注册模块 使用h()函数创建vnode的时候,可以把第二个参数设置为对象,其他参数往后移,如:
import { h, init } from 'snabbdom';
import style from 'snabbdom/modules/style';
import eventlisteners from 'snabbdom/modules/eventlisteners';
let patch = init([style, eventlisteners])
let vnode = h('div', {
style: {
backgroundColor: 'red',
fontSize: '14px',
},
on: {
click: eventHandler
}
}, [
h('h1', 'hello snabbdom'),
h('p', '这是p标签'),
])
function eventHandler(){
console.log('点击我了')
}
let app = document.querySelector('#app');
patch(app, vnode);
snabbdom源码
在这里分享阅读源码时的几个方法:
- 先宏观了解组件实现的功能以及用法
- 将目标分解,带如特定的功能去看源码
- 看源码的过程不求甚解(先把主线逻辑走通,排除其他分支干扰)
- 编写示例子,进行断点调试
snabbdom的核心
所以学习源码的第一步我们需要先从整体上了解snabbdom这个组件实现怎样的功能。我们从上文已经介绍了snabbdom的基本用法与模块的用法。可以将snabbdom核心总结为一下几点:
- 从snabbdom库中导入h函数,和init函数
- 使用h函数创建JavaScript对象(vnode)描述真实dom。
- init() 设置模块,创建patch()。
- patch()比较新旧两个vnode。
- 把变化的内容更新到真实的dom上。
snabbdom源码
接下来需要将源码clone下来,了解源码目录。
此次分析的源码版本是
v0.7.4
- 源码地址:github.com/snabbdom/sn…
- src目录结构
── h.ts 创建vnode的函数
── helpers
└── attachto.ts
── hooks.ts 定义钩子
── htmldomapi.ts 操作dom的一些工具类
── is.ts 判断类型
── modules 模块
├── attributes.ts
├── class.ts
├── dataset.ts
├── eventlisteners.ts
├── hero.ts
├── module.ts
├── props.ts
└── style.ts
── snabbdom.bundle.ts 入口文件
── snabbdom.ts 初始化函数
── thunk.ts 分块
── tovnode.ts dom元素转vnode
── vnode.ts 虚拟节点对象
分析源码实现
一般做法是从入口函数开始解析主线逻辑,所以可以从h函数开始解析。
1、h()
h函数创建虚拟节点(vnodes),函数接收一个字符串形式的标签/选择器、一个可选的数据对象、一个可选的字符串或数组作为子代。
函数重载:
- 参数个数或类型不同的函数
- javascript中没有重载的概念
- typescript中与重载,不过重载的实现还是通过代码调整参数
在ts中通过调整代码实现了函数重载,通过调用vnode函数传入不同的参数值和参数类型,返回一个vnode节点。
// h 函数的重载
export function h(sel: string): VNode;
export function h(sel: string, data: VNodeData): VNode;
export function h(sel: string, children: VNodeChildren): VNode;
export function h(sel: string, data: VNodeData, 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
data = b;
if (is.array(c)) { children = c; } // 如果c是数组,c代表了当前节点的子节点
else if (is.primitive(c)) { text = c; } // 如果c是字符串或数字,代表了文本值
else if (c && c.sel) { children = [c]; } // 如果c是vnode, 会被处理成数组
} else if (b !== undefined) { //处理两个参数的情况
if (is.array(b)) { children = b; } // 如果b是数组,b代表了当前节点的子节点
else if (is.primitive(b)) { text = b; } // 如果b是字符串或数字,代表了文本值
else if (b && b.sel) { children = [b]; } // 如果b是vnode, 会被处理成数组
else { data = b; } // 否则,b作为data
}
if (children !== undefined) {
for (i = 0; i < children.length; ++i) {
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); // 给节点和节点的子节点递归添加命名空间
}
return vnode(sel, data, children, text, undefined); // 调用vnode()函数返回一个vnode
};
export default h; // 将h函数导出为默认模块
2、vnode()
vnode定义了一种vnode数据对象,作为对真实dom的描述。 vnode = vnode(sel,data,children,text,elm)
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};
}
vnode属性
- sel: 选择器
- data: 模块数据
- children: 子节点
- text: 记录vnode对应的真实dom
- elm: 和children互斥
- key: 优化用
3、init()
init接收一个模块列表,并返回一个使用指定模块集的patch函数
用法:patch() = init(modules: Array<Partial<Module>>, domApi?: DOMAPI);
高阶函数:一个函数里面返回另一个函数 高阶函数的作用: 形成闭包:内部函数可以访问外部函数upvalue, patch()可以访问到modules和domAp等外部函数参数;
init()实现过程:
- domapi给定默认值
- 把传入的所有模块的钩子函数,统一存储到cbs对象中
- init 内部返回 patch, 把vnode渲染成真实dom,并返回vnode。
代码实现:
export function init(modules: Array<Partial<Module>>, domApi?: DOMAPI) {
let i: number, j: number, cbs = ({} as ModuleHooks);
// 初始化转换虚拟节点的api
const api: DOMAPI = domApi !== undefined ? domApi : htmlDomApi;
// 把传入的所有模块的钩子函数,统一存储到cbs对象中
// 最终构建的cbs对象形式 cbs={creat:[fn1,fn2], update:[], ...}
for (i = 0; i < hooks.length; ++i) {
// cbs.create = [], cbs.update=[]...
cbs[hooks[i]] = [];
for (j = 0; j < modules.length; ++j) {
// modules 传入的模块数组
// 获取模块中的hook函数
// hook= modules[0][create]...
const hook = modules[j][hooks[i]];
if (hook !== undefined) {
(cbs[hooks[i]] as Array<any>).push(hook);
}
}
}
... // 一系列辅助函数
// init 内部返回 patch, 把vnode渲染成真实dom,并返回vnode
return function patch(oldVnode: VNode | Element, vnode: VNode): VNode {
... // patch内部函数实现
}
4、patch()
init返回的patch函数需要接受两个参数。第一个是表示当前视图的DOM元素或vnode。第二个是表示更新后的新视图的vnode。
用法:patch(oldVnode, newVnode)
作用:打补丁,把新节点中变化的内容渲染到真实的dom,最后返回新节点作为下一次处理的旧节点。
实现过程:
- 对比新旧节点是否相同节点(节点的key和sel相同)
- 如果不是相同节点,删除之前的内容,重新渲染
- 如果是相同的节点,在判断新的vnode是否有text,如果有并且和oldVnode的text不同,直接更新文本
- 如果新的vnode有children,判断子节点是否有变化,判断子节点的过程使用的就是diff算法
- diff的过程只进行同层比较。
代码实现:
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);
}
// 如果新旧节点相同
if (sameVnode(oldVnode, vnode)) {
// 找节点差异并更新dom
patchVnode(oldVnode, vnode, insertedVnodeQueue);
} else {
// 如果节点不同,vnode创建对应的dom
// 获取当前节点的dom元素
elm = oldVnode.elm as Node;
parent = api.parentNode(elm);
// 创建vnode对应的dom元素,并触发init/create 钩子函数
createElm(vnode, insertedVnodeQueue);
if (parent !== null) {
// 如果父节点不为空,把vnode对应的dom插入到文档中
api.insertBefore(parent, vnode.elm as Node, api.nextSibling(elm));
// 移除老节点
removeVnodes(parent, [oldVnode], 0, 0);
}
}
// 执行用户设置的insert钩子函数
for (i = 0; i < insertedVnodeQueue.length; ++i) {
(((insertedVnodeQueue[i].data as VNodeData).hook as Hooks).insert as any)(insertedVnodeQueue[i]);
}
// 执行模块的post函数
for (i = 0; i < cbs.post.length; ++i) cbs.post[i]();
// 返回vnode
return vnode;
};
总结
针对snabbdom源码,笔者对于真实dom到虚拟dom的相互转化的过程整理总结成脑图。希望对于读者理解snabbdom的实现有一点帮助。