在学习过Snabbdom的源码后,自己实现一个虚拟DOM库(上)

176 阅读16分钟

前情提要


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模块导入的 image.png 所以在package.json中需要将type设置为module,同时将init和h进行导出。

image.png 同时由于是使用TypeScript进行开发,所以打包是需要进行编译,我们在src目录下新建一个tsconfig.json,其中配置我们之后再说,在package.json中我们需要设置build指令。

image.png 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。

image.png

进入 h 函数,首先定义了几个变量,来缓存不同的数据,方便重复使用。

image.png

接下来,根据传入参数的个数来进行不同的操作:

  1. 如果是三个参数,先判断是否传入data,如果有则将data数据缓存。再判断第三个参数是否为数组,如果是说明是子节点组,将其缓存到children。如果第三个参数是字符串或数字说明是文本节点,将其缓存到text。如果是VNode节点(虚拟dom节点,包含sel属性默认为VNode节点),将其用数组包装,将其缓存到children。

image.png

  1. 如果是两个参数,先判断第二个参数是否为数组,如果是说明是子节点组,将其缓存到children。如果不是再判断是否为字符串或数字,如果是说明是文本节点,将其缓存到text。如果都不是,直接将缓存到data。这里由于对传参进行了更详细的限定,所以相比于Snabbdom需要更进一步判断是否为VNode对象。

image.png

判断完传参个数之后,开始处理子节点组,如果子节点组中是字符串或数字,则创建一个文本节点,最后,返回创建好的VNode。在Snabbdom中还处理了SVG相关,而在我们自己写的Terdom中,暂不考虑SVG情况,因为这块不会影响我们对虚拟dom的学习(偷懒),所以直接删除。

image.png

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是哪来的?

image.png

接下来,让我们先看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中,我们就支持几个常用的就好:

image.png

  • 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数据类型也进行相应的修改。

image.png

在VNodeData类型中,我们可以看到其实数据就是我们上面导入的模块:

image.png

最后,导出的vnode函数返回的就是包含sel、data、children、text、elm和key的一个对象:

image.png

完整代码:

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的对象。

image.png

然后,我们定义更新dom属性的updateAttrs函数,它应该接受2个参数:旧VNode对象和新VNode对象。所以还需要引入vnode.ts对VNode对象的类型限制。

image.png

进入updateAttrs函数,首先定义了几个变量用于缓存不同的数据

image.png

其次,判断如果新旧VNode都没有attrs属性或如果新旧VNode的attrs属性相同,直接返回。如果新旧VNode的attrs属性中存在undefined,则将其为空对象。

image.png

接着,遍历新VNode的attrs,当新旧VNode中相同key的attrs值不相同时,如果attrs值为true则通过setAttribute设置为空字符串,如果attrs值为false,则删除该条属性,否则通过setAttribute设置值。在Snabbdom中还考虑了SVG,而我们这里暂不考虑。

image.png

最后,遍历旧VNode的attrs,如果新VNode的attrs中没有相同的key,则直接删除该attrs属性。

image.png

在attributes.ts最后导出一个含有create和update属性,并且值都为updateAttrs函数的对象,代表在create和update生命周期中,都需要调用updateAttrs函数,这点我们之后会看到。

image.png

完整代码:

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的对象。

image.png

然后,我们定义更新dom属性的updateClass函数,它应该接受2个参数:旧VNode对象和新VNode对象。所以还需要引入vnode.ts对VNode对象的类型限制。

image.png

进入updateClass函数,首先定义了几个变量用于缓存不同的数据。

image.png

其次,判断如果新旧VNode都没有class属性或如果新旧VNode的class属性相同,直接返回。如果新旧VNode的class属性中存在undefined,则将其为空对象。

image.png

接着,遍历旧VNode的class,如果当前class旧VNode上有但新VNode没有,则删除该VNode。

image.png

最后,遍历新Vnodde的class,如果新旧VNode的class值不同,则根据新VNode的class值是true or false来判断是新增还是删除class。

image.png

在class.ts最后导出一个含有create和update属性,并且值都为updateClass函数的对象,代表在create和update生命周期中,都需要调用updateClass函数。

image.png

完整代码

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对象。

image.png

然后,定义On对象的数据类型是以事件名做为key,事件函数做为value。

image.png

接下来,我们来看eventlisteners的更新函数updateEventListeners,它同样接受两个参数:旧VNode对象和新VNode对象。所以还需要引入vnode.ts对VNode对象的类型限制。

进入函数,首先定义了几个变量用于缓存不同的数据。

image.png

其次,如果新旧VNode监听事件完全一样,直接返回。

image.png

然后,当旧VNode中有已经创建的事件监听,判断新VNode中有没有事件监听。如果没有,遍历旧VNode获取事件名,然后删除dom上的对应的事件监听。如果有,则遍历旧VNode,如果新VNode中没有当前的事件监听,则删除dom上该事件监听。

image.png

最后,判断新VNode是否有事件监听。如果有,先定义一个listener变量缓存事件监听,如果新VNode已经存在事件监听,则直接继承,如果没有则通过createListener函数创建一个新事件监听。 createListener函数的详情我们之后再看。接着,更新listener上的vnode

image.png

然后,判断旧VNode有没有事件监听。如果没有,遍历新VNode上的事件监听,将其添加到dom上。如果有,遍历新VNode上的事件监听,将旧VNode上没有的事件监听添加到dom上。

image.png

好了,接下来我们回头来看之前提到的创建新事件监听功能的createListener函数。这个函数是个高阶函数,它返回了一个handler函数,在handler函数中将调用handleEvent函数。这样做的好处是,VNode会形成一个闭包,这样可以确保每次创建的都是一个独立的事件监听。

image.png

然后来看handleEvent函数,它接受两个参数:当前事件和VNode对象。进入函数,先定义name和on来缓存事件名和VNode的监听事件(on属性值),如果on中存在事件监听,则进行调用invokeHandler函数。

image.png

接着看invokeHandler函数,它接受三个参数:当前事件函数,VNode对象和监听事件。进入函数,首先判断第一个参数是不是函数,如果是,说明只有一个事件监听,将this指向vnode调用。如果第一个参数是数组,说明有多个事件监听,遍历依次调用。

image.png

在eventlisteners.ts最后导出一个含有create、update和destroy属性,并且值都为updateEventListeners函数的对象,代表在create、update和destroy生命周期中,都需要调用updateEventListeners函数。

image.png

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都为字符串的对象。

image.png

然后,我们定义更新dom css样式的updateStyle函数,它应该接受2个参数:旧VNode对象和新VNode对象。所以还需要引入vnode.ts对VNode对象的类型限制。进入函数,首先定义了几个变量用于缓存不同的数据。

image.png

其次,判断如果新旧VNode都没有style属性或如果新旧VNode的style属性相同,直接返回。如果新旧VNode的style属性中存在undefined,则将其为空对象。

image.png

然后,遍历旧VNode的style,当新VNode中没有相同name的style时,判断如果是以 -- 开头,代表是css变量,使用removeProperty删除,否则直接设为空。

image.png

最后,遍历新VNode中的style,创建一个cur变量用于缓存当前style的值,当前style值和旧VNode中同名style的值不同时,判断如果是以 -- 开头,代表是css变量,使用setProperty设置,否则直接设置。

image.png

在style.ts最后导出一个含有create和update属性,并且值都为updateStyle函数的对象,代表在create和update生命周期中,都需要调用updateStyle函数。

image.png

在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 函数及其相关代码分析我们放到下篇来看。