前情提要
Snabbdom是一款非常优秀的虚拟 DOM 库,阅读其源码有益于我们更进一步了解虚拟 DOM的设计思路,夯实自己基础。
创建Terdom的目的是为了更好的学习Snabbdom的源码,不是为了优化改进其功能,所以在Snabbdom的基础上,删减了如模块和钩子函数等功能,并为每一句代码添加了注释。
Snabbdom的官方案例:
import { init } from '../../build/package/init.js'
import { h } from '../../build/package/h.js'
import { classModule } from '../../build/package/modules/class.js'
import { propsModule } from '../../build/package/modules/props.js'
import { styleModule } from '../../build/package/modules/style.js'
import { eventListenersModule } from '../../build/package/modules/eventlisteners.js'
const patch = init([
// 通过传入模块初始化 patch 函数
classModule, // 开启 classes 功能
propsModule, // 支持传入 props
styleModule, // 支持内联样式同时支持动画
eventListenersModule, // 添加事件监听
]);
const container = document.getElementById("container");
const vnode = h("div#container.two.classes", { on: { click: someFn } }, [
h("span", { style: { fontWeight: "bold" } }, "This is bold"),
" and this is just normal text",
h("a", { props: { href: "/foo" } }, "I'll take you places!"),
]);
// 传入一个空的元素节点 - 将产生副作用(修改该节点)
patch(container, vnode);
const newVnode = h(
"div#container.two.classes",
{ on: { click: anotherEventHandler } },
[
h(
"span",
{ style: { fontWeight: "normal", fontStyle: "italic" } },
"This is now italic type"
),
" and this is still just normal text",
h("a", { props: { href: "/bar" } }, "I'll take you places!"),
]
);
// 再次调用 `patch`
patch(vnode, newVnode); // 将旧节点更新为新节点
Terdom的案例:
import { init } from '../../build/package/init.js'
import { h } from '../../build/package/h.js'
// 省略了模块功能
const patch = init();
const container = document.getElementById("container");
const vnode = h("div#container.two.classes", { on: { click: someFn } }, [
h("span", { style: { fontWeight: "bold" } }, "This is bold"),
" and this is just normal text",
h("a", { props: { href: "/foo" } }, "I'll take you places!"),
]);
// 传入一个空的元素节点 - 将产生副作用(修改该节点)
patch(container, vnode);
const newVnode = h(
"div#container.two.classes",
{ on: { click: anotherEventHandler } },
[
h(
"span",
{ style: { fontWeight: "normal", fontStyle: "italic" } },
"This is now italic type"
),
" and this is still just normal text",
h("a", { props: { href: "/bar" } }, "I'll take you places!"),
]
);
// 再次调用 `patch`
patch(vnode, newVnode); // 将旧节点更新为新节点
目前项目已上传GitHub,地址:github.com/zhtzhtx/Ter…
下篇地址:[造轮子]在学习过Snabbdom的源码后,自己实现一个虚拟DOM库(下)
逻辑分析
在开始之前,应该先理清思路。假如我们自己想要设计一个虚拟 DOM 库,应该从哪方面下手呢?应该从功能目的下手,即我这个虚拟 DOM 库最终实现的效果是什么。
虚拟 DOM 库最终想要实现的就是可以将用户传入数据渲染成真实dom,最终更新到页面的dom树上,记住两个关键词:渲染和更新。
还记得之前案例吗,在其中哪两个方法对应渲染和更新呢?答案已经很明显了,h 函数和 patch 函数。
也就是说,其它一切的模块功能都是为这两个函数服务的,所以我们手写虚拟 DOM 库应该从这两个方法下手。
OK,思路已经清晰了,让我们正式开始项目吧!
初始化项目
因为我们要手写一个虚拟 DOM 库,当然要先初始化一个新项目
npm init -y
接下来,我们同样准备使用TypeScript进行开发,所以当然要安装TypeScript
npm i typescript -D
然后,我们注意在案例中patch函数是init函数返回的,而无论init函数还是h函数都是使用ES6模块导入的
所以在package.json中需要将type设置为module,同时将init和h进行导出。
同时由于是使用TypeScript进行开发,所以打包是需要进行编译,我们在src目录下新建一个tsconfig.json,其中配置我们之后再说,在package.json中我们需要设置build指令。
OK,这样package.json的配置就差不多了,当然,我们还可以设置一下name、description、keywords和author。
{
"name": "terdom",
"version": "1.0.0",
"description": "A virtual DOM designed by hanting",
"type": "module",
"exports": {
"./init": "./build/package/init.js",
"./h": "./build/package/h.js"
},
"scripts": {
"build": "tsc --build src/tsconfig.json"
},
"keywords": [
"virtual",
"dom"
],
"author": "hanting",
"license": "ISC",
"devDependencies": {
"typescript": "^4.3.5"
}
}
接着,我们来配置tsconfig.json,这可以参考Snabbdom中的配置,当然我们没必要像他一样配置得详细复杂(你要是想也可以)
{
"compilerOptions": {
// 是否编译构建引⽤项⽬
"composite": true,
// ⽤于指定要包含在编译中的库⽂件
"lib": ["ESNext", "DOM"],
// ⽤于指定要包含在编译中的库⽂件
"module": "ESNext",
// ⽤于指定编译之后的版本⽬录
"target": "ES2015",
// ⽤来指定输出⽂件夹,值为⼀个⽂件夹路径字符串,输出的⽂件都将放置在这个⽂件夹
"outDir": "../build/package"
},
// 指定需要编译的文件列表
"files": []
}
好了,项目初始化终于结束,让我们正式开始手写!
h.ts
我们先在Terdom中的src目录下创建一个h.ts文件。在Snabbdom中,h 函数使用了函数重载,在Terdom中我们保持一致,但在传参类型上做了更进一步的限制,去除了所有的any。
进入 h 函数,首先定义了几个变量,来缓存不同的数据,方便重复使用。
接下来,根据传入参数的个数来进行不同的操作:
- 如果是三个参数,先判断是否传入data,如果有则将data数据缓存。再判断第三个参数是否为数组,如果是说明是子节点组,将其缓存到children。如果第三个参数是字符串或数字说明是文本节点,将其缓存到text。如果是VNode节点(虚拟dom节点,包含sel属性默认为VNode节点),将其用数组包装,将其缓存到children。
- 如果是两个参数,先判断第二个参数是否为数组,如果是说明是子节点组,将其缓存到children。如果不是再判断是否为字符串或数字,如果是说明是文本节点,将其缓存到text。如果都不是,直接将缓存到data。这里由于对传参进行了更详细的限定,所以相比于Snabbdom需要更进一步判断是否为VNode对象。
判断完传参个数之后,开始处理子节点组,如果子节点组中是字符串或数字,则创建一个文本节点,最后,返回创建好的VNode。在Snabbdom中还处理了SVG相关,而在我们自己写的Terdom中,暂不考虑SVG情况,因为这块不会影响我们对虚拟dom的学习(偷懒),所以直接删除。
OK,来看一下 h 函数的完整代码
import { vnode, VNode, VNodeData } from './vnode'
// 类型判断
import * as is from './is'
export type VNodes = VNode[]
export type VNodeChildElement = VNode | string | number | undefined | null
export type ArrayOrElement<T> = T | T[]
export type VNodeChildren = ArrayOrElement<VNodeChildElement>
// 使用函数重构,在snabbdom中h函数的参数类型使用any,这里可以进行更详细的类型限制
// h 函数的重载
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: string, b?: VNodeData | null | VNodeChildren, c?: VNodeChildren): VNode {
// 缓存 VNode 数据
let data: VNodeData = {}
// 缓存 children 数据
let children: VNodeChildElement[]
// 缓存文本数据
let text: string | number
// 用于缓存遍历children的index
let i: number
// 处理参数,实现重载的机制
if (c !== undefined) {
// 处理三个参数的情况
// sel、data、children/text
if (b !== null) {
data = b as VNodeData
}
if (is.array(c)) {
children = c
// 如果c是字符串或者数字
} else if (is.primitive(c)) {
text = c
// 如果c是VNode
} else if (c && c.sel) {
children = [c]
}
} else if (b !== undefined && b !== null) {
// 处理两个参数的情况
// 如果b是数组
if (is.array(b)) {
children = b
// 如果b是字符串或数字
} else if (is.primitive(b)) {
text = b
} else {
// 这里由于b没有使用any类型,所以需要进一步判断b是否为VNode
if (is.isVnode(b)) {
children = [b]
} else {
data = b
}
}
}
// 处理 children 中的原始值(string/number)
if (children !== undefined) {
for (i = 0; i < children.length; ++i) {
// 如果 child 是string/number,创建文本节点
// 这里由于children没有使用any类型,所以需要进一步判断children[i]是否为string | number
// 不能直接使用children[i],所以使用msg缓存
const msg = children[i]
if (is.primitive(msg)) {
children[i] = vnode(undefined, undefined, undefined, msg, undefined)
}
}
}
// 符合VNode
return vnode(sel, data, children, text, undefined)
}
好了,看完了h.ts我们一定好奇vnode和is是哪来的?
接下来,让我们先看is.ts
is.ts
首先,我们在src目录下创建is.ts文件。is.ts的功能很简单,就用来判断传参类型的。我们Terdom中由于对 h 函数的传参进行了进一步判断,所以新增一个判断是否为VNode的方法。
import { VNode } from './vnode'
// 判断是否为数组
export const array = Array.isArray
// 判断是否为字符串或数字
export function primitive(s: any): s is (string | number) {
return typeof s === 'string' || typeof s === 'number'
}
// 判断是否为vnode
export function isVnode(s: any): s is VNode {
return !!s.sel
}
is.ts中也用到了vnode,那么接着就让我们看看vnode是怎么定义的吧
vnode.ts
首先同样在src目录下创建vnode.ts文件。h 函数的功能是将用户传入的数据转化成虚拟dom对象,那什么是虚拟dom对象?vnode.ts文件功能就定义虚拟dom对象(VNode对象)。
在vnode.ts中,首先引入各功能模块中对VNode对象中不同属性的类型限制。在Snabbdom中,VNode对象功能比较全面,而在Terdom中,我们就支持几个常用的就好:
-
attributes
设置dom元素的属性,使⽤setAttribute(),会对布尔类型的属性进⾏判断
-
props
和attributes模块类似,设置dom的属性,但是是以element[attr] = value的形式设置,不会处理布尔类型的属性
-
class
切换类样式
-
eventlisteners
注册和移除事件
-
style
设置⾏内样式
具体每个模块怎么设计编写的,我们之后会详细分析。好了,接下来是对VNode类型数据的定义:
-
sel
dom节点的标签和选择器,如:“div#app.box”
-
data
VNode对象中包含的数据,数据类型为VNodeData,我们之后会分析
-
children
VNode对象的子节点组,和text属性互斥
-
elm
VNode对象对应的真实dom
-
text
VNode对象的文本内容,和children属性互斥
-
key
key,用于优化diff算法
需要注意的是,由于之前在 h 函数中我们相比于Snabbdom对类型进行进一步的判断,所以在VNode类型中对children和text数据类型也进行相应的修改。
在VNodeData类型中,我们可以看到其实数据就是我们上面导入的模块:
最后,导出的vnode函数返回的就是包含sel、data、children、text、elm和key的一个对象:
完整代码:
import { On } from './modules/eventlisteners'
import { Attrs } from './modules/attributes'
import { Classes } from './modules/class'
import { Props } from './modules/props'
import { VNodeStyle } from './modules/style'
// key属性类型
export type Key = string | number
// VNode接口
export interface VNode {
// dom节点的选择器
sel: string | undefined
// 节点数据
data: VNodeData | undefined
// 子节点,和text互斥
children: Array<VNode | string | number> | undefined
// 存储VNode转化成的真实dom
elm: Node | undefined
// 节点的文本内容,和children互斥
text: string | number | undefined
// key,用于优化diff算法
key: Key | undefined
}
export interface VNodeData {
// 设置VNode对应的DOM元素的属性,通过 对象.属性 的方式来设置,它内部不会去处理布尔类型的属性
props?: Props
// 设置VNode对应的DOM元素的属性,通过 setAttributes 来设置,它内部不会去处理布尔类型的属性
attrs?: Attrs
// 设置VNode对应的DOM元素的class
class?: Classes
// 设置VNode对应的DOM元素的css style
style?: VNodeStyle
// 设置VNode对应的DOM元素的监听事件
on?: On
// 设置VNode对应的key
key?: Key
}
export function vnode(sel: string | undefined,
data: any | undefined,
children: Array<VNode | string | number> | undefined,
text: string | number | undefined,
elm: Element | Text | undefined): VNode {
const key = data === undefined ? undefined : data.key
return { sel, data, children, text, elm, key }
}
modules
在vnode.ts中我们引入功能模块中对各个VNode对象属性的类型限制,接下来让我们详细看看不同模块是怎么设计的。
modules/attributes.ts
首先,在src目录下创建modules文件夹,在该文件夹下创建attributes.ts文件。attributes.ts的功能是定义并导出attrs属性的数据类型以及通过setAttribute更新dom属性的方法。
我们先定义attrs属性的数据类型是一个以字符串做为key,字符串、数字或布尔做为value的对象。
然后,我们定义更新dom属性的updateAttrs函数,它应该接受2个参数:旧VNode对象和新VNode对象。所以还需要引入vnode.ts对VNode对象的类型限制。
进入updateAttrs函数,首先定义了几个变量用于缓存不同的数据
其次,判断如果新旧VNode都没有attrs属性或如果新旧VNode的attrs属性相同,直接返回。如果新旧VNode的attrs属性中存在undefined,则将其为空对象。
接着,遍历新VNode的attrs,当新旧VNode中相同key的attrs值不相同时,如果attrs值为true则通过setAttribute设置为空字符串,如果attrs值为false,则删除该条属性,否则通过setAttribute设置值。在Snabbdom中还考虑了SVG,而我们这里暂不考虑。
最后,遍历旧VNode的attrs,如果新VNode的attrs中没有相同的key,则直接删除该attrs属性。
在attributes.ts最后导出一个含有create和update属性,并且值都为updateAttrs函数的对象,代表在create和update生命周期中,都需要调用updateAttrs函数,这点我们之后会看到。
完整代码:
import { VNode, VNodeData } from '../vnode'
export type Attrs = Record<string, string | number | boolean>
function updateAttrs(oldVnode: VNode, vnode: VNode): void {
// 用于缓存attrs的name
var key: string
// 用于缓存Vnode的dom
var elm: Element = vnode.elm as Element
// 用于缓存旧VNode的attrs
var oldAttrs = (oldVnode.data as VNodeData).attrs
// 用于缓存新VNode的attrs
var attrs = (vnode.data as VNodeData).attrs
// 如果新旧VNode都没有attrs属性,直接返回
if (!oldAttrs && !attrs) return
// 如果新旧VNode的attrs属性相同,直接返回
if (oldAttrs === attrs) return
// 如果旧VNode没有attrs,将其设置为空对象
oldAttrs = oldAttrs || {}
// 如果新VNode没有attrs,将其设置为空对象
attrs = attrs || {}
// 遍历新VNode的attrs
for (key in attrs) {
// 获取当前attrs的值
const cur = attrs[key]
// 获取旧VNode对应的attrs的值
const old = oldAttrs[key]
// 如果新旧VNode的attrs值不相同
if (old !== cur) {
// 如果新VNode的attrs值为true
if (cur === true) {
// 通过setAttribute设置为空字符串
elm.setAttribute(key, '')
} else if (cur === false) {
// 如果新VNode的attrs值为false,则删除key
elm.removeAttribute(key)
} else {
// 通过setAttribute设置值,这里我们不用支持SVG
elm.setAttribute(key, cur as any)
}
}
}
// 遍历旧VNode的attrs
for (key in oldAttrs) {
// 如果新VNode的attrs中没有相同的key,则直接删除该attrs属性
if (!(key in attrs)) {
elm.removeAttribute(key)
}
}
}
export const attributesModule = { create: updateAttrs, update: updateAttrs }
modules/props.ts
首先,在modules文件夹下创建props.ts文件。
props.ts的功能和attributes.ts相似,其不同在于attributes.ts是通过setAttribute来设置dom的属性,而props.ts是通过dom[key]来设置。
完整代码:
import { VNode, VNodeData } from '../vnode'
export type Props = Record<string, any>
function updateProps(oldVnode: VNode, vnode: VNode): void {
// 用于缓存props的name
var key: string
// 缓存遍历中当前props的值
var cur: any
// 缓存遍历中旧props的值
var old: any
// 缓存VNode的dom元素
var elm = vnode.elm
// 获取旧VNode的props属性
var oldProps = (oldVnode.data as VNodeData).props
// 获取新VNode的props属性
var props = (vnode.data as VNodeData).props
// 如果新旧VNode都没有props属性,直接返回
if (!oldProps && !props) return
// 如果新旧VNode的props属性完全一样,直接返回
if (oldProps === props) return
// 如果旧VNode没有props,将其设置为空对象
oldProps = oldProps || {}
// 如果新VNode没有props,将其设置为空对象
props = props || {}
// 遍历新VNode的props
for (key in props) {
// 缓存当前prop属性的值
cur = props[key]
// 缓存旧VNode中同名prop的值
old = oldProps[key]
// 如果新旧prop值不同,同时当dom是含有value属性的元素(如:input),当前value值和当前prop值不同时,将值设置为当前prop的值
if (old !== cur && (key !== 'value' || (elm as any)[key] !== cur)) {
(elm as any)[key] = cur
}
}
}
export const propsModule = { create: updateProps, update: updateProps }
modules/class.ts
在modules文件夹下创建class.ts文件。
class.ts的功能是定义并导出class属性的数据类型以及更新dom的class属性的方法。
我们先定义class属性的数据类型是一个以字符串做为key,布尔做为value的对象。
然后,我们定义更新dom属性的updateClass函数,它应该接受2个参数:旧VNode对象和新VNode对象。所以还需要引入vnode.ts对VNode对象的类型限制。
进入updateClass函数,首先定义了几个变量用于缓存不同的数据。
其次,判断如果新旧VNode都没有class属性或如果新旧VNode的class属性相同,直接返回。如果新旧VNode的class属性中存在undefined,则将其为空对象。
接着,遍历旧VNode的class,如果当前class旧VNode上有但新VNode没有,则删除该VNode。
最后,遍历新Vnodde的class,如果新旧VNode的class值不同,则根据新VNode的class值是true or false来判断是新增还是删除class。
在class.ts最后导出一个含有create和update属性,并且值都为updateClass函数的对象,代表在create和update生命周期中,都需要调用updateClass函数。
完整代码
import { VNode, VNodeData } from '../vnode'
export type Classes = Record<string, boolean>
function updateClass(oldVnode: VNode, vnode: VNode): void {
// 用于缓存遍历中当前class属性的值
var cur: any
// 用于缓存遍历中当前class的name
var name: string
// 获取新VNode的dom元素
var elm: Element = vnode.elm as Element
// 获取旧VNode的class数据
var oldClass = (oldVnode.data as VNodeData).class
// 获取新VNode的class数据
var klass = (vnode.data as VNodeData).class
// 如果新旧VNode都没有class,直接返回
if (!oldClass && !klass) return
// 如果新旧VNode完全相同,直接返回
if (oldClass === klass) return
// 如果旧VNode没有class,将其设置为空对象
oldClass = oldClass || {}
// 如果新VNode没有class,将其设置为空对象
klass = klass || {}
// 遍历旧VNode的class
for (name in oldClass) {
// 如果当前class旧VNode上有但新VNode没有,则删除该VNode
if (
oldClass[name] &&
!Object.prototype.hasOwnProperty.call(klass, name)
) {
elm.classList.remove(name)
}
}
// 遍历新Vnodde的class
for (name in klass) {
// 如果新旧VNode的class值不同,则根据新VNode的class值是true or false来判断是新增还是删除class
cur = klass[name]
if (cur !== oldClass[name]) {
(elm.classList as any)[cur ? 'add' : 'remove'](name)
}
}
}
export const classModule = { create: updateClass, update: updateClass }
modules/eventlisteners.ts
在modules文件夹下创建eventlisteners.ts文件。
eventlisteners.ts的功能是定义并导出on属性的数据类型以及更新dom监听事件的方法。
和之前不同的是,由于on类型限制比较复杂,所以我们类型定义需要拆开成两步。
首先,定义一个Listener类型,它接受三个参数分别是当前this指向的VNode对象,触发的事件名(如:click)和VNode对象。
然后,定义On对象的数据类型是以事件名做为key,事件函数做为value。
接下来,我们来看eventlisteners的更新函数updateEventListeners,它同样接受两个参数:旧VNode对象和新VNode对象。所以还需要引入vnode.ts对VNode对象的类型限制。
进入函数,首先定义了几个变量用于缓存不同的数据。
其次,如果新旧VNode监听事件完全一样,直接返回。
然后,当旧VNode中有已经创建的事件监听,判断新VNode中有没有事件监听。如果没有,遍历旧VNode获取事件名,然后删除dom上的对应的事件监听。如果有,则遍历旧VNode,如果新VNode中没有当前的事件监听,则删除dom上该事件监听。
最后,判断新VNode是否有事件监听。如果有,先定义一个listener变量缓存事件监听,如果新VNode已经存在事件监听,则直接继承,如果没有则通过createListener函数创建一个新事件监听。 createListener函数的详情我们之后再看。接着,更新listener上的vnode
然后,判断旧VNode有没有事件监听。如果没有,遍历新VNode上的事件监听,将其添加到dom上。如果有,遍历新VNode上的事件监听,将旧VNode上没有的事件监听添加到dom上。
好了,接下来我们回头来看之前提到的创建新事件监听功能的createListener函数。这个函数是个高阶函数,它返回了一个handler函数,在handler函数中将调用handleEvent函数。这样做的好处是,VNode会形成一个闭包,这样可以确保每次创建的都是一个独立的事件监听。
然后来看handleEvent函数,它接受两个参数:当前事件和VNode对象。进入函数,先定义name和on来缓存事件名和VNode的监听事件(on属性值),如果on中存在事件监听,则进行调用invokeHandler函数。
接着看invokeHandler函数,它接受三个参数:当前事件函数,VNode对象和监听事件。进入函数,首先判断第一个参数是不是函数,如果是,说明只有一个事件监听,将this指向vnode调用。如果第一个参数是数组,说明有多个事件监听,遍历依次调用。
在eventlisteners.ts最后导出一个含有create、update和destroy属性,并且值都为updateEventListeners函数的对象,代表在create、update和destroy生命周期中,都需要调用updateEventListeners函数。
import { VNode, VNodeData } from '../vnode'
type Listener<T> = (this: VNode, ev: T, vnode: VNode) => void
export type On = {
[N in keyof HTMLElementEventMap]?: Listener<HTMLElementEventMap[N]> | Array<Listener<HTMLElementEventMap[N]>>
} & {
[event: string]: Listener<any> | Array<Listener<any>>
}
type SomeListener<N extends keyof HTMLElementEventMap> = Listener<HTMLElementEventMap[N]> | Listener<any>
function invokeHandler<N extends keyof HTMLElementEventMap>(handler: SomeListener<N> | Array<SomeListener<N>>, vnode: VNode, event?: Event): void {
if (typeof handler === 'function') {
// 如果类型是function,说明只有一个事件监听,将this指向vnode调用
handler.call(vnode, event, vnode)
} else if (typeof handler === 'object') {
// 如果类型是对象,说明有多个事件监听,遍历依次调用
for (var i = 0; i < handler.length; i++) {
invokeHandler(handler[i], vnode, event)
}
}
}
function handleEvent(event: Event, vnode: VNode) {
var name = event.type
var on = (vnode.data as VNodeData).on
// 如果on中存在事件监听,则进行运行
if (on && on[name]) {
invokeHandler(on[name], vnode, event)
}
}
// 创建一个事件监听的闭包,这样可以确保每次创建的都是一个独立的事件监听
function createListener() {
return function handler(event: Event) {
handleEvent(event, (handler as any).vnode)
}
}
function updateEventListeners(oldVnode: VNode, vnode?: VNode): void {
// 获取旧VNode中监听的事件
var oldOn = (oldVnode.data as VNodeData).on
// 获取旧VNode中已经创建的监听事件
var oldListener = (oldVnode as any).listener
// 获取旧VNode的dom
var oldElm: Element = oldVnode.elm as Element
// 获取新VNode中监听的事件
var on = vnode && (vnode.data as VNodeData).on
// 获取新VNode中已经创建的监听事件
var elm: Element = (vnode && vnode.elm) as Element
// 缓存遍历中当前事件的名称
var name: string
// 如果新旧VNode监听事件完全一样,直接返回
if (oldOn === on) {
return
}
// 如果旧VNode中有已经创建的事件监听
if (oldOn && oldListener) {
// 如果新VNode中没有事件监听
if (!on) {
for (name in oldOn) {
// 删除旧VNode中的事件监听
oldElm.removeEventListener(name, oldListener, false)
}
} else {
// 如果新VNode中有事件监听,则遍历旧VNode
for (name in oldOn) {
// 如果新VNode中没有当前的事件监听,则删除该事件监听
if (!on[name]) {
oldElm.removeEventListener(name, oldListener, false)
}
}
}
}
// 如果新VNode有事件监听
if (on) {
// 如果新VNode上已经存在事件监听,则直接继承,如果没有则创建一个新事件监听
var listener = (vnode as any).listener = (oldVnode as any).listener || createListener()
// 更新listener上的vnode
listener.vnode = vnode
// 如果旧VNode没有事件监听
if (!oldOn) {
// 遍历新VNode上的事件监听
for (name in on) {
// 将其添加到dom上
elm.addEventListener(name, listener, false)
}
} else {
// 如果旧VNode有事件监听
// 遍历新VNode上的事件监听
for (name in on) {
// 将旧VNode上没有的事件监听添加到dom上
if (!oldOn[name]) {
elm.addEventListener(name, listener, false)
}
}
}
}
}
export const eventListenersModule = {
create: updateEventListeners,
update: updateEventListeners,
destroy: updateEventListeners
}
modules/style.ts
好了,我们来看最后一个style模块,同样在modules文件夹下创建style.ts文件。
style.ts的功能是定义并导出VNode的style属性的数据类型以及更新dom的css样式的方法。
我们先定义style属性的数据类型是一个key和value都为字符串的对象。
然后,我们定义更新dom css样式的updateStyle函数,它应该接受2个参数:旧VNode对象和新VNode对象。所以还需要引入vnode.ts对VNode对象的类型限制。进入函数,首先定义了几个变量用于缓存不同的数据。
其次,判断如果新旧VNode都没有style属性或如果新旧VNode的style属性相同,直接返回。如果新旧VNode的style属性中存在undefined,则将其为空对象。
然后,遍历旧VNode的style,当新VNode中没有相同name的style时,判断如果是以 -- 开头,代表是css变量,使用removeProperty删除,否则直接设为空。
最后,遍历新VNode中的style,创建一个cur变量用于缓存当前style的值,当前style值和旧VNode中同名style的值不同时,判断如果是以 -- 开头,代表是css变量,使用setProperty设置,否则直接设置。
在style.ts最后导出一个含有create和update属性,并且值都为updateStyle函数的对象,代表在create和update生命周期中,都需要调用updateStyle函数。
在snabbdom中style有三个额外的生命周期delayed, remove和destroy,主要是方便我们css动画的使用,而我们terdom是为了学习虚拟dom的原理,所以这里我删除这两个生命周期,方便源码的阅读。
完整代码:
import { VNode, VNodeData } from '../vnode'
export type VNodeStyle = Record<string, string>
/**
* 在snabbdom中style有三个额外的生命周期delayed, remove和destroy,主要是方便我们css动画的使用,而我们terdom是为了学习虚拟dom
* 的原理,所以这里我删除这两个生命周期,方便源码的阅读。
* */
function updateStyle(oldVnode: VNode, vnode: VNode): void {
// 缓存遍历中当前新VNode中的style
var cur: any
// 缓存遍历中当前style的name
var name: string
// 获取dom节点
var elm = vnode.elm
// 获取旧VNode的style
var oldStyle = (oldVnode.data as VNodeData).style
// 获取新VNode的style
var style = (vnode.data as VNodeData).style
// 如果新旧VNode都没有设置style,直接返回
if (!oldStyle && !style) return
// 如果新旧VNode的style完全相同,直接返回
if (oldStyle === style) return
// 如果没有旧VNode的style,设置为空对象
oldStyle = oldStyle || {}
// 如果没有新VNode的style,设置为空对象
style = style || {}
// 遍历旧VNode的style
for (name in oldStyle) {
// 如果新VNode中没有相同name的style
if (!style[name]) {
if (name[0] === '-' && name[1] === '-') {
// 如果是以 -- 开头,代表是css变量,使用removeProperty删除
(elm as any).style.removeProperty(name)
} else {
// 否则直接设为空
(elm as any).style[name] = ''
}
}
}
// 遍历新VNode中的style
for (name in style) {
// 缓存当前style的值
cur = style[name]
if (cur !== oldStyle[name]) {
if (name[0] === '-' && name[1] === '-') {
// 如果是以 -- 开头,代表是css变量,使用setProperty设置
(elm as any).style.setProperty(name, cur)
} else {
// 否则直接设置
(elm as any).style[name] = cur
}
}
}
}
export const styleModule = {
create: updateStyle,
update: updateStyle,
}
结尾
到这里, h 函数和modules相关功能终于介绍完了。由于篇幅问题,patch 函数及其相关代码分析我们放到下篇来看。