JavaScript 事件深入探究

preventDefaultstopPropagation:何时使用哪个以及每个方法的具体作用是什么。

斯蒂芬·斯特彻尔
Stephen Stchur

Event.stopPropagation() 和 Event.preventDefault()

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 之间的每个元素传播(这是一个重要字词,因为它会直接影响 stopPropagation() 方法的工作原理,本文档稍后将介绍)window 传播到其目标元素(在本例中为 #C)。

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

“在捕获阶段,有没有什么监听 window 上的点击事件?”如果是,则会触发相应的事件处理脚本。在我们的示例中,什么都没有,因此不会触发任何处理程序。

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

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

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

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

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

最后,该事件将到达其目标,并且浏览器将询问:“是否有内容监听在捕获阶段 #C 元素的点击事件?”这次的回答是“可以!”事件到达目标的这一短暂时间段称为“目标阶段”。此时,会触发事件处理脚本,浏览器将执行 console.log "#C was click",然后就完成了,对吧?答错了!我们并没有大功告成。该过程会继续,但现在会变为冒泡阶段。

事件气泡显示

浏览器会询问:

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

接下来,该事件将传播到其父元素 #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()”时,在脑海中添加“操作”一词。考虑“阻止默认操作”。

您可能会询问的默认操作是什么?遗憾的是,这个问题的答案并不那么清楚,因为它在很大程度上依赖于相关元素 + 事件的组合。更让人困惑的是,有时根本就没有默认操作!

我们先从一个简单的例子开始理解。当您点击网页上的链接时,预计会发生什么?显然,您希望浏览器导航到该链接指定的网址。 在本例中,该元素是锚标记,而事件是点击事件。该组合 (<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> 元素 + “submit”事件:此组合的 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(),您会记得,它会阻止默认的 action。因此,所有默认操作(例如鼠标滚轮滚动、键盘滚动或突出显示或 Tab 键、链接点击、上下文菜单显示等)都会被阻止,从而使页面处于毫无用处的状态。

现场演示

如需再次集中探索本文中的所有示例,请查看下方的嵌入式演示。

致谢

主打图片,作者:Tom Wilson Unsplash 用户。