Базовый арсенал Frontend-разработчика
February 13

События в HTML на пальцах

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

Специально для этой статьи я создал небольшой интерактивный демо-скрипт, который наглядно демонстрирует все фазы события. Скрипт опубликую в конце статьи.

Что такое события?

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

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

Не смотря на то, что работа с событиями неотрывно связана с JavaScript (функции-слушатели - это обычный функции JavaScript), мы ничего про это не найдем в спецификации ECMA-262, точнее, спецификация оставляет этот механизм полностью на совести HOST-исполнителя.

Чаще всего, мы имеем дело с событиями в в таких окружениях, как Web и Node.js. Первое окружение регулируется стандартом HTML. В частности, работа с событиями описывается в разделе 8.1.8 Events. Если быть точным, здесь описываются, в основном только Event handlers. Сам же механизм событий - предмет отдельного стандарта DOM, в котором, так же, описываются непосредственно DOM-дерево, его узлы, селекторы и т.д.

Node.js же не имеет никакого стандарта или спецификации, но имеет свою внутреннюю документацию. События здесь представлены пакетом node:events и в целом, сильно похожи на события стандарта DOM.

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

Создание события

Ранее мы говорили о том, что событие - это объект. И объект этот имеет конкретный интерфейс, описанный в стандарте 2.2 Interface Event. Чтобы создать произвольное событие, достаточно воспользоваться конструктором new Event().

const event = new Event('myEvent');

Альтернативно, можно воспользоваться методом initEvent()

const event = document.createEvent("Event");
event.initEvent("click");

Однако, этот метод считается устаревшим и оставлен в стандарте только в целях совместимости со старым кодом. Кроме того, этот метод не поддерживает установку флага composed.

Иногда, вместе с событием требуется передать какую-либо дополнительную информацию. В этом случае, простого события может быть недостаточно. Специально на этот случай стандартом предусмотрен отдельный интерфейс CustomEvent, который отличается от обычного только наличием readonly атрибута detail.

const event = new CustomEvent('pet', {
  detail: {
    type: "dog",
    color: "black",
  },
});

const event = new CustomEvent('pet', {
  detail: {
    type: "cat",
    color: "white",
  },
});

Для более глубокой кастомизации, так же, можно использовать классы или прототипное наслелование

class MyEvent extends Event {
  constructor(eventInitDict) {
    super("myEvent", eventInitDict);
  }
  
  myProperty: "";
  
  myMethod: () => {};
}

const event = new MyEvent();

Вызов события

Просто создать событие - не достаточно. Для обретения какого-либо практического смысла, созданное событие нужно вызвать на каком-нибудь объекте. События могут быть как нативными (созданными самим браузером), так и синтетическими (созданными разработчиком через Web API). Нативные события, соответственно, вызываются самим браузером в нужный момент и на нужных объектах. Синтетические же необходимо вызывать самостоятельно посредством метода dispatchEvent(), представленного на всех Web-элементах. Другими словами, вызов события всегда происходит с привязкой к конкретному объекту (Web-елементу).

const event = new Event("myEvent");

document.getElementById("my-element").dispatchEvent(event);

Если нам требуется вызвать событие глобально, не привязываясь к конкретному элементу, это можно сделать, вызвав событие на глобальном объекте Window.

window.dispatchEvent(event);

Прослушка событий

Продолжая тему практичности, вызвав событие на объекте, предполагается, что кто-то это событие будет ожидать. Для того, чтобы дождаться и среагировать на конкретное событие, требуется создать функцию-прослушиватель на этом объекте, посредством addEventListener

window.addEventListener("myEvent", (event) => {
  // Функция будет вызвана, в случае, если на глобальном объекте Window
  // произойдет вызов события "myEvent"
});

Если прослушиватель больше не требуется, его можно удалить посредством removeEventListener

// Важно, чтобы ссылка на саму функцию в обоих методах была одна и так же.
// В противном случае, прослушиватель не будет удален, т.к. движок не
// не сможет найти его в стэке колбэков на этом объекте
const callback = (event) => {/* ... */};

window.addEventListener("myEvent", callback);
window.removeEventListener("myEvent", callback);

Фазы события

С базовыми механиками разобрались, теперь перейдем к деталям. Стандарт HTML определяет для события несколько фаз:

NONE (числовое значение 0) - Событие еще небыло вызвано. В этой фазе оно находится сразу после создания.

CAPTURING_PHASE (числовое значение 1) - Фаза перехвата. В этой фазе событие находится после вызова и до того, как достигло объекта target

AT_TARGET (числовое значение 2) - В этой фазе событие находится после вызова ровно в тот момент, когда достигло объекта target

BUBBLING_PHASE (числовое значение 3) - Фаза всплытия. В этой фазе событие находится после вызова и уже после того, как достигло объекта target

Если с NONE и AT_TARGET все довольно очевидно, с остальными двумя фазами требуются пояснения.

Перехват события (Capturing)

Рассмотрим следующую структуру DOM-дерева

<div id="block-1">
    <div id="block-2">
        <div id="block-3">
            <div id="block-4">
                <div id="block-5">
                    <div id="block-6">
                    </div>
                </div>
            </div>
        </div>
    </div>
</div>

Представим, что мы повесили прослушиватели на все объекты и некое вызвали событие на блоке #block-5

for (let i = 1; i <= 6; i++) {
  document.getElementById(`block-${i}`).addEventListener(
    "myEvent",
    () => {}
  );
}

const event = new Event("myEvent");

document.getElementById("block-5").dispatchEvent(event);

В таком варианте мы получим срабатывание прослушивателя только на самом 5-м блоке (фаза AT_TARGET). Однако, Web API позволяет родительским контейнерам "перехватывать" события своих детей. Для этого необходимо выставить флаг capture на прослушивателе.

for (let i = 1; i <= 6; i++) {
  document.getElementById(`block-${i}`).addEventListener(
    "myEvent",
    () => {},
    {
      capture: true,
    }
  );
}

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

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

Возьмем тот же пример. На этот раз, не будем выставлять флаг capture на прослушивателях. Вместо этого выставим флаг bubbles во время вызова самого события

document.getElementById("block-5").dispatchEvent(event, {
  bubbles: true,
});

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

Комбинирование фаз

Как мы видим из примеров выше, перехват и всплытие событий регулируется настройками в разных местах. Перехват активируется на самом прослушивателе, в то время как всплытие включается в момент вызова события. Что позволяет комбинировать фазы самыми разными способами. Например, можно включить перехват только на блоках 1 и 3, тогда остальные смогут работать в фазе всплытия. Тут важно понимать, что один прослушиватель не может сработать в сразу в нескольких фазах. Фазы идут в строгом порядке CAPTURING_PHASE -> AT_TARGET -> BUBBLING_PHASE. Попав в какую-либо из фаз, в следующей прослушиватель вызван уже не будет.

Если заглянуть в исходный код движке Chromium (на момент написания статьи, версия движка 123.0.6292.1), это выглядит буквально следующим образом

dispatchEvent: function(
    eventType, eventFrom, eventFromAction, mouseX, mouseY, intents) {
  const path = [];
  let parent = this.parent;
  while (parent) {
    Array.prototype.push.call(path, parent);
    parent = parent.parent;
  }
  
  const event = new AutomationEvent(
      eventType, this.wrapper, eventFrom, eventFromAction, mouseX, mouseY,
      intents);
      
  // Dispatch the event through the propagation path in three phases:
  // - capturing: starting from the root and going down to the target's parent
  // - targeting: dispatching the event on the target itself
  // - bubbling: starting from the target's parent, going back up to the root.
  // At any stage, a listener may call stopPropagation() on the event, which
  // will immediately stop event propagation through this path.
  if (this.dispatchEventAtCapturing_(event, path)) {
    if (this.dispatchEventAtTargeting_(event, path)) {
      this.dispatchEventAtBubbling_(event, path);
    }
  }
},

Однако, нам никто не запрещает повесить несколько прослушивателей на один объект, например

document.getElementById("block-2").addEventListener("myEvent", callback, {
  capture: true,
});

document.getElementById("block-2").addEventListener("myEvent", callback, {
  capture: false,
});

Тогда можно добиться эффекта срабатывания прослушивателя на одном объекте как в CAPTURING_PHASE, так и в BUBBLING_PHASE.

Остановка событий

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

stopPropagation

Для того, чтобы событие не опускалось ниже по дереву в фазе CAPTURING_PHASE, или не поднималось вверх по дереву в фазе BUBBLING_PHASE, на любом этапе, слушатель может попытаться его остановить посредством вызовом stopPropagation().

document.getElementById("block-3").addEventListener("myEvent",
  (event) => {
    event.stopPropagation();
    // событие небудет распространяться дальше и не достигнет блоков
    // #block-4 и #block-5
  }, {
    capture: true,
  }
);

document.getElementById("block-3").addEventListener("myEvent",
  (event) => {
    event.stopPropagation();
    // аналогично, в фазе BUBBLING_PHASE, событие будет остановлено и
    // не достигнет блоков #block-2 и #block-1
  }, {
    capture: false,
  }
);

document.getElementById("block-5").dispatchEvent(event, {
  bubbles: true,
});

stopImmediatePropagation

Примеры выше позволяют остановить распространение события "вертикально" по DOM-дереву. Однако, на одном элементе может быть сразу несколько прослушивателей. Метод stopPropagation() не предотвратит срабатывание остальных функций-прослушивателей на том же объекте. Специально для этого случая в стандрате HTML предусмотрен другой метод stopImmediatePropagation(), который, помимо вертикального распространения, останавливает и горизонтальное.

document.getElementById("block-3").addEventListener("myEvent", (event) => {
  event.stopImmediatePropagation();
});

document.getElementById("block-3").addEventListener("myEvent", (event) => {
  // этот прослушиватель вызван не будет
});

Одноразовый прослушиватель

Иногда бывает, что мы не хотим останавливать распространения всего события, а только исключить один конкретный прослушиватель после того как он уже отработал? Специально для этого в стандарте HTML предусмотрен флаг once прослушивателя.

document.getElementById("block-5").addEventListener("myEvent",
  () => {},
  {
    once: true,
  }
);

document.getElementById("block-5").dispatchEvent(event);

// на этом вызове прослушиватель уже будет отменен и не сработает
document.getElementById("block-5").dispatchEvent(event); 

AbortSignal

Еще один альтернативный способ остановки событий - механизм AbortSignal. Формально, механизм считается экспериментальным, однако, на сегодняшний день поддерживается всеми популярными браузерами. Суть механизма заключается в том, что в качестве параметра прослушивателя можно передать ссылку на объект AbortSignal, который, в свою очередь, управляется котроллером AbortController. Получив, в какой-то момент сигнал с флагом aborted, событие на этом прослушиваете будет прервано, а в самом прослушивателе будет сгенерировано исключение

const controller = new AbortController();

document.getElementById("block-5").addEventListener("myEvent",
  () => {},
  {
    signal: controller.signal,
  }
);

controller.abort("Manual stop");
// или
controller.abort(); // сгенерирует общий AbortError автоматически

Данный метод удобен тем, что прерыванием события можно управлять динамически "снаружи". Как правило, эту технику применяют для остановки запросов fetch, когда они находятся еще в процессе, но уже не актуальны. В качестве примера можно привести ситуацию, когда мы находимся в процессе ожидания ответа fetch на запрос каких-либо данных с сервера, но параметры самого запрос поменялись (пользователь изменил фильтр или, например, переключил страницу). В этом случае нет смысла дожидаться ответа старого неактуального запроса. Его можно прервать и отправить новый.

Заключение

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

Как и обещал, публикую интерактивный скрипт, демонстрирующий работу события во всех фазах


Мои телеграмм-каналы:

EN - https://t.me/frontend_almanac
RU - https://t.me/frontend_almanac_ru

English version: https://blog.frontend-almanac.com/events