三、深入 Snabbdom 模块系统
学习完前面这些基础知识后,我们已经知道 Snabbdom 使用方式,并且知道其中三个核心方法入参出参情况和大致作用,接下来开始看本文核心 Snabbdom 模块系统。
1. Modules 介绍
Snabbdom 模块系统是 Snabbdom 提供的一套可拓展、可灵活组合的模块系统,用来为 Snabbdom 提供操作 VNode 时的各种模块支持,如我们组建需要处理 style 则引入对应的 styleModule,需要处理事件,则引入 eventListenersModule 既可,这样就达到灵活组合,可以支持按需引入的效果。
Snabbdom 模块系统的特点可以概括为:支持按需引入、独立管理、职责单一、方便组合复用、可维护性强。
当然 Snabbdom 模块系统还有其他内置模块:
模块名称 | 模块功能 | 示例代码 |
attributesModule | 为 DOM 元素设置属性,在属性添加和更新时使用 setAttribute 方法。 |
h('a', { attrs: { href: '/foo' } }, 'Go to Foo') |
classModule | 用来动态设置和切换 DOM 元素上的 class 名称。 | h('a', { class: { active: true, selected: false } }, 'Toggle') |
datasetModule | 为 DOM 元素设置自定义数据属性(data- * )。然后可以使用 HTMLElement.dataset 属性访问它们。 |
h('button', { dataset: { action: 'reset' } }, 'Reset') |
eventListenersModule | 为 DOM 元素绑定事件监听器。 | h('div', { on: { click: clickHandler } }) |
propsModule | 为 DOM 元素设置属性,如果同时使用 attributesModule,则会被 attributesModule 覆盖。 | h('a', { props: { href: '/foo' } }, 'Go to Foo') |
styleModule | 为 DOM 元素设置 CSS 属性。 | h('span', {style: { color: '#c0ffee'}}, 'Say my name') |
2. Hooks 介绍
Hooks 也称钩子,是 DOM 节点生命周期的一种方法。Snabbdom 提供丰富的钩子选择。模块既使用钩子来扩展 Snabbdom,也在普通代码中使用钩子,用来在 DOM 节点生命周期中执行任意代码。
这里大致介绍一下所有的 Hooks:
钩子名称 | 触发时机 | 回调参数 |
pre |
patch 阶段开始。 | none |
init |
已添加一个 VNode。 | vnode |
create |
基于 VNode 创建了一个 DOM 元素。 | emptyVnode, vnode |
insert |
一个元素已添加到 DOM 元素中。 | vnode |
prepatch |
一个元素即将进入 patch 阶段。 | oldVnode, vnode |
update |
一个元素开始更新。 | oldVnode, vnode |
postpatch |
一个元素完成 patch 阶段。 | oldVnode, vnode |
destroy |
一个元素直接或间接被删除。 | vnode |
remove |
一个元素直接从 DOM 元素中删除。 | vnode, removeCallback |
post |
patch 阶段结束。 | none |
模块中可以使用这些钩子:pre
, create
, update
, destroy
, remove
, post
。 单个元素可以使用这些钩子:init
, create
, insert
, prepatch
, update
, postpatch
, destroy
, remove
。
Snabbdom 是这么定义钩子的:
// snabbdom/src/package/hooks.ts export type PreHook = () => any export type InitHook = (vNode: VNode) => any export type CreateHook = (emptyVNode: VNode, vNode: VNode) => any export type InsertHook = (vNode: VNode) => any export type PrePatchHook = (oldVNode: VNode, vNode: VNode) => any export type UpdateHook = (oldVNode: VNode, vNode: VNode) => any export type PostPatchHook = (oldVNode: VNode, vNode: VNode) => any export type DestroyHook = (vNode: VNode) => any export type RemoveHook = (vNode: VNode, removeCallback: () => void) => any export type PostHook = () => any export interface Hooks { pre?: PreHook init?: InitHook create?: CreateHook insert?: InsertHook prepatch?: PrePatchHook update?: UpdateHook postpatch?: PostPatchHook destroy?: DestroyHook remove?: RemoveHook post?: PostHook }
接下来我们通过 03-modules.js 文件的示例代码,我们需要样式处理和事件操作,因此引入这两个模块,并进行灵活组合:
// src/03-modules.js import { h } from 'snabbdom/src/package/h' import { init } from 'snabbdom/src/package/init' // 1. 导入模块 import { styleModule } from 'snabbdom/src/package/modules/style' import { eventListenersModule } from 'snabbdom/src/package/modules/eventlisteners' // 2. 注册模块 const patch = init([ styleModule, eventListenersModule ]) // 3. 使用 h() 函数的第二个参数传入模块需要的数据(对象) let vnode = h('div', { style: { backgroundColor: '#4fc08d', color: '#35495d' }, on: { click: eventHandler } }, [ h('h1', 'Hello Snabbdom'), h('p', 'This is p tag') ]) function eventHandler() { console.log('clicked.') } const app = document.getElementById('app') patch(app, vnode)
上面代码中,引入了 styleModule 和 eventListenersModule 两个模块,并且作为参数组合,传入 init()
函数中。 此时我们可以看到页面上显示的内容已经有包含样式,并且点击事件也能正常输出日志 'clicked.'
:
这里我们看下 styleModule 模块源码,把代码精简一下:
// snabbdom/src/package/modules/style.ts function updateStyle (oldVnode: VNode, vnode: VNode): void { // 省略其他代码 } function forceReflow () { // 省略其他代码 } function applyDestroyStyle (vnode: VNode): void { // 省略其他代码 } function applyRemoveStyle (vnode: VNode, rm: () => void): void { // 省略其他代码 } export const styleModule: Module = { pre: forceReflow, create: updateStyle, update: updateStyle, destroy: applyDestroyStyle, remove: applyRemoveStyle }
在看看 eventListenersModule 模块源码:
// snabbdom/src/package/modules/eventlisteners.ts function updateEventListeners (oldVnode: VNode, vnode?: VNode): void { // 省略其他代码 } export const eventListenersModule: Module = { create: updateEventListeners, update: updateEventListeners, destroy: updateEventListeners }
明显可以看出,两个模块返回的都是个对象,并且每个属性为一种钩子,如 pre/create
等,值为对应的处理函数,每个处理函数有统一的入参。
继续看下 styleModule 中,样式是如何绑定上去的。这里分析它的 updateStyle
方法,因为元素创建(create 钩子)和元素更新(update 钩子)阶段都是通过这个方法处理:
// snabbdom/src/package/modules/style.ts function updateStyle (oldVnode: VNode, vnode: VNode): void { var cur: any var name: string var elm = vnode.elm var oldStyle = (oldVnode.data as VNodeData).style var style = (vnode.data as VNodeData).style if (!oldStyle && !style) return if (oldStyle === style) return // 1. 设置新旧 style 默认值 oldStyle = oldStyle || {} style = style || {} var oldHasDel = 'delayed' in oldStyle // 2. 比较新旧 style for (name in oldStyle) { if (!style[name]) { if (name[0] === '-' && name[1] === '-') { (elm as any).style.removeProperty(name) } else { (elm as any).style[name] = '' } } } for (name in style) { cur = style[name] if (name === 'delayed' && style.delayed) { // 省略部分代码 } else if (name !== 'remove' && cur !== oldStyle[name]) { if (name[0] === '-' && name[1] === '-') { (elm as any).style.setProperty(name, cur) } else { // 3. 设置新 style 到元素 (elm as any).style[name] = cur } } } }
3. init() 分析
接着我们看下 init()
函数内部如何处理这些 Module。
首先在 init.ts 文件中,可以看到声明了默认支持的 Hooks 钩子列表:
// snabbdom/src/package/init.ts const hooks: Array<keyof Module> = ['create', 'update', 'remove', 'destroy', 'pre', 'post']
接着看 hooks
是如何使用的:
// snabbdom/src/package/init.ts export function init (modules: Array<Partial<Module>>, domApi?: DOMAPI) { let i: number let j: number const cbs: ModuleHooks = { // 创建 cbs 对象,用于收集 module 中的 hook create: [], update: [], remove: [], destroy: [], pre: [], post: [] } // 收集 module 中的 hook,并保存在 cbs 中 for (i = 0; i < hooks.length; ++i) { cbs[hooks[i]] = [] for (j = 0; j < modules.length; ++j) { const hook = modules[j][hooks[i]] if (hook !== undefined) { (cbs[hooks[i]] as any[]).push(hook) } } } // 省略其他代码,稍后介绍 }
上面代码中,创建 hooks
变量用来声明默认支持的 Hooks 钩子,在 init()
函数中,创建 cbs
对象,通过两层循环,保存每个 module 中的 hook 函数到 cbs
对象的指定钩子中。
通过断点可以看到这是 demo 中,cbs
对象是下面这个样子:
这里 cbs
对象收集了每个 module 中的 Hooks 处理函数,保存到对应 Hooks 数组中。比如这里的 create
钩子中保存了 updateStyle
函数和 updateEventListeners
函数。
到这里, init()
函数已经保存好所有 module 的 Hooks 处理函数,接下来就要看看 init()
函数返回的 patch()
函数,这里面将用到前面保存好的 cbs
对象。
4. patch() 分析
init()
函数中最终返回一个 patch()
函数,这边形成一个闭包,闭包里面可以使用到 init()
函数作用域定义的变量和方法,因此在 patch()
函数中能使用 cbs
对象。
patch()
函数会在不同时机点(可以参照前面的 Hooks 介绍),遍历 cbs
对象中不同 Hooks 处理函数列表。
// snabbdom/src/package/init.ts export function init (modules: Array<Partial<Module>>, domApi?: DOMAPI) { // 省略其他代码 return function patch (oldVnode: VNode | Element, vnode: VNode): VNode { let i: number, elm: Node, parent: Node const insertedVnodeQueue: VNodeQueue = [] for (i = 0; i < cbs.pre.length; ++i) cbs.pre[i]() // [Hooks]遍历 pre Hooks 处理函数列表 if (!isVnode(oldVnode)) { oldVnode = emptyNodeAt(oldVnode) // 当 oldVnode 参数不是 VNode 则创建一个空的 VNode } if (sameVnode(oldVnode, vnode)) { // 当两个 VNode 为同一个 VNode,则进行比较和更新 patchVnode(oldVnode, vnode, insertedVnodeQueue) } else { createElm(vnode, insertedVnodeQueue) // 当两个 VNode 不同,则创建新元素 if (parent !== null) { // 当该 oldVnode 有父节点,则插入该节点,然后移除原来节点 api.insertBefore(parent, vnode.elm!, api.nextSibling(elm)) removeVnodes(parent, [oldVnode], 0, 0) } } for (i = 0; i < cbs.post.length; ++i) cbs.post[i]() // [Hooks]遍历 post Hooks 处理函数列表 return vnode } }
patchVnode()
函数定义如下:
function patchVnode (oldVnode: VNode, vnode: VNode, insertedVnodeQueue: VNodeQueue) { // 省略其他代码 if (vnode.data !== undefined) { for (let i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode) // [Hooks]遍历 update Hooks 处理函数列表 } }
createVnode()
函数定义如下:
function createElm (vnode: VNode, insertedVnodeQueue: VNodeQueue): Node { // 省略其他代码 const sel = vnode.sel if (sel === '!') { // 省略其他代码 } else if (sel !== undefined) { for (i = 0; i < cbs.create.length; ++i) cbs.create[i](emptyNode, vnode) // [Hooks]遍历 create Hooks 处理函数列表 const hook = vnode.data!.hook } return vnode.elm }
removeNodes()
函数定义如下:
function removeVnodes (parentElm: Node,vnodes: VNode[],startIdx: number,endIdx: number): void { // 省略其他代码 for (; startIdx <= endIdx; ++startIdx) { const ch = vnodes[startIdx] if (ch != null) { rm = createRmCb(ch.elm!, listeners) for (let i = 0; i < cbs.remove.length; ++i) cbs.remove[i](ch, rm) // [Hooks]遍历 remove Hooks 处理函数列表 } } }
这部分代码跳转较多,总结一下这个过程,如下图:
四、自定义 Snabbdom 模块
前面我们介绍了 Snabbdom 模块系统是如何收集 Hooks 并保存下来,然后在不同时机点执行不同的 Hooks。
在 Snabbdom 中,所有模块独立在 src/package/modules
下,使用的时候可以灵活组合,也方便做解耦和跨平台,并且所有 Module 返回的对象中每个 Hooks 类型如下:
// snabbdom/src/package/init.ts export type Module = Partial<{ pre: PreHook create: CreateHook update: UpdateHook destroy: DestroyHook remove: RemoveHook post: PostHook }> // snabbdom/src/package/hooks.ts export type PreHook = () => any export type CreateHook = (emptyVNode: VNode, vNode: VNode) => any export type UpdateHook = (oldVNode: VNode, vNode: VNode) => any export type DestroyHook = (vNode: VNode) => any export type RemoveHook = (vNode: VNode, removeCallback: () => void) => any export type PostHook = () => any
因此,如果开发者需要自定义模块,只需实现不同 Hooks 并导出即可。
接下来我们实现一个简单的模块 replaceTagModule,用来将节点文本自动过滤掉 HTML 标签。
1. 初始化代码
考虑到方便调试,我们直接在 node_modules/snabbdom/src/package/modules/
目录中新建 replaceTag.ts 文件,然后写个最简单的 demo 框架:
import { VNode, VNodeData } from '../vnode' import { Module } from './module' const replaceTagPre = () => { console.log("run replaceTagPre!") } const updateReplaceTag = (oldVnode: VNode, vnode: VNode): void => { console.log("run updateReplaceTag!", oldVnode, vnode) } const removeReplaceTag = (vnode: VNode): void => { console.log("run removeReplaceTag!", vnode) } export const replaceTagModule: Module = { pre: replaceTagPre, create: updateReplaceTag, update: updateReplaceTag, remove: removeReplaceTag }
接下来引入到 03-modules.js 代码中,并简化下代码:
import { h } from 'snabbdom/src/package/h' import { init } from 'snabbdom/src/package/init' // 1. 导入模块 import { styleModule } from 'snabbdom/src/package/modules/style' import { eventListenersModule } from 'snabbdom/src/package/modules/eventlisteners' import { replaceTagModule } from 'snabbdom/src/package/modules/replaceTag'; // 2. 注册模块 const patch = init([ styleModule, eventListenersModule, replaceTagModule ]) // 3. 使用 h() 函数的第二个参数传入模块需要的数据(对象) let vnode = h('div', '<h1>Hello Leo</h1>') const app = document.getElementById('app') const oldVNode = patch(app, vnode) let newVNode = h('div', '<div>Hello Leo</div>') patch(oldVNode, newVNode)
刷新浏览器,就可以看到 replaceTagModule 的每个钩子都被正常执行:
2. 实现 updateReplaceTag() 函数
我们删除掉多余代码,接下来实现 updateReplaceTag()
函数,当 vnode 创建和更新时,都会调用该方法。
import { VNode, VNodeData } from '../vnode' import { Module } from './module' const regFunction = str => str && str.replace(/\<|\>|\//g, ""); const updateReplaceTag = (oldVnode: VNode, vnode: VNode): void => { const oldVnodeReplace = regFunction(oldVnode.text); const vnodeReplace = regFunction(vnode.text); if(oldVnodeReplace === vnodeReplace) return; vnode.text = vnodeReplace; } export const replaceTagModule: Module = { create: updateReplaceTag, update: updateReplaceTag, }
在 updateReplaceTag()
函数中,比较新旧 vnode 的文本内容是否一致,如果一致则直接返回,否则将新的 vnode 的替换后的文本设置到 vnode 的 text 属性,完成更新。
其中有个细节:
vnode.text = vnodeReplace;
这里直接对 vnode.text
进行赋值,页面上的内容也随之发生变化。这是因为 vnode
是个响应式对象,通过调用其 setter
方法,会触发响应式更新,这样就实现页面内容更新。
于是我们看到页面内容中的 HTML 标签被清空了。
3. 小结
这个小节中,我们实现一个简单的 replaceTagModule
模块,体验了一下 Snabbdom 模块灵活组合的特点,当我们需要自定义某些模块时,便可以按照 Snabbdom 的模块开发方式,开发自定义模块,然后通过 Snabbdom 的 init()
函数注入模块即可。
我们再回顾一下 Snabbdom 模块系统特点:支持按需引入、独立管理、职责单一、方便组合复用、可维护性强。
五、通用模块生命周期模型
下面我将前面 Snabbdom 的模块系统,抽象为一个通用模块生命周期模型,其中包含三个核心层:
- 模块定义层
在本层可以按照模块开发规范,自定义各种模块。
- 模块应用层
一般是在业务开发层或组件层中,用来导入模块。
- 模块初始化层
一般是在开发的模块系统的插件中,提供初始化函数(init 函数),执行初始化函数会遍历每个 Hooks,并执行对应处理函数列表的每个函数。
抽象后的模型如下:
在使用 Module 的时候就可以灵活组合搭配使用啦,在模块初始化层,就会做好调用。
六、总结
本文主要以 Snabbdom-demo 仓库为学习示例,学习了 Snabbdom 运行流程和 Snabbdom 模块系统的运行流程,还通过手写一个简单的 Snabbdom 模块,带大家领略一下 Snabbdom 模块的魅力,最后为大家总结了一个通用模块插件模型。
大家好好掌握 Snabbdom 对理解 Vue 会很有帮助。