关于JavaScript浏览器中事件的一些小了解


笔者最近封装了一个用于制作关键帧动画的库 —— Anikyu,开发过程中遇上了一些坑,事件便是其中之一,在此来记录一下。

思考个问题

假设有一对象oO,继承自EventTarget对象;

let oO = new EventTarget()

我们为oO的 'done' 事件添加了监听器

oO.addEventListener('done', function (e) {
    console.log(e)
})
oO.addEventListener('done', function (e) {
    alert()
})

(在某事完成以后,需要触发一次'done'事件)
如何触发oO上的'done'事件?

初步理解事件

个人理解,浏览器中的事件和我们正常生活中的事件是差不多一样的一种东西。例如:

过马路:红灯亮了停,先别过绿灯亮了走,赶紧过

事件目标:人。事件对象:红绿灯。

编写页面相关时间时,我们的思路也类似这样,例如:

页面跳转:按钮被点击跳转到另一个页面

事件目标:按钮。事件对象:点击。

浏览器中的事件

事件对象

在浏览器环境下,事件被定义为一个Event类,该类是DOM的一部分,由各种方式触发的事件对象(鼠标、键盘、拖拽等等)皆基于自这一对象。

image

参考自:
Event – Web APIs | MDN

今天写博客一查,才发现有这么多。。。实际开发中,以笔者为例,目前常用的有这几种:

  • MouseEvent – 鼠标事件
  • TouchEvent – 触摸事件
  • KeyboardEvent – 键盘事件
  • InputEvent – 输入事件
  • CustomEvent – 自定义事件

其他事件或多或少也有用过,但印象不是太深刻,未来慢慢在此补充吧。

既然各种事件都继承自Event类,我们来看看Event类里面有哪些属性、方法是我们日常经常使用的:

  • type – 表示事件类型的字符串
  • target – 触发事件的目标元素
  • currentTarget – 注册事件监听的元素
  • timeStamp – 事件被触发的时间
  • isTrusted – 事件的触发是否可信,即事件是由用户主动触发的还是由脚本触发的
  • bubbles – 事件是否冒泡
  • cancelable – 事件是否可以取消默认行为
    ……
  • stopPropagation() – 停止冒泡
  • preventDefault() – 取消默认行为
  • createEvent() – 创建事件
  • initEvent() – 初始化事件
    ……

以上这些属性和方法似乎看起来都很眼熟,事实上我们在触发其它继承自Event类的事件对象(MouseEvent、KeyboardEvent等)时都经常使用,但我们一般都不能够直接接触Event事件。

事件目标

事件目标(EventTarget)表示的是一个可以接收事件的对象,该对象能够触发其自生所绑定的一些事件。打开浏览器开发者工具窗口,我们可以在console里面输入window,一层层展开其下方的 .__proto__ 属性,我们可以发现,有一层即是EventTarget。同样,我们也可以看看document、Element等对象对应的 .__proto__ 属性,也能够在某一层找到EventTarget。也就是说,浏览器中很多能够接收事件的对象都继承自EventTarget。

来看一看在浏览器中,EventTarget的实例将会具有哪些方法:
image

  • .addEventListener( type, callback, options )
  • .removeEventListener( type, callback, options )
    增加/移除事件监听,接收三个参数
    1. 事件类型:一个字符串,表示事件的类型,例如'click'
    2. 回调函数:一个函数,事件触发的时候执行。
    3. 第三个参数可以接收两种值
      3.1 监听选项:一个对象,用于配置监听器,选项均为Boolean值(我们似乎总会把打三个参数忽略?以下相关配置来自mdn) - capture - 表示监听器会在事件捕获阶段传播到该 EventTarget 时触发 - once - 表示事件只触发一次,触发后监听器将被移除 - passive - 若该值为true,表示 listener 永远不会调用 preventDefault() 3.2 useCapture – 一个Boolean,表示监听器会在事件捕获阶段传播到该 EventTarget 时触发
  • .dispatchEvent( event )
    手动触发事件,接受一个事件对象(Event的实例)作为参数

日常开发,我们总会使用 .addEventListener() 方法来为某一个对象添加事件监听(最常见的是HTML元素的鼠标点击)。事实上,我们并不能在这一对象上直接找到.addEventListener()方法。该方法其实是由EventTarget所提供的。既然某个对象继承自这个类,那继承的对象自然具有EventTarget相关的方法。

在较旧的浏览器中(比如IE11),EventTarget并不是以类的形式而存在的,仅仅是相关对象包含它提供的方法,window.EventTarget 提示 undefined,因此似乎无法使用继承来让某一对象继承自该类。

