JavaScript 事件深入探究

preventDefaultstopPropagation:何时使用哪种方法以及每种方法的确切用途。

JavaScript 事件处理通常很简单。在处理简单(相对扁平)的 HTML 结构时,这一点尤为重要。不过,当事件在元素层次结构中传播时,情况会变得更加复杂。通常,此时开发者会求助于 stopPropagation() 和/或 preventDefault() 来解决遇到的问题。如果您曾想过“我会试试 preventDefault(),如果没用,我会试试 stopPropagation(),如果不起作用,我会试试两个”,那么本文就是您的理想之选!我将详细说明每种方法的用途、何时使用哪种方法,并为您提供各种实用示例供您探索。我的目标是彻底解决您的疑虑。

不过,在深入探讨之前,我们有必要先简要介绍一下 JavaScript 中可能的两种事件处理方式(在所有现代浏览器中,Internet Explorer 9 之前的版本完全不支持事件捕获)。

事件样式(捕获和冒泡)

所有现代浏览器都支持事件捕获,但开发者很少使用它。 有趣的是,它是 Netscape 最初支持的唯一事件形式。Netscape 的最大竞争对手 Microsoft Internet Explorer 根本不支持事件捕获,而只支持另一种称为事件冒泡的事件处理方式。在 W3C 成立之初,他们发现这两种事件样式都有优点,并宣布浏览器应通过 addEventListener 方法的第三个参数支持这两种事件样式。该参数最初只是一个布尔值,但所有现代浏览器都支持将 options 对象作为第三个参数,您可以使用该对象来指定(以及其他)您是否要使用事件捕获:

someElement.addEventListener('click', myClickHandler, { capture: true | false });

请注意,options 对象及其 capture 属性都是可选项。如果省略任一参数,capture 的默认值为 false,这意味着系统将使用事件冒泡。

事件捕获

如果事件处理脚本“在捕获阶段监听”,这意味着什么?为了了解这一点,我们需要了解事件的起源和传播方式。以下规则适用于所有事件,即使您作为开发者,并不利用、关心或考虑事件也是如此。

所有事件都从该窗口开始,并首先经历捕获阶段。这意味着,在分派事件时,它会启动窗口,并“向下”朝其目标元素前进。即使您仅在冒泡阶段监听,也会发生这种情况。请参考以下示例标记和 JavaScript:

<html>
  <body>
    <div id="A">
      <div id="B">
        <div id="C"></div>
      </div>
    </div>
  </body>
</html>
document.getElementById('C').addEventListener(
  'click',
  function (e) {
    console.log('#C was clicked');
  },
  true,
);

当用户点击元素 #C 时,系统会分派一个源自 window 的事件。然后,此事件将通过其后代传播,如下所示:

window => document => <html> => <body> => 依此类推,直到达到目标。

无论 windowdocument<html> 元素或 <body> 元素(或指向其目标的任何其他元素)是否监听点击事件,都无关紧要。事件仍然从 window 发起,并按所述开始其历程。

在我们的示例中,点击事件将window 传播(这是一个重要的字词,因为它与 stopPropagation() 方法的运作方式直接相关,本文档稍后会对此进行说明),通过 window#C 之间的每个元素传递到其目标元素(在本例中为 #C)。

这意味着点击事件将从 window 开始,并且浏览器会询问以下问题:

“在捕获阶段,是否有内容监听 window 上的点击事件?”如果是,系统会触发相应的事件处理脚本。在我们的例子中,不存在任何事件,因此不会触发任何处理程序。

接下来,事件将传播document,浏览器会询问:“是否有任何监听器在捕获阶段监听 document 上的点击事件?”如果是,则会触发相应的事件处理脚本。

接下来,事件将传播<html> 元素,浏览器会询问:“是否有任何监听器在捕获阶段监听 <html> 元素的点击?”如果是,则会触发相应的事件处理脚本。

接下来,事件将传播<body> 元素,浏览器会询问:“是否有任何监听器在捕获阶段监听 <body> 元素上的点击事件?”如果是这样,系统会触发相应的事件处理脚本。

接下来,事件将传播#A 元素。同样,浏览器会询问:“是否有任何内容在捕获阶段监听 #A 上的点击事件?如果是,系统会触发适当的事件处理脚本。”

接下来,该事件将传播#B 元素(并会询问相同的问题)。

最后,事件将到达其目标,浏览器会询问:“是否有任何内容在捕获阶段监听 #C 元素上的点击事件?”这次的答案是“是!”事件在目标阶段的这一短暂时间段称为“目标阶段”。此时,事件处理脚本将触发,浏览器将 console.log“点击了 #C”,然后我们就完成了,对吗?错误!我们还没有大功告成。该过程会继续进行,但现在它会更改为冒泡阶段。

