Введение в события DOM

Список событий, возможных в DOM, очень длинный: click (клик мышью), touch (касание), load (загрузка), drag (перетягивание), change (изменение), input (ввод), error (ошибка), resize (изменение размера) и т.д. События могут срабатывать для любой части документа вследствие взаимодействия с ним пользователя или браузера. Они не просто начинаются и заканчиваются в одном месте; они циркулируют по всему документу, проходя свой собственный жизненный цикл. Это и делает события DOM столь гибкими и полезными. Разработчики должны понимать как работают события DOM, чтобы суметь использовать их потенциал и построить увлекательный интерфейс.

За всё время работы в фронтенде у меня сложилось впечатление, что ни один из просмотренных мной источников не дал чёткого объяснения как работают события DOM. В этой статье я хотел бы предоставить полный обзор этой темы, чтобы помочь вам освоить её быстрее, чем это удалось мне.

Я опишу основы работы с событиями DOM, затем углублюсь во внутренние аспекты работы и объясню как их можно использовать для решения часто встречающихся практических задач.

Обработка событий

Раньше процесс установки обработчиков событий для узлов дерева документа был довольно непоследовательным. Библиотеки вроде jQuery играли бесценную роль в абстрагировании от связанных с ним странностей.

Чем больше мы приближаемся к соответствию стандартам со стороны браузерных окружений, тем более безопасным становится использование API из официальной спецификации. Чтобы не усложнять, я расскажу как можно управлять событиями именно в современном вебе. Если вы пишите код на JavaScript для Internet Explorer (IE) 8 или старше, рекомендую для управления обработкой событий использовать полифил или фреймворк (такой как jQuery).

В JavaScript обработчик события можно установить используя следующее:

element.addEventListener(<event-name>, <callback>, <use-capture>);

Пример: addEventListener

Удаление приемника события

Удаление приемника события, когда он больше не нужен, считается хорошим тоном (особенно в веб-приложениях с длительным временем выполнения). Чтобы это сделать, используйте метод element.removeEventListener():

element.removeEventListener(<event-name>, <callback>, <use-capture>);

У removeEventListener, однако, есть один нюанс: нужно указать функцию обратного вызова, которая изначально была привязана к событию. Просто element.removeEventListener('click'); работать не будет.

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

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

function callback() {
    alert('Привет один раз');
    element.removeEventListener('click', callback);
}

// Добавление приемника события
element.addEventListener('click', callback);

Пример: removeEventListener

Поддержание контекста обработчика события в рабочем состоянии

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

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

var user = {
    firstname: 'Виктор',
    greeting: function(){
        alert('Меня зовут ' + this.firstname);
    }
};

// Присоединяем user.greeting в качестве обработчика события
element.addEventListener('click', user.greeting);

// alert => 'Меня зовут undefined'

Пример: Некорректный контекст обработчика события

Использование анонимных функций

Мы ожидали, что обработчик события выведет сообщение Меня зовут Виктор. На самом деле он выведет Меня зовут undefined. Чтобы this.firstName возвращало Виктор, нужно вызвать user.greeting в контексте (т.е. это то, что должно быть слева от точки) user.

Когда мы передаём функцию greeting методу addEventListener, мы всего лишь указываем отношение к функции; контекст user с ней не передается. По сути, функция вызвана в контексте element, это значит что this обозначает element, а не user. Следовательно, this.firstname не может быть определено.

Есть два способа предотвратить такой дисбаланс контекста. Во-первых, можно вызвать user.greeting() с правильным контекстом в анонимной функции.

element.addEventListener('click', function() {
    user.greeting();
    // alert => 'Меня зовут Виктор'
});

Пример: анонимные функции

Function.prototype.bind

Описанный выше метод недостаточно хорош, потому что когда мы хотим удалить функцию с помощью .removeEventListener() оказывается что у нас нет к ней доступа. Кроме того, такой подход довольно безобразный. Я предпочитаю использовать метод .bind() (встроен во все функции ECMAScript 5) для генерации новой функции (привязанной), которая всегда выполняется в заданном контексте. Затем мы передаём эту функцию методу .addEventListener() в качестве функции обратного вызова.

// Перекрытие исходной функции 
// функцией, привязанной к контексту 'user'
user.greeting = user.greeting.bind(user);