事件的触发

隐式触发

事件的隐式触发过程是由浏览器内部所实现的。例如,用户点击鼠标,即触发click事件。开发者无需在代码中编写触发事件的具体过程,只需增加事件监听、编写回调函数即可。
这种方式一般触发的都是浏览器内置事件。

相关资料:
Event reference | MDN

显式触发

事件的显式触发过程是由开发者所实现的。例如,开发者期望某操作结束后,触发一次某个事件。此时,只需在提前增加事件监听、编写回调函数,在某操作结束后,手动触发事件。这种方式既可以手动触发浏览器内置事件(即使某事件未达到触发条件),也可以触发由开发者所定义的自定义事件。

如何显式触发一个事件?

我们来看看需求中的问题,如何触发oO所包含的事件'done'

这里的'done'事件,好像没见过呀?在上文链接Event reference | MDN里没找到叫'done'的事件。。。触发不了呀。。。

这是自定义事件(CustomEvent),事件类型字符串是开发者自行定义,若要触发它,需要在脚本中手动触发。如下是触发方式:

推荐的触发方式

  1. 初始化一个事件对象
let theEvent = new CustomEvent('done',{
    detail:{ msg:'i`m a message' }
})

此处直接使用new操作符新建一个事件对象,需要确定一下你要触发的事件是什么类型的,例如鼠标事件为MouseEvent,键盘事件为KeyboardEvent,自定义事件就是此处的CustomEvent。构造函数接收两个参数,第一个参数是表示事件类型的字符串,第二个参数是事件详情。
回调函数若要调用事件的相关信息,需要:

function callback( e ){
    console.log( e.detail.msg )
}
  1. 触发事件
oO.dispatchEvent(theEvent)

将前一步创建的事件对象传入EventTarget实例的.dispatchEvent( … )函数中,即可触发该对象上所包含的事件。


过时的触发方式

  1. 初始化一个事件对象
let theEvent = document.createEvent('CustomEvent')
theEvent.initCustomEvent('done', true, true,{
    msg:'i`m a messgae'
})

此处使用document.createEvent创建一个自定义事件对象。与推荐的方法中展示的一样,需要确定你要触发的事件是什么类型的,传入相对应类型的字符串。

之后初始化事件对象的配置,传入参数为事件类型字符串、是否冒泡、是否取消默认行为以及自定义事件的detail
bad code

  1. 初始化一个事件对象

    let theEvent = document.createEvent('CustomEvent'); theEvent.initEvent('done', true, true); theEvent.msg = 'i`m a message';
    此处使用document.createEvent创建一个自定义事件对象。与推荐的方法中展示的一样,需要确定一下你要触发的事件是什么类型的,然后传入相对应类型的字符串。

之后初始化事件对象的配置,传入参数为事件类型字符串、是否冒泡、是否取消默认行为。

然后设置事件对象中相关参数(如msg值设为一个字符串)。

经过测试,该方法似乎无法对theEvent.detail进行赋值(e.detail总为null),所有事件相关信息仅能够保存在theEvent上。回调函数若要调用事件的相关信息,需要:
function callback( e ){ console.log( e.msg ) }

  1. 触发事件
oO.dispatchEvent(theEvent)

同上。

原生DOM事件(EventTarget、Event)的局限性

(下列问题来自笔者开发Anikyu时使用EventTarget、Event时遇到的问题)

  1. DOM事件为何叫DOM事件?因为DOM事件源于DOM,专用于DOM。正是因此,若我们需要在DOM元素上添加事件监听,首先想到的自然是使用addEventListener方法。 倘若我们要为一个非DOM元素(比如一个简单的对象)添加事件监听,那我们要怎么做呢? 直接按照上面那样,让这个对象继承浏览器自带的EventTarget不就行了 较高版本Chrome浏览器的确可以按照这种方式来实现,但如果我们要兼容IE浏览器呢?IE浏览器任何一个版本的window.EventTarget都是undefined。显然当初早期浏览器在设计时并没有想过以后会有某个类来继承这个类。
  2. DOM事件脱离完全脱离DOM后,事件将不能执行。JavaScript具有不同的宿主环境,例如包含DOM的浏览器环境、不包含DOM的Node.js环境;Node.js全局(global)不包含EventTarget,显然我们并没有办法在Node.js中调用.addEventListener等方法。
  3. 假设我可能还想实现这样一个功能,判断该事件目标上是否存在某一回调函数或者有多少个回调函数,这样的需求如何实现?
    根据我查的相关资料,似乎仅能够在Chrome浏览器开发者工具打开后,在console中手动输入专有的getEventListeners函数来获取;网页脚本无法调用这个函数。因此这个需求可能做不到。

