超级面板
文章目录
最新文章
最近更新
文章分类
标签列表
文章归档

JavaScript 事件研究

浏览器中的事件主要有两个来源: 一些是用户生成的(例如鼠标或键盘事件),而其他由API生成(例如指示动画已经完成运行的事件,视频已被暂停等等)。

目录

本文主要综合介绍一下浏览器的事件,首先来看事件的监听

事件监听注册与移除

注册事件监听有两种方式:注册 on-event 处理器和使用 addEventListener() 事件监听器:

注册 on-event处理器

可以将事件监听函数直接写在 HTML 元素属性内:

<button id="btn" onclick="alert('Hello world!')">click</button>

这种方法将 HTML 和 JS 糅合在一起,不利于维护和开发,尽量避免如此使用。

也可以直接为DOM属性设置指定事件的处理函数:

var btn = document.getElementById('btn');
btn.onclick = function() {
alert('click button');
};

为DOM 属性设置的事件处理器会覆盖绑定在HTML元素属性内的事件处理器,但是使用 getAttribute() 获取到的值仍旧为原始值:

<button id="btn" onclick="console.log('click btn handler1.')">click</button>
<script>
window.onload = function () {
var div = document.getElementById("btn");

console.log("Attribute reflected as a property: ", div.onclick.toString());
// function onclick(event) { console.log('click btn handler1.') }

div.onclick = function() {
console.log('click btn handler2.')
};
console.log("Changed property to: ", div.onclick.toString());
// function () { console.log('click btn handler2.') }

console.log("Attribute value is unchanged: ", div.getAttribute("onclick"));
// console.log('click btn handler1.')

// click btn.
// 'click btn handler2.'
}
</script>

注意 事件处理器的返回值决定了事件默认行为是否被取消,如果返回值为 false,则会取消事件的某些默认行为,相当于调用了 event.preventDefault(),更多细节查看 The event handler processing algorithm

on-event 处理器只能为每个元素设置一个处理函数,但是可以设置一个执行函数,在函数内绑定多个事件处理器:

var eventProxy = function(ele) {
var _fns = {};
var _ele = ele;

var _trigger = function(type) {
_fns[type].forEach(fn => fn(ele));
}

var _addEvent = function(type, fn) {
if (!_fns[type]) {
_fns[type] = [fn];
_ele[type] = _trigger(type);
} else {
_fns[type].push(fn);
}
}

var _removeEvent = function(type, fn) {
if (!_fns[type]) {
return;
} else {
var fns = [];
_fns[type].forEach(function(item) {
if (item !== fn) {
fns.push(item);
}
});
_fns[type] = fns;
}
}

return {
addEvent: _addEvent,
removeEvent: _removeEvent,
trigger: _trigger
}
}

如上,这是一个非常简陋的多事件管理器,当然这个还存在很多问题,比如没有检查参数,没有检查重复,添加删除过程中没有考虑队列是否正在使用等等问题。当然对于多个事件监听,还是推荐以下的标准事件监听函数:

事件监听函数(推荐使用)

addEventListener() 方法将指定的监听器注册到 EventTarget 上,当该对象触发指定的事件时,指定的回调函数就会被执行。 事件目标可以是一个文档上的元素 Document 本身,或者任何其他支持事件的对象 (比如 XMLHttpRequest)。

target.addEventListener(type, listener[, options]);
target.addEventListener(type, listener[, useCapture]);

type 表示事件类型;listener 表示事件监听函数;第三个参数为 boolean 时候,表示该参数是在捕获阶段监听(true)还是在冒泡阶段监听(false),如果为对象,则对象可以有三个选项:capture配置触发是在捕获还是冒泡阶段,once 指定监听器是否只执行一次,passive 指定是否忽略事件监听器中调用的 preventDefault 函数。

// html
<button id="btn">click</button>

//js
var btn = document.getElementById('btn');
btn.addEventListener('click', function() {
alert('click button');
}, false);

注意,listener 中的 this 指向的是元素自身,也与传递给它的 event 对象的 currentTarget 属性值一致。如果依赖了某些上下文,可以使用 bind 函数解决:

var doSomething = function(element) {
this.name = 'Something Name';
this.onclick = function(event) {
console.log(this.name); // undefined, as |this| is the element
};

element.addEventListener('click', this.onclick, false); // undefined
element.addEventListener('click', this.onclick.bind(this), false); // Something Name
}

var s = new doSomething(document.body);

在 IE 9 之前,不支持该方法,必须使用 attachEvent,如果需要兼容 IE,可以这样写:

const addEvent = ((root) => {
if (root.attachEvent) {
return (ele, type, fn) => ele.attachEvent('on' + type, fn);
} else if (root.addEventListener) {
return (ele, type, fn, capture = false) => ele.addEventListener(type, fn, capture);
}
})(window);

同一个 EventTarget 注册了多个相同的 listener,那么重复的实例会被抛弃,所以不必担心 listener 被调用两次。因此要注意尽量避免使用匿名函数,一方面匿名函数无法取消,而另一方面,传递同样内容的匿名函数不会被抛弃,会被重复执行:

var fun1 = () => console.log('bind func1');
btn.addEventListener('click', fun1);
btn.addEventListener('click', fun1);
// click 重复绑定函数被抛除
// bind func1

btn.removeEventListener('click', fun1);
btn.addEventListener('click', () => console.log('bind func2'));
btn.addEventListener('click', () => console.log('bind func2'));
// click 匿名函数内容一致也无法被抛除
// bind func2
// bind func2

注意 IE8 不具有任何替代 useCapture 的方法,如果有依赖这个捕获阶段的函数,请慎重考虑。

事件监听移除

对应于以上两种事件监听注册方式,有两种不同的移除方式:

// on-event 注册方式的移除
btn.onclick = null;

// addEventListener 注册方式的移除
const removeEvent = ((root) => {
if (root.detachEvent) {
return (ele, type, fn) => ele.detachEvent('on' + type, fn);
} else if (root.removeEventListener) {
return (ele, type, fn, capture = false) => ele.removeEventListener(type, fn, capture);
}
})(window);

事件触发过程

上几节定义了事件的注册,本节详细看一下事件的发生过程。

事件捕获和冒泡

事件触发的过程可以分为3个阶段:事件捕获阶段、目标阶段、事件冒泡阶段,这三个阶段的过程图所示:

W3C 事件触发过程

事件对象逐个完成这些阶段。如果浏览器不支持该阶段,或者事件对象的传播已被停止,则将跳过该阶段。例如,如果将事件对象的 bubbles 属性设置为 false,则将跳过冒泡阶段,如果在调度之前调用了 stopPropagation(),则将跳过所有阶段。

  • 事件捕获阶段:事件对象从 window 对象开始,向下经过 document、body 等元素最终传播到事件目标的父级,这个阶段被称为捕获阶段。
  • 目标阶段:事件对象到达事件目标对象(即,event.target),该阶段称为目标阶段。如果事件类型表示事件不冒泡,则事件对象将在此阶段完成后停止,不进入事件冒泡阶段。
  • 事件冒泡阶段:事件对象以事件捕获阶段相反的顺序从事件目标的父级传播到目标的祖先,到 window 对象结束,该阶段称为冒泡阶段。

我们来看个例子:

<div id="outer">
<div>click outer.</div>
<div id="inner">click inner.</div>
</div>
window.onload = function() {
var outer = document.getElementById('outer');
var inner = document.getElementById('inner');

outer.addEventListener('click', function() {
console.log('outer capture phase');
}, true);

outer.addEventListener('click', function() {
console.log('outer bubble phase');
}, false);

inner.addEventListener('click', function() {
console.log('inner capture phase');
}, true);

inner.addEventListener('click', function() {
console.log('inner bubble phase');
}, false);
}

点击后,打印的结果为:

// click outer
"outer capture phase"
"outer bubble phase"

// click inner
"outer capture phase"
"inner capture phase"
"inner bubble phase"
"outer bubble phase"

注册事件监听器的执行顺序

之前提到 addEventListener 函数的第三个参数就是指定事件在哪个阶段处理,设置为 true 则在事件捕获阶段处理,设置为 false 则在事件冒泡阶段处理,实际上并不准确,我们看如下的例子:

a.我们去掉 outer 的事件,修改 inner 事件的注册顺序:

window.onload = function() {
var outer = document.getElementById('outer');
var inner = document.getElementById('inner');

inner.addEventListener('click', function() {
console.log('inner bubble phase');
}, false);

inner.addEventListener('click', function() {
console.log('inner capture phase');
}, true);
}

然后点击 inner 发现,先执行冒泡,后捕获,似乎和注册的顺序有关:

// click inner
"inner bubble phase"
"inner capture phase"

b.这一次,我们去掉 inner 的事件,为 outer 绑定事件:

window.onload = function() {
var outer = document.getElementById('outer');
var inner = document.getElementById('inner');

outer.addEventListener('click', function() {
console.log('outer bubble phase');
}, false);

outer.addEventListener('click', function() {
console.log('outer capture phase');
}, true);
}

此时点击 inner 发现,和事件绑定顺序无关,总是先执行捕获,后执行冒泡:

// click inner/inner
"outer capture phase"
"outer bubble phase"