// Присоединение user.greeting в качестве функции обратного вызова
button.addEventListener('click', user.greeting);

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

button.removeEventListener('click', user.greeting);

Пример: Function.prototype.bind

Если нужно, можете зайти на страницу с информацией о поддержке Function.prototype.bind и страницу с описанием полифила.

Объект event

Объект event создаётся когда соответствующее событие происходит впервые; он сопровождает событие в его путешествии по дереву документа. Объект событие передаётся в качестве первого параметра функции, которую мы прописываем для приемника события как функцию обратного вызова. Этот объект можно использовать, чтобы получить доступ к огромному количеству информации о случившемся событии:

Объект event может принимать множество других свойств, однако они зависят от конкретного типа события. Например, для событий мыши объекта event применяются свойства clientX и clientY чтобы определить размещение указателя в области просмотра.

Для более подробного ознакомления с объектом event и его свойствами лучше всего использовать отладчик в вашем любимом браузере или console.log.

Фазы события

Когда в приложении возникает событие DOM, оно не просто срабатывает один раз в месте своего происхождения; оно отправляется в путь, состоящий из трёх фаз. Вкратце, событие движется от корня документа к цели (фаза перехвата), затем срабатывает для цели события (фаза цели) и движется назад к корню документа (фаза всплытия).

поток события

(Источник изображения: W3C)

Пример: Замедленное воспроизведение продвижения события

Фаза перехвата

Первой фазой является фаза перехвата. Событие начинает своё путешествие в корне документа, проходит каждый слой дерева документа по направлению к цели, срабатывая для каждого узла по пути, пока её не достигнет. Задача фазы перехвата — наметить траекторию распространения, по которой событие будет двигаться в обратном направлении в фазе всплытия.

Как упоминалось ранее, обработчик можно вызвать в фазе перехвата, если в качестве третьего параметра для addEventListener указать true. Я особо не сталкивался с ситуациями, когда требовался вызов обработчика в фазе перехвата, однако, теоретически, с его помощью можно предотвратить срабатывание события щелчка мышью для определённого элемента если событие обрабатывается в фазе перехвата.

var form = document.querySelector('form');

form.addEventListener('click', function(event) {
    event.stopPropagation();
}, true); // Заметьте: 'true'

Если вы не уверены в позитивном результате, вызывайте обработчик события в фазе всплытия, указав последним параметром значение false или undefined.

Фаза цели

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

В случае с вложенными элементами, события мыши и указателя мыши всегда нацелены на наиболее глубоко расположенный вложенный элемент. Если обработчик вызывается для события click элемента <div>, и пользователь кликает по элементу <p> внутри <div>, этот <p> становится целью события. Тот факт, что события «всплывают» значит, что можно вызывать обработчик для кликов по <div> (или любому другому родительскому элементу) и получать функцию обратного вызова при прохождении события.

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

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

Дерево документа можно представить в виде луковицы, а цель события — в виде её сердцевины. В фазе перехвата событие проникает внутрь луковицы, минуя слой за слоем. Когда событие достигает сердцевины, оно срабатывает (фаза цели) и меняет направление на противоположное, слой за слоем прокладывая себе путь назад (фаза распространения).

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

Пример: Определение фаз события

Большинство (но не все) событий всплывают. Если событие не всплывает, для этого наверняка есть веская причина. В случае сомнения по этому поводу, можете свериться со спецификацией.

Останавливаем распространение

Прервать распространение события в любой его момент (т.е. в фазе перехвата или всплытия) можно просто вызвав метод stopPropagation объекта событие. После этого событие не будет вызывать обработчики для узлов, которые он минует на своём пути к цели события и назад к корню документа.

child.addEventListener('click', function(event) {
    event.stopPropagation();
});

parent.addEventListener('click', function(event) {
    // Если произошёл клик мышью по дочернему элементу,
    // этот обработчик не будет вызван
});

Если для одного и того же события установлено несколько обработчиков, вызов event.stopPropagation() не предотвратит их срабатывания для текущей цели события. Если нужно предотвратить вызов любых дополнительных обработчиков для текущего узла, можно использовать более агрессивный метод event.stopImmediatePropagation().

child.addEventListener('click', function(event) {
    event.stopImmediatePropagation();
});

child.addEventListener('click', function(event) {
    // Если произошёл клик мышью по дочернему элементу,
    // этот обработчик не будет вызван
});

Пример: Остановка распространения

Предотвращение поведения, установленного в браузере по умолчанию

Для некоторых событий, которые происходят в документе, в браузере установлено поведение по умолчанию. Наиболее распространённым является клик по ссылке. Когда для элемента <a> происходит событие click, оно всплывает до корня документа, браузер расшифровывает атрибут href и перегружает окно с новой страницей.

В веб-приложениях обычно хотелось бы иметь возможность самостоятельно управлять навигацией, без перезагрузок страницы. Чтобы такую возможность получить, нужно предотвратить установленную по умолчанию реакцию браузера на клик, и вместо неё выполнить то, что задумали мы. Для этого мы вызовем event.preventDefault().

anchor.addEventListener('click', function(event) {
    event.preventDefault();
    // Выполнение нужных нам действий
});

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

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

Пример: Предотвращение поведения, установленного по умолчанию

Пользовательские события

Запустить событие DOM может не только браузер. Мы можем создать собственное пользовательское событие и применить его к любому элементу в документе. Событие такого типа ведёт себя точно так же, как обычное событие DOM.

var myEvent = new CustomEvent("myevent", {
    detail: {
        name: "Виктор"
    },
    bubbles: true,
    cancelable: false
});

// Вызов обработчика для события 'myevent'
myElement.addEventListener('myevent', function(event) {
    alert('Привет, ' + event.detail.name);
});

// Запуск события 'myevent'
myElement.dispatchEvent(myEvent);

Также для имитации пользовательского взаимодействия можно синтезировать «не доверенные» события элементов (например, click). Они могут пригодиться при тестировании библиотеки для DOM. Если вас это заинтересовало, проект Mozilla Developer Network предлагает описание работы с такими событиями.

Помните следующее:

Пример: Пользовательские события

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

Применение делегированного обработчика события является удобным и производительным способом обработки события большого количества узлов DOM при наличии одного обработчика. Например, если в списке есть 100 пунктов и они все должны реагировать на событие click одинаково, мы можем для каждого из них установить обработчик события. Это даст нам 100 отдельных обработчиков события. При каждом добавлении пункта в список нужно было бы устанавливать для него обработчик события click. Это не только дорого, но и неудобно.

Делегированные обработчики событий могут значительно упростить нам жизнь. Вместо того чтобы вешать обработчик события click на каждый элемент, мы устанавливаем только один для родительского элемента <ul>. После клика по <li> событие всплывает до <ul> и возбуждает обработчик события. По какому именно элементу <li> был произведён клик можно определить проверив event.target. Ниже в качестве иллюстрации приведён грубый пример:

var list = document.querySelector('ul');

list.addEventListener('click', function(event) {
    var target = event.target;

    while (target.tagName !== 'LI') {
        target = target.parentNode;
        if (target === list) return;
    }

    // Выполнение каких-то действий
});

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

В приложении такую грубую реализацию я бы не советовал использовать. Лучше обратиться к библиотекам JavaScript для делегирования событий, таким как ftdomdelegate от команды FT Labs. Если вы используете jQuery, то можете применить её встроенную возможность делегирования событий передавая методу .on() селектор в качестве второго параметра.

// Без делегирования события
$('li').on('click', function(){});

// Использование делегирования события
$('ul').on('click', 'li', function(){});

Пример: Делегированный обработчик события

Полезные события

load

Событие load происходит при окончании загрузки любого ресурса (в том числе зависимых ресурсов). Им может быть изображение, таблица стилей, скрипт, видео, аудио файл, документ или окно.

image.addEventListener('load', function(event) {
    image.classList.add('has-loaded');
});

Пример: Событие load объекта изображение

onbeforeunload

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

window.onbeforeunload = function() {
    if (textarea.value != textarea.defaultValue) {
        return 'Вы хотите покинуть страницу и отменить изменения?';
    }
};

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

Пример: onbeforeunload

Избавление от подрагивания окна в Mobile Safari

В приложении Financial Times мы используем простой приём event.preventDefault для избежания подрагивания окна при прокрутке в Mobile Safari.

document.body.addEventListener('touchmove', function(event) {
    event.preventDefault();
});

