JavaScript 事件深入探究

JavaScript 事件处理通常很简单。在处理简单(相对扁平的)HTML 结构时尤其如此。不过,当事件通过元素层次结构传输(或传播)时,情况会更复杂一些。这通常是开发者联系 stopPropagation() 和/或 preventDefault() 来解决所遇到的问题时。如果您曾想过“我会试试 preventDefault(),如果没用,我会试试 stopPropagation(),如果行不通,我会试一试”,那么本文就是您的理想之选!我会确切说明每种方法的用途、何时使用哪一种方法,并为您提供各种工作示例以供您探索。我的目标是彻底消除您的困惑。

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

所有现代浏览器都支持事件捕获,但开发者很少使用该功能。有趣的是,它是 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 和 #C 之间的每个元素从 window 传播到其目标元素(在本例中为 #C)。这个词很重要,因为它将直接联系到 stopPropagation() 方法的工作原理,本文稍后会对此进行说明。

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

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

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

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

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

接下来,该事件将传播到 #A 元素。同样,浏览器将询问:“是否在捕获阶段监听 #A 上的点击事件,如果是,将触发相应的事件处理脚本。

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

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

浏览器将询问以下信息:

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

在了解了事件在捕获阶段和冒泡阶段中的来源位置以及事件如何在 DOM 中移动(即传播)之后,我们现在可以将注意力转向 event.stopPropagation()

可以对(大多数)原生 DOM 事件调用 stopPropagation() 方法。我之所以说“大多数”,是因为对于某些应用,调用此方法不会执行任何操作(因为事件不会传播到开头)。focusblurloadscroll 以及其他一些事件都属于此类别。您可以调用 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 中记录“click on #C in thecapture stage”(在捕获阶段点击 #C)的事件处理脚本仍会执行,而记录“click on #C in the bubbling stage”(在冒泡阶段点击 #C)的事件处理脚本仍会执行。这是非常合理的。我们从前一个调用 stopPropagation(),使其成为事件的传播停止的时间点。

在这些现场演示中,我鼓励您多加尝试。请尝试仅点击 #A 元素或仅点击 body 元素。试着预测会发生什么,然后观察结果是否正确。此时,您应该能够非常准确地进行预测。

这种奇怪且不常使用的方法是什么?它与 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(),则第三个处理程序仍会运行。

如果 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 的链接会进入 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,因此在此代码段中,我们会停止,在所有 clickkeydownmousedowncontextmenu 和 mousewheel 事件到达可能正在监听它们的元素时停止运行。我们还调用 stopImmediatePropagation,以便在此之后连接到文档的所有处理程序也会被阻止。

请注意,stopPropagation() 和 stopImmediatePropagation() 不会(至少不是大部分)导致网页变得无用的因素。它们只是阻止事件转移到其他地方。

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

阅读余下内容
 

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注


京ICP备12002735号