实现“乞丐版”的DOM事件流机制

概述

DOM事件流

先来简单回顾下什么是DOM事件流。

“DOM2级事件”规定的事件流包括三个阶段:事件捕获阶段==>处于目标阶段==>事件冒泡阶段。首先发生的是事件捕获阶段,为截获事件提供了机会。然后是实际的目标接收事件。最后一个阶段是冒泡阶段。用一张来自w3c的图片说明:

核心要素

从上图分析可知,要实现一个可用的DOM事件流机制,需要实现以下三个核心要素。

事件对象

事件对象用于保存事件的属性、状态和事件要传递的内容。

事件属性包括:

  • type:事件类型,用于不同事件的隔离
  • detail:事件要传递的内容
  • bubbles:是否支持冒泡
  • cancelable:是否支持取消事件默认行为

事件状态包括:

  • eventPhase:事件流当前所处的阶段
  • cancelBubble:是否取消了冒泡
  • defaultPrevented:是否取消了事件默认行为

除此之外,事件对象还需要包含以下方法:

  • stopPropagation:停止事件继续向上冒泡
  • preventDefault:阻止执行事件默认行为

事件目标

如上图中的windowdocument等对象,都属于事件目标,它们可以监听事件和分发事件。同时,还保存了它们的父级和子级的引用,以便可以在捕获和冒泡阶段,快速找到事件的传递路径。

事件目标需要包含以下方法:

  • addEventListener:监听事件,注册事件处理的回调函数
  • removeEventListener:移除事件监听
  • dispatchEvent:分发事件

事件中心

事件中心可以耦合到事件目标当中,也可以独立为一个模块。

事件中心用于保存监听的事件和调用事件回调函数,类似于Node.js中的EventEmitter对象。

稍微梳理一下,就应该是一个下面这样的模型:

源码实现

接下来,我们按照梳理的要素来逐步实现DOM事件流机制。

Event

实现Event对象很简单,这里直接贴代码。

class Event implements IEvent {
    readonly type: string;
    readonly bubbles: boolean;
    readonly cancelable: boolean;
    readonly eventPhase: EventPhase;
    readonly currentTarget: any;
    readonly target: any;
    readonly timeStamp: number;
    readonly detail: any;

    cancelBubble: boolean;
    defaultPrevented: boolean;

    constructor(type: string, eventInit?: TEventInit) {
        const options = eventInit || {};

        this.type = type;
        this.detail = options.detail;
        this.timeStamp = Date.now();
        this.bubbles = options.bubbles || false;
        this.cancelable = options.cancelable || false;
        this.target = null;
        this.currentTarget = null;

        this.eventPhase = EventPhase.NONE;
        this.cancelBubble = !this.bubbles;
        this.defaultPrevented = false;
    }

    preventDefault() {
        if (!this.cancelable) return;
        this.defaultPrevented = true;
    }

    stopPropagation() {
        this.cancelBubble = true;
    }
}

EventEmitter

EventEmitter很简单,只要通过一个listeners对象保存所有的事件监听,并在emit时执行监听对应的方法即可。需要注意的是:要对Event对象的eventPhase状态进行判断。下面是EventEmitter的核心代码:

class EventEmitter {
    // WeakMap: 在移除事件监听时,对应的 option 可以自动被垃圾回收
    private readonly options: WeakMap<TListener, TConfig="">;
    private readonly listeners: {
        [index:string]: Array<TListener>;
    };

    constructor() {
        this.options = new WeakMap();
        this.listeners = {};
    }

    on(event: string, listener: TListener) {
        if (!this.listeners[event]) {
            this.listeners[event] = [];
        }

        this.listeners[event].push(listener);
    }

    off(event: string, listener: TListener) {
        if (!this.listeners[event]) return;

        const listeners = this.listeners[event];
        if (listeners) {
            const index = listeners.indexOf(listener);
            if (index !== -1) listeners.splice(index, 1);
        }
    }

    emit(event: string, e: IEvent) {
        if (!this.listeners[event]) return;

        const listeners = this.listeners[event];
        if (listeners) {
            for (let i:number = 0; i < listeners.length; i++) {
                const listener = listeners[i];
                const option = this.options.get(listener) as TConfig;
                const { currentTarget, useCapture, isDefault } = option;
                if (
                    (isDefault && !e.defaultPrevented) ||
                    (!isDefault && (
                        (e.eventPhase === EventPhase.AT_TARGET) ||
                        (e.eventPhase === EventPhase.CAPTURING_PHASE && useCapture) ||
                        (e.eventPhase === EventPhase.BUBBLING_PHASE && !useCapture)
                    ))
                ) {
                    Object.defineProperty(e, 'currentTarget', {
                        configurable: true,
                        enumerable: true,
                        value: currentTarget,
                    });
                    listeners[i](e);
                }
            }
        }
    }
}

</TListener,>

事件对象

以下是EventTarget对象的核心代码。