Стоит также знать, что это заблокирует любое встроенное прокручивание (такое как overflow: scroll). Чтобы разрешить встроенное прокручивание для набора элементов, которые в нём нуждаются, следует установить обработчик для того же события элемента, который должен прокручиваться, и установить флаг для объекта события. В обработчике на уровне документа мы решаем нужно ли предотвратить действие по умолчанию для события касания, исходя из наличия флага isScrollable.

// Ниже по дереву мы устанавливаем флаг
scrollableElement.addEventListener('touchmove', function(event) {
    event.isScrollable = true;
});

// Выше по DOM проверяем наличие этого флага чтобы определить
// нужно ли разрешить браузеру выполнить прокручивание
document.addEventListener('touchmove', function(event) {
    if (!event.isScrollable) event.preventDefault();
});

В IE 8 и старше нельзя управлять объектом события. Чтобы обойти эту проблему можно установить свойства для узла event.target.

resize

Возможность установить обработчик для события resize объекта window очень удобна при сложной отзывчивой верстке страницы. Добиться такой верстки только с помощью CSS не всегда возможно. Иногда приходится использовать JavaScript для расчёта и применения размера элементов. Когда изменяется размер окна или же ориентация устройства, нам нужно подстроить эти размеры.

window.addEventListener('resize', function() {
    // обновление верстки
});

Я рекомендую использовать обработчик с ограничением количества вызовов чтобы нормализировать частоту вызова обработчика и избежать слишком частого пересчёта верстки.

Пример: изменение размеров окна

transitionEnd

Сегодня для реализации большинства переходов и анимации в наших приложениях мы используем CSS. Однако иногда нам все же нужно узнать когда выполнение определённой анимации подошло к концу.

el.addEventListener('transitionEnd', function() {
    // Выполнение каких-либо действий
});

Обратите внимание на следующее:

Пример: Окончание перехода

animationiteration

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

function start() {
    div.classList.add('spin');
}

function stop() {
    div.addEventListener('animationiteration', callback);

    function callback() {
        div.classList.remove('spin');
        div.removeEventListener('animationiteration', callback);
    }
}

Если вы заинтересовались, я написал о событии animationiteration более подробно в своём блоге.

Пример: итерации анимации

error

Если в процессе загрузки ресурса происходит ошибка, нам наверняка нужно на это как-то отреагировать, особенно если у наших пользователей плохое соединение с сетью. В приложении Financial Times используется событие error для определения изображений, которые не удалось загрузить в статье и их немедленного скрытия. Так как согласно спецификации «DOM Уровень 3 События (DOM Level 3 Events)» событие error всплывать не должно, обработать его можно одним из двух способов.

imageNode.addEventListener('error', function(event) {
    image.style.display = 'none';
});

К сожалению, addEventListener подходит не во всех случаях. Мой коллега Kornel любезно представил мне пример, который доказывает что, к сожалению, единственный способ гарантировать вызов обработчика события error для изображения состоит в использовании строчных обработчиков события (хоть они и не приветствуются).

<img src="http://example.com/image.jpg" onerror="this.style.display='none';" />

Причиной этому является то, что нельзя быть уверенным что код, с помощью которого установлен обработчик события error, будет выполнен до того, как собственно произойдёт событие error. Использование строчного обработчика значит, что наш обработчик события error будет установлен при обработке разметки и запросе изображения.

Пример: Ошибка загрузки изображения

Выводы

Из успешности концепции событий DOM можно сделать определённые выводы. Мы можем использовать похожие концепции в наших собственных проектах. Модули в приложении могут быть настолько сложными, насколько это нужно, пока это не сказывается на простоте интерфейса. Большое количество фронтенд фреймворков (таких как Backbone.js) в большей мере основаны на событиях.

Архитектура, построенная на событиях великолепна. Она даёт нам простой и понятный интерфейс, в котором можно писать приложения, отзывчивые к физическим взаимодействиям на тысячах устройств! Посредством событий устройства с точностью говорят нам что произошло и когда, давая возможность отреагировать так, как мы пожелаем. То, что происходит «под капотом», нас не волнует; мы получаем тот уровень абстрагирования, который даёт нам полную свободу для создания великолепного приложения.

Материалы для дальнейшего чтения

Особую благодарность хочу выразить Kornel за отличный технический анализ.