Skip to main content

JavaScript 事件系统

· 9 min read

要点一: 事件系统的三大角色 —— 事件对象(存储状态)、事件源(被操作对象)、监听函数(回调处理)

  • 事件最早在 IE3 和 Netscape Navigator 2 中出现,用于分担服务器运算负载
  • IE4/Netscape4 时代出现了两种对立的事件流:IE 的冒泡流 vs Netscape 的捕获流
  • DOM2 级规范统一了事件模型,形成捕获阶段 → 目标阶段 → 冒泡阶段的三阶段事件流
要点二:关键 API 与易错点
  • addEventListener 第三个参数 useCapture 决定在捕获/冒泡阶段触发,默认 false(冒泡)
  • stopPropagation() 只阻止向其他元素传播,不阻止当前元素的其他监听器
  • stopImmediatePropagation() 彻底阻止传播,包括当前元素的后续监听器
  • 事件代理(委托)利用冒泡特性,将子元素监听统一挂载到父节点,性能更优

事件系统概述

JavaScript 和 HTML 之间的交互通过事件实现。事件是文档或浏览器窗口中发生的特定交互瞬间,通过侦听器(处理程序)监听并执行相应代码。

三大核心角色

角色作用示例
事件对象存储事件的状态信息(位置、类型、目标等)event.target, event.clientX
事件源对象当前被操作的对象元素节点、document、window、XMLHttpRequest
事件监听函数事件触发时执行的回调函数function(e) { ... }

通俗类比:事件源相当于"当事人",监听函数相当于"监护人",事件对象相当于"事故详情"。当事人出事了,详情记录在案,监护人据此做出反应。

历史发展

事件最早在 IE3 和 Netscape Navigator 2 中出现,作为分担服务器运算负载的手段。到 IE4 和 Navigator4 发布时,两种浏览器提供了相似但不同的 API,并存了多个版本。直到 DOM2 级规范才开始以逻辑方式标准化 DOM 事件。

tip

IE9、Firefox、Opera、Safari 和 Chrome 均已实现"DOM2 级事件"核心模块,IE8 是最后一个使用专有事件系统的主要浏览器。

浏览器事件系统相对复杂:尽管主流浏览器都实现了 DOM2 级事件,但规范本身并未涵盖所有事件类型;BOM 事件与 DOM 事件的关系长期缺乏明确规范(HTML5 后来给出了详细说明);DOM3 级的出现使事件 API 更加繁琐。但核心概念是必须理解的。


事件传播

浏览器发展到第四代(IE4 及 Netscape Communicator4)时,团队遇到了一个关键问题:点击目标元素时,同时也点击了其容器,甚至整个页面。如果这些元素都绑定了点击事件,执行顺序应该是怎样的?

事件流示意图

事件流描述页面接收事件的顺序。IE 和 Netscape 团队提出了几乎完全相反的概念:

  • IE 事件流:事件冒泡流(由内及外)
  • Netscape 事件流:事件捕获流(由外及内)

事件冒泡 vs 事件捕获

模式传播方向描述
事件冒泡由内及外最具体的元素先接收,逐级向上传播到不具体的节点(document)
事件捕获由外及内不具体的节点先接收,最具体的节点最后接收
冒泡与捕获对比
caution

所有现代浏览器都支持事件冒泡,但实现略有差异:

  • IE5.5 及更早版本跳过 html 元素(body 直接到 document)
  • IE9+、Firefox、Chrome、Safari 将事件一直冒泡到 window 对象
caution

IE9+、Firefox、Chrome、Opera、Safari 均支持事件捕获。尽管 DOM 标准要求事件从 document 开始传播,但这些浏览器都是从 window 对象开始捕获。

tip

由于老版本浏览器不支持事件捕获,实际开发中建议优先使用事件冒泡,仅在特殊需要时使用捕获。

DOM2 级事件流

"DOM2 级事件"规定的事件流包括三个阶段:

