События: реакция на клики, ввод и клавиши

Зачем:Страница оживает, когда реагирует на посетителя. После урока вы вешаете обработчики на клики, ввод и клавиши, понимаете, как событие всплывает по дереву и зачем делегирование, и не ломаете управление с клавиатуры.

25 минут

Озвучено синтезом речи (Qwen3-TTS), голос не принадлежит человеку.

Событие — сообщение браузера о действии посетителя: клике, вводе, нажатии клавиши. Скрипт подписывается на него через addEventListener и реагирует.

Страница, которая слушает

Браузер непрерывно замечает, что делает посетитель: нажал, провёл, напечатал букву, переключил вкладку, повернул телефон. Каждое такое замечание браузер оформляет как событие и сообщает странице. Страница ничего не делает, пока не «подписана» на событие, а подписать её должен скрипт.

const button = document.querySelector('#menu-button');

button.addEventListener('click', (event) => {
  console.log('Нажали, тип события:', event.type);
});

Метод addEventListener принимает два аргумента: тип события и обработчик, функцию, которую браузер вызовет, когда событие случится. Ей он передаёт объект события: у него есть type (какое событие), target (где оно возникло), для клавиатуры key (какая клавиша), и многое другое. Снять подписку можно через removeEventListener.

Старых способов два, и оба хуже. Атрибут в разметке (<button onclick="...">) смешивает разметку с кодом и позволяет повесить лишь один обработчик. Свойство (button.onclick = ...) тоже даёт один обработчик на событие: следующий затрёт предыдущий. addEventListener позволяет подписывать на одно событие сколько угодно функций.

События, которые нужны сайту

Событий десятки, но почти всё на сайте строится на пяти:

  • click: нажатие мышью или пальцем, а у кнопки ещё и клавиши Enter и пробел;
  • input: значение поля меняется, пока человек печатает (а change срабатывает, когда он закончил, например отпустил поле или переключил флажок);
  • submit: отправка формы, по кнопке или по Enter;
  • keydown: нажата клавиша, event.key называет её ('Escape', 'Enter', 'a');
  • DOMContentLoaded: страница разобрана. В нашем редакторе модульные скрипты и так выполняются после этого момента, поэтому слушать это событие не нужно.

Отмена действия браузера

У многих событий есть действие по умолчанию: ссылка ведёт по адресу, форма отправляется и страница перезагружается, Tab переводит фокус. Когда нужно сделать по-своему, действие отменяют вызовом event.preventDefault().

form.addEventListener('submit', (event) => {
  event.preventDefault();   // страница не перезагрузится
  // дальше отправляем данные сами: это урок про fetch
});

Без этой строки Enter в поле поиска отправит форму и страница перезагрузится с пустым результатом: самая частая ошибка начинающего.

Проверьте себя: что будет, если в обработчике submit забыть preventDefault?

Браузер выполнит действие по умолчанию: отправит форму и перезагрузит страницу, и всё, что скрипт успел сделать, пропадёт.

Всплытие и делегирование

Событие не остаётся в одном месте. Когда вы нажимаете на ссылку внутри карточки, браузер сначала передаёт событие самой ссылке, а потом всплывает вверх по предкам: карточка, контейнер списка, body, document. Каждый из них может иметь свой обработчик и получить то же событие. Поэтому у объекта события два адреса:

  • event.target: где событие возникло (самый глубокий элемент, по которому нажали);
  • event.currentTarget: на ком висит сейчас сработавший обработчик.

Это даёт приём, которым пользуются все: делегирование. Вместо обработчика на каждой из ста карточек вешают один на контейнер и смотрят в event.target, что именно нажали:

cards.addEventListener('click', (event) => {
  const card = event.target.closest('.card');   // ближайшая карточка вверх от места нажатия
  if (!card) return;                            // нажали мимо
  card.classList.toggle('selected');
});

Метод closest ищет подходящий элемент от места нажатия вверх по дереву. Выгод две. Слушателей меньше, а главное, обработчик работает и для карточек, которые скрипт добавит позже: события от них тоже всплывут до контейнера. Поэтому, если у вас список, который меняется, вешайте обработчик на родителя.

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

Посмотрите, как событие идёт по дереву. Выберите, куда нажимать, на каких узлах стоят обработчики, и попробуйте остановить всплытие:

Куда летит нажатие
Нажали на:
Где стоит обработчик click
  1. a: событие проходит сквозь, обработчика нет
  2. article.card: событие проходит сквозь, обработчика нет
  3. div.cards: обработчик сработал: target = a, currentTarget = div.cards
  4. body: событие проходит сквозь, обработчика нет
  5. document: событие проходит сквозь, обработчика нет

Что ещё случится. Браузер перейдёт по ссылке: действие по умолчанию никто не отменил.

