事件机制详解
页面交互的本质是"事件":用户点击、键盘输入、窗口滚动……浏览器把这一切抽象为事件对象,沿文档树传播。理解事件流三阶段与委托机制,是写出健壮交互代码的前提。
一、事件流三阶段:捕获、目标、冒泡
点击一个按钮时,事件并不是"只发生在按钮上",而是从 window 出发沿树下行,到达目标后再上行的完整旅程:
阶段 1 捕获:window → document → html → body → ... → 目标元素
阶段 2 目标:事件到达目标元素
阶段 3 冒泡:目标元素 → ... → body → html → document → windowconst outter = document.querySelector("#outter");
const inner = document.querySelector("#inner");
// 冒泡阶段监听(默认)
outter.addEventListener("click", () => console.log("outter 冒泡"));
inner.addEventListener("click", () => console.log("inner 冒泡"));
// 捕获阶段监听(第三参数 true)
outter.addEventListener("click", () => console.log("outter 捕获"), true);
// 点击 inner 的输出顺序:
// outter 捕获 → inner 冒泡 → outter 冒泡| 参数 | 值 | 含义 |
|---|---|---|
useCapture | false(默认) | 冒泡阶段触发 |
useCapture | true | 捕获阶段触发 |
| options | { capture: true } | 等价写法,更可读 |
多数事件都会冒泡(focus、blur、scroll 等少数不冒泡),所以"在祖先上监听、在目标上处理"才成为可能——这就是事件委托的根基。
二、addEventListener 与 onXxx 属性对比
两种绑定方式差别很大:
const btn = document.querySelector("#btn");
// 方式一:onXxx 属性
btn.onclick = () => console.log("第一次");
btn.onclick = () => console.log("第二次"); // 覆盖!只输出"第二次"
// 方式二:addEventListener
btn.addEventListener("click", () => console.log("A"));
btn.addEventListener("click", () => console.log("B")); // 都执行| 对比项 | onXxx 属性 | addEventListener |
|---|---|---|
| 同一事件多监听器 | 不能,后写覆盖前写 | 可以,按注册顺序执行 |
| 移除方式 | 赋 null | removeEventListener |
| 捕获阶段控制 | 不支持 | 支持(第三参数) |
| options(once/passive) | 不支持 | 支持 |
| 冒泡阶段触发 | 是 | 默认是,可切换 |
// 移除监听
const handler = () => console.log("clicked");
btn.addEventListener("click", handler);
btn.removeEventListener("click", handler); // 必须传同一个函数引用注意 removeEventListener 的三大要求:同一元素、同一事件名、同一函数引用(不能写匿名函数),且第三参数要与绑定时一致。
三、事件对象 event
每个事件处理器都会收到一个事件对象,最常用的是这些成员:
| 成员 | 含义 |
|---|---|
event.type | 事件类型字符串,如 "click" |
event.target | 触发事件的最深元素(委托时是真正被点的那个) |
event.currentTarget | 当前监听器挂载的元素(委托时是祖先) |
event.preventDefault() | 阻止默认行为(如跳转、提交) |
event.stopPropagation() | 阻止继续冒泡/捕获传播 |
event.stopImmediatePropagation() | 阻止传播 + 阻止同元素后续监听器 |
event.timeStamp | 事件发生时间戳 |
document.querySelector("#list").addEventListener("click", (event) => {
console.log(event.target); // 实际点击的 li(或更深元素)
console.log(event.currentTarget); // #list 本身
});3.1 target 与 currentTarget
// 点击 li 时
outter.addEventListener("click", (event) => {
console.log(event.target === inner); // true,真正被点的元素
console.log(event.currentTarget === outter); // true,挂监听器的元素
});委托场景下判断"点到的是什么"必须用 target;同一回调绑定多个元素时,currentTarget 才是"当前这个元素"。
3.2 preventDefault 与 stopPropagation 区别
| 方法 | 阻止的内容 | 典型场景 |
|---|---|---|
preventDefault() | 浏览器的默认行为(跳转、提交、右键菜单) | 表单校验失败时阻止提交 |
stopPropagation() | 事件继续传播(冒泡/捕获),其他监听器照样执行 | 阻止点击冒泡到父容器 |
stopImmediatePropagation() | 传播 + 同元素剩余监听器都不执行 | 紧急拦截,优先级最高的处理 |
link.addEventListener("click", (event) => {
event.preventDefault(); // 不跳转
// 但事件仍会冒泡到 document
});
document.addEventListener("click", () => console.log("仍会执行"));四、事件委托:原理与实现
事件委托利用冒泡,把监听器挂到公共祖先上,用 target 分发处理。动态新增的子元素无需重新绑定,且大幅减少监听器数量:
const list = document.querySelector("#list");
list.addEventListener("click", (event) => {
const item = event.target.closest("li"); // 向上找最近的 li
if (!item) return; // 点击空白处忽略
console.log("点击了:", item.textContent);
// 按 data-action 分发不同操作
const action = item.dataset.action;
if (action === "edit") editItem(item);
if (action === "delete") removeItem(item);
});<ul id="list">
<li data-action="edit">编辑 A</li>
<li data-action="delete">删除 B</li>
</ul>委托 vs 逐个绑定:
| 对比项 | 逐个绑定 | 事件委托 |
|---|---|---|
| 监听器数量 | 每个元素一个 | 一个祖先一个 |
| 动态新增元素 | 需要重新绑定 | 自动生效 |
| 内存占用 | 高 | 低 |
| 定位目标 | 直接 this | 需 closest/target 判断 |
| 适合场景 | 少量固定元素 | 列表、表格、大量相似元素 |
委托的注意事项:target 可能不是想要的目标(点了 li 里的 <b>),用 closest() 向上收拢;同时判断"是否在目标范围内",避免误触发。
五、常用事件类型
| 分类 | 事件 | 说明 |
|---|---|---|
| 鼠标 | click、dblclick、mousedown、mouseup、mousemove、mouseenter、mouseleave | 后两者不冒泡,mouseover/mouseout 冒泡 |
| 键盘 | keydown、keyup、keypress(已废弃) | event.key、event.code 区分按键 |
| 焦点 | focus、blur(不冒泡)、focusin、focusout(冒泡) | 常用委托场景 |
| 表单 | submit、change、input | input 每键入一次触发一次 |
| 滚动 | scroll | 高频事件,配合节流 |
| 加载 | DOMContentLoaded、load、beforeunload | 加载时机控制 |
| 触摸 | touchstart、touchmove、touchend | 移动端 |
// 键盘:判断按键
document.addEventListener("keydown", (event) => {
if (event.key === "Enter" && event.ctrlKey) {
console.log("Ctrl + Enter");
}
});
// 输入防抖(见网络请求篇的防抖实现)
const input = document.querySelector("#search");
input.addEventListener("input", debounce(() => {
console.log("搜索:", input.value);
}, 300));
// 页面加载时序
window.addEventListener("DOMContentLoaded", () => {
console.log("DOM 已解析,可安全操作元素");
});
window.addEventListener("load", () => {
console.log("所有资源(图片、样式)加载完成");
});六、自定义事件 CustomEvent
业务逻辑需要"自己发信号"时,用 CustomEvent 搭配 dispatchEvent:
// 触发方:发布自定义事件并携带数据
const box = document.querySelector("#box");
box.dispatchEvent(new CustomEvent("user-login", {
detail: { userId: 1001, name: "张三" },
bubbles: true, // 允许冒泡,父级也能听到
cancelable: true,
}));
// 监听方
box.addEventListener("user-login", (event) => {
console.log(event.detail.userId); // 1001,数据在 detail 里
});| API | 作用 |
|---|---|
new CustomEvent(name, { detail }) | 创建事件,数据放 detail |
element.dispatchEvent(event) | 触发事件 |
event.detail | 读取自定义数据 |
自定义事件很适合模块间解耦:组件 A 完成操作后发事件,组件 B 负责监听响应,两者互不引用。也可以直接 new Event("name") 创建无数据的事件。
七、监听器生命周期管理
组件销毁时忘记移除监听器,会形成内存泄漏(元素被引用无法回收)或重复触发。三种常见做法:
// 1. 命名函数 + removeEventListener
function onResize() { console.log("resized"); }
window.addEventListener("resize", onResize);
// 清理时
window.removeEventListener("resize", onResize);
// 2. once 选项:只执行一次后自动移除
btn.addEventListener("click", handler, { once: true });
// 3. AbortController:统一取消一组监听器(现代写法)
const controller = new AbortController();
window.addEventListener("resize", onResize, { signal: controller.signal });
document.addEventListener("keydown", onKey, { signal: controller.signal });
// 清理时一次性移除全部
controller.abort();| 方式 | 优点 | 注意 |
|---|---|---|
| removeEventListener | 兼容性最好 | 必须同引用、同参数 |
| once | 简单 | 只适合"一次"语义 |
| AbortController | 批量取消 | 现代浏览器支持 |
八、捕获监听与被动监听
8.1 捕获阶段监听
某些场景需要在目标之前拦截,比如"阻止子元素处理"或"统计所有点击":
document.addEventListener("click", (event) => {
console.log("全局捕获:", event.target.tagName);
}, true); // 捕获阶段,先于任何目标的监听器8.2 passive 与滚动性能
passive: true 告诉浏览器"这个监听器不会调用 preventDefault()",浏览器就可以不等待 JS 直接滚动,显著提升滚动流畅度:
window.addEventListener("touchmove", handler, { passive: true });
window.addEventListener("scroll", handler, { passive: true }); // 新版默认被动| options | 含义 | 影响 |
|---|---|---|
capture: true | 捕获阶段触发 | 改变执行时机 |
once: true | 执行一次后自动移除 | 防止重复触发 |
passive: true | 声明不阻止默认行为 | 提升滚动/触摸性能 |
signal: AbortSignal | 可批量取消 | 便于清理 |
注意:passive: true 的监听器里调用 preventDefault() 会被浏览器忽略并告警。需要在滚动中阻止默认行为时,不能用 passive。
九、综合示例:可交互的任务列表
把委托、自定义事件、classList 组合起来:
<ul id="tasks"></ul>
<script>
const list = document.querySelector("#tasks");
const tasks = ["学习事件流", "练习事件委托", "掌握 CustomEvent"];
tasks.forEach((text) => {
const li = document.createElement("li");
li.innerHTML = `${text} <button data-action="done">完成</button>`;
list.appendChild(li);
});
// 事件委托:一个监听器管所有按钮 + 动态新增项
list.addEventListener("click", (event) => {
const btn = event.target.closest("[data-action]");
if (!btn) return;
if (btn.dataset.action === "done") {
btn.closest("li").classList.toggle("finished");
// 发出自定义事件,其他模块可以监听
list.dispatchEvent(new CustomEvent("task-done", {
detail: { text: btn.closest("li").textContent },
bubbles: true,
}));
}
});
document.addEventListener("task-done", (event) => {
console.log("任务完成:", event.detail.text);
});
</script>事件机制的三个关键词:捕获与冒泡决定执行顺序,委托减少监听器数量,preventDefault 与 stopPropagation 控制默认行为与传播路径。掌握这些,任何复杂交互都能拆解成清晰的事件流。