DOM2级事件流
  1. 捕获阶段:事件从 document → html → body 向下传播,不涉及实际目标
  2. 目标阶段:事件在目标元素上发生,事件处理被视为冒泡阶段的一部分
  3. 冒泡阶段:事件从目标元素向上传播到文档(div → body → html → document
注意事项
  1. DOM2 级规范明确要求捕获阶段不涉及实际目标,但 IE9+、Chrome、Firefox、Safari、Opera9.5+ 都会在捕获阶段触发目标上的事件,导致目标元素事件执行两次
  2. 并非所有事件都有冒泡阶段,但所有事件都会经过捕获和目标阶段。例如 focusblur 事件跳过冒泡阶段

典型示例

嵌套div示例
<div id="wrap">
<div id="outer">
<div id="inner"></div>
</div>
</div>
const wrap = document.getElementById('wrap');
const outer = document.getElementById('outer');
const inner = document.getElementById('inner');

Q1: wrap 注册了点击事件,点击哪些 div 会触发?

wrap.addEventListener('click', () => alert('wrap'), false);

A1: wrap、outer、inner —— 事件最终都会冒泡到 wrap。


Q2: 三个元素均在冒泡阶段注册事件,点击 inner 时执行顺序?

wrap.addEventListener('click', () => alert('wrap'), false);
outer.addEventListener('click', () => alert('outer'), false);
inner.addEventListener('click', () => alert('inner'), false);

A2: inner → outer → wrap


Q3: 同一元素同时在捕获和冒泡阶段注册事件,点击 inner 时执行顺序?

// 冒泡阶段
wrap.addEventListener('click', () => alert('wrap bubbling'), false);
outer.addEventListener('click', () => alert('outer bubbling'), false);
inner.addEventListener('click', () => alert('inner bubbling'), false);

// 捕获阶段
wrap.addEventListener('click', () => alert('wrap capture'), true);
outer.addEventListener('click', () => alert('outer capture'), true);
inner.addEventListener('click', () => alert('inner capture'), true);

A3: wrap capture → outer capture → inner bubbling → inner capture → outer bubbling → wrap bubbling

目标元素上的事件按注册顺序执行,与 capture/bubble 参数无关。

stopPropagation()

希望事件到某个节点为止不再传播,使用 stopPropagation()

// 捕获阶段:事件传播到 p 后不再向下
p.addEventListener('click', e => e.stopPropagation(), true);

// 冒泡阶段:事件冒泡到 p 后不再向上
p.addEventListener('click', e => e.stopPropagation(), false);
caution

stopPropagation() 只阻止事件向其他元素传播,不会阻止当前元素的其他 click 监听器。不是彻底取消 click 事件。

p.addEventListener('click', e => {
e.stopPropagation();
console.log(1);
});

p.addEventListener('click', () => {
console.log(2); // 仍然会触发!
});

输出:1 → 2

stopImmediatePropagation()

想要彻底阻止事件传播,包括当前元素的后续监听器,使用 stopImmediatePropagation()

p.addEventListener('click', e => {
e.stopImmediatePropagation();
console.log(1);
});

p.addEventListener('click', () => {
console.log(2); // 不会触发!
});

输出:1


事件代理(委托)

由于事件在冒泡阶段向上传播到父节点,因此可以将子节点的监听函数定义在父节点上,由父节点统一处理多个子元素的事件。这种方法叫做事件代理(delegation)

const ul = document.querySelector('ul');

ul.addEventListener('click', e => {
if (e.target.tagName.toLowerCase() === 'li') {
// 处理 li 的点击事件
}
});

优势:

  1. 只需定义一个监听函数,即可处理多个子节点
  2. 动态添加的子节点自动生效,无需重新绑定
  3. 减少内存占用,提升性能

React 合成事件系统正是基于事件代理机制实现的。


事件监听函数

响应某个事件的函数称为事件监听函数。为事件指定处理程序的方式有多种:

事件监听函数分类
caution

由于 HTML 事件监听函数中 HTML 与 JavaScript 紧密耦合,已被大多数程序员摒弃。

HTML 事件监听函数

通过 HTML 特性直接指定:

<!-- 直接写代码 -->
<input type="button" value="Click Me" onclick="alert('clicked!')" />

<!-- 调用函数 -->
<input type="button" value="Click Me" onclick="showMessage()" />
<script>
function showMessage() {
alert('clicked!');
}
</script>

事件触发时会自动创建局部变量 event,可直接访问事件对象:

<!-- 获取目标元素 -->
<input type="button" value="Click Me" onclick="console.log(event.target)" />

<!-- this 指向当前元素 -->
<input type="button" value="Click Me" onclick="console.log(this)" />

缺点:

  • 时差问题:用户可能在元素刚出现就触发事件,此时处理函数可能尚未加载
  • 耦合问题:HTML 与 JavaScript 紧密耦合,修改需要改动两处

DOM0 级事件监听函数

通过 JavaScript 将函数赋值给事件属性:

<input type="button" id="clicker" value="点击" />
<script>
const clicker = document.getElementById('clicker');
clicker.onclick = function() {
console.log('点击了!');
};
</script>
tip

以这种方式添加的事件,会在事件流的冒泡阶段被处理。

优点:所有浏览器支持,简单,跨浏览器兼容

缺点:绑定事件不能累加,最后绑定的会覆盖之前的

clicker.onclick = function() { alert('第一次'); }; // 被覆盖
clicker.onclick = function() { alert('第二次'); }; // 只执行这个

删除事件:

clicker.onclick = null;
caution

使用 HTML 事件监听函数指定的程序,可以被 DOM0 级事件监听函数覆盖,也可以以同样方式删除。

DOM2 级事件监听函数

DOM2 级事件定义了两个标准方法:

添加事件:

target.addEventListener(type, listener[, useCapture]);
参数类型说明
typeString事件类型,如 'click'
listenerFunction/Object事件处理函数或实现 EventListener 接口的对象
useCaptureBoolean默认 false(冒泡),true 表示捕获阶段触发

移除事件:

target.removeEventListener(type, listener[, useCapture]);

主要优势:可以添加多个事件监听函数,按注册顺序触发

const btn = document.getElementById('btn');

btn.addEventListener('click', () => console.log('第一个'));
btn.addEventListener('click', () => console.log('第二个'));
注意

通过 addEventListener() 添加的事件监听函数只能使用 removeEventListener() 移除,且参数必须完全相同。匿名函数无法移除!

btn.addEventListener('click', first, false);
btn.addEventListener('click', () => console.log('第二个'), false); // 匿名函数

btn.removeEventListener('click', first, false); // 成功移除
btn.removeEventListener('click', () => console.log('第二个'), false); // 失败!
caution

如果同一个监听函数分别为"事件捕获"和"事件冒泡"各注册一次,需要分别移除,两者互不干扰。

tip

为最大限度兼容各种浏览器,建议将事件监听函数添加到冒泡阶段,除非特殊需要才使用捕获阶段。

三种方式同时存在的优先级

  • HTML 事件与 DOM0 级事件不能共存,会相互覆盖
  • DOM0 级事件不能累积添加,只执行最后一个
  • DOM2 级事件不受前两者影响,按注册顺序执行,可累积添加

this 指向

tip

监听函数内部this 指向触发事件的元素节点

<button id="btn" onclick="console.log(this.id)">点击</button>
<!-- 输出: btn -->
const btn = document.getElementById('btn');

// DOM0 级
btn.onclick = function() {
console.log(this.id); // btn
};

// DOM2 级
btn.addEventListener('click', function(e) {
console.log(this.id); // btn
}, false);

参考资料

  1. JavaScript 事件系统详解 - wanglehui
  2. JavaScript 标准参考教程 - 事件模型 - 阮一峰