JavaScript - Всплытие и погружение событий

JavaScript - Всплытие и погружение событий
Содержание:
  1. Распространение события
  2. Фаза погружения события
  3. Фаза цели
  4. Фаза всплытия
  5. Пример, показывающий весь цикл путешествия события
  6. Делегирование событий
  7. Прерывания всплытия или погружения события
  8. Задачи
  9. Комментарии

В этой статье рассмотрим:

  • как происходит распространения события и из каких фаз оно состоит;
  • как назначить обработчики на фазах погружения и всплытия;
  • пример, детально показывающий процесс путешествия события;
  • что такое делегирование событий, зачем оно нужно и как его применять;
  • метод для прерывания распространения события - stopPropagation;

Распространение события

Когда некоторый объект инициирует событие, то оно не просто возникает на нём, а распространяется в документе определённым образом.

Это распространение является двунаправленным: от window к целевому элементу и обратно.

Согласно стандарту, оно делится на 3 фазы:

  1. Фаза погружения или захвата – от window к родителю цели (цель – это объект, который инициировал это событие).
  2. Фаза цели – событие на цели.
  3. Фаза всплытия – обратно, от родителя цели к window.

Самое главное для нас, когда событие путешествует по документу, то браузер вызывает обработчики элементов, через которые оно проходит.

Фаза погружения события

На этой фазе могут быть вызваны только обработчики, зарегистрированные посредством addEventListener, у которых аргумент capture имеет значение true:

JavaScript
$element.addEventListener(..., ..., {capture: true, ...});
// или просто "true"
$element.addEventListener(..., ..., true);

Этот аргумент задаёт фазу, на которой нужно отлавливать событие.

По умолчанию аргумент capture имеет значение false. Если его не указать или установить равным false, то обработчик в этом случае будет работать на этапе всплытия.

Например, в следующем примере фаза погружения при клике на <img> будет представлять вот такую цепочку: windowdocumenthtmlbodyarticlesection.

HTML
<body>
  <article>
    <section></section>
    <section>
      <img src="..." alt="...">
    </section>
    <section></section>
  </article>
  <aside></aside>
</body>
Фаза погружения события

Пример, в котором обработаем событие click на фазе погружения:

JavaScript
const $element = document.querySelector('article');
// добавим обработчик к элементу article на фазе погружения
$element.addEventListener('click', function (e) {
  console.log(`Фаза: ${e.eventPhase}`);
  console.log(`Элемент, для которого запущен обработчик: <${e.currentTarget.tagName.toLowerCase()}>`);
  console.log(`Элемент, который инициировал событие click: <${e.target.tagName.toLowerCase()}>`);
}, true);
Добавление обработчика на фазе погружения события

В реальном коде обработка событий на этапе погружения используется довольно редко, но такая возможность в JavaScript имеется и в некоторых сценариях она может быть полезной.

В обработчике получить элемент для которого вызван обработчик можно не только с помощью currentTarget, но и посредством ключевого слова this.

Фаза цели

На этой фазе будут вызваны все обработчики, прикреплённые к целевому объекту, независимо от того каким способом они назначены.

В примере который рассмотрели выше фаза цели при клике на <img> это конечно сам элемент: img.

Фаза цели

Добавление обработчика посредством HTML-атрибута onclick:

JavaScript
...
<img src="..." alt="..." onclick="imageOnClick(event)">
...

<script>
function imageOnClick(e) {
  console.log(`Фаза: ${e.eventPhase}`);
  console.log(`Элемент, для которого запущен обработчик: <${e.currentTarget.tagName.toLowerCase()}>`);
  console.log(`Элемент, который инициировал событие click: <${e.target.tagName.toLowerCase()}>`);
}
</script>
Добавление обработчика к элементу

Добавление обработчика посредством свойства onclick:

JavaScript
...
<img src="..." alt="...">
...

<script>
const $element = document.querySelector('img');
// добавим обработчик к элементу посредством свойства onclick
$element.onclick = function (e) {
  console.log(`Фаза: ${e.eventPhase}`);
  console.log(`Элемент, для которого запущен обработчик: <${e.currentTarget.tagName.toLowerCase()}>`);
  console.log(`Элемент, который инициировал событие click: <${e.target.tagName.toLowerCase()}>`);
}
</script>

Добавление обработчика через addEventListener:

JavaScript
...
<img src="..." alt="...">
...

<script>
const $element = document.querySelector('img');
// добавим обработчик к элементу
$element.addEventListener('click', function (e) {
  console.log(`Фаза: ${e.eventPhase}`);
  console.log(`Элемент, для которого запущен обработчик: <${e.currentTarget.tagName.toLowerCase()}>`);
  console.log(`Элемент, который инициировал событие click: <${e.target.tagName.toLowerCase()}>`);
});
</script>

Фаза всплытия

На фазы всплытия будут вызываться обработчики, которые мы зарегистрировали через HTML-атрибут on{event}, свойство on{event} и addEventListenercapture равным false или без его указания).

В примере приведённом выше фаза всплытия при клике на <img> будет распространяться вот так: sectionarticlebodyhtmldocumentwindow.

Фаза всплытия события

Пример, в котором обработаем событие click на фазе всплытия:

JavaScript
const $element = document.querySelector('article');
// добавим обработчик к элементу article на фазе погружения
$element.addEventListener('click', function (e) {
  console.log(`Фаза: ${e.eventPhase}`);
  console.log(`Элемент, для которого запущен обработчик: <${e.currentTarget.tagName.toLowerCase()}>`);
  console.log(`Элемент, который инициировал событие click: <${e.target.tagName.toLowerCase()}>`);
});
Добавление обработчика к элементу на фазе всплытия

Пример, показывающий весь цикл путешествия события

Рассмотрим следующий пример:

JavaScript
<body>
  <div id="level-1">
    <div id="level-2">
      <div id="level-3"></div>
    </div>
  </div>
</body>

При клике на элементе #level3 событие click начнёт путешествовать от window вниз по цепочке родителей до этого целевого элемента. Как только оно его достигнет, оно пойдёт вверх по цепочке родителей обратно до window.

Т.е. при клике по #level3:

  1. Фаза погружения: windowdocumenthtmlbody#level-1#level-2
  2. Фаза цели: #level-3
  3. Фаза всплытия: #level-2#level-1bodyhtmldocumentwindow

Чтобы программно посмотреть, как это происходит добавим обработчики ко всем участвующих в этом процессе элементам, а также к объектам document и window:

JavaScript
// обработчик события (выводит сообщение в консоль)
function log(e) { ... }

// получим все элементы на странице и добавим к ним обработчик события log на фазе погружения и всплытия
const $elements = document.querySelectorAll('*');
$elements.forEach(function ($element) {
  // на фазе всплытия
  $element.addEventListener('click', log);
  // на фазе погружения
  $element.addEventListener('click', log, true);
});

// добавляем обработчик к document на фазе всплытия
document.addEventListener('click', log);
// добавляем обработчик к document на фазе погружения
document.addEventListener('click', log, true);
// добавляем обработчик к window на фазе всплытия
window.addEventListener('click', log);
// добавляем обработчик к window на фазе погружения
window.addEventListener('click', log, true);
Распространение события в браузере

На скриншоте самый маленький по размеру квадрат - div#level-3, тот который побольше - div#level-2, а самый большой - div#level-1.

Обработчики на цели срабатывают как те, которые установлены на погружение, так и на всплытие. Поэтому у нас это сообщение «#level-3» вывелось 2 раза.

Получение номера фазы осуществляется с помощью свойства eventPhase (1 - погружение, 2 - цель, 3 - всплытие).

Делегирование событий

Делегирование событий – это механизм реагирования на событие через одного общего для этих элементов родителя.

Как это работает?

Делегирование возможно благодаря тому, что события всплывают. А это означает, что оно возникает не только на самом элементе, но затем на его родителе, потом на родителе его родителя и так далее вверх по цепочке до window.

Таким образом, при добавлении обработчика к одному из родителей, он будет выполняться всякий раз, когда это событие будет происходит на любом из его дочерних элементов. В обработчике получить элемент, который инициировал это событие можно посредством свойства target.

Зачем это нужно?

Делегирование позволяет не добавлять обработчик к каждому элементу, а сделать этого только для одного общего для всех них элемента.

Допустим, есть список:

HTML
<ul id="list">
  <li>Один</li>
  <li>Два</li>
  <li>Три</li>
</ul>

Предположим, что при нажатии на каждый элемент списка должно что-то произойти. Например, выводиться сообщение.

Мы можем добавить отдельный обработчик для каждого <li>. Но что, если у нас элементы <li> динамически добавляются и удаляются из этого списка?

В этом случае лучшим решением, конечно, будет добавить обработчик к родительскому элементу. Но если вы добавите обработчик к <ul>, то как вы узнаете, какой элемент был нажат?

Всё просто. Для этого предназначено свойство объекта события target. Оно содержит ссылку на элемент, на который фактически нажали.

Например:

JavaScript
document.querySelector('#list').addEventListener('click', function (e) {
  if (e.target && e.target.nodeName === 'LI') {
    alert(e.target.textContent);
  }
});

Ещё один пример, в котором добавим возможность переключать видимость вложенных списков при клике на элемент <span>. Чтобы не привязывать событие конкретно к элементу <span> выполним этого посредством делегирования, т.е. назначим обработчик элементу ul.list:

JavaScript - Делегирование событий (пример)
JavaScript
<button id="list-add">Добавить ещё в список</button>

<ul class="list">
  <li>
    <span class="hide">Первый список</span>
    <ul>
      <li>Один</li>
      <li>Два</li>
      <li>Три</li>
    </ul>
  </li>
  <li>
    <span class="hide">Второй список</span>
    <ul>
      <li>Четыре</li>
      <li>Пять</li>
      <li>Шесть</li>
    </ul>
  </li>
</ul>

<script>
  const $list = document.querySelector('.list');
  $list.addEventListener('click', function (e) {
    const $trigger = e.target.closest('span');
    if ($trigger) {
      $trigger.classList.toggle('hide');
    }
  });

  const $listAdd = document.querySelector('#list-add');
  $listAdd.addEventListener('click', function (e) {
    const $li = document.createElement('li');
    $li.innerHTML = '<span class="hide">Третий список</span><ul><li>Семь</li><li>Восемь</li><li>Девять</li></ul>'
    $list.appendChild($li);
  });
</script>

Прерывания всплытия или погружения события

Всплытие или погружение события можно прервать. Осуществляется это посредством вызова метода объекта события stopPropagation в обработчике.

Бесспорно, всплытие — это очень удобно и архитектурно прозрачно. Не прекращайте его без явной нужды.

Например, прервём всплытие события click на элементе body:

HTML
<div id="level-1">
  <div id="level-2">
    <div id="level-3"></div>
  </div>
</div>

<script>
  // назначим обработчик события click на элементе #level-1
  document.querySelector('#level-1').addEventListener('click', function () {
    console.log('click на элементе #level-1');
  });
  // назначим обработчик события click на элементе body
  document.body.addEventListener('click', function (e) {
    e.stopPropagation();
    console.log('click на элементе body');
  });
  // назначим обработчик события click на элементе html
  document.querySelector('html').addEventListener('click', function (e) {
    console.log('click на элементе html');
  });
</script>

Теперь, если кликнуть на элементе #level-3, событие начнёт всплывать. Когда оно дойдёт до body, вызов метода stopPropagation остановит дальнейшее всплытие события click. Таким образом событие click не возникнет на элементе html, и следовательно обработчик, который мы добавили вызван не будет.

JavaScript - Прерывание всплытия события click

Теперь, если закомментировать строчку, где вызываем stopPropagation, то увидим, что прерывания всплытия события не произойдёт.

JavaScript - Без прерывания всплытия события click

Задачи

Вывести в контент кнопки количество кликов по ней

Добавьте на страницу JavaScript код, который будет при нажатии на кнопку выводить количество кликов по ней. При этом кнопки на страницу добавляются динамически.

JavaScript - Вывести в контент кнопки количество кликов по ней

Исходный код динамически добавляющий кнопки на страницу:

JavaScript
// код для динамического добавления кнопок на страницу
const btnCount = parseInt(Math.random() * 30);
for (let i = 0; i < btnCount; i++) {
  const $btn = document.createElement('button');
  $btn.type = 'button';
  $btn.textContent = '0';
  document.body.appendChild($btn);
}
Решение

При нажатию на кнопку перейти в начало страницы

Перед каждым заголовком h2 кроме первого динамически добавлена кнопка с классом top.

JavaScript - При нажатию на кнопку перейти в начало страницы
JavaScript
// код, динамически добавляющий кнопки перед каждым h2 на странице
const h2 = document.querySelectorAll('h2:not(h2:nth-child(1))');
h2.forEach(function ($element) {
  $element.insertAdjacentHTML('beforebegin', '<button class="top">Top ↑</button>');
})

Необходимо написать код, который будет при нажатии на любую из этих кнопок прокручивать страницу к её началу.

Решение

Комментарии: 4

iznutri
iznutri

Спасибо! Хорошая полезная статья.

Александр Мальцев
Александр Мальцев

Рад, что понравилась.

mikhailone
mikhailone

Подскажите, пожалуйста, для чего используется знак $ перед переменной?

Александр Мальцев
Александр Мальцев

Просто понимать, что это DOM-элемент или их коллекция. Сейчас больше нравиться добавлять в конце el или elem:

const btnElem = document.createElement('button');