来实现一个自己的EventTarget – EventDoer

(网上查资料,听过这就是传说中的观察者模式?)

规划类的功能

基于以上局限性,Anikyu在开发时自行根据EventTarget相关特性写出了EventDoer类,以用于模拟EventTarget。

let EventDoer = function(){
    this.listeners = {} // 存储监听器的地方
}

上文我们已经了解到,浏览器原生EventTarget实例中包含的方法有:

  • .addEventListener( type, callback, options )
  • .removeEventListener( type, callback, options )
  • .dispatchEvent( event )

在此,我们要对这三个函数进行一下修改:

  • 由于我们自己写的EventDoer已经脱离了DOM,我们可以假设对象没有父子层级的嵌套,事件仅需对单个对象执行即可,不必考虑冒泡等选项,因此.addEventListener.removeEventListener的第三个参数可以去掉。
  • 在触发事件的过程中,我们希望直接按照事件类型名字来触发事件,同时能够传入事件详情。在.dispatchEvent方法中所传入的event对象,需要经过new Event(准确来说是继承自Event的其他类)来生成稍显繁琐,且生成的对象具有较大限制,因此我们将这个方法抛弃,重新写一个.fireEvent方法,其接收事件类型名称(name – String)、事件详情(detail – Object)作为参数

我们将这三个经过改造的函数加入到EventTarget的原型中,基于EventTarget的实例将可以执行这三个方法。

EventDoer.prototype = {
    addEventListener( type, callback ){},
    removeEventListener( type, callback ){},
    fireEvent( name, detail ){},
}

思考个问题,事件的监听器的回调函数应该存放在哪里?

事件的回调函数应该是可以按照事件类型名称来获取的,一个名称可对应对各回调函数(触发事件的时候,一个事件能够触发多个回调函数)。这样一想,似乎用对象套数组的方式就能够实现。类似如下数据结构:

// key(click、mousedown、done)为事件名称,value(后面数组)是该事件触发后将执行的一些函数
let listeners = {
    click: [ 函数a , 函数b ],
    mousedown: [ 函数a, 函数b.bind(), 函数c, 函数d ],
    done: [函数a , 函数d]
}

一个EventDoer实例对应一组事件监听,因此我们可以把这些事件监听放到EventDoer中;同样上文提到我们希望能够有个函数可以通过事件类型名称来获得某一事件下对应的所有监听器,所以我们可在EventDoer原型里添加一个这样的方法'.getListeners(name)'

最终我们得到的EventDoer类长这样:

EventDoer.prototype = {
    listeners:{}, // 有个疑问,为何原型上也有一个listeners?删了该值似乎也可以正确执行?但删除类本身的listeners在执行时出现意外效果
    addEventListener( type, callback ){},
    removeEventListener( type, callback ){},
    fireEvent( name, detail ){},
    getListeners( name ){}
}

实现类内的方法

  • addEventListener( type, callback )
    查找listeners对象中是否存在给定的type,不存在则将其赋值为空数组,存在则将callback压入该数组。
function addEventListener (type, callback){
    if(!(type in this.listeners)){
        this.listeners[type] = [];
    }
    this.listeners[type].push(callback);
}
  • removeEventListener( type, callback )
    查找listeners对象中是否存在给定的type,不存在直接return,存在则继续查找对应的数组中是否包含callback,若包含则直接移除。
function removeEventListener (type, callback){
    if(!(type in this.listeners)) return;
    let typeHandlers = this.listeners[type];
    for(let i = 0;i < typeHandlers.length;i++){
        if(typeHandlers[i] === callback){
            typeHandlers.splice(i,1);
            return;
        }
    }
}
  • fireEvent( name, detail )
    (这里的name对应其他函数中的type)
    查找listeners对象中是否存在给定的type,不存在直接return,存在则遍历对应数组中所有函数,将detail传入这些函数中,依次执行。
function fireEvent (name, detail){
    if(!(name in this.listeners)){
        return true;
    }
    let typeHandlers = this.listeners[name].concat();
    for(let i = 0;i < typeHandlers.length;i++){
        typeHandlers[i].call(this,detail);
    }
}
  • getListeners( name )
    (这里的name对应其他函数中的type)
    以name为key,listeners中对应的数组,将该数组直接返回。若不传入name则直接返回listeners。
