DOM 的事件传送机制:捕获与冒泡

#前言

今天为大家带来的内容是 DOM 里面的事件传递机制,而与这些事件相关的代码,相信大家应该不陌生,就是 addEventListener, preventDefaultstopPropagation

简单来说,就是事件在 DOM 里面传输的顺序,以及你可以对这些事件做什么。

为什么会有 “传输顺序” 这一词呢?假设你有一个 ul 元素,底下有很多 li,代表不同的 item。当你点击任何一个 li 的时候,其实你也点击了 ul,因为 ul 把所有的 li 都包含了。

假如我在两个元素上面都加了 eventListener,哪一个会先执行?这个时候,知道事件的执行順序就很重要。

另外,由于某些浏览器(IE)的机制比较不一样,因此那些东西我完全不会提到,有兴趣的可以研究文末附的参考资料。

#简单范例

为了之后方便说明,我们先写一个非常简单的范例出来:

<!DOCTYPE html>
<html>
  <body>
    <ul id="list">
      <li id="list_item">
        <a id="list_item_link" target="_blank" href="http://google.com">
          google.com
        </a>
      </li>
    </ul>
  </body>
</html>

在这个范例里面,就是最外层一个 ul,再来 li,最后则是一个超链接。为了方便辨识,id 的取名也跟层级结构有关系。DOM 画成图大概是长这样:

有了这个简单的 HTML 结构之后,就可以很清楚的说明 DOM 的事件传递机制了。

#事件的三個 Phase

要帮一个 DOM 加上 click 事件,你会这样写:

const $list = document.getElementById('list');
$list.addEventListener('click', (e) => {
  console.log('click!');
});

而这里的 e 里面就包含了许多这次事件的相关参数,其中有一个叫做 eventPhase,是一个数字,表示这个事件在哪一个 Phase 触发。

const $list = document.getElementById('list');
$list.addEventListener('click', (e) => {
  console.log(e.eventPhase);
});

eventPhase 的定义可以在 DOM specification 里面找到:

// PhaseType
const unsigned short    CAPTURING_PHASE   = 1;
const unsigned short    AT_TARGET         = 2;
const unsigned short    BUBBLING_PHASE    = 3;

这三个阶段,就是我们今天的重点。

DOM 的事件在传播时,会先从根节点开始往下传递到 target,这边你如果加上事件的话,就会处于 CAPTURING_PHASE,捕获阶段。

target 就是你所点击的那个目标,这时候在 target 身上所加的 eventListener 会是 AT_TARGET 这一个 Phase

最后,事件再往上从子节点一路逆向传回根节点,这时候就叫做 BUBBLING_PHASE,也就是大家比较熟知的冒泡阶段。

这边用文字你可能会觉得云里雾里,直接引用一张 W3C event flow 的图,相信大家就清楚了。

使用 DOM 事件流在 DOM 树中调度的事件的图形表示

你点击那个 td 的时候,这个点击事件会先从 window 开始往下传,一直传到 td 为止,到这边就叫做 CAPTURING_PHASE,捕获阶段。接着事件传到 td 本身,这时候叫做 AT_TARGET。最后事件会从 td 一路传回 window,这时候叫做 BUBBLING_PHASE,冒泡阶段。

所以,在看一些講事件機制的文章的時候,都會看到一個口訣:

所以,再看一些将事件机制的文章的时候,都会看到一个口诀:先捕獲,再冒泡。就是这样来的。

可是,我要怎么决定我要在捕获阶段还是冒泡阶段去监听这个事件呢?

其实,一样是用大家所熟悉的 addEventListener,只是这函数其实有第三个参数,true 代表把这个 listener 添加到捕获阶段,false 或是没有传参就代表把 listener 添加到冒泡阶段。

#实际演练

