虚拟DOM 之 Snabbdom 二、源码解析(h函数,虚拟DOM对比,Diff算法)
Snabbdom 源码解析
核心源码解析
如何学习源码
- 先宏观了解:学习库的核心执行过程
- 带着目标看源码,比如:
- VNode是如何创建的
- VNode是如何渲染成真实DOM的
- 看源码的过程要不求甚解
- 看源码的过程要围绕核心目标
- 因为一个开源项目的功能会非常的多,代码分支逻辑会非常的多,分支会干扰看源码
- 要先把主线逻辑走通,涉及分支的部分可以先不看
- 这样可以提高源码的阅读速度
- 调试
- 一旦主线逻辑走通,可以写一个小Demo,对代码进行调试,加深理解
- 参考资料
- 在看源码之前,可以看别人写的文章,帮助更好理解,以提升源码阅读效率
vscode看源码快捷键
- 右键-转到定义:快速跳转到定义 或 导入变量的位置
- 当定义在当前文件中,会跳转到定义的位置
- 当定义不在当前文件中,会弹出展示定义的小窗口
- 窗口顶部是定义所在的文件,点击可以快速跳转
- 当操作时鼠标/光标就是定义 或 导入的位置,同样会弹出定义的小窗口
- Ctrl + 鼠标左键:效果同【转到定义】
- F12:效果同【转到定义】
- Alt + 左右方向键:在跳转历史中前进后退
- 可用于查看完定义后,快速回到之前的位置
Snabbdom 的核心
- 使用h()函数创建 Javascript 对象(VNode) 描述真实DOM
- init() 设置模块,创建patch()
- patch()根据diff算法比较新旧两个VNode
- 把变化的内容更新到真实 DOM 树上
Diff 算法
百度百科:
虚拟DOM中采用的算法
把树形结构按照层级分解,只比较同级元素。不同层级的节点只有创建和删除操作。
把列表结构的每个单元添加唯一的key属性,方便比较。
虚拟DOM的目的是实现最小更新。
Diff算法是用于对比虚拟DOM树并更新视图的优化算法。
对比包括两个过程:
- 判断新旧节点是否是相同节点
- 根据对比结果,将差异更新到视图
完整对比一个树结构数据,时间复杂度是O(n^2)。
n是树的层级。
对于两个树结构的变化,若要达到最小更新,首先要对比每个节点是否相同,也就是:
for (var x = 0; x < n; x++ ) {
// 遍历旧节点树的层级:n
var oldVnode = oldTree[x] // 获取该层的某个节点
for (var y = 0; y < n; y++) {
// 遍历新节点树的层级:n^2
var newVnode = newTree[y]
// 判断oldVnode 和 newVnode 是否相同
if (oldVnode == newVnode) {
// ...
} else {
//...
}
}
}
找到差异后还要计算最小转换方式,将差异渲染到视图中,这个操作又会遍历一遍。(这个过程不知道如何表达)
所以使用完整对比方式的时间复杂度为O(n^3)。
Diff算法的背景是对比新旧DOM树,前端操作中一般不会出现节点跨层级移动或修改。
所以Diff算法只需要对比同层级节点即可。
Snabbdom中根据节点对象的属性 key 和 sel是否相同判断两个节点是否相同,根本不需要判断子节点是否相同。省略了n^2的遍历。
- 如果是相同节点,直接更新该节点
- 如果有子节点,将每个子节点作为新的树结构继续对比差异
- 如果不是相同节点,直接替换或删除
所以Diff算法的时间复杂度为O(n),效率高于 完整对比。
Prev Last
A A
/ \ / \
/ \ / \
B D ====> D B
/ \
C C
例如对比上面两个树结构:
- 完整对比
- n1 - 判断A是否相同,会先遍历Prev的所有节点
- n2 - 用Prev的每个节点遍历对比Last的每个节点
- n3 - 找到差异后,在寻找最小更新到视图的方式
- Diff
- n1 - 遍历Prev的每个节点
- 以Snabbdom为例,判断Prev.node 和 Last.node 的 key 和 sel 是否相同
- 根据结果直接执行相应处理
- n1 - 遍历Prev的每个节点
源码目录结构
当前查看Snabbdom@0.7.4版本,查看以安装的node_modules/snabbdom目录:
- dist 存放编译后的结果
- es 存放编译后的结果
- helpers 存放编译后的结果
- modules 存放编译后的结果
- examples 官方示例
- src 存放源码
- 其他文件:
- 配置文件
- 打包后的文件(.ts,.js)
snabbdom使用gulp打包。
查看源码过程主要查看 examples 和 src 目录。
examples
Snabbdom官方总共提供4个示例,两个svg 和 两个列表。
- hero:用于演示自定义模块(与核心流程无关,暂不关心)
- reorder-animation:演示列表和动画效果
- build.js:编译之后的结果
- index.html:网页,在浏览器中打开查看演示效果
- script.js:源码
src
- helpers:
- attachto.ts:定义了一些类型,这些类型在src/vnode.ts中使用(与核心流程无关,暂不关心)
- modules:官方提供的模块
- modules.ts:模块中要使用到的钩子函数
- hero.ts:是 examples/hero 示例中使用的自定义模块
- 其他官方提供的模块,用于设置属性、样式、事件等
- h.ts:定义了h()函数
- hooks.ts:定义了一些钩子函数,与Vue中的生命周期钩子函数类似
- htmldomapi.ts:封装了一些DOM操作
- is.ts:包含两个函数,判断是否是数组 和 判断是否是数字或字符串
- snabbdom.bundle.ts:gulp 打包任务 [bundle:snabbdom] 的入口文件
- 它默认导出包含两个函数的对象 snabbdomBundle:
- patch:通过init()注册了attributes class props style eventlisteners 5个模块的补丁函数(没有注册dataset模块)
- h:h函数
- 它默认导出包含两个函数的对象 snabbdomBundle:
- snabbdom.ts:gulp 打包任务 [bundle:snabbdom:init] 的入口文件,打包的结果(snabbdom.js)就是 snabbdom@0.7.x 版本使用 CommonJS 方式导入的默认文件。
- 内部导出3个函数:
- h
- thunk
- init
- 内部导出3个函数:
- thunk.ts:导出thunk()函数,用于优化。(与核心流程无关,暂不关心)
- tovnode.ts:导出一个tovnode()函数,用于把DOM元素转化成VNode
- vnode.ts:定义了Virtual DOM的结构(ts接口),以及导出一个vnode()函数,用于创建vnode
分析源码主要查看 h.ts、 snabbdom.ts、 vnode.ts。
h() 函数 - h.ts
h() 函数介绍
- h() 函数在使用Vue中的render选项中使用过。
- vue中使用的 h() 函数改造自snabbdom的h()函数,在它大的基础上增加了组件的机制。
- 所以vue中的h()函数的使用方式与snabbdom的 h() 函数一样。
- h() 函数最早见于 hyperscript,它是使用 JavaScript 创建超文本(HyperText:也就是HTML字符串)
- Snabbdom 中的 h() 函数不是用来创建超文本,而是创建 VNode。
- Snabbdom 中的 h() 函数来源于 hyperscript 中的 h() 函数,并增强了它。
函数重载
源码中使用到了函数重载的概念。
概念
函数重载一般指重载函数。
在Java中,重载函数是指多个同名的函数,它们形参的个数或类型不同,调用这个名称的函数时,编译器根据实参和形参的个数和类型,匹配对应的函数。
这就实现了调用同一个(同名)函数,完成不同的功能。
在JavaScript中没有 重载 的概念,但也可以通过判断 实参和形参的个数和类型,执行对应的功能,来实现 重载。
TypeScript中有 重载 ,不过 重载 的实现还是通过代码调整参数。
总结来说,重载指的是对同一个名称的函数进行多次定义,每次定义的形参个数或类型不同,实现一个函数可以完成多个功能。
重载的示意
JavaScript 不支持重载,多次定义同名函数,会被覆盖。
// 定义两个参数个数不同的add函数
function add(a, b, c) {
console.log(a + b + c)
}
// 这里将重新覆盖add函数
function add(a, b) {
console.log(a + b)
}
add(1, 2) // 3
add(1, 2, 3) // 3
实现重载:
function add(a, b, c) {
if (c !== undefined) {
console.log(a + b + c)
} else {
console.log(a + b)
}
}
add(1, 2) // 3
add(1, 2, 3) // 6
h.ts 源码
snabbdom源码使用TypeScript语法。
// 导入依赖的模块
import {vnode, VNode, VNodeData} from './vnode';
// 定义并导出了一些类型
export type VNodes = Array<VNode>;
export type VNodeChildElement = VNode | string | number | undefined | null;
export type ArrayOrElement<T> = T | T[];
export type VNodeChildren = ArrayOrElement<VNodeChildElement>
// 导入依赖的模块
import * as is from './is';
// 辅助函数:添加命名空间
function addNS(data: any, children: VNodes | undefined, sel: string | undefined): void {
// 仅仅是对元素子元素的VNodeData添加ns
data.ns = 'http://www.w3.org/2000/svg';
if (sel !== 'foreignObject' && children !== undefined) {
for (let i = 0; i < children.length; ++i) {
let childData = children[i].data;
if (childData !== undefined) {
addNS(childData, (children[i] as VNode).children as VNodes, children[i].sel);
}
}
}
}
// 定义了 h 函数的4种形式的重载
// 只定义了形式,未定义实现,仅仅用于智能提示
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;
// 真正定义了实现的 h 函数
export function h(sel: any, b?: any, c?: any): VNode {
var data: VNodeData = {}, children: any, text: any, i: number;
if (c !== undefined) {
// 判断传了3个参数的形式
// 该形式下:
// b是VNodeData,存储vnode数据的对象
// c是VNodeChildren
data = b;
// 判断第三个参数
// 数组:子元素列表
// 原始类型(字符串/数字):文本节点
// 包含sel属性的对象:VNode,转化成数组,便于处理
if (is.array(c)) { children = c; }
else if (is.primitive(c)) { text = c; }
else if (c && c.sel) { children = [c]; }
} else if (b !== undefined) {
// 判断只传了2个参数的形式
// 判断第二个参数
// 数组:VNodeChildren
// 原始类型:文本节点
// 包含sel属性的对象:VNode
// 其他情况:VNodeData
if (is.array(b)) { children = b; }
else if (is.primitive(b)) { text = b; }
else if (b && b.sel) { children = [b]; }
else { data = b; }
}
if (children !== undefined) {
// 处理children种的原始值(string/number)
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);
}
// 返回 VNode(通过vnode函数创建的虚拟节点)
return vnode(sel, data, children, text, undefined);
};
// 导出模块
export default h;
函数重载:
在真正定义 h 函数之前,定义了 h 函数4种形式的重载。
TypeScript支持重载,而JavaScript不支持重载,TypeScript最终会被编译成JavaScript,所以这里仅仅定义了4个重载的形式,并没有相应的实现。
这4种重载相应的实现,都在第五次定义的 h 函数中。
定义4种重载的形式的目的是利用编辑器的智能提示。
导出方式:
源码中使用了两种导出 h 函数的方法 :
- export function h() {}
- export default h
这样的目的是为了使用时更方便(主要是snabbdom内部):
- 既可以通过
import { h } from '...'
导入 - 也可以通过
import h from '...'
导入
总结:
h() 函数的作用就是调用 vnode() 函数创建并返回一个虚拟节点。
vnode()函数 - vnode.ts
主要关注VNode接口,和 vnode() 函数如何实现。
接口(interface)是TypeScript的语法,它的作用是约束实现这个接口的对象都拥有相同的指定属性。
VNode 接口:
children和text是互斥的,二者只能存在一个,另一个为undefined
- text:元素间的内容
- 如果元素间只设置文本内容,可以使用text
- children:子节点列表
- 如果元素间想设置元素和文本,需使用children
// 导入依赖的模块
import {Hooks} from './hooks';
import {AttachData} from './helpers/attachto'
import {VNodeStyle} from './modules/style'
import {On} from './modules/eventlisteners'
import {Attrs} from './modules/attributes'
import {Classes} from './modules/class'
import {Props} from './modules/props'
import {Dataset} from './modules/dataset'
import {Hero} from './modules/hero'
// 定义并导出了3种类型:1个type 和 2个接口
export type Key = string | number;
// 接口:约束实现这个接口类型的对象,都拥有相同的属性
// 虚拟DOM类型
export interface VNode {
// selector 选择器
sel: string | undefined;
// 节点数据:属性/样式/事件等,它的类型由VNodeData接口定义
data: VNodeData | undefined;
// 子节点,和 text 互斥
children: Array<VNode | string> | undefined;
// element:存储VNode转化的真实DOM
elm: Node | undefined;
// 节点种的内容,和 children 互斥
text: string | undefined;
// 用于优化
key: Key | undefined;
}
// 节点数据类型:?代表可选
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?: Array<any>; // for thunks
[key: string]: any; // for any other 3rd party module
}
// 定义vnode函数,创建vnode,它是一个VNode对象
export function vnode(sel: string | undefined,
data: any | undefined,
children: Array<VNode | string> | undefined,
text: string | undefined,
elm: Element | Text | undefined): VNode {
// VNode类型的key属性,由data传递
let key = data === undefined ? undefined : data.key;
// 返回与VNode类型匹配的对象,这个对象用于描述虚拟节点
return {sel, data, children, text, elm, key};
}
// 导出vnode函数
export default vnode;
VNode 渲染真实 DOM
源码在 src/snabbdom.ts 文件中,核心函数是 patch(oldVnode, newVnode),它是整个Snabbdom的核心,俗称“打补丁”。
patch 把新节点中变化的内容渲染到真实DOM,最后返回新节点作为下一次处理的旧节点。
patch 的整体过程
- 首先对比新旧VNode是否是相同节点(判断节点的 key 和 sel 相同)
- key 是节点的唯一值
- sel 是节点的选择器
- 如果不是相同的节点,不用对比,直接替换之前的内容,重新渲染。
- 如果是相同的节点,再判断新的 VNode 是否有text属性
- 如果newVnode有text属性,并且与oldVnode的text不同,直接更新文本内容
- 如果newVnode有children属性,判断子节点是否有变化。
- 判断子节点的过程使用的是 diff 算法
- diff 过程只会进行同层级比较
- 因为DOM操作中,很少是跨层级的移动DOM元素,所以Virtual DOM 只会对同一层级的元素进行对比,这也是Snabbdom中对diff算法优化的一部分(React / Vue 同理)。
- 由于不需要跨层级比较,效率更高,时间复杂度(n是oldVnode节点数量):
- 跨层级完全比较:O(n^3)
- 2层对比差异,1层计算最小转换方式
- 同层级比较:O(n)
- 跨层级完全比较:O(n^3)
init() 函数 - snabbdom.ts
init() 函数内部返回了 patch() 函数,所以学习 patch() 函数之前,需要看看 init() 函数内部做了哪些工作。
在函数内部返回一个函数,被称为高阶函数。
Vue中旧使用了很多高阶函数。
高阶函数的好处是为了让(返回的)函数使用更方便:
- 不需要每次调用都传入相同的参数或定义相同的配置
- 返回的函数内部使用了外部函数中的参数,形成了闭包,一方面不需要再次传递这些参数,另一方面内存中这些数据只需存储一份即可。
Snabbdom入口函数init生成patch函数,在init函数内部缓存了两个参数,即在返回的patch函数中可以通过闭包访问到init中初始化的模块和DOM操作的api。
// snabbdom.ts
/* global module, document, Node */
// 导入依赖的模块(接口类型 和 方法)
import {Module} from './modules/module';
import {Hooks} from './hooks';
import vnode, {VNode, VNodeData, Key} from './vnode';
import * as is from './is';
import htmlDomApi, {DOMAPI} from './htmldomapi';
// 定义了一些类型 和 辅助函数
function isUndef(s: any): boolean { return s === undefined; }
function isDef(s: any): boolean { return s !== undefined; }
type VNodeQueue = Array<VNode>;
const emptyNode = vnode('', {}, [], undefined, undefined);
function sameVnode(vnode1: VNode, vnode2: VNode): boolean {
return vnode1.key === vnode2.key && vnode1.sel === vnode2.sel;
}
function isVnode(vnode: any): vnode is VNode {
return vnode.sel !== undefined;
}
type KeyToIndexMap = {[key: string]: number};
type ArraysOf<T> = {
[K in keyof T]: (T[K])[];
}
type ModuleHooks = ArraysOf<Module>;
function createKeyToOldIdx(children: Array<VNode>, beginIdx: number, endIdx: number): KeyToIndexMap {
// 省略的代码
}
// 创建一个常量,存放了钩子函数的名字
const hooks: (keyof Module)[] = ['create', 'update', 'remove', 'destroy', 'pre', 'post'];
// 导出 h thunk init 3个方法
export {h} from './h';
export {thunk} from './thunk';
/**
* 定义并导出 init 函数
* @param modules 模块:处理样式、属性、事件等
* @param domApi (可选)DOM操作的方法
*/
export function init(modules: Array<Partial<Module>>, domApi?: DOMAPI) {
let i: number, j: number, cbs = ({} as ModuleHooks);
// 初始化转换虚拟节点的 API
// 默认是htmlDomApi对象,该对象封装了一些DOM操作的方法
// 如将虚拟DOM转化为真实DOM,删除、添加、插入DOM等
// 如果想把虚拟DOM转化为 HTML 字符串 或者 转化成其他类型的内容
// 可以通过传递 domApi, 传入一些自定义操作,把虚拟DOM转化成具体想要的内容
const api: DOMAPI = domApi !== undefined ? domApi : htmlDomApi;
// 循环:
// 把传入的所有模块的钩子函数,统一存储到 cbs(callbacks) 对象中
// 将同一钩子的函数存储在一起,方便统一调用
// cbs.create = [], cbs.update = []...
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 Array<any>).push(hook);
}
}
}
function emptyNodeAt(elm: Element) {
// 省略的代码
}
function createRmCb(childElm: Node, listeners: number) {
// 省略的代码
}
function createElm(vnode: VNode, insertedVnodeQueue: VNodeQueue): Node {
// 省略的代码
}
function addVnodes(parentElm: Node,
before: Node | null,
vnodes: Array<VNode>,
startIdx: number,
endIdx: number,
insertedVnodeQueue: VNodeQueue) {
// 省略的代码
}
function invokeDestroyHook(vnode: VNode) {
// 省略的代码
}
function removeVnodes(parentElm: Node,
vnodes: Array<VNode>,
startIdx: number,
endIdx: number): void {
// 省略的代码
}
function updateChildren(parentElm: Node,
oldCh: Array<VNode>,
newCh: Array<VNode>,
insertedVnodeQueue: VNodeQueue) {
// 省略的代码
}
function patchVnode(oldVnode: VNode, vnode: VNode, insertedVnodeQueue: VNodeQueue) {
// 省略的代码
}
// init 内部返回 patch 函数
return function patch(oldVnode: VNode | Element, vnode: VNode): VNode {
// 省略的代码
return vnode;
};
}
patch() 函数 - snabbdom.ts
patch()函数是Snabbdom的核心,它的作用是对比两个vnode,把vnode的差异渲染到真实DOM,并返回新的vnode。
// init 内部返回 patch 函数
return function patch(oldVnode: VNode | Element, vnode: VNode): VNode {
let i: number, elm: Node, parent: Node;
// 定义一个常量:存储新插入节点的队列,为了方便触发这些节点上设置的钩子函数
const insertedVnodeQueue: VNodeQueue = [];
// 执行所有模块的 pre 钩子函数
// pre:预处理,处理节点之前先执行的内容
for (i = 0; i < cbs.pre.length; ++i) cbs.pre[i]();
// oldVnode 可以是两种类型: VNode | Element
// 判断 oldVnode 不是 VNode(那就是Element)
if (!isVnode(oldVnode)) {
// 将其转化成 空的VNode
oldVnode = emptyNodeAt(oldVnode);
}
// 判断是否是相同的节点(key和sel相同)
if (sameVnode(oldVnode, vnode)) {
// 相同节点
// patchVnode 找节点的差异并更新 DOM
patchVnode(oldVnode, vnode, insertedVnodeQueue);
} else {
// 不是相同节点:用新节点创建真实DOM,插入旧节点前面,然后删除就旧节点
// 获取当前 DOM 元素
elm = oldVnode.elm as Node;
// 获取当前 DOM 元素的父节点
parent = api.parentNode(elm);
// 调用createElm:
// 1. 将 vnode 转化成真实DOM 并存储到 vnode.elm 属性上
// 2. 触发 init/create 钩子函数
// 3. 将新插入的节点(vnode)添加到队列(insertedVnodeQueue)
createElm(vnode, insertedVnodeQueue);
if (parent !== null) {
// 如果存在父节点,在旧节点位置之后插入新节点对应的 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]();
return vnode;
};
调试 patch()
使用一开始的代码演示调试patch执行过程,调用patch的位置打断点。
调试中遇到一个小问题:
由于查看项目中的node_modules/snabbdom源码时,直接将注释写在了文件中。
打包调试时,第一次点击F11本应进入patch函数内部,但是缺进入了patchVnode,并且直到执行结束也没有进入patch函数。
而且在patch函数内部无法打断点(行号为灰色)。
在网上查找的解决方法说,左下角出现了{}
图标,说明调试工具将这个文件认为是压缩过的,所以不能断点调试。
点击{}
重新格式化代码即可打断点。
但是尝试后发现会新打开一个:formatted
的文件,在里面确实可以打断点,但依然没有进入patch函数内部。
回想自己做的事情,只有添加了注释,于是将源码恢复最初的样子,再次运行,可以正常调试。
所以问题出在添加的注释上,而调试工具中查看源码是通过Source Map。
Source Map存储的是代码行列的映射。
所以问题的原因就是在源文件增加的注释影响了行的位置,但是map文件使用的是模块打包好的,所以在调试时定位位置错误。
于是尝试恢复注释,重新打包生成map文件,再次调试就正常了。
createElm() 函数
在 patch() 函数内部调用了3个复杂的函数:createElm()、patchVnode()、removeVnodes()。
createElm() 函数的作用:
- 执行用户设置的 init 钩子函数
- 把VNode转换成对应的DOM元素(但是并不负责渲染到视图,渲染视图是调用的api.insetBefore),并存储到VNode对象的elm属性上
sel
为!
时:创建注释节点- 使用的document.createComment方法
sel
不为空时:- 创建对应的 DOM 对象
- 触发模块的钩子函数 create
- createElm 创建所有子节点对应的 DOM 对象
- 触发用户的钩子函数 create
- 如果 vnode 有 insert 钩子函数,追加vnode到队列
sel
为空时:创建文本节点
- 返回创建好的DOM元素
function createElm(vnode: VNode, insertedVnodeQueue: VNodeQueue): Node {
let i: any, data = vnode.data;
if (data !== undefined) {
// 执行用户设置的 init 钩子函数
if (isDef(i = data.hook) && isDef(i = i.init)) {
i(vnode);
data = vnode.data;
}
}
// 把 vnode 转换成 真实DOM对象,并存储到vnode的elm属性上(没有渲染到页面)
let children = vnode.children, sel = vnode.sel;
if (sel === '!') {
// 创建注释节点
if (isUndef(vnode.text)) {
vnode.text = '';
}
vnode.elm = api.createComment(vnode.text as string);
} else if (sel !== undefined) {
// 选择器不为空
// 解析选择器:
// 解析的顺序默认把id选择器放在了class选择器前面
// 所以sel传值的时候id应该在class前面,否则会出问题
const hashIdx = sel.indexOf('#');
const dotIdx = sel.indexOf('.', hashIdx);
const hash = hashIdx > 0 ? hashIdx : sel.length;
const dot = dotIdx > 0 ? dotIdx : sel.length;
const tag = hashIdx !== -1 || dotIdx !== -1 ? sel.slice(0, Math.min(hash, dot)) : sel;
// 创建普通标签 或 带有命名空间(ns)的标签
const elm = vnode.elm = isDef(data) && isDef(i = (data as VNodeData).ns) ? api.createElementNS(i, tag)
: api.createElement(tag);
// 设置id
if (hash < dot) elm.setAttribute('id', sel.slice(hash + 1, dot));
// 设置class
if (dotIdx > 0) elm.setAttribute('class', sel.slice(dot + 1).replace(/\./g, ' '));
// 执行模块的 create 钩子函数
for (i = 0; i < cbs.create.length; ++i) cbs.create[i](emptyNode, vnode);
if (is.array(children)) {
// 如果 vnode 中有子节点,创建子 vnode 对应的 DOM 元素,并追加到 DOM 树上
for (i = 0; i < children.length; ++i) {
const ch = children[i];
if (ch != null) {
api.appendChild(elm, createElm(ch as VNode, insertedVnodeQueue));
}
}
} else if (is.primitive(vnode.text)) {
// 如果是原始值(string/number),表示文本内容,则创建文本节点,并追加到 DOM 树上
api.appendChild(elm, api.createTextNode(vnode.text));
}
// 获取钩子函数对象
i = (vnode.data as VNodeData).hook; // Reuse variable
if (isDef(i)) {
// 执行用户传入的钩子 create
if (i.create) i.create(emptyNode, vnode);
// 判断如果有 insert 钩子函数,就把 vnode 添加到队列中,为后续执行 insert 钩子做准备
// patch函数在最后负责执行队列中 vnode 的insert钩子函数
if (i.insert) insertedVnodeQueue.push(vnode);
}
} else {
// 选择器为空,表示是文本节点
vnode.elm = api.createTextNode(vnode.text as string);
}
// 返回创建好的DOM元素
return vnode.elm;
}
调试 createElm
演示如何传入用户定义的钩子函数
let vnode = h(
'div#container.cls',
{
hook: {
init(vnode) {
console.log(vnode.elm)
},
create(emptyVnode, vnode) {
console.log(vnode.elm)
}
}
},
'hello world!'
)
addVnodes 和 removeVnodes
- removeNodes():批量删除节点
- addVnodes():批量添加节点
// 批量添加节点
function addVnodes(parentElm: Node, // 父节点
before: Node | null, // 参考节点,会插入到该节点之前
vnodes: Array<VNode>, // 添加的子节点
startIdx: number,
endIdx: number,
insertedVnodeQueue: VNodeQueue) {
// 循环遍历范围内的节点
for (; startIdx <= endIdx; ++startIdx) {
const ch = vnodes[startIdx];
if (ch != null) {
// 如果节点不为空,创建真实DOM,插入到参考节点前
api.insertBefore(parentElm, createElm(ch, insertedVnodeQueue), before);
}
}
}
// 调用模块、节点和子节点的 destroy 钩子函数
function invokeDestroyHook(vnode: VNode) {
let i: any, j: number, data = vnode.data;
if (data !== undefined) {
// 执行用户设置的 destroy 钩子函数
if (isDef(i = data.hook) && isDef(i = i.destroy)) i(vnode);
// 执行模块的 destroy 钩子函数
for (i = 0; i < cbs.destroy.length; ++i) cbs.destroy[i](vnode);
// 如果有子节点并且不是原始值,递归调用invokeDestroyHook,执行 destroy 钩子函数
if (vnode.children !== undefined) {
for (j = 0; j < vnode.children.length; ++j) {
i = vnode.children[j];
if (i != null && typeof i !== "string") {
invokeDestroyHook(i);
}
}
}
}
}
// 批量删除节点
function removeVnodes(parentElm: Node,
vnodes: Array<VNode>,
startIdx: number,
endIdx: number): void {
// 循环vnodes数组中指定范围的每一个元素
for (; startIdx <= endIdx; ++startIdx) {
// ch获取当前遍历的元素
let i: any, listeners: number, rm: () => void, ch = vnodes[startIdx];
// 判断元素为null不做任何处理
if (ch != null) {
if (isDef(ch.sel)) {
// 如果是元素节点
// 执行 destroy 钩子函数
// invokeDestroyHook 会执行当前节点及所有子节点的 destroy 钩子函数
invokeDestroyHook(ch);
// 获取模块中 remove 钩子函数的个数并+1
// 它的作用是在模块的remove钩子函数全部执行完后执行删除元素,防止多次删除元素
listeners = cbs.remove.length + 1;
// createRmCb也是一个高阶函数,创建并返回一个用于删除元素的回调函数
rm = createRmCb(ch.elm as Node, listeners);
// 执行模块的 remove 钩子函数
// 这里rm不会被调用
for (i = 0; i < cbs.remove.length; ++i) cbs.remove[i](ch, rm);
// 这里rm最终会被调用
if (isDef(i = ch.data) && isDef(i = i.hook) && isDef(i = i.remove)) {
// 获取并调用 用户设置 的 remove 钩子函数
i(ch, rm);
} else {
// 如果用户没有设置 remove 钩子函数,直接调用删除元素的回调函数
rm();
}
} else { // Text node
// 如果是文本节点,直接调用删除元素的方法
api.removeChild(parentElm, ch.elm as Node);
}
}
}
}
patchVnode() 函数
patchVnode(oldVnode, newVnode) 函数的作用是对比新旧两个节点,更新它们的差异。
整体执行过程:
- 触发 prepatch 钩子函数(在对比之前)
- 判断新旧节点是否相同
- 如果相同,直接返回,什么也不做
- 触发 update 钩子函数(在对比之前)
- prepatch 和 update 都是在对比之前执行,区别是
- update 在判断新旧节点不同时才会执行
- prepatch 无论新旧节点是否不同都会执行
- prepatch 和 update 都是在对比之前执行,区别是
- 对比节点,判断:
- newVnode 没有 text 属性,继续判断
- newVnode 和 oldVnode 都有 children
- 并且 children 不相等,调用updateChildren()函数对比差异,进行更新。
- newVnode 有children,oldVnode 没有
- 首先判断当前DOM是否有文本内容(oldVnode 有 text),有的话清空文本内容
- 然后调用 addVnodes() 批量添加newVnode的children
- newVnode 没有 children,oldVnode 有
- 说明新节点是个空节点,直接清空元素内容,由于有子节点,所以调用 removeVnodes() 移除所有子元素
- newVnode 和 oldVnode 都没有 childre,oldVnode 有 text
- 说明新节点是个空节点,当前DOM元素只有文本内容,使用 setTextContent 清空文本即可
- newVnode 和 oldVnode 都有 children
- newVnode 有 text 属性
- 判断当前DOM是否有子元素,如果有先调用 removeVnodes() 移除全部
- 最后调用 setTextContent 设置新的文本内容
- newVnode 没有 text 属性,继续判断
- 触发 postpatch 钩子函数(在对比之后)
// patch函数内部调用patchVnode之前会判断心就节点是否相同(key和sel相同)
// 如果相同才会调用patchVnode,对比二者的差异
function patchVnode(oldVnode: VNode, vnode: VNode, insertedVnodeQueue: VNodeQueue) {
let i: any, hook: any;
// 首先执行用户设置的 prepatch 钩子函数
if (isDef(i = vnode.data) && isDef(hook = i.hook) && isDef(i = hook.prepatch)) {
i(oldVnode, vnode);
}
// 获取旧节点对应的DOM元素
const elm = vnode.elm = (oldVnode.elm as Node);
// 获取旧节点的子节点
let oldCh = oldVnode.children;
// 获取新节点的子节点
let ch = vnode.children;
// 如果新旧节点相同,什么也不做
if (oldVnode === vnode) return;
// 暂未发现 vnode.data === undefined 的场景
if (vnode.data !== undefined) {
// 执行模块的 update 函数
for (i = 0; i < cbs.update.length; ++i) cbs.update[i](oldVnode, vnode);
// 执行用户设置的 update 函数
i = vnode.data.hook;
if (isDef(i) && isDef(i = i.update)) i(oldVnode, vnode);
}
if (isUndef(vnode.text)) {
// 如果 新节点没有 text
if (isDef(oldCh) && isDef(ch)) {
// 新旧节点都有children
// 但是二者的子节点不同,调用 updateChildren (使用diff算法)对比更新
if (oldCh !== ch) updateChildren(elm, oldCh as Array<VNode>, ch as Array<VNode>, insertedVnodeQueue);
} else if (isDef(ch)) {
// 新节点有children,旧节点没有
// 先清空DOM元素的内容(只有文本)
if (isDef(oldVnode.text)) api.setTextContent(elm, '');
// 批量添加新节点的children
addVnodes(elm, null, ch as Array<VNode>, 0, (ch as Array<VNode>).length - 1, insertedVnodeQueue);
} else if (isDef(oldCh)) {
// 旧节点有children,新节点没有
// 说明新节点是个空元素,直接移除所有子元素
removeVnodes(elm, oldCh as Array<VNode>, 0, (oldCh as Array<VNode>).length - 1);
} else if (isDef(oldVnode.text)) {
// 旧节点有 text
// 仅清空文本内容即可
api.setTextContent(elm, '');
}
} else if (oldVnode.text !== vnode.text) {
// 如果 新节点有 text, 但是与旧节点的text不同
// 只需更新节点中的内容 为 新的文本内容
if (isDef(oldCh)) {
// 如果旧节点有children
// 移除所有子节点
removeVnodes(elm, oldCh as Array<VNode>, 0, (oldCh as Array<VNode>).length - 1);
}
// 更新文本内容
api.setTextContent(elm, vnode.text as string);
}
// 最后执行用户设置的 postpatch 钩子函数
if (isDef(hook) && isDef(i = hook.postpatch)) {
i(oldVnode, vnode);
}
}
updateChildren() 整体分析
updateChildren() 是整个 Virtual DOM 的核心,内部使用 diff 算法,对比新旧节点的 children,更新DOM 。
同级别对比:
- 要完全对比两棵树的差异,可以取第一棵树的每个节点依次和第二棵树的每个节点比较,但是这样的时间复杂度为 O(n^3)。
- 在DOM操作的时候,很少会把一个父节点 移动/更新 到某个子节点。
- 因此只需要找 同级别 的子节点 依次比较,然后再找下一级别的节点比较,这样算法的时间复杂度为 O(n)。Snabbdomde的diff算法就是这样进行比较的。
执行过程:
- 对比环节:遍历节点对比,根据对比节点的结果,进行以下处理
- 更新节点
- 移动节点
- 插入新节点
- 批量处理环节:遍历在新旧节点的索引相遇后结束,所以对比处理完,仍然有未遍历到的节点,对这些节点执行批量增删处理,并渲染到视图:
- 批量添加 newVnode 中未遍历的节点(表示是新节点)
- 比如只在尾部添加节点的情况
- 批量移除 oldVnode 中未遍历的节点(表示是要删除的节点)
- 批量添加 newVnode 中未遍历的节点(表示是新节点)
对比场景:
- 在进行同级别比较的时候,首先会对新旧节点数组的开始和结尾节点设置标记索引(startIdx、endIdx),遍历的过程中移动索引。
- 索引指向的节点分别称为 开始节点 和 结束节点
- 遍历条件是:新旧节点的索引都未交叉
- oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx
- 首先判断节点是否为空,因为在场景Scene5处理节点时,可能会把节点设置为undefined。
- 如果节点为空,直接更新索引
- 开始和结束节点 总共有五种比较的场景:
- Scene1:oldStartVnode / newStartVnode(旧开始节点 / 新开始节点)
- Scene2:oldEndVnode / newEndVnode(旧结束节点 / 新结束节点)
- Scene3:oldStartVnode / newEndVnode(旧开始节点 / 新结束节点)
- Scene4:oldEndVnode / newStartVnode(旧结束节点 / 新开始节点)
- Scene5:key和sel不能全部匹配,则匹配key,按此规则依次遍历oldStartVnode - oldEndVnode,对比newStartVnode
对比顺序:
5种场景按顺序依次比较节点是否相同(key 和 sel 相同)
- 如果相同则进行 移动/更新,并更新索引
- 如果不相同,则比较下一个场景
- Scence 5 是开始结束节点无法匹配时执行的判断规则
更新移动:
对节点的 更新 / 移动 指的是对 DOM 的操作。
获取新旧节点对比后的差异,直接通过 setTextContent 和 insertBefore 更新视图。
oldVnode只用于对比,并未被改动,所以patch方法最后返回的是 newVnode。
根据与 oldVnode 对比的结果进行处理:
- 对相同的节点(key 和 sel相同),进行数据更新、位置移动
- 对不同的节点,进行移除、新增
insertBefore方法:
Snabbdom中实现移动节点 和 插入新节点,使用的是 DOM APIs (insertBefore)方法。
insertBefore 被 父节点 调用,接收两个参数:
- 参数1:要插入的dom元素
- 要插入的元素如果是页面中已有的元素,而不是新建的或已有元素的副本,则会移动这个已有元素
- 也就是会更新 parentNode.children的顺序
- patchVnode 使用 Vnode中的 elm 作为insertBefore的参数,它只是DOM元素的指向,所以VNode中的children并没有被影响,idx 可以继续遍历。
- 参数2:插入到哪个元素之前
- snabbdom中使用endVnode.nextSibling获取末尾(而不是最后一个节点),实现向父节点末尾插入元素
以下介绍的对节点的操作都是指的 oldVnode 对象。
对比场景单独介绍:
Scene1 和 Scene2 是开始节点的新旧对比 和 结束节点的新旧对比,两种情况类似:
Scene1:
- 首先对比 oldStartVnode 和 newStartVnode 是 sameVnode(相同节点:key 和 sel相同)
- 如果相同就调用 patchVnode() 对比和更新节点
- 将索引后移:oldStartIdx++ / newStartIdx++
- 继续对比下一组
- 如果不相同,则判断 Scene2 场景
- 如果相同就调用 patchVnode() 对比和更新节点
Scene2 同 Scene1 类似,只不过方向是从后往前:
- 对比 oldEndVnode 和 newEndVnode 是 sameVnode
- 如果相同,就调用 patcheVnode 对比和更新节点
- 将索引前移:oldStartIdx-- / newStartIdx–
- 继续对比下一组
- 如果不同,则判断 Scene3 场景
- 如果相同,就调用 patcheVnode 对比和更新节点
Scene3:
- 对比 oldStartVnode 和 newEndVnode 是 sameVnode
- 如果相同,从节点对象中获取对应的DOM元素,将其右移:
- 先调用 patchVnode 对比和更新节点
- 然后调用 domApi.insertBefore 将 oldStartVnode 表示的DOM元素(elm) 移动到 oldEndVnode 表示的DOM元素(elm) 后面
- insertBefore移动已有元素时,自己仍然占位,所以获取新位置时应该是 oldEndIdx+1 的位置,即oldEndVnode 后面。
- 注意由于索引会更新,所以此时的startVnode和endVnode不一定就是真正的首尾节点。而应该是start/end索引指向的节点。
- 更新索引:oldStartIdx++ / newEndIdx–
- 如果不相同,则判断 Scene4 场景
- 如果相同,从节点对象中获取对应的DOM元素,将其右移:
Scene4:
- 对比 oldEndVnode 和 newStartVnode 是 sameVnode
- 如果相同,将DOM元素左移:
- 先调用 patchVnode 对比和更新节点
- 然后调用 domApi.insertBefore 将 oldStartVnode.elm 移动到 oldStartVnode.elm 前面
- 位置的原理参考Scene3
- 更新索引:oldEndIdx-- / newStartIdx++
- 如果不同免责判断 Scene5 场景
- 如果相同,将DOM元素左移:
Scene5:
- 获取oldStartVnode - oldEndVnode 所有包含key属性的节点的key和索引位置,存储在oldKeyToIdx对象中。
- 使用 newStartVnode 的 key 在 oldKeyToIdx 中查找相同的 key
- 如果没找到,说明 newStartVnode 是新节点
- 调用 createElm 创建新节点对应的真实DOM
- 调用 domApi.insertBefore,将新建的DOM,插入到 oldStartVnode.elm 前面
- 更新索引:newStartIdx++
- 如果找到了,获取这个节点并存储到 elmToMove
- 判断 elmToMove 和 newStartVnode 的 sel 是否相同
- 如果不相同,说明节点被修改了
- 调用 createElm 重新创建新节点对应的真实DOM
- 调用 domApi.insertBefore,将新建的DOM,插入到 oldStartVnode.elm 前面
- 如果相同,表示是sameVnode,同 Scene4 同理
- 先调用 patchVnode 对比和更新节点
- 将原位置的节点设为undefined(避免被遍历到时再参与对比,设置为undefined而不是移除它,是为了占位,保证索引不会被影响)
- 然后调用 domApi.insertBefore 将 elmToMove 插入到 oldStartVnode.elm 前面
- 如果不相同,说明节点被修改了
- 更新索引:newStartIdx++
- 判断 elmToMove 和 newStartVnode 的 sel 是否相同
- 如果没找到,说明 newStartVnode 是新节点
- 使用 newStartVnode 的 key 在 oldKeyToIdx 中查找相同的 key
- 执行完进入批量处理环节
批量处理环节:
遍历对比完 oldVnode,最后进行批量判断处理环境。
- 判断停止遍历的场景:
- oldVnode 遍历完:oldStartIdx > oldEndIdex
- 说明 newStartVnode - newEndVnode 是未参与遍历的新节点
- 调用 addVnodes 将它们插入 索引位置 newEndIdx +1 的dom元素前
- newVnode 遍历完:else(newStartIdx > newEndIdx)
- 说明 oldStartVnode - oldEndVnode 是未参与遍历的要被删除的节点
- 调用 removeNodes 将它们从视图中移除
- oldVnode 遍历完:oldStartIdx > oldEndIdex
function updateChildren(parentElm: Node,
oldCh: Array<VNode>,
newCh: Array<VNode>,
insertedVnodeQueue: VNodeQueue) {
// 创建索引 和 索引指向的vnode
let oldStartIdx = 0, newStartIdx = 0;
let oldEndIdx = oldCh.length - 1;
let oldStartVnode = oldCh[0];
let oldEndVnode = oldCh[oldEndIdx];
let newEndIdx = newCh.length - 1;
let newStartVnode = newCh[0];
let newEndVnode = newCh[newEndIdx];
// 一些其他辅助变量
let oldKeyToIdx: any;
let idxInOld: number;
let elmToMove: VNode;
let before: any;
// 循环遍历
// 结束条件:旧节点数组先遍历完成 或者 新节点数组先遍历完成
while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
// 索引变化后,可能会把节点设置为空(实际设置为undefined,所以这里是双等号)
// 设置为空的场景参考文档 Scene5
// 节点为空移动索引
if (oldStartVnode == null) {
oldStartVnode = oldCh[++oldStartIdx]; // Vnode might have been moved left
} else if (oldEndVnode == null) {
oldEndVnode = oldCh[--oldEndIdx];
} else if (newStartVnode == null) {
newStartVnode = newCh[++newStartIdx];
} else if (newEndVnode == null) {
newEndVnode = newCh[--newEndIdx];
// 场景 Scene1 - Scene4
} else if (sameVnode(oldStartVnode, newStartVnode)) {
patchVnode(oldStartVnode, newStartVnode, insertedVnodeQueue);
oldStartVnode = oldCh[++oldStartIdx];
newStartVnode = newCh[++newStartIdx];
} else if (sameVnode(oldEndVnode, newEndVnode)) {
patchVnode(oldEndVnode, newEndVnode, insertedVnodeQueue);
oldEndVnode = oldCh[--oldEndIdx];
newEndVnode = newCh[--newEndIdx];
} else if (sameVnode(oldStartVnode, newEndVnode)) { // Vnode moved right
patchVnode(oldStartVnode, newEndVnode, insertedVnodeQueue);
api.insertBefore(parentElm, oldStartVnode.elm as Node, api.nextSibling(oldEndVnode.elm as Node));
oldStartVnode = oldCh[++oldStartIdx];
newEndVnode = newCh[--newEndIdx];
} else if (sameVnode(oldEndVnode, newStartVnode)) { // Vnode moved left
patchVnode(oldEndVnode, newStartVnode, insertedVnodeQueue);
api.insertBefore(parentElm, oldEndVnode.elm as Node, oldStartVnode.elm as Node);
oldEndVnode = oldCh[--oldEndIdx];
newStartVnode = newCh[++newStartIdx];
// 场景 Scene5
// 新旧开始节点和结束节点都不相同
} else {
// 获取旧节点开始结束之间的所有节点的key和位置,组成键值对对象
// 使用 newStartVnode 的 key 在旧节点键值对中寻找相同 key 的节点
if (oldKeyToIdx === undefined) {
oldKeyToIdx = createKeyToOldIdx(oldCh, oldStartIdx, oldEndIdx);
}
// 寻找与newStartVnode相同key的节点
idxInOld = oldKeyToIdx[newStartVnode.key as string];
if (isUndef(idxInOld)) { // New element
// 找不到,说明新节点是新增节点
api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm as Node);
newStartVnode = newCh[++newStartIdx];
} else {
// 找到后再判断 sel
elmToMove = oldCh[idxInOld];
// sel 不同,说明是不同的节点,按照新增节点处理
// sel 相同,说明是相同节点,按照 sameVnode 处理,类似Scene3和Scene4
if (elmToMove.sel !== newStartVnode.sel) {
api.insertBefore(parentElm, createElm(newStartVnode, insertedVnodeQueue), oldStartVnode.elm as Node);
} else {
patchVnode(elmToMove, newStartVnode, insertedVnodeQueue);
oldCh[idxInOld] = undefined as any;
api.insertBefore(parentElm, (elmToMove.elm as Node), oldStartVnode.elm as Node);
}
newStartVnode = newCh[++newStartIdx];
}
}
}
// 批量进行添加未遍历的新节点和删除未遍历的旧节点表示的DOM元素
if (oldStartIdx <= oldEndIdx || newStartIdx <= newEndIdx) {
if (oldStartIdx > oldEndIdx) {
// 如果就节点数组先遍历完,说明有未遍历的新节点
// 把剩余的新节点表示的DOM元素插入
before = newCh[newEndIdx+1] == null ? null : newCh[newEndIdx+1].elm;
addVnodes(parentElm, before, newCh, newStartIdx, newEndIdx, insertedVnodeQueue);
} else {
// 如果新节点数组先遍历完,说明有未遍历的旧节点
// 把剩余的旧节点表示的DOM元素移除
removeVnodes(parentElm, oldCh, oldStartIdx, oldEndIdx);
}
}
}
key
从源码可知,给VNode设置key之后,当在对元素列表排序,或者给列表插入新项的时候会重用上一次对应的DOM对象,减少渲染次数,因此会提高性能。
也就是Scene5中判断sameVnode的场景。
Modules 源码
- Snabbdom的核心就是patch()方法,内部调用:patch() -> patchVnode() -> updateChildren()
- Snabbdom 为了保证核心库的精简,把处理元素的 属性 / 事件 / 样式等工作,放置到模块中。
- 模块可以按需引入,官方文档
- 模块实现的核心时基于 Hooks
hooks.ts
Hooks 钩子是 挂载到 DOM 节点生命周期的一种方法。
模块使用钩子来扩展Snabbdom,并在常规代码中使用钩子在虚拟节点声明中的期望点执行任意代码。
源码:src/hooks.ts 文件中定义了Snabbdom中预定义的所有钩子函数。
常用钩子:
- create - 在把VNode转化成真实 DOM 之前触发
- 可以用于给DOM元素添加属性 事件等。
- update - 在patchVnode对比节点之前调用
- 可以在对比前先去决定两个VNode对应的DOM的属性是否需要变化
使用限制:
- 模块中允许使用的钩子只有:
- pre,create,update,destroy,remove,post
- h函数中定义VNode的数据的hook属性,可以使用:
- init,create,insert,prepatch,update,postpatch,destroy,remove
import {VNode} from './vnode';
// 定义每个钩子的类型
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 {
// patch 函数开始执行的时候触发
pre?: PreHook;
// createElm 函数开始之前的时候触发
// 在把 VNode 转换成真实 DOM 之前触发
init?: InitHook;
// createElm 函数末尾调用
// 创建完真实 DOM 后触发
create?: CreateHook;
// patch 函数末尾执行
// 真实 DOM 添加到 DOM 树种触发
insert?: InsertHook;
// patchVnode 函数开头调用
// 开始对比两个 VNode 的差异之前触发
prepatch?: PrePatchHook;
// patchVnode 函数开头调用
// 两个 VNode 对比过程中触发,比 prepatch 稍晚
update?: UpdateHook;
// patchVnode 的最末尾调用
// 两个 VNode 对比结束执行
postpatch?: PostPatchHook;
// removeVnodes -> invokeDestroyHook 中调用
// 在删除元素之前触发,子节点的 destroy 也会被触发
destroy?: DestroyHook;
// removeVnodes 中调用
// 元素被删除的时候触发
remove?: RemoveHook;
// patch 函数的最后调用
// patch 全部执行完毕触发
post?: PostHook;
}
src/modules
- module.ts - 定义了模块可以使用的钩子函数,并不是所有的钩子函数。
- hero.ts - 官方示例中自定义的模块。
- 其他 - Snabbdom预定义的模块(解析其中一个即可)。
// src/modules/module.ts
import {PreHook, CreateHook, UpdateHook, DestroyHook, RemoveHook, PostHook} from '../hooks';
export interface Module {
pre: PreHook;
create: CreateHook;
update: UpdateHook;
destroy: DestroyHook;
remove: RemoveHook;
post: PostHook;
}
attributes.ts
import {VNode, VNodeData} from '../vnode';
import {Module} from './module';
// 定义一些类型
// because those in TypeScript are too restrictive: https://github.com/Microsoft/TSJS-lib-generator/pull/237
declare global {
interface Element {
setAttribute(name: string, value: string | number | boolean): void;
setAttributeNS(namespaceURI: string, qualifiedName: string, value: string | number | boolean): void;
}
}
export type Attrs = Record<string, string | number | boolean>
// 定义一些常量
const xlinkNS = 'http://www.w3.org/1999/xlink';
const xmlNS = 'http://www.w3.org/XML/1998/namespace';
const colonChar = 58;
const xChar = 120;
// 定义模块核心函数
function updateAttrs(oldVnode: VNode, vnode: VNode): void {
var key: string, elm: Element = vnode.elm as Element,
oldAttrs = (oldVnode.data as VNodeData).attrs,
attrs = (vnode.data as VNodeData).attrs;
// 如果新旧节点都没有 attrs 属性,直接返回
if (!oldAttrs && !attrs) return;
// 如果新旧节点的 attes 属性相同,直接返回
if (oldAttrs === attrs) return;
oldAttrs = oldAttrs || {};
attrs = attrs || {};
// update modified attributes, add new attributes
// 遍历新节点所有属性,与旧节点同key的属性对比,更新修改过的属性,添加新增的属性
for (key in attrs) {
const cur = attrs[key];
const old = oldAttrs[key];
// 如果属性相同就不需做任何操作
if (old !== cur) {
// 如果新节点属性值是 布尔类型时:selected checked
if (cur === true) {
elm.setAttribute(key, "");
} else if (cur === false) {
elm.removeAttribute(key);
// 新节点属性值不是 布尔类型时
} else {
// 判断是否是命名空间的属性:
// xml xmlns 属性可以在文档中定义命名空间
// xmlns:<svg xmlns="http://www.w3.org/2000/svg">
// xml:<html lang="en" xml:lang="en">
// 具有前缀的命名空间:
// xmlns:<svg xmlns:xlink="http://www.w3.org/1999/svg">
// 判断第一个字母是否是‘x’,x的charCode=120
if (key.charCodeAt(0) !== xChar) {
// 如果不是,正常设置属性的值
elm.setAttribute(key, cur);
} else if (key.charCodeAt(3) === colonChar) {
// 根据冒号位置判断xml命名空间
elm.setAttributeNS(xmlNS, key, cur);
} else if (key.charCodeAt(5) === colonChar) {
// 根据冒号位置判断xlink命名空间
elm.setAttributeNS(xlinkNS, key, cur);
} else {
// 没有前缀的 xmlns 用正常方式设置
elm.setAttribute(key, cur);
}
}
}
}
// remove removed attributes
// use `in` operator since the previous `for` iteration uses it (.i.e. add even attributes with undefined value)
// the other option is to remove all attributes with value == undefined
// 移除旧的节点中没有在新节点中保留的属性
// 英文提示这里使用 in 运算符判断,也可以根据 value==undfined 判断
for (key in oldAttrs) {
if (!(key in attrs)) {
elm.removeAttribute(key);
}
}
}
// 导出一个包含两个钩子的对象
// 通过两种方式导出,是为了方便使用
export const attributesModule = {create: updateAttrs, update: updateAttrs} as Module;
export default attributesModule;
总结
模块中的工作其实就是定义一些钩子函数,在DOM元素创建好后,对DOM元素做一些额外的操作,例如属性、样式、事件 或 自定义的操作。
使用模块的时机是init()函数的开始位置,遍历传入的所有模块并整理到cbs对象中。
在patch()函数执行到特定时机时,执行cbs中所有的钩子函数。