最近学习了Javascript事件流和事件处理程序的相关知识,以下做个总结。
JavaScript 与 HTML 的交互是通过事件实现的,事件代表文档或浏览器窗口中某个有意义的时刻。 可以使用仅在事件发生时执行的监听器(也叫处理程序)订阅事件。在传统软件工程领域,这个模型叫 “观察者模式”,其能够做到页面行为(在 JavaScript 中定义)与页面展示(在 HTML 和 CSS 中定义)的 分离。
事件流
事件流描述了页面接收事件的顺序。事件流分为两种:事件冒泡和事件捕获。
事件冒泡
事件冒泡又称IE事件流,指从最具体的元素(绑定事件处理程序的元素)开始触发,然后向上传播直至文档元素(#document)。
<html>
<head>
<title>事件冒泡</title>
</head>
<body>
<div id="myDiv">Click Me</div>
</body>
</html>
在点击上方div元素后,click事件会以如下顺序执行:
- div
- body
- html
- #document
所有现代浏览器都支持事件冒泡,只是在实现方式上会有一些变化。IE5.5及早期版本会跳过 元素(从直接到 document)。现代浏览器中的事件会一直冒泡到 window 对象。
事件捕获
事件捕获是Netscape Communicator团队提出的一种事件流。其含义为最不具体的节点先收到事件,而最具体的元素最后收到事件。事件捕获实际上是为了在事件到达最终目标前拦截事件。如果前面的例子使用的是事件捕获的话,则click事件会以如下顺序执行:
- #document
- html
- body
- div
实际上,所有浏览器都是从 window 对象开始捕获事件,而 DOM2 Events规范规定的是从 document 开始。由于旧版本浏览器不支持,因此实际当中几乎不会使用事件捕获。通常建议使用事件冒泡,特殊情 况下可以使用事件捕获。
DOM事件流
DOM2 Events 规范规定事件流分为 3 个阶段:事件捕获、到达目标和事件冒泡。事件捕获最先发生,为提前拦截事件提供了可能。然后实际的目标元素接受事件,最后进行冒泡响应事件。
事件处理程序
事件意味着用户或浏览器执行的某种动作。比如,单击(click)、加载(load)、鼠标悬停 (mouseover)。为响应事件而调用的函数被称为事件处理程序(或事件监听器)。事件处理程序的名字以“on”开头,且有很多方式来指定事件处理程序。
HTML事件处理程序
特定元素支持的每个事件都可以使用事件处理程序的名字以 HTML 属性的形式来指定。例如:
<input type="button" value="click me" onclick="console.log('clicked!')" />
点击这个按钮后控制台会输出“clicked!”。这种交互能力是通过onclick属性指定javascript代码值来实现的。注意,因为属性的值是javascript代码,所以不能以未转义的情况下使用HTML语法字符,如:和号(&)、双引号(")、小于号(<)、大于号(>)。为了避免使用HTML实体,可以使用单引号来代替双引号,或使用转义符号代替。上面的代码可以改为如下:
<input type="button" value="click me" onclick="console.log("clicked!")" />
在HTML中定义的时间处理程序可以包含精准的动作指令,也可以调用在页面其他地方定义的javascript脚本,比如:
<script>
function showMessage() { console.log("Hello world!"); }
</script>
<button type="button" onclick="showMessage()">showMessage</button>
在这个例子中,点击按钮时会调用showMessage()函数。showMessage()函数是单独在<script>元素中定义的,也可以在外部js文件中定义。
以这种方式指定的事件处理程序有一些特殊的地方。首先,会创建一个函数来封装属性的值。这个 函数有一个特殊的局部变量 event,其中保存的就是 event 对象:
<button type="button" onclick="console.log(event.type)">click me!</button> //输出“click”
有了这个对象,就不用开发者另外定义其他变量,也不用从包装函数的参数列表中去取了。 在这个函数中,this 值相当于事件的目标元素,如下面的例子所示:
<button type="button" onclick="console.log(this.type)">click me!</button> //输出“click”
这个动态创建的包装函数还有一个特别有意思的地方,就是其作用域链被扩展了。在这个函数中, document 和元素自身的成员都可以被当成局部变量来访问。这是通过使用 with 实现的:
function() {
with(document) {
with(this) {
// 属性值
}
}
}
这意味着事件处理程序可以更方便地访问自己的属性。下面的代码与前面的示例功能一样:
<button type="button" onclick="console.log(type)">click me!</button> //输出“click”
DOM0事件处理程序
指以Javascript形式在DOM对象上以事件动作(如:onclick、onmouseover 等)为属性名指定事件处理程序。
let btn = document.getElementById("myBtn");
btn.onclick = function() {
console.log("Clicked");
};
像这样使用 DOM0 方式为事件处理程序赋值时,所赋函数被视为元素的方法。因此,事件处理程 序会在元素的作用域中运行,即 this 等于元素。 如:
let btn = document.getElementById("myBtn");
btn.onclick = function() {
console.log(this.id); // "myBtn"
};
这个 ID 是通过 this.id 获取的。不仅仅是 id,在事件处 理程序里通过 this 可以访问元素的任何属性和方法。以这种方式添加事件处理程序是注册在事件流的冒泡阶段的。
通过将事件处理程序属性的值设置为 null,可以移除通过 DOM0 方式添加的事件处理程序。如下:
btn.onclick = null; // 移除事件处理程序
DOM2 事件处理程序
DOM2 Events 为事件处理程序的赋值和移除定义了两个方法:addEventListener() 和 removeEventListener()。这两个方法暴露在所有 DOM 节点上,它们接收 3 个参数:事件名、事件处理函 数和一个布尔值,true 表示在捕获阶段调用事件处理程序,false(默认值)表示在冒泡阶段调用事件处理程序。
仍以给按钮添加 click 事件处理程序为例,可以这样写:
let btn = document.getElementById("myBtn");
btn.addEventListener("click", () => {
console.log(this.id);
}, false);
以上代码为按钮添加了会在事件冒泡阶段触发的 onclick 事件处理程序(因为最后一个参数值为 false)。与 DOM0 方式类似,这个事件处理程序同样在被附加到的元素的作用域中运行。使用 DOM2 方式的主要优势是可以为同一个事件添加多个事件处理程序。如下:
<button id="myBtn">click me!</button>
<script>
let btn = document.getElementById("myBtn");
btn.addEventListener("click", () => { console.log(this.id); }, false);
btn.addEventListener("click", () => { console.log("Hello world!"); }, false);
</script>
以上代码执行结果为:当点击id为myBtn的元素时,会按照顺序执行事件处理程序。先打印this.id,后打印Hello world!。
由开头引用我们知道当addEventListener()函数的第三个参数为true时,事件会在捕获阶段触发。再结合我们上面所说的DOM事件流来举个实例。如下:
<div id="parent">
<div id="child">
I'm child
</div>
</div>
<script>
const parent = document.querySelector('#parent');
const child = document.querySelector('#child');
parent.addEventListener('click', function() {
console.log('Im parent1');
});
parent.addEventListener('click', function() {
console.log('Im parent2');
}, true);
child.addEventListener('click', function() {
console.log('Im child1');
});
child.addEventListener('click', function() {
console.log('Im child2');
}, true);
</script>
这里分别给id为parent和child的div元素添加了一个冒泡阶段事件和一个捕获阶段事件。结合上述DOM事件流关系图可以推断出:当点击childdiv元素时,会先在捕获阶段触发parent的捕获阶段点击事件,然后触发child的捕获阶段点击事件,然后在冒泡阶段先触发child的冒泡阶段点击事件,最后触发parent的冒泡阶段点击事件。所以上面的代码的打印结果为:
- Im parent2
- Im child2
- Im child1
- Im parent1
通过 addEventListener()添加的事件处理程序只能使用 removeEventListener()并传入与添加时同样的参数来移除。这意味着使用addEventListener()添加的匿名函数无法移除。如下:
let btn = document.getElementById("myBtn");
btn.addEventListener("click", () => {
console.log(this.id);
}, false);
btn.removeEventListener("click", () => { // 没有效果
console.log(this.id);
}, false);
这里因为removeEventListener()函数传入的第二个参数与addEventListener()函数传入的第二个参数不是同一个引用的函数所以没有任何效果。必须传入同一个才会有效。如:
let btn = document.getElementById("myBtn");
let handleBtn = function() {
console.log(this.id);
}
btn.addEventListener("click", handleBtn, false);
btn.removeEventListener("click", handleBtn, false); // 有效
由于传给removeEventListener()函数和传给addEventListener()函数的第二个参数为同一引用函数,所以会起到效果移除事件处理程序。
IE 事件处理程序
IE 实现了与 DOM 类似的方法,即 attachEvent()和 detachEvent()。这两个方法接收两个同样 的参数:事件处理程序的名字和事件处理函数。因为 IE8 及更早版本只支持事件冒泡,所以使用 attachEvent()添加的事件处理程序会添加到冒泡阶段
要使用 attachEvent()给按钮添加 click 事件处理程序,可以使用以下代码:
var btn = document.getElementById("myBtn");
btn.attachEvent("onclick", function() {
console.log("Clicked");
});
注意,attachEvent()的第一个参数是"onclick",而不是 DOM 的 addEventListener()方法 的"click"。
在 IE 中使用 attachEvent()与使用 DOM0 方式的主要区别是事件处理程序的作用域。使用 DOM0 方式时,事件处理程序中的 this 值等于目标元素。而使用 attachEvent()时,事件处理程序是在全局作用域中运行的,因此 this 等于 window。比如:
var btn = document.getElementById("myBtn");
btn.attachEvent("onclick", function() {
console.log(this === window); // true
});
与使用 addEventListener()一样,使用 attachEvent()方法也可以给一个元素添加多个事件处理程序。如下:
var btn = document.getElementById("myBtn");
btn.attachEvent("onclick", function() {
console.log("Clicked");
});
btn.attachEvent("onclick", function() {
console.log("Hello world!");
});
上述代码执行结果为:当点击id为myBtn的元素时,分别执行了两个不同的事件处理程序。不过,与DOM2方法不同的是,这里的事件处理程序会以他们定义的顺序反向触发。所以会先打印Hello world!,然后打印Clicked。
使用 attachEvent()添加的事件处理程序将使用 detachEvent()来移除,只要提供相同的参数。 与使用 DOM 方法类似,作为事件处理程序添加的匿名函数也无法移除。但只要传给 detachEvent() 方法相同的函数引用,就可以移除。如下:
var btn = document.getElementById("myBtn");
var handler = function() {
console.log("Clicked");
};
btn.attachEvent("onclick", handler);
btn.detachEvent("onclick", handler);
跨浏览器事件处理程序
通过以上定义事件处理程序的概述,为了能够使浏览器兼容处理事件,可以封装如下代码
var EventUtil = {
addHandler: function(element, type, handler) {
if (element.addEventListener) {
element.addEventListener(type, handler, false);
} else if (element.attachEvent) {
element.attachEvent("on" + type, handler);
} else {
element["on" + type] = handler;
}
},
removeHandler: function(element, type, handler) {
if (element.removeEventListener) {
element.removeEventListener(type, handler, false);
} else if (element.detachEvent) {
element.detachEvent("on" + type, handler);
} else {
element["on" + type] = null;
}
}
};
两个方法都是首先检测传入元素上是否存在 DOM2 方式。如果有 DOM2 方式,就使用该方式,传入事件类型和事件处理函数,以及表示冒泡阶段的第三个参数 false。否则,如果存在 IE 方式,则使用该方式。注意这时候必须在事件类型前加上"on",才能保证在 IE8 及更早版本中有效。最后是使用 DOM0 方式(在现代浏览器中不会到这一步)。注意使用 DOM0 方式时使用了中括号计算属性名,并将事件处理程序或 null 赋给了这个属性。