Skip to main content

事件机制

DOM 事件流分为三个阶段:捕获阶段(从外向内)、目标阶段、冒泡阶段(从内向外)。实际开发中,我们主要利用冒泡机制来实现事件委托(代理),以减少内存消耗并支持动态元素的事件绑定。

事件流与传播机制

浏览器的事件模型指的是事件在 DOM 树中传播的顺序。历史上有网景(Netscape)主张的事件捕获和 IE 主张的事件冒泡,W3C 最终将两者结合,形成了如今的标准 DOM 事件流。

一次完整的事件传播包含三个阶段:

  1. 捕获阶段 (Capture Phase):事件从 window -> document -> html -> body 一路向下传导到目标节点。
  2. 目标阶段 (Target Phase):在真正触发事件的节点上执行。
  3. 冒泡阶段 (Bubbling Phase):事件从目标节点向外层逐级冒泡,直到 window

addEventListener 的第三个参数 addEventListener(type, listener, useCapture / options)

  • 如果传 false 或不传(默认),回调函数在冒泡阶段触发。
  • 如果传 true,回调函数在捕获阶段触发。
  • 还可以传配置对象,如 { once: true }(只触发一次)、{ passive: true }(承诺不调用 preventDefault,常用于优化移动端滚动性能)。

事件监听的几种方式

  1. DOM Level 0 事件dom.onclick = function() {} 或者 HTML 属性 <div onclick="say()"></div>。缺点是一个元素同一个事件只能绑定一个处理函数,后面的会覆盖前面的。
  2. DOM Level 2 事件addEventListener可以定义多个相同事件,并指定冒泡或捕获阶段触发。这两种方式中,回调函数的 this 均指向绑定的 DOM 节点。

事件对象 (Event)

事件发生以后,会产生一个事件对象并传给监听函数

1. 核心属性对比:target vs currentTarget

  • e.target真正触发事件的元素(比如你点到了一个具体的 <span>)。
  • e.currentTarget绑定了事件监听器的元素(比如你把事件绑定在了父元素 <ul> 上)。在事件委托中,这两者往往是不一样的。

2. 核心方法对比

  • e.preventDefault():阻止事件的默认行为(如 <a> 标签跳转、表单提交)。
  • e.stopPropagation():阻止事件继续传播(即阻止冒泡或捕获),但不影响绑定在同一个节点上的其他同类型监听器。
  • e.stopImmediatePropagation():不仅阻止事件冒泡,还直接截断当前节点上的其他后续监听函数的执行。
  • e.composedPath():返回一个数组,成员是事件的最底层节点和依次冒泡经过的所有上层节点。

事件代理 (事件委托)

由于事件会在冒泡阶段向上传播,我们可以把子节点的监听函数定义在父节点上,由父节点的监听函数统一处理多个子元素的事件。

优势

  1. 节省内存开销:不需要为每个子元素挂载监听器,1000 个 <li> 只需要在 <ul> 上绑定 1 个监听器。
  2. 支持动态元素:新增或删除的子元素,无需重新绑定或解绑事件。
// 经典的事件委托手写实现
export function addEventListener(element, type, fn, selector) {
element.addEventListener(type, function (event) {
// e.target 是真正触发的元素
const target = event.target;
// 如果匹配了选择器,才执行回调
if (target.matches(selector)) {
// 指定 this 为真正触发的元素
fn.call(target, event);
}
});
}

局限性

  • 并非所有事件都会冒泡。例如 focusblurmouseentermouseleave 等事件没有冒泡机制,无法进行委托。
  • mousemovemouseout 这样的事件虽然冒泡,但触发频率极高,每次都要计算定位,性能消耗大,不适合事件委托。

高频易混淆事件类型

面试题:mouseover/mouseoutmouseenter/mouseleave 有什么区别?

  • mouseover / mouseout会冒泡。当鼠标移动到子元素上时,也会触发父元素的 out 和 over,非常容易导致“闪烁”或多次触发的 Bug。
  • mouseenter / mouseleave不冒泡。只有鼠标真正离开/进入当前绑定元素本身时才会触发,更符合日常开发直觉。

自定义事件 (CustomEvent)

在组件通信或跨层级通知中,我们可以利用 CustomEvent 发布订阅。

// 1. 创建自定义事件,并携带自定义数据 detail
const customEvent = new CustomEvent("myCustomEvent", {
detail: { name: "lucas" },
bubbles: true, // 是否允许冒泡
});

// 2. 监听事件
document.addEventListener("myCustomEvent", function (event) {
console.log("收到了数据:", event.detail.name); // 'lucas'
});

// 3. 触发事件
document.dispatchEvent(customEvent);

实战场景:监听单页应用 (SPA) 的路由变化

原生的 history.pushState 不会触发任何事件。我们可以利用自定义事件,劫持并重写它,让它每次执行时都派发一个全局通知(这是 Vue-Router/React-Router 底层监听 URL 变化的原理之一):

const _historyWrap = function (type) {
const orig = history[type];
return function () {
// 执行原生方法
const rv = orig.apply(this, arguments);
// 派发自定义事件
const e = new Event(type);
window.dispatchEvent(e);
return rv;
};
};

// 劫持原生方法
history.pushState = _historyWrap("pushState");
history.replaceState = _historyWrap("replaceState");

// 现在可以监听到 pushState 了!
window.addEventListener("pushState", function (e) {
console.log("前端路由跳转了!");
});