И второй вопрос схемы: сколько обработчиков нужно, когда карточек становится больше и когда одну из них добавил скрипт:

Сколько обработчиков нужно
Обработчик на каждой карточке: 5

Нажмите на карточки: каждая отвечает своим обработчиком.

Один обработчик на контейнере: 1

Добавьте карточку: она тоже заработает без нового кода.

Проверьте себя: чем event.target отличается от event.currentTarget?

target: где событие возникло. currentTarget: на каком элементе висит сработавший обработчик. При делегировании обработчик на контейнере (currentTarget), а нажали на элемент внутри (target).

Доступно с клавиатуры

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

  1. Используйте настоящий button. Он получает фокус клавишей Tab, нажимается Enter и пробелом, объявляется диктором как «кнопка», и всё это бесплатно. div с обработчиком click ничего из этого не умеет, а ссылка <a href="#">, которая работает как кнопка, путает и тех, и других.
  2. Сообщайте состояние. У кнопки, раскрывающей блок, есть атрибут aria-expanded (false или true), и скрипт меняет его вместе с блоком. Атрибут aria-controls называет id управляемого блока. Диктор прочитает «Разделы, кнопка, свёрнуто».
  3. Закрывайте по Escape и возвращайте фокус. Если меню открыли клавишей, фокус остался на кнопке. Закрывая меню, не теряйте его: button.focus().
  4. Не убирайте видимый фокус. Рамка вокруг элемента, до которого дошёл Tab, это единственный способ понять, где вы находитесь.

В первом правиле есть и вторая половина: если без скрипта меню должно работать (а мы разберём почему в конце части), то список разделов в разметке виден всегда, а скрипт прячет его за кнопку. Не наоборот.

Практика

Продолжаем страницу эпохи 2014 года. Добавим два типичных элемента: меню, которое сворачивается за кнопку, и поиск по предложениям, который прямо при вводе скрывает лишние карточки.

Заметьте три детали в задании. Атрибут hidden проигрывает любому правилу CSS, которое задаёт display, поэтому нужно правило [hidden] { display: none !important }. Форма поиска получает обработчик submit с preventDefault, иначе Enter перезагрузит страницу. А карточки ищутся в момент ввода, а не один раз при запуске: список может поменяться, и вы увидите это в уроке про fetch.

Проверки, как и в прошлых уроках, читают разметку и текст скрипта. Работает ли он, вы проверяете сами: нажмите Tab до кнопки меню, откройте и закройте её клавишами, введите слово в поиск.

Что было не так раньше

В эпоху до addEventListener обработчики вешали по-разному: Internet Explorer до девятой версии использовал attachEvent, остальные браузеры addEventListener, и каждый разработчик писал свою обёртку (jQuery с её .click() и .on() была в том числе такой обёрткой). Меню делали из ссылок <a href="#">, у которых вместо адреса стоял «ничего»: они нажимались мышью, не объявлялись как кнопки и добавляли в адресную строку лишнюю решётку. Всплытие часто останавливали по привычке, и поэтому чужие скрипты (аналитика, например) переставали видеть клики.

Что осталось верным: слушать события лучше на общем родителе, не навешивая сто обработчиков, и считать клавиатуру равноправной с мышью.

Практика

Готовлю вашу страницу…

Тест

Вопрос 1 из 7

Что делает event.preventDefault() в обработчике события submit формы?

Один ответ

Карточки

Эти карточки попадут в повторение, когда вы завершите урок.

  • Как навесить обработчик события?

    Ответ

    element.addEventListener("click", функция). Функция получит объект события: у него есть type, target, key и другие поля. Атрибуты onclick в разметке не используют.

  • Что такое всплытие события?

    Ответ

    После срабатывания на элементе событие идёт вверх по предкам: карточка, контейнер, body, document. Каждый предок с обработчиком получит его. event.target остаётся тем, где всё началось.

  • Что такое делегирование и когда оно нужно?

    Ответ

    Один обработчик на общем родителе вместо многих на детях; внутри смотрят event.target или closest. Нужно, когда детей много или их добавляет скрипт позже.

  • Для чего preventDefault?

    Ответ

    Отменяет действие браузера по умолчанию: переход по ссылке, отправку формы. Для формы без него Enter перезагрузит страницу, и ваш код не успеет сработать.

  • Как сделать интерактивность доступной с клавиатуры?

    Ответ

    Использовать настоящие button и a, показывать фокус, менять aria-expanded вместе с состоянием, закрывать по Escape и возвращать фокус. Один только click на div этого не умеет.

Источники

Видео

Уроки JavaScript для начинающих, №11: События и обработчик событий (Школа itProger)

Видео загрузится с youtube-nocookie.com только после нажатия.

Открыть на сайте

Дальше

Теперь вы знаете: события и их всплытие.