JavaScript 事件深入探究

preventDefaultstopPropagation:何时使用哪种方法,以及每种方法的具体用途。

Event.stopPropagation() 和 Event.preventDefault()

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() 方法的工作方式,本文档稍后将对此进行说明)到其目标元素(在本例中为 #C),途径 window#C 之间的每个元素。

这意味着点击事件将从 window 开始,浏览器将提出以下问题:

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

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

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

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

接下来,该事件将传播#A 元素。同样,浏览器会询问:“在捕获阶段,是否有任何内容在监听 #A 上的点击事件?如果有,则会触发相应的事件处理程序。

接下来,事件将传播#B 元素(系统会提出相同的问题)。

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

事件冒泡

浏览器会询问:

“是否有任何内容在冒泡阶段监听 #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"

event.stopPropagation()

了解事件的来源以及事件在捕获阶段和冒泡阶段如何通过 DOM 传播后,我们现在可以关注 event.stopPropagation() 了。

stopPropagation() 方法可针对(大多数)原生 DOM 事件调用。之所以说“大多数”,是因为在少数设备上调用此方法不会有任何作用(因为事件根本不会传播)。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"

如何在冒泡阶段的 #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目标阶段调用 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() 因此事件的传播将在此处停止。

在任何这些实时演示中,我都鼓励您随意操作。尝试仅点击 #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() 时,请在脑海中添加“操作”一词。可理解为“阻止默认操作”。

您可能会问,默认操作是什么?遗憾的是,这个问题的答案并不那么明确,因为这在很大程度上取决于所讨论的元素和事件组合。更令人困惑的是,有时根本没有默认操作!

我们先从一个非常简单的示例开始,以便理解。您希望在点击网页上的链接时发生什么情况?显然,您希望浏览器导航到相应链接指定的网址。 在本示例中,元素是锚标记,事件是点击事件。该组合键(<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”的链接会跳转到 www.theavettbrothers.com。不过,在这种情况下,我们已将点击事件处理脚本连接到 <a> 元素,并指定应阻止默认操作。因此,当用户点击此链接时,系统不会将用户导航到任何位置,而只是在控制台中记录“Maybe we should just play some of their music right here instead?”。

还有哪些元素/事件组合可让您阻止默认操作?我无法一一列出,有时您只需进行实验即可了解。不过,下面简要介绍一下:

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

  • <a> 元素 +“click”事件:对于此组合,preventDefault() 可防止浏览器导航到 <a> 元素的 href 属性中指定的网址。

  • document + “mousewheel”事件:preventDefault() 对于此组合,可防止使用鼠标滚轮滚动网页(不过使用键盘滚动仍然有效)。
    ↜ 这需要使用 { passive: false } 调用 addEventListener()

  • document + “keydown”事件:此组合的 preventDefault() 为致命。这会使网页几乎无法使用,并阻止键盘滚动、制表和键盘突出显示。

  • 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(),您可能还记得,这会阻止默认的 action。因此,任何默认操作(例如鼠标滚轮滚动、键盘滚动或突出显示或 Tab 键切换、链接点击、上下文菜单显示等)都会被阻止,从而使网页处于相当无用的状态。

致谢

主打图片由 Tom Wilson 拍摄,选自 Unsplash 网站。