浏览器通过事件机制与用户交互。点击、滚动、键盘——所有这些行为都经过事件流的三阶段(捕获、目标、冒泡)进行传播。事件代理则利用冒泡机制,用最少的监听器处理最多的交互。
事件流:三个阶段
一个 DOM 事件从触发到完成,经历三个阶段:
捕获阶段:window → document → html → body → ... → 触发的父节点
目标阶段:到达触发事件的元素本身
冒泡阶段:触发元素 → 父节点 → ... → body → html → document → window
// 捕获阶段监听(第三个参数 true)
element.addEventListener('click', handler, true);
// 冒泡阶段监听(默认)
element.addEventListener('click', handler, false);
// 等价于
element.addEventListener('click', handler);大多数场景监听冒泡就够了。捕获阶段主要用于在事件到达目标元素之前拦截——比如全局日志记录、权限校验、在子元素处理之前先做一些预处理。
不是所有事件都会冒泡。
focus、blur、scroll、mouseenter、mouseleave、load、unload等事件不会冒泡。focus和blur有对应的冒泡版本focusin和focusout。
事件绑定
const btn = document.getElementById('btn1');
// 标准绑定
btn.addEventListener('click', (e) => {
console.log('clicked', e.target);
});
// 配置选项
btn.addEventListener('click', handler, {
once: true, // 只触发一次
passive: true, // 不调用 preventDefault(),暗示浏览器可以优化滚动性能
capture: true, // 在捕获阶段触发
});
// 移除监听(需要同一个函数引用,匿名函数无法移除)
const handler = (e) => console.log('clicked');
btn.addEventListener('click', handler);
btn.removeEventListener('click', handler);事件对象的关键属性
btn.addEventListener('click', (e) => {
e.target; // 实际触发事件的元素(点击的最深层元素)
e.currentTarget; // 绑定监听器的元素(即 this)
e.type; // 事件类型 'click'
e.eventPhase; // 1=捕获, 2=目标, 3=冒泡
e.preventDefault(); // 阻止默认行为(链接跳转、表单提交)
e.stopPropagation(); // 阻止冒泡
e.stopImmediatePropagation(); // 阻止冒泡 + 阻止同元素上的其他监听器
// 鼠标事件
e.clientX;
e.clientY; // 相对视口
e.pageX;
e.pageY; // 相对文档(含滚动)
e.offsetX;
e.offsetY; // 相对目标元素
// 键盘事件
e.key; // 'Enter'、'a' 等
e.code; // 'KeyA'、'Digit1'(物理按键位置)
e.ctrlKey;
e.shiftKey;
e.altKey;
e.metaKey;
});e.target 和 e.currentTarget 的不同在事件代理中非常关键——前者告诉你”谁被点了”,后者告诉你”监听器绑在谁身上”。
事件冒泡与阻止
事件从触发元素沿 DOM 树向上传播:
function bindEvent(elem, type, fn) {
elem.addEventListener(type, fn);
}
const p1 = document.getElementById('p1');
const body = document.body;
bindEvent(p1, 'click', (e) => {
e.stopPropagation(); // 阻止继续向上冒泡
console.log('激活');
});
bindEvent(body, 'click', (e) => {
// 点击 p1 时不会执行到这里
console.log('取消');
});stopPropagation() 会阻止事件代理,也会影响第三方脚本(如埋点、错误监控),避免滥用。
自定义事件
除了浏览器内置事件,也可以用 CustomEvent 创建自定义事件:
// 创建自定义事件
const event = new CustomEvent('my-event', {
detail: { message: 'hello' },
bubbles: true, // 是否冒泡
cancelable: true, // 是否可取消
});
// 派发
element.dispatchEvent(event);
// 监听
element.addEventListener('my-event', (e) => {
console.log(e.detail.message); // 'hello'
});在 Component/Widget 设计中,自定义事件是实现组件间解耦通信的常用手段。Vue 的
$emit和 React 的 props 回调本质也是类似的思路。
事件代理
把监听器绑定在父元素上,利用冒泡统一处理所有子元素的事件。核心依赖 e.target 和 Element.matches()。
const div1 = document.getElementById('div1');
div1.addEventListener('click', (e) => {
e.preventDefault();
const target = e.target;
if (target.nodeName === 'A') {
alert(target.innerHTML);
}
});用 matches 精确匹配
nodeName === 'A' 只能匹配标签名,matches() 支持任意 CSS 选择器:
div1.addEventListener('click', (e) => {
// 只处理带有 data-action 属性的 <a> 标签
if (e.target.matches('a[data-action]')) {
handleAction(e.target.dataset.action);
}
// 只处理 .btn-primary,包括其内部元素(需要向上查找)
const btn = e.target.closest('.btn-primary');
if (btn) {
handleClick(btn);
}
});closest(selector) 从当前元素开始向上查找匹配的祖先元素(包含自身),对于”点击按钮内部任意位置都算点击按钮”的场景非常实用。
事件代理的利弊
优点:
- 代码简洁:一行监听代替 N 次绑定
- 动态友好:新增的子元素自动获得事件处理
- 节省内存:1000 个按钮只需 1 个监听器
局限:
- 不适合需要
stopPropagation的场景(代理依赖冒泡) - 命中逻辑过于复杂时,
if嵌套难维护 - 不冒泡的事件(如
focus、scroll)无法代理——但可以用focusin替代
通用事件绑定函数
/**
* 事件绑定,支持普通绑定和代理绑定
* @param {Element} elem 绑定的元素
* @param {string} type 事件类型
* @param {string} [selector] 代理模式下的子元素 CSS 选择器
* @param {Function} fn 回调函数
*/
function bindEvent(elem, type, selector, fn) {
if (fn == null) {
fn = selector;
selector = null;
}
elem.addEventListener(type, (event) => {
const target = event.target;
if (selector) {
// 代理模式:检查触发元素是否匹配选择器
if (target.matches(selector)) {
fn.call(target, event);
}
} else {
// 普通模式
fn.call(target, event);
}
});
}
// 普通绑定
bindEvent(div1, 'click', function (e) {
e.preventDefault();
alert(this.innerHTML);
});
// 代理绑定
bindEvent(div1, 'click', 'a', function (e) {
e.preventDefault();
alert(this.innerHTML); // this 指向被点击的 <a>
});事件委托更健壮的写法
上面的 matches 方案有一个局限:如果点击的是 <a> 内部的 <span>,e.target 是 <span> 而不是 <a>,代理就会失效。可以用 closest 修复:
elem.addEventListener(type, (event) => {
const target = event.target.closest(selector);
if (target && elem.contains(target)) {
fn.call(target, event);
}
});closest 从触发元素开始向上查找,直到找到匹配 selector 的元素。elem.contains(target) 确保目标确实在代理范围内(而不是冒泡到了外面)。