preventDefault
和 stopPropagation
:何时使用哪种方法以及每种方法的确切用途。
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>
=> 依此类推,直到达到目标。
无论 window
、document
、<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()
方法。我之所以说“大多数”,是因为对于某些应用,调用此方法不会执行任何操作(因为事件不会传播到开头)。focus
、blur
、load
、scroll
等事件属于此类别。您可以调用 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
,因此在此代码段中,我们将阻止所有 click
、keydown
、mousedown
、contextmenu
和 mousewheel
事件到达可能监听它们的任何元素。我们还会调用 stopImmediatePropagation
,以便在此之后连接到文档的所有处理脚本也会被阻止。
请注意,stopPropagation()
和 stopImmediatePropagation()
并不是导致页面无用的主要原因(至少不是主要原因)。它们只是阻止事件进入原本会进入的位置。
不过,我们还调用了 preventDefault()
,您记得它会阻止默认的操作。因此,系统会阻止任何默认操作(例如滚动鼠标滚轮、键盘滚动或突出显示或按 Tab 键、点击链接、显示上下文菜单等),从而使页面处于完全无用的状态。
实时演示
如需在一处重新探索本文中的所有示例,请查看下面嵌入的演示。
致谢
主打图片由 Unsplash 用户 Tom Wilson 提供。