事件冒泡

浏览器会询问:

“是否有任何内容在冒泡阶段监听 #C 上的点击事件?”请格外小心。 您完全可以在捕获冒泡阶段监听点击(或任何事件类型)。如果您在两个阶段连接了事件处理脚本(例如,通过调用 .addEventListener() 两次,一次使用 capture = true,一次使用 capture = false),则是的,这两个事件处理脚本绝对会针对同一元素触发。但请务必注意,它们会在不同的阶段触发(一个在捕获阶段,一个在冒泡阶段)。

接下来,事件将传播(更常称为“冒泡”,因为事件似乎在 DOM 树中“向上”传播)到其父元素 #B,浏览器会询问:“在冒泡阶段,是否有任何内容监听 #B 上的点击事件?”在我们的示例中,没有任何事件,因此不会触发任何处理脚本。

接下来,事件将冒泡到 #A,浏览器会询问:“在冒泡阶段,是否有任何监听 #A 上的点击事件?”

接下来,该事件将会以气泡形式显示 <body>:“是否在冒泡阶段监听 <body> 元素的点击事件?”

接下来是 <html> 元素:“是否有任何内容在冒泡阶段监听 <html> 元素上的点击事件?

接下来,document:“是否有任何内容在冒泡阶段监听 document 上的点击事件?”

最后,window:“是否有内容在冒泡阶段监听窗口上的点击事件?”

大功告成!这是一个漫长的过程,我们的活动到现在可能已经筋疲力尽了,但信不信由您,这正是每项活动都要经历的历程!大多数情况下,这一点不会被注意到,因为开发者通常只关注其中一个事件阶段(通常是冒泡阶段)。

不妨花些时间研究事件捕获和事件冒泡,并在处理脚本触发时将一些备注记录到控制台。查看事件所遵循的路径非常有帮助。以下示例会监听这两个阶段中的每个元素。

<html>
  <body>
    <div id="A">
      <div id="B">
        <div id="C"></div>
      </div>
    </div>
  </body>
</html>
document.addEventListener(
  'click',
  function (e) {
    console.log('click on document in capturing phase');
  },
  true,
);
// document.documentElement == <html>
document.documentElement.addEventListener(
  'click',
  function (e) {
    console.log('click on <html> in capturing phase');
  },
  true,
);
document.body.addEventListener(
  'click',
  function (e) {
    console.log('click on <body> in capturing phase');
  },
  true,
);
document.getElementById('A').addEventListener(
  'click',
  function (e) {
    console.log('click on #A in capturing phase');
  },
  true,
);
document.getElementById('B').addEventListener(
  'click',
  function (e) {
    console.log('click on #B in capturing phase');
  },
  true,
);
document.getElementById('C').addEventListener(
  'click',
  function (e) {
    console.log('click on #C in capturing phase');
  },
  true,
);

document.addEventListener(
  'click',
  function (e) {
    console.log('click on document in bubbling phase');
  },
  false,
);
// document.documentElement == <html>
document.documentElement.addEventListener(
  'click',
  function (e) {
    console.log('click on <html> in bubbling phase');
  },
  false,
);
document.body.addEventListener(
  'click',
  function (e) {
    console.log('click on <body> in bubbling phase');
  },
  false,
);
document.getElementById('A').addEventListener(
  'click',
  function (e) {
    console.log('click on #A in bubbling phase');
  },
  false,
);
document.getElementById('B').addEventListener(
  'click',
  function (e) {
    console.log('click on #B in bubbling phase');
  },
  false,
);
document.getElementById('C').addEventListener(
  'click',
  function (e) {
    console.log('click on #C in bubbling phase');
  },
  false,
);