function getListeners (name){
    if(name){
        return this.listeners[name];
    }
    return this.listeners;
}

最终代码

Anikyu中的EventDoer

参考资料:
EventTarget – Web APIs | MDN
漫谈js自定义事件、DOM/伪DOM自定义事件 « 张鑫旭-鑫空间-鑫生活
谈谈JS的观察者模式(自定义事件) – 小蚊 – 博客园

后续

经过以上步骤,我们就已经成功实现了一个和浏览器原生EventTarget相似的EventDoer。

若要期望某个对象可以存在事件监听,只需让该对象继承自EventDoer即可。当然,由于这里的对象不存在类似DOM的元素的嵌套关系,因此并没有冒泡一类的特性。

笔者的另一个项目,Anikyu便使用了EventDoer。每一个Anikyu实例都需要一些事件监听,包括动画进行过程中对每一帧绘制的监听、动画结束时的监听、实例被废弃时的监听等等。经过测试,Anikyu的简易demo是可以在Node.js环境下运行的。因此理论上来说,Anikyu是能够兼容IE8甚至更低版本浏览器的(之前想用Rollup打包,然而笔者不太会配置,就还是用了Webpack,然而Webpack打出来的包由于包含Object.defineProperty,因此不支持IE8)。

完结后的疑问

以上就是本人对浏览器中事件的一些粗浅了解。

但仍然还有几个问题,本人没搞懂:

  1. 过时的触发方式小节中,如何向回调函数的事件对象中传递detail?为何事件对象的detail总是为null?

今天(2020.6.10)突然想起这个问题,遂百度查了查资料,发现我方法用错了。错就错在使用.initEvent()方法来初始化事件对象。

let theEvent = document.createEvent('CustomEvent')
theEvent.initEvent('done', true, true)
theEvent.msg = 'i`m a message'

在这里我们创建的事件是一个自定义事件(CustomEvent),此处的确可以使用.initEvent()来初始化事件对象。但是,该函数初始化的事件对象一般来说应该是事件(Event)(各种事件对象的基类),然而Event对象不能够传入detail,这也就导致了事件虽然能够触发,但却无法接收一个detail。因此我们需要把调用的方法改为.initCustomEvent()

正确代码:

let theEvent = document.createEvent('CustomEvent')
theEvent.initCustomEvent('done', true, true,{
    msg:'i`m a messgae'
})

.initCustomEvent()方法第4个参数即为detail。

提交:https://github.com/gogoend/gogoend.github.io/commit/c04dff3caaaffc6e503fefc788d53ae4e74c4923

参考自:
javascript – event.initEvent vs event.initCustomEvent – Stack Overflow
自定义事件——Event和CustomEvent – 苏帕 – 博客园

  1. 规划类的功能小节中,为何要在类本身的构造函数及其原型上都定义一个listeners对象?为何删除原型上的listeners后,代码也可以正确执行?但删除类本身的listeners,代码执行就不正确?

今天(2020.6.7)写另一篇issue的时候(有关JS中各类继承姿势优缺点的了解 )翻红宝书看了一眼(6.2.3 原型模式),内容大意是,原型上定义的东西是类产生的实例所共享的,而类构造函数里定义的属性this.xxx才是实例自己的~(尴尬了沙雕了,emmmmm)

  1. 由问题1可知,由document.createEvent(‘CustomEvent’)创建的事件可以调用initEventinitCustomEvent两个方法来初始化事件对象,但事件类型各不相同,在detail的处理上有所区别。为何这两个初始化事件对象方法都能够被创建的事件调用?

提这个问题的时候,突然想试一下,有没有这样的可能性:某种事件对象被其他类型的事件创建以及触发(例如鼠标事件能够初始化自定义事件的对象)最终试了试,但显然是我多虑了 —— initEvent相关函数是由各个事件对象所维护的,特定事件只能创建和当前事件一致的事件对象,

事件有多种,Event是各种事件的父类,例如自定义事件、UI事件(UI事件又是鼠标事件、键盘事件等等的父类)等等,他们的继承关系靠原型链维护。既然子类继承了父类,那原型链上就包含子类自身以及从父类继承而来的的方法。因此,各类事件理论上来说都可以使用initEvent来初始化自己的事件对象。

然而父类的initEvent功能有限(比如不接受自己传入的detail),子类的相关方法扩展了父类的方法,这样通过子类的相应方法就成功传入了detail。因此初始化时间时,最好还是应当调用子类上相关的init***Event函数。


评论