c.在 b 的基础上,为 outer 设置 padding,然后点击 outer,避免点击其子元素,会发现,此时又是和事件注册顺序有关,先执行冒泡,后捕获:

"outer bubble phase"
"outer capture phase"

事件监听器的执行顺序是这样的:

  1. 事件发生后,首先进入捕获阶段。
  2. 在捕获阶段经过的每一个元素(这些元素均为事件目标元素的祖先元素),查看是否有定义在该阶段的事件监听器和该元素上的事件监听器,如果有则按照定义顺序逐个执行,最终进入目标阶段。
  3. 进入目标阶段,按照事件监听器定义顺序,逐个执行定义在事件目标元素上的事件监听器,此时,不考虑听器在注册时 capture 参数值是 true 还是 false,完成后进入下一步。
  4. 如果允许冒泡在开始冒泡,并在冒泡阶段,逐个检查并运行绑定在当前元素上的冒泡阶段执行的事件监听器。

总结:如果事件点击目标(event.target)和事件的绑定目标一致(event.currentTarget) 一致,则多个事件监听器按照监听器的绑定顺序执行。否则,事件按照先执行捕获事件,后执行冒泡顺序的事件执行。

更详细的事件触发过程可以参考 DOM 的规范定义:This specification standardizes the DOM#2.8. Dispatching events

In the browsers that support the W3C DOM, a traditional event registration element1.onclick = doSomething2; is seen as a registration in the bubbling phase.

注意,在支持捕获和冒泡双阶段的浏览器中,使用 on-event 的传统方式注册的事件,可以认为是发生在冒泡阶段的。

顺便说个小插曲,这两个阶段是当年 netscape 和 微软的战争遗留,当时,netscape 主张捕获方式,微软主张冒泡方式。后来 w3c 采用折中的方式,制定了统一的标准:先捕获再冒泡。

阻止事件继续执行

在有些场合下,我们已经捕获到了想要处理的事件,不想让事件继续向下执行,这个时候就要阻止事件的冒泡:

event.stopPropagation

stopPropagation 方法可以停止事件继续传播:

window.onload = function() {
var outer = document.getElementById('outer');
var inner = document.getElementById('inner');

outer.addEventListener('click', function() {
console.log('outer run');
event.stopPropagation();
}, true);

inner.addEventListener('click', function() {
console.log('inner stoped');
}, true);
}

// "outer run"

也可以使用 e.cancelBubble = true 来替代 e.stopPropagation(),但是 cancelBubble 支持程度可能受限,为了保险,可以同时设置:

const cancelBubble = e => {
if (!e) e = window.event;
2e.cancelBubble = true;

2if (e.stopPropagation) e.stopPropagation();
}

event.stopImmediatePropagation

stopPropagation 方法,可以阻止事件传播,但是不能阻止当前事件绑定的其他的事件监听器,而本函数可以阻止事件传播,同时阻止当前事件注册的其他事件监听器:

window.onload = function() {
var outer = document.getElementById('outer');
var inner = document.getElementById('inner');

outer.addEventListener('click', function() {
console.log('outer run1');
event.stopPropagation();
}, true);

outer.addEventListener('click', function() {
console.log('outer run2');
}, true);

inner.addEventListener('click', function() {
console.log('inner stoped');
}, true);
}
// stopPropagation 无法阻止当前事件绑定的其他的事件监听器
//"outer run1"
//"outer run2"

window.onload = function() {
var outer = document.getElementById('outer');
var inner = document.getElementById('inner');

outer.addEventListener('click', function() {
console.log('outer run1');
event.stopImmediatePropagation();
}, true);

outer.addEventListener('click', function() {
console.log('outer run2');
}, true);

inner.addEventListener('click', function() {
console.log('inner stoped');
}, true);
}
// stopImmediatePropagation 可以阻止
// "outer run1"

事件委托

由于事件的冒泡机制,所有的子元素节点发生的事件都会经过父元素,并返回父元素,这样,可以将事件的处理交给父元素,这就是事件委托。

看这个例子:

// html
<ul id="list">
<li>Item 1</li>
<li>Item 2</li>
<li>Item 3</li>
<li>Item 4</li>
<li>Item 5</li>
</ul>

//js
document.getElementById("list").addEventListener("click", function(e) {
if ( event.target.tagName == 'LI' ) {
// do sth.
}
});

使用事件委托有两个优点:

  1. 可以减少事件的绑定,不用给每一个元素都绑定事件;
  2. 对于使用脚本动态添加的元素,仍然可以监听之前绑定的事件,不需要重新绑定。

事件对象 Event

上面简单介绍了如何为元素注册和移除事件,本节看一下传递给事件监听器的事件对象。

