事件机制
DOM 事件流分为三个阶段:捕获阶段(从外向内)、目标阶段、冒泡阶段(从内向外)。实际开发中,我们主要利用冒泡机制来实现事件委托(代理),以减少内存消耗并支持动态元素的事件绑定。
事件流与传播机制
浏览器的事件模型指的是事件在 DOM 树中传播的顺序。历史上有网景(Netscape)主张的事件捕获和 IE 主张的事件冒泡,W3C 最终将两者结合,形成了如今的标准 DOM 事件流。
一次完整的事件传播包含三个阶段:
- 捕获阶段 (Capture Phase):事件从
window->document->html->body一路向下传导到目标节点。 - 目标阶段 (Target Phase):在真正触发事件的节点上执行。
- 冒泡阶段 (Bubbling Phase):事件从目标节点向外层逐级冒泡,直到
window。
addEventListener的第三个参数addEventListener(type, listener, useCapture / options)
- 如果传
false或不传(默认),回调函数在冒泡阶段触发。- 如果传
true,回调函数在捕获阶段触发。- 还可以传配置对象,如
{ once: true }(只触发一次)、{ passive: true }(承诺不调用 preventDefault,常用于优化移动端滚动性能)。
事件监听的几种方式
- DOM Level 0 事件:
dom.onclick = function() {}或者 HTML 属性<div onclick="say()"></div>。缺点是一个元素同一个事件只能绑定一个处理函数,后面的会覆盖前面的。 - 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():返回一个数组,成员是事件的最底层节点和依次冒泡经过的所有上层节点。
事件代理 (事件委托)
由于事件会在冒泡阶段向上传播,我们可以把子节点的监听函数定义在父节点上,由父节点的监听函数统一处理多个子元素的事件。
优势
- 节省内存开销:不需要为每个子元素挂载监听器,1000 个
<li>只需要在<ul>上绑定 1 个监听器。 - 支持动态元素:新增或删除的子元素,无需重新绑定或解绑事件。
// 经典的事件委托手写实现
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);
}
});
}
局限性
- 并非所有事件都会冒泡。例如
focus、blur、mouseenter、mouseleave等事件没有冒泡机制,无法进行委托。 mousemove、mouseout这样的事件虽然冒泡,但触发频率极高,每次都要计算定位,性能消耗大,不适合事件委托。
高频易混淆事件类型
面试题:
mouseover/mouseout与mouseenter/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("前端路由跳转了!");
});