近几年随着 React、Vue 等前端框架不断兴起,Virtual DOM 概念也越来越火,被用到越来越多的框架、库中。Virtual DOM 是基于真实 DOM 的一层抽象,用简单的 JS 对象描述真实 DOM。本文要介绍的 Snabbdom 就是 Virtual DOM 的一种简单实现,并且 Vue 的 Virtual DOM 也参考了 Snabbdom 实现方式。
对于想要深入学习 Vue Virtual DOM 的朋友,建议先学习 Snabbdom,对理解 Vue 会很有帮助,并且其核心代码 200 多行。
本文挑选 Snabbdom 模块系统作为主要核心点介绍,其他内容可以查阅官方文档《Snabbdom》。
一、Snabbdom 是什么
Snabbdom 是一个专注于简单性、模块化、强大特性和性能的虚拟 DOM 库。其中有几个核心特性:
- 核心代码 200 行,并且提供丰富的测试用例;
- 拥有强大模块系统,并且支持模块拓展和灵活组合;
- 在每个 VNode 和全局模块上,都有丰富的钩子,可以在 Diff 和 Patch 阶段使用。
接下来从一个简单示例来体验一下 Snabbdom。
1. 快速上手
安装 Snabbdom:
npm install snabbdom -D
接着新建 index.html,设置入口元素:
<div id="app"></div>
然后新建 demo1.js 文件,并使用 Snabbdom 提供的函数:
// demo1.js import { h } from 'snabbdom/src/package/h' import { init } from 'snabbdom/src/package/init' const patch = init([]) let vnode = h('div#app', 'Hello Leo') const app = document.getElementById('app') patch(app, vnode)
这样就实现一个简单示例,在浏览器打开 index.html,页面将显示 “Hello Leo” 文本。
接下来,我会以 snabbdom-demo 项目作为学习示例,从简单示例到模块系统使用的示例,深入学习和分析 Snabbdom 源码,重点分析 Snabbdom 模块系统。
二、Snabbdom-demo 分析
Snabbdom-demo 项目中的三个演示代码,为我们展示如何从简单到深入 Snabbdom。 首先克隆仓库并安装:
$ git clone https://github.com/zyycode/snabbdom-demo.git $ npm install
虽然本项目没有 README.md 文件,但项目目录比较直观,我们可以轻松的从 src 目录找到这三个示例代码的文件:
- 01-basicusage.js
- 02-basicusage.js
- 03-modules.js -> 本文核心介绍
接着在 index.html 中引入想要学习的代码文件,默认 <script src="./src/01-basicusage.js"></script>
,通过 package.json 可知启动命令并启动项目:
$ npm run dev
1. 简单示例分析
当我们要研究一个库或框架等比较复杂的项目,可以通过官方提供的简单示例代码进行分析,我们这里选择该项目中最简单的 01-basicusage.js 代码进行分析,其代码如下:
// src/01-basicusage.js import { h } from 'snabbdom/src/package/h' import { init } from 'snabbdom/src/package/init' const patch = init([]) let vnode = h('div#container.cls', 'Hello World') const app = document.getElementById('app') // 入口元素 const oldVNode = patch(app, vnode) // 假设时刻 vnode = h('div', 'Hello Snabbdom') patch(oldVNode, vnode)
运行项目以后,可以看到页面展示了“Hello Snabbdom”文本,这里你会觉得奇怪,前面的 “Hello World” 文本去哪了?
原因很简单,我们把 demo 中的下面两行代码注释后,页面便显示文本是 “Hello World”:
vnode = h('div', 'Hello Snabbdom') patch(oldVNode, vnode)
这里我们可以猜测 patch()
函数可以将** VNode** 渲染到页面。更进一步可以理解为,这边第一个执行 patch()
函数为首次渲染,第二次执行 patch()
函数为更新操作。
2. VNode 介绍
这里可能会有小伙伴疑惑,示例中的 VNode 是什么?这里简单解释下:
VNode,该对象用于描述节点的信息,它的全称是虚拟节点(virtual node)。与 “虚拟节点” 相关联的另一个概念是 “虚拟 DOM”,它是我们对由 Vue 组件树建立起来的整个 VNode 树的称呼。“虚拟 DOM” 由 VNode 组成的。 —— 全栈修仙之路 《Vue 3.0 进阶之 VNode 探秘》
其实 VNode 就是一个 JS 对象,在 Snabbdom 中是这么定义 VNode 的类型:
export interface VNode { sel: string | undefined; // selector的缩写 data: VNodeData | undefined; // 下面VNodeData接口的内容 children: Array<VNode | string> | undefined; // 子节点 elm: Node | undefined; // element的缩写,存储了真实的HTMLElement text: string | undefined; // 如果是文本节点,则存储text key: Key | undefined; // 节点的key,在做列表时很有用 } export interface VNodeData { props?: Props attrs?: Attrs class?: Classes style?: VNodeStyle dataset?: Dataset on?: On hero?: Hero attachData?: AttachData hook?: Hooks key?: Key ns?: string // for SVGs fn?: () => VNode // for thunks args?: any[] // for thunks [key: string]: any // for any other 3rd party module }
在 VNode 对象中含描述节点选择器 sel
字段、节点数据 data
字段、节点所包含的子节点 children
字段等。
在这个 demo 中,我们似乎并没有看到模块系统相关的代码,没事,因为这是最简单的示例,下一节会详细介绍。
我们在学习一个函数时,可以重点了解该函数的“入参”和“出参”,大致就能判断该函数的作用。
从这个 demo 主要执行过程可以看出,主要用到有三个函数: init()
/ patch()
/ h()
,它们到底做什么用的呢?我们分析一下 Snabbdom 源码中这三个函数的入参和出参情况:
3. init() 函数分析
init()
函数被定义在 package/init.ts
文件中:
// node_modules/snabbdom/src/package/init.ts export function init (modules: Array<Partial<Module>>, domApi?: DOMAPI) { // 省略其他代码 }
其参数类型如下:
function init(modules: Array<Partial<Module>>, domApi?: DOMAPI): (oldVnode: VNode | Element, vnode: VNode) => VNode export type Module = Partial<{ pre: PreHook create: CreateHook update: UpdateHook destroy: DestroyHook remove: RemoveHook post: PostHook }> export interface DOMAPI { createElement: (tagName: any) => HTMLElement createElementNS: (namespaceURI: string, qualifiedName: string) => Element createTextNode: (text: string) => Text createComment: (text: string) => Comment insertBefore: (parentNode: Node, newNode: Node, referenceNode: Node | null) => void removeChild: (node: Node, child: Node) => void appendChild: (node: Node, child: Node) => void parentNode: (node: Node) => Node | null nextSibling: (node: Node) => Node | null tagName: (elm: Element) => string setTextContent: (node: Node, text: string | null) => void getTextContent: (node: Node) => string | null isElement: (node: Node) => node is Element isText: (node: Node) => node is Text isComment: (node: Node) => node is Comment }
init()
函数接收一个模块数组 modules
和可选的 domApi
对象作为参数,返回一个函数,即 patch()
函数。 domApi
对象的接口包含了很多 DOM 操作的方法。 这里的 modules
参数本文将重点介绍。
4. patch() 函数分析
init()
函数返回了一个 patch()
函数,其类型为:
// node_modules/snabbdom/src/package/init.ts patch(oldVnode: VNode | Element, vnode: VNode) => VNode
patch()
函数接收两个 VNode 对象作为参数,并返回一个新 VNode。
5. h() 函数分析
h()
函数被定义在 package/h.ts
文件中:
// node_modules/snabbdom/src/package/h.ts 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{ // 省略其他代码 }
h()
函数接收多种参数,其中必须有一个 sel
参数,作用是将节点内容挂载到该容器中,并返回一个新 VNode。
6. 小结
通过前面介绍,我们在回过头看看这个 demo 的代码,大致调用流程如下: