When to actually use preventDefault(), stopPropagation(), and setTimeout() in Javascript event listeners

2025-06-04

何时在 Javascript 事件监听器中实际使用 preventDefault()、stopPropagation() 和 setTimeout()

不幸的是,在 Google 上搜索“何时使用 stopPropagation()”和“何时调用 stopPropagation()”,除了一些与该主题相关的非常有缺陷和半缺陷的 文章外,几乎没有找到任何答案,但没有一篇回答何时可以使用 stopPropagation() 的问题。stopPropagation() 是存在的,因此应该被使用……但是什么时候呢?

现在是时候纠正这些错误信息,并给出关于何时调用 preventDefault() 和 stopPropagation() 以及 setTimeout() 的正确答案了。我保证 setTimeout() 是半相关的。

Web 浏览器中的事件处理对大多数人来说都相当难掌握……甚至对专家来说也是如此!编写自定义 JavaScript 代码时需要考虑85 多个事件。幸运的是,其中常用的只有少数几个:

keydown, keyup, keypress
mouseenter, mousedown, mousemove, mouseup, mouseleave, wheel
touchstart, touchmove, touchend
click, input, change
scroll, focus, blur
load, submit, resize
Enter fullscreen mode Exit fullscreen mode

我尝试将它们分成不同的类别,大多数事件的功能应该非常明显(例如,“click”表示点击了某个对象,“mousemove”表示鼠标移动)。但它们的分类依据是:键盘、鼠标、触摸屏、输入元素、焦点和滚动以及其他事件。

深入研究浏览器事件

Web 浏览器会按照特定的顺序触发事件:先捕获,然后冒泡。这到底是什么意思呢?我们用一张图来解释一下:

Web 浏览器事件模型图

我会参考上面的图表进行后续操作。当我提到“步骤 5”或“步骤 2”之类的词时,我指的是这张特定的图表。

如果写如下代码:

<style type="text/css">
.otherclass { width: 50px; height: 50px; background-color: #000000; }
</style>

<div class="theclass"><div class="otherclass"></div></div>

<script>
(function() {
  var elem = document.getElementsByClassName('theclass')[0];

  var MyEventHandler = function(e) {
console.log(e);
console.log(e.target);
console.trace();
  };

  elem.addEventListener('click', MyEventHandler);
  window.addEventListener('click', MyEventHandler);
})();
</script>
Enter fullscreen mode Exit fullscreen mode

这将设置两个冒泡事件处理程序。在本例中,点击处理程序应用于类名为“theclass”的 div 和窗口。当用户点击其中的 div 时,“点击”事件会在步骤 7 和步骤 10(如上图所示)到达 MyEventHandler。浏览器在捕获阶段沿着层次结构向下移动到目标,然后在冒泡阶段向上移动到窗口,并按此顺序触发已注册的事件监听器,并且只有到达末尾或函数调用 stopPropagation() 时才会停止。

当事件到达时,“e.target” 包含 DOM 中导致事件创建的元素及其目标节点。“e.target” 是最重要的信息,因为它包含触发事件的 DOM 节点。

实用提示:与其在层级结构中的每个按钮、div 和小玩意上注册事件,不如在具有相似特征的一组节点的父元素上注册单个事件,这样效率更高。使用“data-”/dataset 属性,即使有 500 多个子元素,也能在 O(1) 时间内完成查找。

可能出现的问题:一个例子

在深入研究 preventDefault() 和 stopPropagation() 之前,让我们先看看如果不了解事件和事件传播的工作原理会发生什么:

在上面的示例中,Bootstrap 用于在点击“下拉”按钮时显示一个选项菜单。点击“普通按钮”时,菜单会按预期关闭,但点击“远程链接”按钮时,菜单不会关闭。“远程链接”按钮使用了另一个库来处理“点击”事件,该库调用了 stopPropagation(),并且在文档的某个位置有一个冒泡的“点击”事件处理程序。

《停止事件传播的危险》一书的作者指责 jquery-ujs 的作者调用了 stopPropagation() 函数,但我们很快就会发现,实际上存在两个 bug——一个在 jquery-ujs 中,另一个在 Twitter Bootstrap 中……这两个 bug 的出现都是因为这两个库的作者并不真正理解浏览器事件模型,因此在遇到常见场景时,这两个库会发生惊人的冲突。文章作者还在文章结尾处提出了一个导致不幸情况的建议。需要注意的是,这篇文章在 Google 搜索结果中排名靠前!

理解 preventDefault() 和 stopPropagation()

让我们来看看 preventDefault() 函数,因为它的用途可能会让人有些困惑。preventDefault() 函数的作用是阻止浏览器的默认操作。例如,按下键盘上的“Tab”键,默认操作是移动到 DOM 中下一个带有“tabIndex”的元素。在“keydown”事件处理程序中调用 preventDefault() 函数会告知浏览器您不希望浏览器执行默认操作。浏览器可以忽略该提示并执行任何它想做的事情,但它通常会接受这个提示。

何时应该调用 preventDefault()?当你知道浏览器会执行你不希望它执行的操作时。换句话说,通常情况下,不要调用它,然后观察会发生什么。如果浏览器的默认行为不符合预期,那么只有在那时才需要确定何时何地调用 preventDefault()。覆盖默认行为应该始终对最终用户有意义。例如,如果在“keydown”处理程序中调用 preventDefault(),并且用户按下“Tab”,则处理程序应该执行一些合理的操作,将焦点移至“下一个”元素。如果用户按下“Shift + Tab”,则处理程序应该转到“上一个”元素。

现在让我们看看 stopPropagation() ,因为它实际上的作用更加令人困惑。当调用“e.stopPropagation()”时,浏览器会完成当前步骤的所有事件调用,然后停止运行事件回调。“e.target”节点有一个例外,即使在步骤 5 中调用了 stopPropagation(),它也会同时处理步骤 5 和步骤 6。(这些“步骤”指的是之前的图表。)

调用 stopPropagation() 的问题在于,它会立即停止事件处理。这会给后续的监听器带来问题,因为它们监听的事件无法传递。例如,如果“mousedown”事件传递到正在监听“mousedown”事件的父级,以便开始执行某些操作,然后又监听了匹配的冒泡“mouseup”事件,但其他事件在其自身的“mouseup”事件处理程序中调用 stopPropagation() ,那么“mouseup”事件就永远不会到达,用户界面就会崩溃!

有人建议调用 preventDefault() 并使用 e.defaultPrevented 来阻止事件处理,而不是 stopPropagation()。然而,这个想法存在问题,因为它也会告诉浏览器不执行其默认操作。在执行更高级的操作时,这也会引入许多细微的错误。例如,在将 draggable 设置为 true 的节点上的 mousedown 处理程序中调用 preventDefault() 会导致 dragstart 永远不会被调用,从而导致各种问题。仅仅查看 e.defaultPrevented 并返回给调用者而不执行任何其他操作也是不合适的。

可以说,使用 'e.defaultPrevented' 也行不通。那么什么方法有效呢?正确的答案是谨慎地调用 preventDefault(),只偶尔结合 DOM 层次结构检查 'e.defaultPrevented'(通常是为了打破循环),并且极少调用 stopPropagation()。

回答问题

现在让我们回答最初的问题:“什么时候才可以使用 stopPropagation()?” 正确的答案是只在“模态窗口”中调用 stopPropagation() 。Web 浏览器中的模态窗口的定义比“子窗口阻止访问父窗口直到其关闭”的定义更加灵活,但概念是相似的。在这种情况下,我们希望将其困在沙盒中,让事件在 DOM 树上继续向下/向上传播是没有意义的。

一个例子是下拉菜单,它允许用户使用鼠标和键盘浏览菜单。对于鼠标来说,在菜单的任何位置单击“鼠标按下”都会选中一个菜单项,而在页面的其他位置单击菜单则会关闭菜单(取消)并在其他位置执行其他操作。在这个例子中,调用 stopPropagation() 是错误的,因为这样做会阻止鼠标正常运行,需要额外的单击才能执行操作。

然而,对于键盘来说,情况就完全不同了。键盘应该将焦点放在菜单上,并且焦点应该一直被困在沙盒中,直到用户使用键盘(或使用鼠标)导航离开。这是预期的行为!键盘事件(keydown/keyup/keypress)与鼠标事件相关的用户体验完全不同。键盘导航始终遵循一系列连续的步骤。

对于下拉菜单,按下键盘上的“Esc”或“Tab”键应该会退出菜单。但是,如果允许事件沿 DOM 树向上传递,按下 Esc 键也可能会取消父对话框(另一个模态窗口!)。stopPropagation() 是键盘事件的正确解决方案,当键盘焦点位于模态窗口时。除非在屏幕上显示真正的模态窗口,否则鼠标和触摸事件几乎从不模态。因此,键盘事件更可能频繁地出现在模态窗口的场景中,因此 stopPropagation() 是正确的解决方案。

整合起来

好的,让我们回到之前的 Bootstrap/jquery-ujs 示例,并利用我们对浏览器事件模型的新理解来解决这个问题。我们知道在“远程链接”按钮处理程序中调用 stopPropagation() 是错误的,因为它导致 Bootstrap 无法关闭弹出窗口。但是,还记得我说过这里有两个 bug 吗?Bootstrap 错误地监视了冒泡事件来关闭下拉菜单。如果你同时查看之前的图表和事件列表,你能弄清楚 Bootstrap 应该查找哪个事件,以及它应该在哪个步骤中监视该事件吗?

.................................................................................................................................











如果你猜的是窗口上的捕获焦点改变事件(也就是步骤 1),那么你猜对了!它看起来应该是这样的:

  window.addEventListener('focus', CloseDropdownHandler, true);
Enter fullscreen mode Exit fullscreen mode

处理程序必须确保焦点改变事件的目标元素仍然位于下拉菜单的弹出窗口内,但这只需遍历“parentNode”列表,查找弹出窗口的包装元素即可。如果弹出窗口不在从“e.target”到窗口的层级结构中,则表示用户已访问其他内容,此时需要取消弹出窗口。这还可以避免其他库错误调用 stopPropagation() 而导致干扰的情况,并且为了捕获所有可能的情况,需要在浏览器中注册的事件数量也减少了!

在 setTimeout() 上

既然我们讨论的是元素焦点,处理元素焦点是 preventDefault()/stopPropagation() 的一大难题。这可能会导致一些与 setTimeout() 相关的、非常丑陋的 hack,而这些 hack 本来是没有必要存在的,例如:

  var elem = origelem;

  // But somelem or one of its children has the focus!
  someelem.parentNode.removeChild(somelem);

  // Doesn't appear to work...
  elem.focus();

  // But this does work.
  setTimeout(function() {
    elem.focus();
  }, 0);
Enter fullscreen mode Exit fullscreen mode

这种情况发生在不恰当的焦点变化导致“document.body”元素获得焦点时,因为焦点元素过早地从 DOM 中移除。为了在所有事件都结束后再切换焦点,调用 setTimeout() 并设置 0 毫秒始终是一种 hack 行为。setTimeout()/setInterval() 仅在完成 ​​UI 更新后运行,这就是为什么上面 setTimeout() 中的第二个“elem.focus()”能够“正常工作”。但在短暂的时间内,焦点会落在 body 元素上,这可能会造成各种混乱。

stopPropagation() 有时会与此 hack 结合使用,例如,为了防止移除会影响视觉外观的 CSS 类(例如,移除 CSS 类后过一会儿再重新添加会导致视觉闪烁)。所有这些都会导致鼠标和键盘用户体验不佳,并且需要大量的变通方法。此 hack 可以通过先将焦点移至另一个不会被移除的可聚焦元素,然后再从 DOM 中移除当前获得焦点的元素来解决:

  var elem = origelem;

  // Now elem has the focus.
  elem.focus();

  // somelem can be removed safely.
  someelem.parentNode.removeChild(somelem);

  // No hacky setTimeout()!
Enter fullscreen mode Exit fullscreen mode

调用 setTimeout() 完全合法的情况很少——或许只在偶尔真正超时的情况下使用它?当 setTimeout() 用于超时以外的其他用途时,几乎总会有一些被忽略的地方,而这些地方可以做得更好,对每个人都更好。

结论

希望你在这里学到了一些关于捕获/冒泡事件的知识,以及 preventDefault() 和 stopPropagation() 在这种环境下是如何工作的。之前的事件模型图可能是我见过的最清晰、最准确的 Web 浏览器捕获/冒泡事件模型图。这张图甚至可能值得打印出来!也许不适合“放进相框挂在墙上”,但打印出来应该没问题。

本文最初发表于Blogger 上的 CubicSpot

文章来源:https://dev.to/cubiclesocial/when-to-actually-use-preventdefault-stoppropagation-and-settimeout-in-javascript-event-listeners-48n7
PREV
我最喜欢的开发者播客
NEXT
“防御性编程”真的健康吗?AWS GenAI 上线!