刚会用 JavaScript 操作 DOM 时,我做了一个待办列表。点击每行的“删除”按钮,有时一条记录会被删除,有时连下一条也消失。控制台里同一个 id 偶尔打印两次。我最初在每个回调里加 return false,又到处塞 stopPropagation(),问题暂时消失,却不知道自己阻止了什么。

真正的原因是我同时给列表容器和每个按钮绑定了处理器。按钮点击后先在目标上执行,事件又冒泡到容器,两个处理器都调用了删除逻辑。动态新增的行只有容器代理,旧行却有两套监听器,因此现象看起来还不稳定。

DOM 事件从 document 捕获到目标,再逐层冒泡的传播流程

图 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() 更能长期复用。