刚会用 JavaScript 操作 DOM 时,我做了一个待办列表。点击每行的“删除”按钮,有时一条记录会被删除,有时连下一条也消失。控制台里同一个 id 偶尔打印两次。我最初在每个回调里加 return false,又到处塞 stopPropagation(),问题暂时消失,却不知道自己阻止了什么。
真正的原因是我同时给列表容器和每个按钮绑定了处理器。按钮点击后先在目标上执行,事件又冒泡到容器,两个处理器都调用了删除逻辑。动态新增的行只有容器代理,旧行却有两套监听器,因此现象看起来还不稳定。
图 1:一次用户动作只创建一个事件对象,但它会经过多个节点和阶段。
先把事件路径打印出来
与其继续猜,我在捕获和冒泡阶段分别注册日志:
const list = document.querySelector("#todo-list");
for (const capture of [true, false]) {
document.addEventListener("click", (event) => {
console.log({
phase: event.eventPhase,
capture,
target: event.target.dataset.action,
current: event.currentTarget.nodeName,
});
}, capture);
}target 是真正被点击的元素,传播过程中不会改变;currentTarget 是当前正在执行监听器的节点。过去我把两者当成同一个东西,容器处理器拿到按钮事件时就很容易误判。
事件代理只保留一个入口
列表项会动态增加,逐个绑定监听器既重复又容易漏掉。我最后只在容器保留一个处理器,并用 closest 找到动作元素:
list.addEventListener("click", (event) => {
const button = event.target.closest("button[data-action]");
if (!button || !list.contains(button)) return;
const row = button.closest("li[data-id]");
if (!row) return;
if (button.dataset.action === "delete") {
removeTodo(row.dataset.id);
}
});list.contains(button) 这一句是后来补上的。closest 可能找到嵌套在列表之外的祖先元素;代理处理器必须确认目标仍然属于自己的边界。
不要把 stopPropagation 当修复
阻止传播有合理场景,例如一个可点击卡片里放了独立菜单,菜单操作不应该触发卡片跳转。但它不该用来掩盖重复职责。
| 问题 | 更合适的处理 |
|---|---|
| 同一业务动作绑定两处 | 删除一套处理器,保留单一入口 |
| 父组件不应响应子动作 | 父处理器检查目标和动作类型 |
| 组件确实需要隔离事件 | 在明确边界调用 stopPropagation |
| 同一处理器被重复注册 | 保存引用并在销毁时 removeEventListener |
还有一个教训是,匿名函数很难解除:
element.addEventListener("click", () => save());
element.removeEventListener("click", () => save()); // 不是同一个函数引用如果组件会反复挂载,应该保留处理器引用,或使用生命周期统一注册和清理。否则“点击两次”可能不是传播,而是监听器泄漏。
用可观察结果验证,而不是只看日志
最后我给列表留下三个手工回归用例:旧行和新行都只删除一次;点击行内文本不会删除;点击菜单不会触发行跳转。后来有了测试框架,我会直接统计业务函数调用次数:
deleteButton.click();
expect(removeTodo).toHaveBeenCalledTimes(1);
expect(removeTodo).toHaveBeenCalledWith("todo-17");这次 bug 让我第一次真正去理解浏览器的运行规则。很多前端问题表面上是一行代码,背后却是事件、渲染和生命周期的系统行为。知道事件为什么经过这里,比记住在哪里加一句 stopPropagation() 更能长期复用。