Event接口用于向事件处理程序提供关于事件的上下文信息。实现 Event 接口的对象通常作为第一个参数传递给事件监听器。

Web浏览器定义了不同种类的许多事件,每个不同的事件的定义都是继承自 Event Prototype 对象的对象。事件的继承层次如下:
The Event object hierarchy

Event 对象通用的属性和方法

通用属性:

  • bubbles: ([boolean, readonly])指示事件是否可以冒泡。如果可以冒泡,则值为 true,否则该值为 false。
  • cancelable: ([boolean, readonly])指示事件是否可以取消其默认动作。如果可以取消该值为 true,否则为 false。
  • cancelBubble: ([boolean])在事件处理器中将其值设置为 true 可防止事件传播。
  • currentTarget: ([EventTarget, readonly])指示正​​在处理其 EventListenerEventTarget。这在捕获和冒泡时特别有用。
  • defaultPrevented: ([boolean, readonly])指示事件是否调用了 event.preventDefault()
  • eventPhase: ([unsigned short, readonly])指示当前事件正处于的哪个阶段。
  • srcElement :([EventTarget, readonly]) target 的别名。
  • target: ([EventTarget, readonly])指示事件最初发送到的 EventTarget
  • timeStamp: ([DOMTimeStamp, readonly])创建事件的时间(以秒为单位)
  • type: ([DOMString, readonly])事件的名称(不区分大小写)

通用方法:

  • preventDefault: 取消事件对应的默认行为,比如点击超链接跳转新页面,如果 cancelable 属性值为 false 则不可取消,此时调用该方法无效。
  • stopPropagation 阻止当前事件在捕获和冒泡阶段的进一步传播。
  • stopImmediatePropagation 阻止事件冒泡,如果几个监听器被绑定到同一个元素的相同事件上,则按照它们被添加的顺序调用它们,如果有一个调用期间调用了event.stopImmediatePropagation(),则不会调用剩余的监听器。

自定义事件

在有些场合下,我们会想要自定义事件,这在我们自己的类库、组件的开发场合下很有用,本节介绍如何自定义事件,触发事件:

使用 Event、CustomEvent 创建自定义事件

简单创建自定义事件,可以使用 Event 构造函数:

var event = new Event(typeArg, [eventInit]);

  • typeArg:是 DOMString 类型,表示所创建事件的名称。
  • eventInit:可选,接受以下字段:
    • bubbles:可选,Boolean 类型,默认值为 false,表示该事件是否冒泡。
    • cancelable:可选,Boolean 类型,默认值为 false, 表示该事件能否被取消。

如果需要更高级的附带自定义数据的事件,可以使用 CustomEvent

var myEvent = new CustomEvent(eventName, [options]);

  • eventName:是 String 类型,表示所创建自定义事件的名称。
  • options:可选,接受以下字段:
    • detail:可选,默认值为 null,表示该事件任意的自定义数据。
    • bubbles:可选,Boolean 类型,默认值为 false,表示该事件是否冒泡。
    • cancelable:可选,Boolean 类型,默认值为 false, 表示该事件能否被取消。

如下示例:

// add an appropriate event listener
obj.addEventListener("cat", function(e) { process(e.detail) });

// create and dispatch the event
var event = new CustomEvent("cat", {
detail: {
hazcheeseburger: true
}
});

obj.dispatchEvent(event);

触发事件

除了自定义的事件外,浏览器内置的一些事件(比如,click 事件)也可以被模拟触发,比如下面这个模拟触发 click 事件的例子:

function simulateClick() {
var event = new MouseEvent('click', {
'view': window,
'bubbles': true,
'cancelable': true
});
var cb = document.getElementById('checkbox');
var cancelled = !cb.dispatchEvent(event);
if (cancelled) {
// A handler called preventDefault.
alert("cancelled");
} else {
// None of the handlers called preventDefault.
alert("not cancelled");
}
}

在自定义触发事件的时候,常用到的两个方法是:Event Constructor(例如 Event()MouseEvent())、element.dispatchEvent(event)

有的地方可能会用如下的方法,但是注意,这种方法已经过时

function simulateClick() {
// 这种方法已经过时,不推荐使用
var evt = document.createEvent("MouseEvents");
evt.initMouseEvent("click", true, true, window,
0, 0, 0, 0, 0, false, false, false, false, 0, null);
}

使用自定义事件实现发布订阅者模式

class EventEmitter {
listen: window.addEventListener,
unlisten: window.removeEventListener,
trigger(type, data) {
window.dispatchEvent(new CustomEvent(type, { detail: data }))
}
}

参考

本文涉及内容较多,也不可能面面具到,本文主要参考以下内容,更多资料还请阅读下文: