Сейчас мы с вами разберем некоторые продвинутые вещи при работе с объектом Event, а именно: всплытие и перехват, а также делегирование событий.

Всплытие событий

Представьте себе, что у вас есть несколько вложенных друг в друга блоков:

<div>
	<div>
		<div>самый внутренний блок</div>
	</div>
</div>

Когда вы кликаете на самый внутренний блок, событие onclick возникает сначала в нем, а затем срабатывает в его родителе, в родителе его родителя и так далее, пока не дойдет то тега body и далее до тега html (затем до document и до window).

И это логично, ведь кликая на внутренний блок, вы одновременно кликаете на все внешние.

Давайте убедимся в этом на следующем примере: у нас есть 3 блока, к каждому из них привязано событие onclick:

<div onclick="alert('зеленый');">
	<div onclick="alert('голубой');">
		<div onclick="alert('красный');"></div>
	</div>
</div>

Нажмите на самый внутренний красный блок - и вы увидите, как сначала сработает onclick красного блока, потом голубого, потом зеленого:

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

event.target

Пусть у нас есть два элемента: div и абзац p, лежащий внутри этого дива. Пусть onlick мы привязали в диву:

<div onclick="alert('!');">
	<p></p>
</div>

Когда мы кликаем на этот див, мы можем попасть по абзацу, а можем попасть в место, где этого абзаца нет.

Как такое может быть - посмотрите на следующем примере: зеленый цвет - это наш див, а голубой - наш абзац:

Если кликнуть в зеленую часть - мы кликнем именно по диву, а если кликнуть на голубую часть - клик произойдет сначала по абзацу, а потом уже по диву. Но так как onclick привязан именно к диву - мы в общем-то присутствие абзаца можем и не заметить.

Однако, иногда нам хотелось бы знать - клик произошел непосредственно по диву или по его потомку абзацу. В этом нам поможет объект Event и его свойство event.target - в нем хранится именно тот элемент, в котором произошел клик.

В следующем примере у нас есть div, внутри него лежит p, а внутри него - span.

Давайте привяжем событие onclick самому верхнему элементу (диву) и будем кликать на разные элементы: на div, на p, на span. С помощью event.target получим самый нижний элемент, в котором случилось событие и выведем его название с помощью tagName.

Если кликнуть, к примеру, на span - то событие отловит наш div (ведь именно к нему привязан onclick), но в event.target будет лежать именно span:

<div onclick="alert(event.target.tagName);">
	<p>
		<span></span>
	</p>
</div>

Покликайте по разным блокам - вы увидите результат:

Прекращение всплытия

Итак, вы уже знаете, что все события всплывают до самого верха (до тега html, а затем до document, а затем до window). Иногда есть нужда это всплытие остановить. Это может сделать любой элемент, через который всплывает событие. Для этого в коде элемента следует вызвать метод event.stopPropagation().

В следующем примере клик по красному блоку сработает на нем самом, затем на голубом блоке и все - голубой блок прекращает дальнейшее всплытие и зеленый блок уже никак не отреагирует:

<div onclick="alert('зеленый');">
	<div onclick="alert('голубой'); event.stopPropagation();">
		<div onclick="alert('красный');"></div>
	</div>
</div>

Кликните на красный блок - вы увидите результат:

Погружение

Кроме всплытия событий есть еще и погружение (по научному стадия перехвата). Это значит, что событие сначала идет сверху вниз (стадия перехвата), доходит до нашего элемента (стадия цели) и только потом начинает всплывать (стадия всплытия).

Повесить обработчик события с учетом стадии перехвата можно только с помощью addEventListener. Для этого у него есть третий параметр: если он равен true - событие сработает на стадии перехвата, а если false - на стадии всплытия (это по умолчанию):

var green = document.getElementById('green');
green.addEventListener('click', func, true);

function func(event) {

}

Стадию, на которой произошло событие можно определить с помощью свойства event.eventPhase. Оно может принимать следующие значения: 1 - стадия перехвата, 2 - стадия цели, 3 - стадия всплытия.

Вступление к делегированию

Представим себе ситуацию: пусть у нас есть ul с несколькими li. К каждой li привязано следующее событие: по нажатию на li ей в конец добавляется '!'.

Давайте реализуем описанное:

<ul id="ul">
	<li>пункт 1</li>
	<li>пункт 2</li>
	<li>пункт 3</li>
	<li>пункт 4</li>
	<li>пункт 5</li>
</ul>
var li = document.querySelectorAll('#ul li');

//В цикле вешаем функцию addSign на каждую li:
for (var i = 0; i < li.length; i++) {
	li[i].addEventListener('click', addSign);
}
function addSign() {
	this.innerHTML = this.innerHTML + '!';
}

Понажимайте на li - вы увидите, как им в конец добавляется '!':

  • пункт 1
  • пункт 2
  • пункт 3
  • пункт 4
  • пункт 5

Пусть теперь у нас также есть кнопочка, по нажатию на которую в конец ul добавляется новая li с текстом 'пункт'. Нас ждет сюрприз: привязанное событие не будет работать для новых li! Убедимся в этом:

<ul id="ul">
	<li>пункт 1</li>
	<li>пункт 2</li>
	<li>пункт 3</li>
	<li>пункт 4</li>
	<li>пункт 5</li>
</ul>

<button id="button">Добавить li</button>
var li = document.querySelectorAll('#ul li');

for (var i = 0; i < li.length; i++) {
	li[i].addEventListener('click', addSign);
}
function addSign() {
	this.innerHTML = this.innerHTML + '!';
}

//Реализация кнопочки добавления новой li:
var ul = document.getElementById('ul');
var button = document.getElementById('button');
button.addEventListener('click', addLi);
function addLi() {
	var li = document.createElement('li');
	li.innerHTML = 'новая li';
	ul.appendChild(li);
}

Нажмите на кнопочку для добавления li, а затем на эту новую li - она не среагирует:

  • пункт 1
  • пункт 2
  • пункт 3
  • пункт 4
  • пункт 5

Для решения проблемы можно в момент создания новой li повесить на нее функцию addSign через addEventListener. Давайте реализуем это:

<ul id="ul">
	<li>пункт 1</li>
	<li>пункт 2</li>
	<li>пункт 3</li>
	<li>пункт 4</li>
	<li>пункт 5</li>
</ul>

<button id="button">Добавить li</button>
var li = document.querySelectorAll('#ul li');

for (var i = 0; i < li.length; i++) {
	li[i].addEventListener('click', addSign);
}
function addSign() {
	this.innerHTML = this.innerHTML + '!';
}

//Реализация кнопочки добавления новой li:
var ul = document.getElementById('ul');
var button = document.getElementById('button');
button.addEventListener('click', addLi);
function addLi() {
	var li = document.createElement('li');
	li.innerHTML = 'новая li';
	li.addEventListener('click', addSign); //навесим событие на новую li
	ul.appendChild(li);
}

Нажмите на кнопочку для добавления li, а затем на эту новую li - она среагирует:

  • пункт 1
  • пункт 2
  • пункт 3
  • пункт 4
  • пункт 5

Существует и второй способ обойти проблему - делегирование событий. Давайте его разберем.

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

Суть делегирования в следующем: навесим событие не на каждую li, а на их родителя - на ul.

При этом работоспособность нашего скрипта должна сохраниться: по-прежнему при клике на li ей в конец будет добавляться '!'. Только событие в новом варианте будет навешано на ul:

var ul = document.getElementById('ul');

//Вешаем событие на ul:
ul.addEventListener('click', addSign);
function addSign() {
	
}

Как мы это провернем: так как событие навешано на ul, то внутри функции мы можем поймать li с помощью event.target. Напомню, что такое event.target - это именно тот тег, в котором случился клик, в нашем случае это li.

Итак, вот решение нашей задачи через делегирование:

<ul id="ul">
	<li>пункт 1</li>
	<li>пункт 2</li>
	<li>пункт 3</li>
	<li>пункт 4</li>
	<li>пункт 5</li>
</ul>
var ul = document.getElementById('ul');

ul.addEventListener('click', addSign);
function addSign() {
	event.target.innerHTML = event.target.innerHTML + '!';
}

Результат выполнения кода:

  • пункт 1
  • пункт 2
  • пункт 3
  • пункт 4
  • пункт 5

При этом наше решение будет работать автоматически даже для новых li, ведь событие навешено не на li, а на ul:

<ul id="ul">
	<li>пункт 1</li>
	<li>пункт 2</li>
	<li>пункт 3</li>
	<li>пункт 4</li>
	<li>пункт 5</li>
</ul>

<button id="button">Добавить li</button>
var ul = document.getElementById('ul');

ul.addEventListener('click', addSign);
function addSign() {
	event.target.innerHTML = event.target.innerHTML + '!';
}

//Реализация кнопочки добавления новой li:
var button = document.getElementById('button');
button.addEventListener('click', addLi);
function addLi() {
	var li = document.createElement('li');
	li.innerHTML = 'новая li';
	ul.appendChild(li);
}

Нажмите на кнопочку для добавления li, а затем на эту новую li - она среагирует:

  • пункт 1
  • пункт 2
  • пункт 3
  • пункт 4
  • пункт 5

Наш код рабочий, однако не без недостатков. Давайте разберем эти недостатки и напишем более универсальное решение.

Универсальное делегирование событий

Недостаток нашего кода проявится в том случае, когда внутри li будут какие-то вложенные теги. В нашем случае пусть это будут теги i:

<ul id="ul">
	<li>пункт курсив 1</li>
	<li>пункт курсив 2</li>
	<li>пункт курсив 3</li>
	<li>пункт курсив 4</li>
	<li>пункт курсив 5</li>
</ul>

В этом случае нажатие на i приведет к добавлению восклицательного знака в конец тега i, а не тега li, как мы хотели бы (если нажать на li вне курсива - то все будет ок):

<ul id="ul">
	<li>пункт курсив 1</li>
	<li>пункт курсив 2</li>
	<li>пункт курсив 3</li>
	<li>пункт курсив 4</li>
	<li>пункт курсив 5</li>
</ul>
var ul = document.getElementById('ul');

ul.addEventListener('click', addSign);
function addSign() {
	event.target.innerHTML = event.target.innerHTML + '!';
}

Нажмите на курсив - вы увидите как '!' добавится ему в конец (нажатие вне курсива будет работать нормально):

  • пункт курсив 1
  • пункт курсив 2
  • пункт курсив 3
  • пункт курсив 4
  • пункт курсив 5

Проблема исправляется следующим образом (описанный способ не единственный, но самый простой): с помощью метода closest найдем ближайшую li, котоорая является родителем для event.target вот так: event.target.closest('li').

Как это работает: если клик был на i, то в event.target лежит этот i, а в event.target.closest('li') - наша li, для которой должно сработать событие.

Если же клик был на самой li, то и в event.target, и в event.target.closest('li') будет лежать наша li.

Давайте проверим:

<ul id="ul">
	<li>пункт курсив 1</li>
	<li>пункт курсив 2</li>
	<li>пункт курсив 3</li>
	<li>пункт курсив 4</li>
	<li>пункт курсив 5</li>
</ul>
var ul = document.getElementById('ul');

ul.addEventListener('click', function(event) {
	var li = event.target.closest('li');
	if (li) { //проверяем, вдруг li-родителя вообще нет
		li.innerHTML = li.innerHTML + '!';
	}
});

Результат выполнения кода:

  • пункт курсив 1
  • пункт курсив 2
  • пункт курсив 3
  • пункт курсив 4
  • пункт курсив 5

Не важно, какая глубина вложенности: тег i может лежать в теге b, а тот в теге span и только потом в li - это не имеет значения: конструкция event.target.closest('li') найдет родителя из любого уровня вложенности.

Дополнительные материалы

Рекомендую посмотреть тренинг по делегированию событий.