Intro
Для тех кто не в теме.
Итак, все мы пользуемся браузером, если не слышали в такой форме, значит слышали о Google Chrome.
Одни просто сёрфят просторы интернета, кто-то работает, а остальные котиков рассматривают. А еще есть категория людей которая пишет весь этот код. Так вот, таким людям рано или поздно приходятся изучать и разбираться в тонкостях работы различных браузеров, изучать различные подходы разработки и т.п. Одной из таких тем является знание и умение работать с событиями.
Глоссарий
DOM - Document Object Model - это набор HTML-тегов представляющий собой древовидную структуру.
Узел - это каждый элемент DOM-дерева.
<!DOCTYPE HTML>
<html>
<head>
<title> Заколок </title>
</head>
<body>
Содержимое …
</body>
</html>
Событие — это сигнал от браузера о том, что что-то произошло. Все DOM-узлы подают такие сигналы.
В браузере каждое действие пользователя сопровождается событием: нажал на кнопку мыши — сработало событие “click”. Проскролил страницу — сработал “scroll”, да даже мышкой двинешь, сработает несколько событий: “mousemove”, “mouseover”, “mouseout”.
Ознакомиться с более подробным списком событий вы можете тут.
Целевой элемент - узел на котором произошло событие.
Capture phase — фаза когда событие движется в сторону целевого элемента.
Target phase — фаза когда событие достигло целевого элемента
Bubling phase — фаза когда событие движется от целевого элемента.
useCapture — метка, указывающая подписаться на событие во время фазы погружения.
Делегирование события — подход, который подразумевает подписку на события родительского элемента вместо нескольких подписок на однотипные дочерние.
Типичное представление
Распространение события - это движение события от корневого элемента к целевому и обратно - по сути, прохождение всех трёх фаз. Когда мы кликаем по кнопке, событие происходит не только на кнопке, а на всех его родительских элементах. Кнопка, в данной случае, является целевым элементом.
Чтобы проще понять, что такое распространение события, представьте пластиковый полый шарик, брошенный в стакан с водой. В зависимости от высоты падения, шарик погрузится на определенную глубину, с момента погружения до его остановки. Это аналогия с фазой «Capturing». Глубина на которой шарик остановится будет является «Target» фазой (целевой элемент). Далее шарик начнёт всплывать. Собственно до момента его полного выхода на поверхность будет длиться фаза «Bubbling». Нахождение шарика вне стакана можете представить как не начавшееся событие, как будто шарик дрейфует в пространстве и ожидает входа в стакан. Соприкосновение с водой означает начало работы события, оно начинается с корневого элемента DOM дерева.
Описанный выше пример - это типичное представление модели распространения события, которое мы привыкли видеть в интернете.
Новое представление
Когда я впервые изучал эту тему, она давалась мне не просто. Спустя некоторое время, набравшись опыта, я вернулся к ней, «прошерстил» кучу статей, и таки приблизился к пониманию.
Тогда я выдохнул и сказал: «Наконец-то!»
Вы когда-нибудь задумывались, как образуются звуки, которые мы произносим? Основное четкое звучание слышится на выдохе. Но мы можем «выдавить» слова и на вдохе, что не очень удобно, но, тем не менее, возможно.
Две крупные компании: Netscape и Miсrosoft, когда-то предложили две разные модели поведения (концепции) распространения события.
Netscape Event Capturing
Предложенная концепция компанией Netscape E.Capturing предполагает «ловить» событие когда оно находится в фазе «Погружения» т.е. событие движется в DOM дереве от корневого к целевому (на котором произошло событие).
Microsoft Event Bubling
MS предложила альтернативную версию E.Bubbling - ловить событие на фазе “Всплытия» - при движении от целевого к корневому.
Два гиганта столкнулись в ожесточенной битве.
Затем пришёл дядя W3C и сказал: «Девочки не ссорьтесь. Каждый будет услышан». И объединил две концепции в одну.
События будут двигаться от корневого к целевому и обратно. По умолчанию все события будут ловиться на фазе всплытия, но по требованию событие можно отловить и на фазе погружения.
Событие можно представить, как некий бегунок-контроллер. Он пробегает по структуре DOM, опрашивая узлы на наличие подписки на событие. Подписка есть - имеешь право «говорить» (выполнить функцию).
Проход по узлам напоминает рекурсивный подход обхода дерева. Но т.к. нам известен целевой элемент, то событие движется непосредственно по родственным узлам. Во время движения не затрагиваются узлы-братья/сестры (те что находятся на одном уровне).
Теперь вернёмся к звукам и дыханию.
Я представил весь процесс не опираясь на модель поведения пузырька, а как дыхательный процесс - заменил погружение на вдох, всплытие на выдох, наличие подписки на событие обозначил издаваемым звуком.
И вся концепция W3C сохранилась: на вдохе мы издаём звук при крайней необходимости. Это будет не удобно, но я могу. Т.е. если я хочу озвучить что-то перед тем как начну основную речь ????, например выразить эмоцию озарения, я сделаю это на вдохе, а потом выскажу чем именно меня осенило. Точно так же большинство подписок выполняется на всплытии(выдох), и только если нам нужно что-то выполнить перед тем как будет «озвучена основная речь» мы подписываемся с ключом useCapture, который сообщает, что событие надо ловить в фазе погружения(вдох).
useCapture - это есть само решение выразить эмоцию, которое мы передаём при подписке на событие. Каждое слово - это одно из событий.
Из основной концепции утеряно ещё одно понятие target phase. В новом видении это очень походит на момент перехода между вдохом и выдохом. По сути, это количество воздуха поступившее в легкие (текущий объем). Он же (момент) в типичной модели является моментом перехода состояния шарика из погружающегося в всплывающий.
Примеры.
1. Event Bubling and Capturing.
В данном примере мы навешиваем два события ‘click’ на два элемента: на блок #boxBtn, и на кнопку #btn;
Согласно концепции принятой W3C, что события выполняются на фазе всплытия, сначала мы получим сообщение "Click for button" , а затем "Click for box".
Но если нам необходимо изменить порядок и перехватить событие клика по #boxBtn, то нам необходимо явно сообщить при подписке, что мы хотим выполнить это событие на фазе погружения (с помощью useCapture). В этом случае порядок вывода сообщения изменится.
Показать код
<!DOCTYPE html>
<html>
<head>
<title>Event Propagation. Example #1</title>
<meta charset="UTF-8" />
</head>
<body>
<div class="wrapper">
<h1 class="title">
Event Propagation. Event Bubling and Capturing
</h1>
<div id="boxBtn">
<button id="btn">CLICK ME</button>
</div>
</div>
<script src="src/index.js"></script>
</body>
</html>
const box = document.getElementById("boxBtn");
const btn = document.getElementById("btn");
const handlerClickBox = () => {
alert("Click for box");
};
const handlerClickBtn = () => {
alert("Click for button");
};
// служит идентифиактором для изменения порядка обработки события
const useCapture = true;
box.addEventListener("click", handlerClickBox);
// что бы проверить как изменяется поведение обработчика,
// раскомментируй код ниже, и закоментируй выше
// box.addEventListener("click", handlerClickBox, useCapture);
btn.addEventListener("click", handlerClickBtn);
Поиграть в песочнице.
2. Cancel Event.
На определенные события браузер имеет поведение по умолчанию. Иногда это противоречит поведению системы, потому полезно будет знать, что события можно отменять, иначе говоря отключать поведение по умолчанию.
В данном примере мы видим форму, на которую наложена самая примитивная валидация - все поля обязательны к заполнению. Проверка проводится после попытки отправить форму, вот тут нам как раз и поможет отмена. Что бы прервать событие, необходимо вызвать специальный его метод - preventDefault.
В обработчике события ‘submit’ (функция handleSubmitForm) первым делом мы останавливаем событие используя e.preventDefault(), а так же еще один метод stopPropagation - он останавливает распространение события прямо на текущем элементе. Далее выполняется проверка формы на отсутствие незаполненных полей. Если есть не заполненные поля, выводит сообщение об ошибке “You have empty fields”, в противном случае возобновляем событие ‘submit’ используя метод .submit() того же события.
Дополнительно, немного улучшим юзабилити нашего интерфейса. Будем сбрасывать ошибку при изменении любого поля нашей формы. Для этого навешаем на нашу форму событие ‘change’, которое будет очищать наше блок с ошибкой.
Показать код
<!DOCTYPE html>
<html>
<head>
<title>Event Propagation. Example #2</title>
<meta charset="UTF-8" />
</head>
<body>
<div class="wrapper">
<h1 class="title">Event Propagation. Cancel Event</h1>
<div id="boxForm" >
<form id="form" action="/form" onsubmit="() => false">
<input name="input_1" class="form-field" placeholder="input 1"></input>
<input name="input_2" class="form-field" placeholder="input 2"></input>
<input name="input_3" class="form-field" placeholder="input 3"></input>
<input type="submit" id="btn" value="Submit"></input>
</form>
<p id="error"></p>
</div>
</div>
<script src="src/index.js"></script>
</body>
</html>
const form = document.getElementById("form");
const error = document.getElementById("error");
const handleSubmitForm = (e) => {
e.preventDefault();
e.stopPropagation();
let hasEmptyField = false;
for (let i = 0; i < form.childElementCount - 1; i++) {
if (!form.children[i].value) {
// при наличии незаполненных полей вывести ошибку
error.innerHTML = "You have empty fields";
hasEmptyField = true;
}
}
if (!hasEmptyField) {
// возобновляем событие если нет пустых полей;
e.target.submit();
console.log("Submited form");
}
};
// сбрасываем ошибку при изменении полей;
form.addEventListener("change", () => {
error.innerHTML = "";
});
form.addEventListener("submit", handleSubmitForm);
Поиграть в песочнице
3. Missclick.
Одним из наиболее частых кейсов в разработке, является обработка события клика вне области элемента. Наиболее часто применяется для сворачивания выпадающего списка. Данное действие носит название missclick (миссклик) . Иногда можно встретить альтернативное названия outSideClick.
Ниже посмотрим реализацию. У нас есть:
кнопка SHOW | HIDE;
список абстрактных элементов, скрытый по умолчанию;
по клику на кнопку список разворачивается и сворачивается.
Для работы понадобится функция которая скрывает список toggleStateList изменяя его состояние. По сути она является обработчиком клика по кнопке. И определяет какую в данный момент функцию следует вызвать:
show - показать список;
hide - скрыть список;
Вторая функция missClick - её задача отловить событие click в любой точке документа (вешаем обработчик на document), и проверить, является ли элемент по которому кликнули нашей кнопкой. Если нет то выполняем функцию hide. Именно для этого мы разделили логику toggleStateList на две доп. функции, что бы вызвать её отдельно в missClick.
Показать код
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="stylesheet" href="/src/styles.css" />
<title>Event Propagation. Example #3</title>
</head>
<body>
<div class="wrapper">
<h1 class="title">
Event Propagation. Missclick (outSideClick)
</h1>
<div id="boxBtn">
<div id="btn">SHOW</div>
<hr />
<ul id="list" hidden>
<li>item 1</li>
<li>item 2</li>
<li>item 3</li>
<li>item 4</li>
<li>item 5</li>
<li>item 6</li>
<li>item 7</li>
</ul>
</div>
</div>
<script src="./src/index.js"></script>
</body>
</html>
const list = document.getElementById("list");
const btn = document.getElementById("btn");
// получаем текущее состояние списка
let isHiddenList = list.hidden;
const hide = () => {
list.setAttribute("hidden", true);
btn.innerHTML = "SHOW";
isHiddenList = true;
};
const show = () => {
list.removeAttribute("hidden");
btn.innerHTML = "HIDE";
isHiddenList = false;
};
// функция для скрытия и отображения списка
const toggleStateList = (event) => {
isHiddenList ? show() : hide();
event.stopPropagation();
};
// функция которая провярет был ли совершен клик по нужному элементу
const missClick = (event) => {
if (!btn.contains(event.target)) {
hide();
}
};
// закоментируй строчку ниже чтобы убедится что при клике вне кнопки
// список не сворачивается
document.addEventListener("click", missClick);
btn.addEventListener("click", toggleStateList);
Поиграть в песочнице.
Так же можете посмотреть реализацию хука useMissClick на TypeScript.
4. Делегирование события.
Еще одним из популярных приемов является делегирование события. Для того что бы понять предлагаю посмотреть реализацию игры в крестики нолики (Tic-Tac-Toe). Так как основная задача показать как правильно делегировать события, игра не полноценна, от сюда выброшены правила и не определяется победитель.
И так, мы знаем что событие всплывает, это означает что кликнув по элементу мы отловим событие клика на его родительском элементе. У события есть свойство target, которое содержит целевой элемент, зафиксированный в Target Phase. Это дает нам возможность не навешивать события на каждый элемент в отдельности, а отловив событие на родительском элементе, исключить все которые нас не интересуют. Мы проверяем является ли target тем элементом который мы ожидаем. В нашем коде нас интересует клик по ячейке, которые имеет name=’cell’. Остальные клики мы игнорируем.
if (cell.getAttribute("name") !== "cell") return
В этом и заключается суть делегирования, в остальном все просто мы после каждого клика должны установить символ в ячейку соответствующий конкретному игроку, создаем activePlayer содержащий символ первого или второго игрока, ход за ходом он будет меняться.
Показать код
<!DOCTYPE html>
<html>
<head>
<title>Event Propagation. Example #4</title>
<meta charset="UTF-8" />
</head>
<body>
<div class="wrapper">
<h1 class="title">Event Propagation. Delegating an event</h1>
<h3 title="Крестики-нолики">Tit-Tac-Toe</h3>
<div id="playSpace">
<div class="borderSpace">
<div class="row">
<span name="cell" class="col"></span>
<span name="cell" class="col"></span>
<span name="cell" class="col"></span>
</div>
<div class="row">
<span name="cell" class="col"></span>
<span name="cell" class="col"></span>
<span name="cell" class="col"></span>
</div>
<div class="row">
<span name="cell" class="col"></span>
<span name="cell" class="col"></span>
<span name="cell" class="col"></span>
</div>
</div>
</div>
</div>
<script src="src/index.js"></script>
</body>
</html>
const playSpace = document.getElementById("playSpace");
const players = {
first: "X",
second: "0"
};
let activePlayer = players.first;
const stepPlayer = () => {
const symbol = activePlayer;
if (symbol === players.first) activePlayer = players.second;
else activePlayer = players.first;
return symbol;
};
const handlerClick = (e) => {
const cell = e.target;
// делегируем событие - исключаем все клики не относящиеся к нашим ячейкам
if (cell.getAttribute("name") !== "cell") return;
const cellSymbol = stepPlayer();
cell.innerHTML = cellSymbol;
};
playSpace.addEventListener("click", handlerClick);
Поиграть в песочнице.