Подробное описание событий JavaScript

preventDefault и stopPropagation : когда использовать и что именно делает каждый метод.

Обработка событий 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 и начинает свое путешествие, как только что описано.

В нашем примере событие щелчка затем будет распространяться (это важное слово, поскольку оно напрямую связано с тем, как работает метод stopPropagation() и будет объяснено позже в этом документе) от window к его целевому элементу (в данном случае, #C ) посредством каждого элемента между window и #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() .

Метод 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"

Вы можете поиграть с этим в интерактивном режиме в живой демонстрации ниже. Нажмите на элемент #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 в живой демонстрации и посмотрите вывод консоли.

Еще один, просто для развлечения. Что произойдет, если мы вызовем 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() из первого метода, так что это точка, в которой распространение события прекратится.

Вы можете поиграть с этим в интерактивном режиме в живой демонстрации ниже. Нажмите на элемент #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() , добавьте слово «действие». Подумайте: «Предотвратите действие по умолчанию».

И какое действие по умолчанию вы можете спросить? К сожалению, ответ на этот вопрос не так однозначен, поскольку он сильно зависит от рассматриваемой комбинации элемент + событие. И что еще больше запутывает ситуацию, иногда действия по умолчанию вообще не существует!

Начнем с очень простого для понимания примера. Что вы ожидаете, если щелкнете ссылку на веб-странице? Очевидно, вы ожидаете, что браузер перейдет по 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 и обратите внимание на вывод консоли (и на то, что вы не перенаправлены на веб-сайт Avett Brothers).

Обычно щелчок по ссылке The Avett Brothers приводит к переходу на www.theavettbrothers.com . Однако в данном случае мы подключили обработчик события щелчка к элементу <a> и указали, что действие по умолчанию должно быть запрещено. Таким образом, когда пользователь нажимает на эту ссылку, он никуда не переходит, а вместо этого на консоли просто появляется сообщение: «Может быть, нам стоит вместо этого просто воспроизвести немного их музыки прямо здесь?»

Какие еще комбинации элементов/событий позволяют предотвратить действие по умолчанию? Я не могу перечислить их все, и иногда вам нужно просто поэкспериментировать, чтобы увидеть. Но вкратце, вот некоторые из них:

  • Элемент <form> + событие «отправить»: preventDefault() для этой комбинации предотвратит отправку формы. Это полезно, если вы хотите выполнить проверку, и если что-то потерпит неудачу, вы можете условно вызвать PreventDefault, чтобы остановить отправку формы.

  • Элемент <a> + событие «click»: preventDefault() для этой комбинации не позволяет браузеру перейти по URL-адресу, указанному в атрибуте href элемента <a> .

  • Событие document + «mousewheel»: preventDefault() для этой комбинации предотвращает прокрутку страницы с помощью колесика мыши (хотя прокрутка с помощью клавиатуры все равно будет работать).
    ↜ Для этого необходимо вызвать addEventListener() с { passive: false } .

  • document + событие «keydown»: preventDefault() для этой комбинации смертельно опасно. Это делает страницу практически бесполезной, предотвращая прокрутку клавиатуры, табуляцию и подсветку клавиатуры.

  • 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() , который, как вы помните, предотвращает действие по умолчанию. Таким образом, любые действия по умолчанию (такие как прокрутка колесика мыши, прокрутка клавиатуры, выделение или табуляция, щелчок по ссылке, отображение контекстного меню и т. д.) блокируются, что оставляет страницу в довольно бесполезном состоянии.

Живые демонстрации

Чтобы еще раз изучить все примеры из этой статьи в одном месте, посмотрите встроенную демонстрацию ниже.

Благодарности

Изображение героя Тома Уилсона на Unsplash .