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
и начинает свой путь, как описано выше.
В нашем примере событие click затем распространится (это важное слово, поскольку оно напрямую связано с тем, как работает метод stopPropagation()
, и будет объяснено далее в этом документе) от window
к его целевому элементу (в данном случае #C
) через каждый элемент между window
и #C
.
Это означает, что событие щелчка начнется в window
, и браузер задаст следующие вопросы:
«Отслеживает ли что-либо событие щелчка по window
на этапе захвата?» Если да, то сработают соответствующие обработчики событий. В нашем примере ничего не отслеживается, поэтому обработчики не сработают.
Затем событие распространится на document
, и браузер спросит: «Отслеживает ли что-нибудь событие щелчка по document
на этапе захвата?» Если да, сработают соответствующие обработчики событий.
Затем событие распространится на элемент <html>
, и браузер спросит: «Отслеживает ли что-нибудь нажатие на элемент <html>
на этапе захвата?» Если да, сработают соответствующие обработчики событий.
Затем событие распространится на элемент <body>
, и браузер спросит: «Отслеживает ли что-нибудь событие нажатия на элемент <body>
на этапе захвата?» Если да, сработают соответствующие обработчики событий.
Затем событие распространится на элемент #A
. Браузер снова спросит: «Отслеживает ли что-либо событие нажатия на #A
в фазе захвата? Если да, то будут ли срабатывать соответствующие обработчики событий».
Далее событие распространится на элемент #B
(и будет задан тот же вопрос).
Наконец, событие достигает цели, и браузер спрашивает: «Что-нибудь ожидает события click на элементе #C
в фазе захвата?» На этот раз ответ — «да!» Этот короткий период времени, когда событие находится в целевой точке, называется «фазой цели». В этот момент срабатывает обработчик событий, браузер выводит в console.log сообщение «#C was clicked», и на этом всё, верно? Неверно! Мы ещё не закончили. Процесс продолжается, но теперь он переходит в фазу всплытия.
Всплытие событий
Браузер спросит:
«Отслеживает ли что-нибудь событие click на #C
в фазе всплытия?» Обратите на это особое внимание. Вполне возможно отслеживать click (или события любого типа) как в фазе захвата , так и в фазе всплытия. И если бы вы подключили обработчики событий в обеих фазах (например, дважды вызвав .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. Я говорю «большинство», потому что есть несколько событий, для которых вызов этого метода ничего не даст (потому что событие изначально не распространяется). К этой категории относятся такие события, как focus
, blur
, load
, scroll
, и некоторые другие. Вы можете вызвать stopPropagation()
но ничего интересного не произойдёт, поскольку эти события не распространяются.
Но что делает stopPropagation
?
Он делает практически то, что заявлено. После вызова событие с этого момента перестаёт распространяться на все элементы, к которым оно иначе бы переместилось. Это справедливо в обоих направлениях (захват и всплытие). Поэтому, если вы вызовете stopPropagation()
в любом месте фазы захвата, событие никогда не достигнет целевой фазы или фазы всплытия. Если вы вызовете его в фазе всплытия, оно уже пройдёт фазу захвата, но прекратит «всплытие» с точки, в которой вы его вызвали.
Возвращаясь к нашему примеру разметки, что, по-вашему, произойдет, если мы вызовем stopPropagation()
на этапе захвата в элементе #B
?
Результатом будет следующий вывод:
"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"
Ещё один, просто для развлечения. Что произойдёт, если мы вызовем stopPropagation()
в целевой фазе для #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"
Обратите внимание, что обработчик событий #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()
, мысленно добавьте слово «действие». Представьте себе «предотвратить действие по умолчанию».
А какое действие по умолчанию, спросите вы? К сожалению, ответ на этот вопрос не так однозначен, поскольку он сильно зависит от рассматриваемой комбинации «элемент + событие». И, что ещё больше запутывает ситуацию, иногда действие по умолчанию вообще отсутствует!
Начнём с очень простого примера для понимания. Что вы ожидаете, когда нажимаете на ссылку на веб-странице? Очевидно, вы ожидаете, что браузер перейдёт по URL-адресу, указанному в этой ссылке. В данном случае элемент — это якорный тег, а событие — событие щелчка. Эта комбинация ( <a>
+ click
) имеет «действие по умолчанию» — переход по атрибуту href ссылки. Что, если вы хотите запретить браузеру выполнять это действие по умолчанию? То есть, предположим, вы хотите запретить браузеру переходить по URL-адресу, указанному атрибутом href
элемента <a>
? Именно это и сделает функция 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
. Однако в данном случае мы подключили обработчик событий click к элементу <a>
и указали, что действие по умолчанию должно быть заблокировано. Таким образом, когда пользователь щёлкает по этой ссылке, он никуда не перейдёт, а вместо этого консоль просто выведет сообщение: «Может, нам лучше просто включить их музыку прямо здесь?»
Какие ещё комбинации элементов/событий позволяют предотвратить действие по умолчанию? Я не могу перечислить их все, и иногда приходится экспериментировать, чтобы понять, что именно. Но вот несколько:
Элемент
<form>
+ событие "submit":preventDefault()
в этой комбинации предотвратит отправку формы. Это полезно, если вы хотите выполнить валидацию, и в случае сбоя можно вызвать метод preventDefault по условию, чтобы предотвратить отправку формы.Элемент
<a>
+ событие «click»:preventDefault()
для этой комбинации запрещает браузеру переходить по URL-адресу, указанному в атрибуте href элемента<a>
.Событие
document
+ "mousewheel":preventDefault()
для этой комбинации предотвращает прокрутку страницы с помощью колесика мыши (хотя прокрутка с помощью клавиатуры по-прежнему будет работать).
↜ Для этого требуется вызватьaddEventListener()
с{ passive: false }
.Событие
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 .