Click、touch、load、drag、change、input、error、risize — 这些都是冗长的DOM(文档对象模型)事件列表的一部分。事件可以在文档(Document)结构的任何部分被触发,触发者可以是用户操作,也可以是浏览器本身。事件并不是只是在一处被触发和终止;他们在整个document中流动,拥有它们自己的生命周期。而这个生命周期让DOM事件有更多的用途和可扩展性。
作为一个开发人员,我们必须要理解DOM事件是如何工作的,然后才能更好的驾驭它,利用它们潜在的优势,开发出更高交互性的参与体验(engaging experiences)。
反观我做前端开发的这么长时间里,我觉得我从来没有看到过一个关于DOM事件是如何工作的较为直接准确的解释。今天我的目标就是在这个课题上给大家一个清晰的介绍,让大家能够更快速的了解它。 我首先会介绍DOM事件的基本使用方式,然后会深入挖掘事件内部的工作机制,解释我们如何使用这些机制来解决一些常见的问题。
监听事件
在过去,主流浏览器之间对于如何给DOM节点添加事件监听有着很大的不一致性。jQuery这样的前端库为我们封装和抽象了这些差异行为,为事件处理带来了极大的便利。
如今,我们正一步步走向一个标准化的浏览器时代,我们可以更加安全地使用官方规范的接口。为了简单起见,这篇文章将主要介绍在现代浏览器中如何管理事件。如果你在为IE8或者更低版本写JavaScript,我会推荐你使用polyfill或者一些框架(如jQuery)来管理事件监听。
在JavaScript中,我们使用如下的方式为元素添加事件监听:
1 |
element.addEventListener(<event-name>, <callback>, <use-capture>); |
event-name
(string)
这是你想监听的事件的名称或类型。它可以是任何的标准DOM事件(click, mousedown, touchstart, transitionEnd,等等),当然也可以是你自己定义的事件名称(我们会在后面介绍自定义事件相关内容)。callback
(function)(回调函数)
这个函数会在事件触发的时候被调用。相应的事件(event)对象,以及事件的数据,会被作为第一个参数传入这个函数。use-capture
(boolean)
这个参数决定了回调函数(callback)是否在“捕获(capture)”阶段被触发。不用担心,我们稍后会对此做详细的解释。
1 2 3 4 5 6 7 8 |
var element = document.getElementById('element'); function callback() { alert('Hello'); } // Add listener element.addEventListener('click', callback); |
移除监听
移除不再使用的事件监听是一个最佳实践(尤其对于长时间运行的Web应用)。我们使用element.removeEventListener()
方法来移除事件监听:
1 |
element.removeEventListener(<event-name>, <callback>, <use-capture>); |
但是removeElementListener
有一点需要注意的是:你必须要有这个被绑定的回调函数的引用。简单地调用element.removeEventListener('click');
是不能达到想要的效果的。
本质上来讲,如果我们考虑要移除事件监听(我们在长时间运行(long-lived)的应用中需要用到),那么我们就需要保留回调函数的句柄。意思就是说,我们不能使用匿名函数作为回调函数。
1 2 3 4 5 6 7 8 9 |
var element = document.getElementById('element'); function callback() { alert('Hello once'); element.removeEventListener('click', callback); } // Add listener element.addEventListener('click', callback); |
维护回调函数上下文
一个很容易遇到的问题就是回调函数没有在预想的运行上下文被调用。让我们看一个简单的例子来解释一下:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
var element = document.getElementById('element'); var user = { firstname: 'Wilson', greeting: function(){ alert('My name is ' + this.firstname); } }; // Attach user.greeting as a callback element.addEventListener('click', user.greeting); // alert => 'My name is undefined' |
Demo: Incorrect callback context
使用匿名函数(Anonymous Functions)
我们希望回调函数中能够正确的输出”My name is Wilson”。事实上,结果确是”My name is undefined”。为了使得 this.firstName 能够返回”Wilson”,user.greeting必须在user对象的上下文环境(context)中被执行(这里的运行上下文指的是.号左边的对象)。
当我们将greeting函数传给addEventListener方法的时候,我们传递的是一个函数的引用;user相应的上下文并没有传递过去。运行的时候,这个回调函数实际上是在element的上下文中被执行了,也就是说,在运行的时候,this指向的是element,而不是user。所以this.firstName是undefined。
有两种方式可以避免这种上下文错误的问题。第一种方法,我们可以在一个匿名函数内部调用user.greeting()方法,从而获得正确的函数执行上下文(user)。
1 2 3 4 |
element.addEventListener('click', function() { user.greeting(); // alert => 'My name is Wilson' }); |
使用Function.prototype.bind
上一种方式并不是非常好,因为我们不能获得回调函数的句柄以便后面通过.removeEventListener()移除事件监听。另外,这种方式也比较丑陋。。我更喜欢使用.bind()方法(做为ECMAScript 5的标准内建在所有的函数对象中)来生成一个新的函数(被绑定过的函数),这个函数会在指定的上下文中被执行。然后我们将这个被绑定过的函数作为参数传给.addEventListener()的回调函数。
与此同时,我们获得了回调函数的句柄,从而可以随时从元素上移除相应的事件监听。
1 |
button.removeEventListener('click', user.greeting); |
想获取Function.prototype.bind的更多信息,请点击的浏览器支持页面,以及polyfill的介绍。
Event对象
Event对象在event第一次触发的时候被创建出来,并且一直伴随着事件在DOM结构中流转的整个生命周期。event对象会被作为第一个参数传递给事件监听的回调函数。我们可以通过这个event对象来获取到大量当前事件相关的信息:
type
(String) — 事件的名称target
(node) — 事件起源的DOM节点currentTarget?
(node) — 当前回调函数被触发的DOM节点(后面会做比较详细的介绍)bubbles
(boolean) — 指明这个事件是否是一个冒泡事件(接下来会做解释)preventDefault
(function) — 这个方法将阻止浏览器中用户代理对当前事件的相关默认行为被触发。比如阻止<a>元素的click事件加载一个新的页面stopPropagation
(function) — 这个方法将阻止当前事件链上后面的元素的回调函数被触发,当前节点上针对此事件的其他回调函数依然会被触发。(我们稍后会详细介绍。)stopImmediatePropagation
(function) — 这个方法将阻止当前事件链上所有的回调函数被触发,也包括当前节点上针对此事件已绑定的其他回调函数。cancelable
(boolean) — 这个变量指明这个事件的默认行为是否可以通过调用event.preventDefault来阻止。也就是说,只有cancelable为true的时候,调用event.preventDefault才能生效。defaultPrevented
(boolean) — 这个状态变量表明当前事件对象的preventDefault方法是否被调用过isTrusted
(boolean) — 如果一个事件是由设备本身(如浏览器)触发的,而不是通过JavaScript模拟合成的,那个这个事件被称为可信任的(trusted)eventPhase
(number) — 这个数字变量表示当前这个事件所处的阶段(phase):none(0), capture(1),target(2),bubbling(3)。我们会在下一个部分介绍事件的各个阶段timestamp
(number) — 事件发生的时间
此外事件对象还可能拥有很多其他的属性,但是他们都是针对特定的event的。比如,鼠标事件包含clientX和clientY属性来表明鼠标在当前视窗的位置。
我们可以使用熟悉的浏览器的调试工具或者通过console.log在控制台输出来更具体地查看事件对象以及它的属性。
事件阶段(Event Phases)
当一个DOM事件被触发的时候,它并不只是在它的起源对象上触发一次,而是会经历三个不同的阶段。简而言之:事件一开始从文档的根节点流向目标对象(捕获阶段),然后在目标对向上被触发(目标阶段),之后再回溯到文档的根节点(冒泡阶段)。
(图片来源:W3C)
事件捕获阶段(Capture Phase)
事件的第一个阶段是捕获阶段。事件从文档的根节点出发,随着DOM树的结构向事件的目标节点流去。途中经过各个层次的DOM节点,并在各节点上触发捕获事件,直到到达事件的目标节点。捕获阶段的主要任务是建立传播路径,在冒泡阶段,事件会通过这个路径回溯到文档跟节点。
正如文章一开始的地方提到,我们可以通过将addEventListener的第三个参数设置成true来为事件的捕获阶段添加监听回调函数。在实际应用中,我们并没有太多使用捕获阶段监听的用例,但是通过在捕获阶段对事件的处理,我们可以阻止类似clicks事件在某个特定元素上被触发。
1 2 3 4 5 |
var form = document.querySelector('form'); form.addEventListener('click', function(event) { event.stopPropagation(); }, true); // Note: 'true' |
如果你对这种用法不是很了解的话,最好还是将useCapture设置为false或者undefined,从而在冒泡阶段对事件进行监听。
目标阶段(Target Phase)
当事件到达目标节点的,事件就进入了目标阶段。事件在目标节点上被触发,然后会逆向回流,直到传播至最外层的文档节点。
对于多层嵌套的节点,鼠标和指针事件经常会被定位到最里层的元素上。假设,你在一个<div>元素上设置了click事件的监听函数,而用户点击在了这个<div>元素内部的<p>元素上,那么<p>元素就是这个事件的目标元素。事件冒泡让我们可以在这个<div>(或者更上层的)元素上监听click事件,并且事件传播过程中触发回调函数。
冒泡阶段(Bubble Phase)
事件在目标元素上触发后,并不在这个元素上终止。它会随着DOM树一层层向上冒泡,直到到达最外层的根节点。也就是说,同一个事件会依次在目标节点的父节点,父节点的父节点。。。直到最外层的节点上被触发。
将DOM结构想象成一个洋葱,事件目标是这个洋葱的中心。在捕获阶段,事件从最外层钻入洋葱,穿过途径的每一层。在到达中心后,事件被触发(目标阶段)。然后事件开始回溯,再次经过每一层返回(冒泡阶段)。当到达洋葱表面的时候,这次旅程就结束了。
冒泡过程非常有用。它将我们从对特定元素的事件监听中释放出来,相反,我们可以监听DOM树上更上层的元素,等待事件冒泡的到达。如果没有事件冒泡,在某些情况下,我们需要监听很多不同的元素来确保捕获到想要的事件。
Demo: Identifying event phases
绝大多数事件会冒泡,但并非所有的。当你发现有些事件不冒泡的时候,它肯定是有原因的。不相信?你可以查看一下相应的规范说明。
停止传播(Stopping Propagation)
可以通过调用事件对象的stopPropagation方法,在任何阶段(捕获阶段或者冒泡阶段)中断事件的传播。此后,事件不会在后面传播过程中的经过的节点上调用任何的监听函数。
1 2 3 4 5 6 7 8 |
child.addEventListener('click', function(event) { event.stopPropagation(); }); parent.addEventListener('click', function(event) { // If the child element is clicked // this callback will not fire }); |
调用event.stopPropagation()
不会阻止当前节点上此事件其他的监听函数被调用。如果你希望阻止当前节点上的其他回调函数被调用的话,你可以使用更激进的event.stopImmediatePropagation()
方法。
1 2 3 4 5 6 7 8 |
child.addEventListener('click', function(event) { event.stopImmediatePropagation(); }); child.addEventListener('click', function(event) { // If the child element is clicked // this callback will not fire }); |
阻止浏览器默认行为
当特定事件发生的时候,浏览器会有一些默认的行为作为反应。最常见的事件不过于link被点击。当一个click事件在一个<a>元素上被触发时,它会向上冒泡直到DOM结构的最外层document,浏览器会解释href属性,并且在窗口中加载新地址的内容。
在web应用中,开发人员经常希望能够自行管理导航(navigation)信息,而不是通过刷新页面。为了实现这个目的,我们需要阻止浏览器针对点击事件的默认行为,而使用我们自己的处理方式。这时,我们就需要调用event.preventDefault()
.
1 2 3 4 |
anchor.addEventListener('click', function(event) { event.preventDefault(); // Do our own thing }); |
我们可以阻止浏览器的很多其他默认行为。比如,我们可以在HTML5游戏中阻止敲击空格时的页面滚动行为,或者阻止文本选择框的点击行为。
调用event.stopPropagation()只会阻止传播链中后续的回调函数被触发。它不会阻止浏览器的自身的行为。
Demo:Preventing default vehaviour
自定义事件
浏览器并不是唯一能触发DOM事件的载体。我们可以创建自定义的事件并把它们分派给你文档中的任意节点。这些自定义的事件和通常的DOM事件有相同的行为。
1 2 |