class EventTarget {
    private readonly parent: EventTarget | null;
    private readonly children: Array<EventTarget>;

    private events: EventEmitter;

    constructor(parent?: EventTarget) {
        this.parent = parent || null;
        this.children = [];
        if (parent) parent.children.push(this);

        this.events = new EventEmitter();
    }

    addEventListener(type: string, listener: TListener, options?: TOption) {
        // 保存监听事件的当前对象为 currentTarget
        const listenerConfig: TConfig = { currentTarget: this, ...options };
        this.events.on(type, listener, listenerConfig);
    }

    removeEventListener(type: string, listener: TListener) {
        this.events.off(type, listener);
    }

    dispatchEvent(event: IEvent): void {
        event.target = this;

        let path: Array<EventTarget> = [];
        let node: EventTarget | null = this;
        while (node) {
            path.push(node);
            node = node.parent;
        }

        // capture
        event.eventPhase = EventPhase.CAPTURING_PHASE;
        for (let i = path.length - 1; i > 0; i--) {
            path[i].events.emit(event.type, event);
        }

        // target
        event.eventPhase = EventPhase.AT_TARGET;
        this.events.emit(event.type, event);

        // bubble
        event.eventPhase = EventPhase.BUBBLING_PHASE;
        for (let i = 1; i < path.length; i++) {
            if (event.cancelBubble) break;
            path[i].events.emit(event.type, event);
        }
    }
}

重点看下dispatchEvent方法的实现过程:

  1. 向上查找parent,直到parent === null,得到事件流的完整路径;
  2. 反向遍历数组(不包括数组的第一个元素),此过程即为事件的捕获阶段;
  3. 访问当前EventTarget,此过程即为事件的目标阶段;
  4. 正向遍历数组(不包括数组的第一个元素),此过程即为事件的冒泡阶段;

扩展阅读

原文链接:juejin.im

上一篇:「面试重点」聊一聊JS中call、apply、bind里的小心思
下一篇:CSS/CSS3 7 常见“坑”

相关推荐

  • 高性能JavaScript之DOM篇

    问题一:如何获取页面中所有class为div1和div2的div元素。 问题二:你了解HTMLCollection和NodeList吗?有什么区别? ...

    9 个月前
  • 高性能JavaScript DOM编程(1)

    我们知道,DOM是用于操作XML和HTML文档的应用程序接口,用脚本进行DOM操作的代价很昂贵。有个贴切的比喻,把DOM和JavaScript(这里指ECMScript)各自想象为一个岛屿,它们之间用...

    3 年前
  • 项目中资源缓存机制实践(静态资源和本地数据缓存)

    网络资源的缓存 核心方案 1. HTML文件 nocache 2. js,css文件 时间戳/哈希/版本号,长缓存 3. 图片资源 长缓存,更新时使用新链接地址 1. 前后端...

    1 年前
  • 页面渲染机制(二)

    前言 这是我自己在学习页面渲染机制时觉得有用的知识点,并且融入了自己的思考梳理成了笔记 上一篇 页面渲染机制(一) 渲染树的构建 在 DOM 树和 CSSOM 树都渲染完成以后,就会开始构建渲染树。

    2 个月前
  • 页面渲染机制(二)

    前言 这是我自己在学习页面渲染机制时觉得有用的知识点,并且融入了自己的思考梳理成了笔记 上一篇 页面渲染机制(一) 渲染树的构建 在 DOM 树和 CSSOM 树都渲染完成以后,就会开始构建渲染树。

    3 个月前
  • 页面渲染机制(一)

    页面的渲染过程 当我们在浏览器里输入一个 URL 后,最终会呈现一个完整的网页。会经历以下几个步骤: 1、HTML 的加载 页面上输入 URL 后,会先拿到 HTML 文件。

    3 个月前
  • 面试官问:如何利用 random 计算 π

    前言 这是基友面试 RingCenter 时被问到的一个题目 表面上考察的是概率论等基础知识,实际可能还会问到事件循环等底层知识,以及 React Fiber 蒙特卡洛法求 π 说蒙特卡洛可能不太理...

    3 个月前
  • 面试官问你有没有了解过 javascript 垃圾回收机制

    我们通常理解的 javascript 垃圾回收机制都停留在表面,"会释放不被引用变量内存",最近在读《深入浅出node.js》的书,详细了解了下 v8 垃圾回收的算法,记录了一些学习笔记。

    2 年前
  • 附backbone.js观点存在与插入到DOM元素的EL

    Ben Roberts(https://stackoverflow.com/users/652693/benroberts)提出了一个问题:attaching backbone.js views to...

    2 年前
  • 重构 - 设计API的扩展机制

    1.前言 上篇文章,主要介绍了重构的一些概念和一些简单的实例。这一次,详细的说下项目中的一个重构场景给API设计扩展机制。目的就是为了方便以后能灵活应对需求的改变。

    2 年前

官方社区

扫码加入 JavaScript 社区