JavaScript 事件深入探究
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>
=>,依此类推,直到达到目标。
如果 window
、document
、<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"
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"
在冒泡阶段,如何在 #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
元素。试着预测会发生什么,然后观察结果是否正确。此时,您应该能够非常准确地进行预测。
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 的链接会进入 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 键、点击链接、显示上下文菜单等)都会被阻止,从而使页面处于相当无用的状态。