How DOM Events Work [译]


这篇文章虽说是简介,但是讲的方面非常多,是 Smashing Magazine 上新鲜出炉的一篇好文,所以决定翻译一下,也当作一个学习。
原文链接:An Introduction to DOM Events

引子

DOM事件一说,肯定是一大堆的,比如:click, touch, load, drag, change, input, error, resize 等等。事件通常来讲有如下几个特性:

  • 事件是可以在文档的任何一个位置被触发的,不管是通过用户交互还是通过浏览器自动触发
  • 事件不是说在一个地方被触发就在那里结束,它通常会在文档中上串下跳,直到它的生命结束
  • 事件的生命周期,正是事件本身的奥妙所在,也是我们能够理解事件是如何工作的精髓所在

接下来,本文主要要介绍的,就是告诉你,DOM事件是如何工作的。

监听事件

在过去,浏览器对于如何在DOM节点上设置监听器的实现方式不一而足。而jQuery库也正因为它做到了把这些不同剔除,提供了一个统一的事件监听机制而变得弥足珍贵(这句话我就不吐槽了,因为jQuery对IE 6也是束手无策的)。
随着浏览器环境越来越标准化,我们就可以使用官方文档提供的API时也会显得更安全一些(可以兼容更多浏览器)。这篇文章里,主要描述在现代浏览器中如何操作事件。对于IE8及其以下的,可以找 polyfill 或者 jQuery 救火。
在Js中,设置事件监听的API如下:

// API, 3 params
element.addEventListener(<event-name>, <callback>, <use-capture>);

// example
var element = document.getElementById('element');

function callback() {
    alert('hola!');
    }

element.addEventListener('click', callback);

如上所示,是很简单的一个监听器的设置。

移除监听

一般人只设置,不移除,而在最佳实践中,移除是有好处的,其API如下:

// API, 3 params, at least first 2 params should be provided
element.removeEventListener(<event-name>, <callback>, <use-capture>);

// example
var element = document.getElementById('element');

function callback() {
    alert('Hello once');
    element.removeEventListener('click', callback);
}

element.addEventListener('click', callback);

维护正确的回调函数上下文

错误的回调上下文

首先来看一个错误的回调上下文的例子:

var element = document.getElementById('element');

var user = {
    firstname : 'Wilson', 
    greeting : function() {
        alert('My name is ' + this.firstname);
    }
};

element.addEventListener('click', user.greeting);

// result: alert
'My name is undefined'

上面这个例子,我们把 user.greeting 丢给 addEventListener 时,**我们仅仅丢了一个 greeting 函数的引用给它,但是 user 对象的上下文却没有随同 greeting 这个引用一起被丢过去。所以,在 addEventListener 内部,回调函数是在 element 的上下文中被执行,那么当跑到 this.firstname 的时候,这个 this 指向的就是 element 而不是 user 上下文了,所以抛出了 undefined 也就不奇怪了,懂了么,骚年?

包一层匿名函数解决问题

element.addEventListener('click', function() {
    user.greeting();   // alert 'My name is Wilson'
});

在包了一层匿名函数之后,这时候 user.greeting()有带括号的,表示立即执行,它当然去读取的执行上下文就是 user 了,所以这个时候跑起来是正确的。

用bind解决

这个方案比较不提倡,但是也是个方法就是了:

// 重写 user 中的 greeting 方法
user.greeting = user.greeting.bind(user);

// 丢一个引用给监听器
element.addEventListener('click', user.greeting);

// 可以选择性移除
element.removeEventListener('click', user.greeting);

Event对象

关于这个对象,有几点我们需要清楚的:

  1. event对象在事件发生时产生
  2. event对象跟随事件在文档中上串下跳
  3. event对象是作为事件监听器的回调函数的第一个参数,这个参数包含了很多可以提供我们操作的属性:
    • type : 事件的名称,string 类型
    • target : 指示事件发出的源头DOM节点,node 类型
    • currentTarget : 指示目前该事件的回调函数作用在哪个DOM节点上,node 类型
    • bubbles : 指示该事件是否是一个冒泡事件,boolean 类型
    • preventDefault : 阻止浏览器对事件所做出的默认的响应(行为),function 类型
    • stopPropagation : 阻止该事件的回调函数在别的DOM节点上触发,function 类型
    • stopImmediatePropagation : 阻止在所有DOM节点上的所有回调函数,function 类型
    • cancelable : 指示 event.preventDefault 的可用性,boolean 类型
    • defaultPrevented : 指示 event.preventDefault 方法是否已经在event对象上被调用了
    • isTrusted : boolean 类型
    • eventPhase : number 类型,指示目前该事件处在哪个阶段,后面细讲
    • timestamp : number 类型,指示事件产生的时间戳
    • 其他属性,非公共的,事件特定的属性,可以,console.log 看一下都有哪些属性

Event的阶段

当一个DOM事件触发时,它不仅仅只是在事件发起的源头触发一次。事件踏上了一趟很奇妙的旅途,这趟旅途有三段路要走,也就是上面的 eventPhase 说到的对应的数字,以下数字0代表无事件:

  1. none
  2. capture
  3. target
  4. bubbling