大概知道事件的傳遞機制之後,我們拿上面寫好的那一個簡單範例來示範一下,一樣先附上事件傳遞的流程圖(假設我們點擊的對象是#list_item_link)

大概知道事件的传递机制之后,我们拿上面写好的那个简单范例来示范一下,一样先附上事件传递的流程图(假设我们点击的对象是 #list_item_link)。

接着,来试试看帮每个元素的每个阶段都添加事件,看一看结果跟我们想想的是否一样:

const get = (id) => document.getElementById(id);
const $list = get('list');
const $list_item = get('list_item');
const $list_item_link = get('list_item_link');

// list 的捕获
$list.addEventListener(
  'click',
  (e) => {
    console.log('list capturing', e.eventPhase);
  },
  true,
);

// list 的冒泡
$list.addEventListener(
  'click',
  (e) => {
    console.log('list bubbling', e.eventPhase);
  },
  false,
);

// list_item 的捕获
$list_item.addEventListener(
  'click',
  (e) => {
    console.log('list_item capturing', e.eventPhase);
  },
  true,
);

// list_item 的冒泡
$list_item.addEventListener(
  'click',
  (e) => {
    console.log('list_item bubbling', e.eventPhase);
  },
  false,
);

// list_item_link 的捕获
$list_item_link.addEventListener(
  'click',
  (e) => {
    console.log('list_item_link capturing', e.eventPhase);
  },
  true,
);

// list_item_link 的冒泡
$list_item_link.addEventListener(
  'click',
  (e) => {
    console.log('list_item_link bubbling', e.eventPhase);
  },
  false,
);

点一下超链接,console 输出以下结果:

'list capturing', 1
'list_item capturing', 1
'list_item_link capturing', 2
'list_item_link bubbling', 2
'list_item bubbling', 3
'list bubbling', 3

1 是 CAPTURING_PHASE,2 是 AT_TARGET,3 是 BUBBLING_PHASE

从这里就可以很明晰看出,时间的确是从最上层一直传递到 target,而在这传递的过程里,我们用 addEventListener 的第三个参数把 listener 添加在 CAPTURING_PHASE

然后事件传递到我们点击的超链接(a#list_item_link)本身,在这里无论你设置 addEventListener 的第三个参数是 true 还是 false,这里的 e.eventPhase 都会变成 AT_TARGET

最后,在从 target 不断冒泡传回去,先传到上一层的 #list_item,再传到上上层的 #list

#先捕获,再冒泡的小陷阱

既然是先捕获,再冒泡,意思是无论那些 addEventListener 的顺序怎么变,输出的东西应该还是一样才对。我们把捕获跟冒泡的顺序对调,看一下输出的结果是否一样。

const get = (id) => document.getElementById(id);
const $list = get('list');
const $list_item = get('list_item');
const $list_item_link = get('list_item_link');

// list 的冒泡
$list.addEventListener(
  'click',
  (e) => {
    console.log('list bubbling', e.eventPhase);
  },
  false,
);

// list 的捕獲
$list.addEventListener(
  'click',
  (e) => {
    console.log('list capturing', e.eventPhase);
  },
  true,
);

// list_item 的冒泡
$list_item.addEventListener(
  'click',
  (e) => {
    console.log('list_item bubbling', e.eventPhase);
  },
  false,
);

// list_item 的捕獲
$list_item.addEventListener(
  'click',
  (e) => {
    console.log('list_item capturing', e.eventPhase);
  },
  true,
);

// list_item_link 的冒泡
$list_item_link.addEventListener(
  'click',
  (e) => {
    console.log('list_item_link bubbling', e.eventPhase);
  },
  false,
);

// list_item_link 的捕獲
$list_item_link.addEventListener(
  'click',
  (e) => {
    console.log('list_item_link capturing', e.eventPhase);
  },
  true,
);

同样点击超链接,输出结果是:

'list capturing', 1
'list_item capturing', 1
'list_item_link bubbling', 2
'list_item_link capturing', 2
'list_item bubbling', 3
'list bubbling', 3

可以发现一件神奇的事,那就是 list_item_link 居然是先执行了添加在冒泡阶段的 listener,才执行捕获阶段的 listener。

这是为什么呢?其实刚刚上面有提到,当事件传递到点击的真正对象,也就是 e.target 的时候,无论你是使用 addEventListener 的第三个参数是 true 还是 false,这里的 e.eventPhase 都会变成 AT_TARGET

既然这里已经编成 AT_TARGET,自然就没有什么捕获跟冒泡之分,所以执行顺序就会根据你 addEventListener 的顺序而定,先添加的先添加的先执行,后添加的后执行。

所以,这就是为什么我们上面把捕获跟冒泡的顺序换了以后,会先出现 list_item_link bubbling 的原因。

关于事件的传递顺序,只要记住两个原则就好:

  • 先捕获,再冒泡
  • 当事件传到 target 本身,沒有分捕获跟冒泡

#取消事件传递

接着要讲的是,这一串事件链这么长,一定有方法可以中断,让事件的传递不再继续,而这个方法就是 e.stopPropagation

这个方法及在哪边,事件的传递就断在哪里,不会再继续往下传递。

例如说以上那个例子来讲,假如我加在 #list 的捕获阶段:

// list 的捕獲
$list.addEventListener(
  'click',
  (e) => {
    console.log('list capturing', e.eventPhase);
    e.stopPropagation();
  },
  true,
);

这样,console 就只会输出:

'list capturing', 1

因为事件的传递被停止,所以剩下的 listener 都不会再收到任何的事件。

不过,这里依然有一个地方要特别注意。这里指的 “事件传递被停止” 的意思不是说不会再把事件传递给 “下一个节点”,但若是你在同一个节点上有不止一个 listener,还是会被执行到。

例如说:

// list 的捕獲
$list.addEventListener(
  'click',
  (e) => {
    console.log('list capturing');
    e.stopPropagation();
  },
  true,
);

// list 的捕獲 2
$list.addEventListener(
  'click',
  (e) => {
    console.log('list capturing2');
  },
  true,
);

输出的结果是:

list capturing
list capturing2

尽管已经使用 e.stopPropagation,但对于同一层级,剩下的 listener 还是会被执行到。

若不想同一级的其它 listener 被执行,可以改用 e.stopImmediatePropagation()。比如:

// list 的捕獲
$list.addEventListener(
  'click',
  (e) => {
    console.log('list capturing');
    e.stopImmediatePropagation();
  },
  true,
);

// list 的捕獲 2
$list.addEventListener(
  'click',
  (e) => {
    console.log('list capturing2');
  },
  true,
);

结果输出:

list capturing

#取消默认行为

常常有人搞不清楚 e.stopPropagatione.preventDefault 的区别,前者刚刚已经说明了,就是取消事件往下继续传递,而后者则是取消浏览器的默认行为。

最常见的做法是阻止超链接跳转:

// list_item_link 的冒泡
$list_item_link.addEventListener(
  'click',
  (e) => {
    e.preventDefault();
  },
  false,
);

这样,当点击超链接的时候,就不会执行原本的默认行为(新开分页或者跳转),而是不做任何行为,这就是 preventDefault 的作用。

所以说,preventDefault 与 JavaScript 的事件传递一点关系都没有,加上这一行后,事件还会继续往下传递。

需要注意的地方是 W3C 文件里面写道:

Once preventDefault has been called it will remain in effect throughout the remainder of the event's propagation.

意思是说一旦执行了 preventDefault,这之后传递下去的事件里面也会有效果。

// list 的捕獲
$list.addEventListener(
  'click',
  (e) => {
    console.log('list capturing', e.eventPhase);
    e.preventDefault();
  },
  true,
);

我们在 #list 的捕获事件里面执行了 e.preventDefault(),而根据文件上面所说的,这个效果会在之后的传递事件里面一直延续。因此,之后事件传递到 #list_item_link 的时候,会发现点超链接一样没反应。

#实际应用

知道了事件的传递机制、取消传递事件和取消默认行为之后,在实际开发上有什么用处呢?

最常见的用法其实就是时间代理(Delegation),例如有一个 ul,包裹着 1000 个 li,如果帮每个 li 绑定 eventListener,就新建了 1000 个 function。但是我们已经了解,任何点击 li 的事件都会传到 ul 上,于是可以在 ul 上绑定一个 listener 就好。

<!DOCTYPE html>
<html>
  <body>
    <ul id="list">
      <li data-index="1">1</li>
      <li data-index="2">2</li>
      <li data-index="3">3</li>
    </ul>
  </body>
</html>
<script>
  document.getElementById('list')
    .addEventListener('click', (e) => {
      console.log(e.target.getAttribute('data-index'));
    });
</script>

而这样的另一个好处是当新增或者删除某一个 li 的时候,不用去处理那个元素相关的 listener,因为 listener 是在 ul 上代理。这样透过父节点来处理子节点的事件,就叫做事件代理。

除此之外,有一个有趣的应用,在知道原理后,我们可以这样使用 e.preventDefault()

window.addEventListener(
  'click',
  (e) => {
    e.preventDefault();
    e.stopPropagation();
  },
  true,
);

只要这样一段代码,就可以把页面上的所有元素的点击事件停用,像 <a> 点击也不会跳转链接,<form> 按了 submit 没反应,因为阻止了事件冒泡,其它的 onClick 事件都不会执行。

或者,也可以这样用:

window.addEventListener(
  'click',
  (e) => {
    console.log(e.target);
  },
  true,
);

利用事件传递机制的特性,在 window 上面使用捕获,就能保证一定是第一个被执行的事件,就可以在这个 function 里面监听页面中每个元素的点击,可以传送到服务端做数据统计及分析。

#总结

DOM 的事件传递机制算是 JavaScript 众多经典面试题里面相对简单很多的,只要掌握事件传递的原则跟顺序,其实就差不多。

而 e.preventDefault 与 e.stopPropagation 的区别在知道事件传递顺序之后也容易理解,前者只是取消默认行为,与事件传递没有关系,后者是让事件不再往下传递。

#参考资料