# learnSnabbdom **Repository Path**: taoyouyou/learnSnabbdom ## Basic Information - **Project Name**: learnSnabbdom - **Description**: 从Snabbdom的用法入手,解析Snabbdom源码 - **Primary Language**: Unknown - **License**: MIT - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 1 - **Created**: 2020-07-10 - **Last Updated**: 2020-12-19 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # 从Snabbdom的基本用法解剖源码实现 ## 使用vnode的优点 1. 手动操作dom比较复杂,还需要考虑浏览器的兼容性问题,虽然有jquery等库简化dom操作,但是随着项目越来越复杂,dom操作复杂提升 2. 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` - 源码地址:[http://github.com/snabbdom/snabbdom](http://github.com/snabbdom/snabbdom) - 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 | 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>, domApi?: DOMAPI);` > 高阶函数:一个函数里面返回另一个函数 高阶函数的作用: 形成闭包:内部函数可以访问外部函数upvalue, patch()可以访问到modules和domAp等外部函数参数; init()实现过程: 1. domapi给定默认值 2. 把传入的所有模块的钩子函数,统一存储到cbs对象中 3. init 内部返回 patch, 把vnode渲染成真实dom,并返回vnode。 代码实现: ``` export function init(modules: Array>, 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).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的实现有一点帮助。 ![](https://gitee.com/taoyouyou/taoyouyou/raw/master/assets/photos/sanbbdom.png)