具体阶段对应见下图:

eventFlow

紧接着是作者写的很好的一个例子,在jsbin中可以看到,事件冒泡/捕获例子

捕获阶段

这是第一个阶段,事件从文档的根节点开始它的旅途,一层层沿着DOM树往下,在每一层的DOM节点上事件都是可以触发的,只要该节点上绑定了该类型事件的处理函数(回调函数),最后来到事件触发的源头节点,也就是 target 所指向的DOM节点。用户想要在这个阶段监听事件,只需要将事件监听器(addEventListener)的第三个参数设置为 true 即可。

捕获设置其实比较少用,捕获阶段的主要工作就是创建事件传播路径,好让这个事件懂得回家的路。当然,你也可以在这个阶段的任何一个DOM节点上添加监听器并进行处理,然后阻止该事件继续往下传播,就算事件的触发源是在比该节点更底层的节点,这个事件也不会达到事件触发源了,只会到达设置停止的那个DOM节点。设置停止事件传播的代码如下:

// 假设有如下DOM结构
html
  body
    div
      ul
        li

// 假设有如下回调函数
function callback() { ... }

// 假设我们在 div 处设置断点,停止事件传播路径的创建
div.addEventListener('click', function(e) {
    e.stopPropagation();
}, true);

// 场景假设:我们现在在 li 上进行了一次 click
1. li 上发生click,首先捕获阶段发生,click事件从文档根部(html)开始传播
2. 无论在 html 和 body 处有没有别的处理函数,click事件还是会安全地往下直到 div
3. click事件传到div时,这里的处理函数是停止该事件继续传播了,click事件就在此歇菜了
4. 所以即使“案发源头”是li,click事件也没办法继续从div往更深层次传播了,就算用户本来
   的意愿是在 li 上面绑定好了监听器和处理函数,也用不上了,fool

瞄准阶段

其实确切讲其实不是一个阶段,就是一个时间点,就是事件传播到了“案发地点”,在事件冒泡回文档的最外层之前,事件在“案发地点”的DOM节点上被处理了的这个过程。

当然啦,你也可以选择不需要在“案发地点”处理,而是可以拉回警察局处理这个事件,这个就需要靠冒泡阶段了,你可以把冒泡当作是回城卷轴或者回城的警车咯

冒泡阶段

一个事件瞄准之后,它并没有停下脚步,无论在“案发地点”有没有处理,它还是要顺着来时的路,回去警局(最外层DOM),而这一路上这个事件又会在这一路上每层DOM节点上在开一次火,说:“hey,伙计,我是sx,你要处理我不?”,如果不被处理,继续往上,就算被处理,如果不是捕获阶段的 stopPropagation ,它还是照样沿路返回,直到最外层,才算完成整趟旅程。

来看下面一个简单的例子:

// 假设有下面嵌套的元素
<div1>这里是海淀区公安局
  <div2>这里是朝阳路地方派出所
      <p>这里是案发现场,天一在这犯事了(看作click事件)

// 真实的过程可能是这样的,假设click事件发生了
1. 天一犯事了                              -- click 在 <p> 发生
2. 海淀区公安局收到报告                    -- 捕获阶段:从文档最外层开始
    - 如果决定处理,但是不往外声张了       -- 相当于 div1 进行了 stopPropagation
    - 如果决定不处理,Y的不管了            -- 相当于 click 事件继续往下传播
3. 朝阳路派出所收到报告                    -- 依然处于捕获阶段
    - 如果决定处理,李将军干预不声张了     -- 相当于 div2 进行了 stopPropagation
    - 如果决定不管,Y的你们自己搞定吧      -- 相当于 click 事件继续往下传播
4. 案发地点(天上人间之类)                -- 瞄准阶段了
    - 好嘞,逮到你小子了,“城管”当场管教   -- 相当于 click 事件在 target 处被处理掉了
    - 尼玛,城管一看,李少爷,我还是上报吧 -- 相当于 click 事件在 target 处也没办法处理掉
5. 朝阳路派出所                            -- 冒泡阶段了
    - 如果第4步处理了,这边相当于收到报告  -- 例行冒泡,无论有没有被处理
    - 如果第4步没处理,这边也还是要考虑下  -- 冒泡阶段,决定处理不处理
6. 海淀区派出所                            -- 冒泡阶段的最后了,Y到底处不处理
    - 第5步说,没办法,还是带回总局办吧    -- 所以只好在这边处理了
    - 第5步办了的话,那这里就是收到个报告  -- 也是冒泡的最后阶段了

其实经过上面的一个例子,我们很清楚地晓得,DOM事件的三个阶段,很刚好的反应了天朝处理事件的能力呀,强
当然,不是所有的事件都会冒泡,但是大部分都冒,也有些地方自己独大嘛,你们懂的。

左银右煌 /
Published under (CC) BY-NC-SA in categories programming  tagged with JavaScript