控制台输出将取决于您点击的元素。如果您点击 DOM 树中的“最深层”元素(#C 元素),您会看到所有这些事件处理脚本都会触发。通过一些 CSS 样式,可以更清楚地区分各个元素,下面是控制台输出 #C 元素(以及屏幕截图):

"click on document in capturing phase"
"click on <html> in capturing phase"
"click on <body> in capturing phase"
"click on #A in capturing phase"
"click on #B in capturing phase"
"click on #C in capturing phase"
"click on #C in bubbling phase"
"click on #B in bubbling phase"
"click on #A in bubbling phase"
"click on <body> in bubbling phase"
"click on <html> in bubbling phase"
"click on document in bubbling phase"

您可以在下面的现场演示中以交互方式进行试验。点击 #C 元素并观察控制台输出。

event.stopPropagation()

了解了事件的起源以及它们在捕获阶段和冒泡阶段如何穿过 DOM(即传播),现在我们可以将注意力转向 event.stopPropagation()

可以对(大多数)原生 DOM 事件调用 stopPropagation() 方法。我之所以说“大多数”,是因为对于某些应用,调用此方法不会执行任何操作(因为事件不会传播到开头)。focusblurloadscroll 等事件属于此类别。您可以调用 stopPropagation(),但不会发生任何有趣的事情,因为这些事件不会传播。

但是 stopPropagation 有什么用呢?

它的作用与其名称基本一致。调用此方法后,事件将从该时间点开始停止传播到原本会传播到的任何元素。这适用于两个方向(捕获和冒泡)。因此,如果您在捕获阶段的任意位置调用 stopPropagation(),事件将永远不会进入目标阶段或冒泡阶段。如果您在冒泡阶段调用它,它将已通过捕获阶段,但从您调用它开始,它将停止“冒泡”。

回到我们之前的示例标记,如果我们在捕获阶段调用 #B 元素中的 stopPropagation(),您认为会发生什么情况?

这将产生以下输出:

"click on document in capturing phase"
"click on <html> in capturing phase"
"click on <body> in capturing phase"
"click on #A in capturing phase"
"click on #B in capturing phase"

您可以在下面的实时演示中进行互动式操作。点击实时演示中的 #C 元素,然后观察控制台输出。

在冒泡阶段,如何在 #A 停止传播?这将产生以下输出:

"click on document in capturing phase"
"click on <html> in capturing phase"
"click on <body> in capturing phase"
"click on #A in capturing phase"
"click on #B in capturing phase"
"click on #C in capturing phase"
"click on #C in bubbling phase"
"click on #B in bubbling phase"
"click on #A in bubbling phase"

您可以在下面的实时演示中进行互动式操作。点击实时演示中的 #C 元素,然后观察控制台输出。

再说一个,纯粹娱乐一下。如果我们在 #C目标阶段调用 stopPropagation(),会发生什么情况? 回想一下,“目标阶段”是指事件到达目标的时间段。这将产生以下输出:

"click on document in capturing phase"
"click on <html> in capturing phase"
"click on <body> in capturing phase"
"click on #A in capturing phase"
"click on #B in capturing phase"
"click on #C in capturing phase"

请注意,我们记录“在捕获阶段点击 #C”的 #C 事件处理脚本会执行,但我们记录“在冒泡阶段点击 #C”的事件处理脚本不会执行。这应该很有道理。我们从前者调用了 stopPropagation(),因此事件的传播将在该点停止。

您可以在下面的实时演示中对此进行互动式操作。点击实时演示中的 #C 元素,然后观察控制台输出。

我建议您在这些实时演示中尽情探索。尝试仅点击 #A 元素或仅点击 body 元素。尝试预测接下来会发生什么,然后观察结果是否正确。此时,您应该能够做出相当准确的预测。

event.stopImmediatePropagation()

这种奇怪且不常使用的方法是什么?它与 stopPropagation 类似,但与阻止事件传播到子孙(捕获)或祖先(冒泡)不同,此方法仅适用于将多个事件处理程序连接到单个元素的情况。由于 addEventListener() 支持多播式事件,因此完全可以将事件处理脚本多次连接到单个元素。在这种情况下,(在大多数浏览器中),事件处理脚本会按连接顺序执行。调用 stopImmediatePropagation() 会阻止任何后续处理脚本触发。请参考以下示例:

<html>
  <body>
    <div id="A">I am the #A element</div>
  </body>
</html>
document.getElementById('A').addEventListener(
  'click',
  function (e) {
    console.log('When #A is clicked, I shall run first!');
  },
  false,
);

document.getElementById('A').addEventListener(
  'click',
  function (e) {
    console.log('When #A is clicked, I shall run second!');
    e.stopImmediatePropagation();
  },
  false,
);

document.getElementById('A').addEventListener(
  'click',
  function (e) {
    console.log('When #A is clicked, I would have run third, if not for stopImmediatePropagation');
  },
  false,
);

上述示例将生成以下控制台输出:

"When #A is clicked, I shall run first!"
"When #A is clicked, I shall run second!"

请注意,由于第二个事件处理脚本调用了 e.stopImmediatePropagation(),因此第三个事件处理脚本永远不会运行。如果我们改为调用 e.stopPropagation(),第三个处理脚本仍会运行。

event.preventDefault()

如果 stopPropagation() 阻止事件“向下传播”(捕获)或“向上传播”(冒泡),preventDefault() 会执行什么操作?听起来好像有点类似。是吗?

不一定。虽然这两者经常被混淆,但实际上它们之间没有太大关系。看到 preventDefault() 时,在脑海中加上“action”一词。想想“阻止默认操作”。

您可能会询问的默认操作是什么?遗憾的是,这个问题的答案并不明确,因为它在很大程度上取决于相关元素和事件的组合。让情况更加混乱的是,有时根本没有默认操作!

我们先从一个非常简单的示例开始。你希望在点击网页上的链接后发生什么?显然,您希望浏览器导航到该链接指定的网址。在这种情况下,元素是锚标记,事件是点击事件。该组合(<a> + click)的“默认操作”是导航到链接的 href。如果您想阻止浏览器执行此默认操作,该怎么办?也就是说,假设您想阻止浏览器转到由 <a> 元素的 href 属性指定的网址。preventDefault() 会为您执行此操作。请参考下面的示例:

<a id="avett" href="https://www.theavettbrothers.com/welcome">The Avett Brothers</a>
document.getElementById('avett').addEventListener(
  'click',
  function (e) {
    e.preventDefault();
    console.log('Maybe we should just play some of their music right here instead?');
  },
  false,
);

您可以在下面的实时演示中对此进行互动式操作。点击链接 The Avett Brothers,然后观察控制台输出(以及您未被重定向到 Avett Brothers 网站)。

通常,点击标签为“The Avett Brothers”的链接会导致浏览 www.theavettbrothers.com。不过,在本例中,我们已将点击事件处理程序连接到 <a> 元素,并指定应阻止默认操作。因此,当用户点击此链接时,系统不会将其转到任何位置,而控制台只会记录“也许我们应该直接在此处播放他们的一些音乐?”

您还可以通过哪些其他元素/事件组合来阻止默认操作?我不可能列出所有原因,有时您只能通过实验来找出原因。下面简要介绍了其中一些:

  • <form> 元素 +“提交”事件:此组合的 preventDefault() 会阻止表单提交。如果您想执行验证,在验证失败时可以有条件地调用 preventDefault,以停止提交表单。

  • <a> 元素 +“点击”事件:此组合的 preventDefault() 会阻止浏览器转到在 <a> 元素的 href 属性中指定的网址。

  • document +“mousewheel”事件:此组合的 preventDefault() 会阻止使用鼠标滚轮滚动网页(不过,使用键盘滚动仍可正常运行)。
    ↜ 这需要使用 { passive: false } 调用 addEventListener()

  • document +“keydown”事件:对于此组合,preventDefault() 是致命的。这会使网页基本无法使用,阻止键盘滚动、按 Tab 键和键盘突出显示。

  • document +“mousedown”事件:此组合的 preventDefault() 会阻止使用鼠标突出显示文本,以及阻止用户按下鼠标时调用任何其他“默认”操作。

  • <input> 元素 +“keypress”事件:对于此组合,preventDefault() 会阻止用户输入的字符传递到输入元素(但请勿这样做;很少有有效的原因会导致这样做)。

  • document +“contextmenu”事件:此组合的 preventDefault() 会阻止在用户右键点击或长按(或以任何其他可能显示上下文菜单的方式)时显示原生浏览器上下文菜单。

这绝不是详尽无遗的列表,但希望能让您大致了解 preventDefault() 的用法。

能讲个好玩的恶作剧吗?

如果您在抓取阶段从文档开始stopPropagation()preventDefault(),会发生什么情况?让人忍俊不禁!以下代码段会使任何网页几乎完全无法正常呈现:

function preventEverything(e) {
  e.preventDefault();
  e.stopPropagation();
  e.stopImmediatePropagation();
}

document.addEventListener('click', preventEverything, true);
document.addEventListener('keydown', preventEverything, true);
document.addEventListener('mousedown', preventEverything, true);
document.addEventListener('contextmenu', preventEverything, true);
document.addEventListener('mousewheel', preventEverything, { capture: true, passive: false });

我真的不知道您为什么要这样做(除非是想开个玩笑),但不妨思考一下发生了什么,并了解为什么会造成这种情况。

所有事件都源自 window,因此在此代码段中,我们将阻止所有 clickkeydownmousedowncontextmenumousewheel 事件到达可能监听它们的任何元素。我们还会调用 stopImmediatePropagation,以便在此之后连接到文档的所有处理脚本也会被阻止。

请注意,stopPropagation()stopImmediatePropagation() 并不是导致页面无用的主要原因(至少不是主要原因)。它们只是阻止事件进入原本会进入的位置。

不过,我们还调用了 preventDefault(),您记得它会阻止默认的操作。因此,系统会阻止任何默认操作(例如滚动鼠标滚轮、键盘滚动或突出显示或按 Tab 键、点击链接、显示上下文菜单等),从而使页面处于完全无用的状态。

实时演示

如需在一处重新探索本文中的所有示例,请查看下面嵌入的演示。

致谢

主打图片由 Unsplash 用户 Tom Wilson 提供。