Страница, которая только показывает данные, — это плакат. Живой сайт реагирует: кнопка «В корзину» добавляет товар, поле поиска сразу фильтрует список, форма заявки не перезагружает страницу. За каждой такой реакцией стоит событие. Сегодня вы научитесь «слушать» клики, ввод и отправку форм — и сделаете настоящий интерактивный список, как в приложениях, которыми пользуетесь каждый день.
Подписываться на события через addEventListener и отписываться через removeEventListener
Читать объект события: type, target, currentTarget, key, value
Останавливать действие браузера по умолчанию через preventDefault (ссылки, формы, горячие клавиши)
Понимать всплытие и перехват и управлять ими
Писать один обработчик на много элементов — делегирование событий с closest и data-*
Описывать баг для ИИ так, чтобы он помог с первого раза
Как работать с этой страницей
Код на тёмном фоне с кнопкой ▶ Запустить — живой: меняйте его и смотрите результат в консоли. Блоки с вкладками index.html / style.css / script.js — мини-страницы: под кодом появляется предпросмотр, где можно по-настоящему кликать и печатать. Подсказки к задачам спрятаны — сначала пробуйте сами 15 минут.
Важно про задачи. Пользователь кликает мышкой, а проверка задач — автоматическая и должна давать один и тот же результат каждый раз. Поэтому в задачах клики и ввод «делает» сам код: button.click(), input.dispatchEvent(...). Для вашего обработчика это неотличимо от настоящего клика.
01 Что такое событие и addEventListener
Пока вы пишете код сверху вниз, он выполняется один раз и заканчивается. Но пользователь делает что-то позже и непредсказуемо: кликает, нажимает клавишу, вводит текст, прокручивает страницу. Браузер замечает каждое такое действие и создаёт событие — сигнал «что-то произошло». Наша задача — заранее сказать: «если случится вот это событие на вот этом элементе — выполни вот эту функцию».
Аналогия из жизни
Над дверью чойхоны висит колокольчик. Пока никто не входит — тишина, официант занят своими делами. Но стоит открыть дверь — колокольчик звенит, и официант идёт встречать гостя. Вы заранее договорились: «зазвенел — иди к двери». Событие — это звон колокольчика, обработчик — действия официанта, а addEventListener — сама договорённость.
Событие
Сигнал от браузера: click, input, submit, keydown… У каждого события есть тип — строка с названием.
Обработчик
Функция, которая выполнится, когда событие случится. Ещё говорят handler или listener («слушатель»).
Подписка
Связка «элемент + тип события + функция». Делается один раз, а срабатывает сколько угодно раз.
Как выглядит подписка
button.addEventListener("click", greet);
Чей элемент слушаем → «подпиши» → на какое событие → что вызвать
Посмотрим на живом примере. Мы создаём кнопку, подписываемся на клик и «нажимаем» кнопку из кода двумя вызовами click():
Можно редактировать · Ctrl + Enter
document.body.innerHTML = `<button id="buy">Купить</button>`;
const buy = document.querySelector("#buy");
functiononBuy() {
console.log("Спасибо за покупку!");
}
buy.addEventListener("click", onBuy);
console.log("Обработчик навешен, клика ещё не было");
buy.click(); // программный клик — как будто нажали мышкой
buy.click();
Обратите внимание: обработчик не выполняется в момент addEventListener. Он просто запоминается и ждёт. Каждый клик вызывает его заново.
Попробуйте по-настоящему
Нажмите ▶ Запустить, а потом кликайте по кнопке в предпросмотре. Попробуйте поменять текст в обработчике или сделать так, чтобы счётчик рос сразу на 5.
Не используем: смешивает разметку и код, неудобно проверять
Свойство
btn.onclick = function () {…}
Только в простых примерах: можно повесить лишь один обработчик
addEventListener
btn.addEventListener("click", fn)
Всегда. Обработчиков может быть сколько угодно, их можно снимать
Главная разница видна, когда обработчиков несколько. Свойство onclick хранит одну функцию — следующее присваивание затирает предыдущее. А addEventListener складывает их в список, и они срабатывают в порядке добавления:
В addEventListener передают саму функцию, а не результат её вызова. Скобки () вызывают функцию прямо сейчас — и обработчиком становится то, что она вернула (обычно undefined).
Частая ошибка: функция вызвалась сразу, а не по клику · Ctrl + Enter
document.body.innerHTML = `<button id="b">Кнопка</button>`;
const b = document.querySelector("#b");
functionhello() {
console.log("Салом!");
}
b.addEventListener("click", hello()); // ✖ скобки: «Салом!» выведется сразу, один раз
console.log("Клика ещё не было, а салом уже прозвучал");
b.click(); // а на клик никто не отвечает
Если нужно передать в обработчик свои данные, оборачивайте вызов в стрелочную функцию: b.addEventListener("click", () => hello("Мадина")).
Снять обработчик и сработать один раз
Подписку можно отменить: removeEventListener с теми же параметрами. Поэтому функцию нужно хранить в переменной — анонимную стрелку снять не получится, это будет «другая» функция.
Можно редактировать · Ctrl + Enter
document.body.innerHTML = `<button id="b">Талон</button>`;
const b = document.querySelector("#b");
functiontakeTicket() {
console.log("Ваш талон выдан");
b.removeEventListener("click", takeTicket); // тот же элемент, тип и функция
}
b.addEventListener("click", takeTicket);
b.click();
b.click(); // тишина — обработчик снят
console.log("Второй клик ничего не сделал");
Для случая «сработать один раз» есть короткий путь — третий аргумент с настройками. Браузер сам снимет обработчик после первого срабатывания:
Во всех задачах кнопку «нажимает» код — вам нужно написать обработчик.
ЗАДАЧА 1.1
Колокольчик в чойхоне
лёгкая
Над дверью чойхоны висит колокольчик — кнопка #bell. Подпишитесь на её клик так, чтобы каждый раз выводилось «Динь-динь!». Три гостя зашли — три звона.
Можно редактировать · Ctrl + Enter
document.body.innerHTML = `<button id="bell">Дверь</button>`;
const bell = document.querySelector("#bell");
// ваш код здесь
bell.click();
bell.click();
bell.click();
Ожидаемый результат
Динь-динь!
Динь-динь!
Динь-динь!
Подсказка 1
Нужен метод bell.addEventListener(тип, функция). Тип события — строка "click".
Подсказка 2
bell.addEventListener("click", () => { console.log("…"); }); — стрелочная функция прямо в аргументе.
ЗАДАЧА 1.2
Найди баг: приветствие раньше времени
лёгкая
Ожидалось, что «Салом, Мадина!» выведется два раза — по одному на каждый клик, и после строки «Кнопка готова». Сейчас всё иначе: найдите и исправьте одну ошибку.
Запустите как есть и посмотрите на порядок строк: приветствие появилось до клика. Значит, функция вызвалась в момент подписки.
Подсказка 2
Уберите круглые скобки после sayHello в addEventListener: браузер сам вызовет функцию при клике.
ЗАДАЧА 1.3
Предскажи порядок
лёгкая
Вот четыре обработчика на одной кнопке. Один из них затирает другой. Запишите в массив prediction, какие буквы и в каком порядке попадут в actual после одного клика. Не запускайте код сразу — сначала подумайте. Ответ не выводится на экран: консоль только сообщит, угадали ли вы.
Что делает второе присваивание b.onclick = … с первым? Одна ли функция останется в этом «слоте»?
Подсказка 2
Чтобы проверить себя, временно добавьте console.log(actual.join(" ")) перед последней строкой. Но сначала сделайте прогноз!
ЗАДАЧА 1.4
Лимит кликов
средняя
Акция: только первые три клика получают «скидку». Допишите обработчик onClick: он увеличивает счётчик, выводит «Клик 1», «Клик 2», «Клик 3», а на третьем клике дополнительно выводит «Лимит исчерпан, обработчик снят» и сам себя снимает. Дальше кнопка молчит (код нажмёт её пять раз).
Можно редактировать · Ctrl + Enter
document.body.innerHTML = `<button id="promo">Скидка</button>`;
const promo = document.querySelector("#promo");
let count = 0;
functiononClick() {
// ваш код здесь
}
promo.addEventListener("click", onClick);
for (let i = 0; i < 5; i++) {
promo.click();
}
Ожидаемый результат
Клик 1
Клик 2
Клик 3
Лимит исчерпан, обработчик снят
Подсказка 1
Внутри функции: count++, потом console.log("Клик " + count), потом if (count === 3) { … }.
Подсказка 2
Снять обработчик: promo.removeEventListener("click", onClick); — функция у нас в переменной с именем onClick, поэтому снять её можно.
02 Объект события
Когда браузер вызывает ваш обработчик, он передаёт ему один аргумент — объект события. Обычно его называют event, evt или коротко e. В нём лежит всё, что известно о случившемся: какого оно типа, на каком элементе произошло, где была мышь, какая клавиша нажата.
Аналогия из жизни
Диспетчер маршрутки записывает в журнал: «что случилось (посадка), где (остановка «Шохмансур»), во сколько, кто сел». Объект события — такой же журнальный листок. Хотите узнать подробности — загляните в него.
Как «нажать клавишу» из кода? Создаём событие вручную и отправляем его элементу методом dispatchEvent. Так мы будем делать в задачах — для вашего обработчика это выглядит как настоящее нажатие.
target и currentTarget: кто сработал и кто слушает
Это два разных свойства, и путаница между ними — одна из самых частых. Пусть внутри кнопки есть значок <b>. Если щёлкнуть по значку, то:
target
Самый глубокий элемент, по которому реально щёлкнули: значок <b>.
currentTarget
Элемент, у которого вызван обработчик: кнопка. Всегда тот, на ком вы написали addEventListener.
Можно редактировать · Ctrl + Enter
document.body.innerHTML = `<button id="cart"><b id="icon">🛒</b> Корзина</button>`;
const cart = document.querySelector("#cart");
const icon = document.querySelector("#icon");
cart.addEventListener("click", (e) => {
console.log("target:", e.target.tagName);
console.log("currentTarget:", e.currentTarget.tagName);
});
icon.click(); // щёлкнули по значку внутри кнопки
cart.click(); // щёлкнули по самой кнопке
Пока элемент один, они совпадают. Но как только внутри есть вложенные элементы, target может оказаться «глубже». Это важно: в разделе 06 мы построим на этом целую технику.
А что такое this?
В обычной функции-обработчике this тоже указывает на элемент, на котором висит обработчик — то же, что currentTarget. В стрелочной функции this другой. Чтобы не гадать, всегда используйте event.currentTarget — он работает везде одинаково.
Частая ошибка: свойство не из того события
Набор свойств зависит от типа события. У клика нет клавиши, у нажатия клавиши нет координат мыши. JavaScript не ругается — он просто вернёт undefined, и вы потратите полчаса на поиск «бага».
Частая ошибка: у клика нет свойства key · Ctrl + Enter
document.body.innerHTML = `<button id="b">Кнопка</button>`;
const b = document.querySelector("#b");
b.addEventListener("click", (e) => {
console.log(e.key); // undefined — клавиши у клика нет
console.log(e.clientX); // а координаты есть (0, потому что клик программный)
});
b.click();
Если сомневаетесь, какие свойства есть у события, выведите весь объект: console.log(e) — и посмотрите в DevTools.
Допишите обработчик клика по кнопке #pay: он выводит три строки — тип события, id элемента-источника и название его тега. Формат — как в ожидаемом результате.
Можно редактировать · Ctrl + Enter
document.body.innerHTML = `<button id="pay">Оплатить</button>`;
const pay = document.querySelector("#pay");
pay.addEventListener("click", (event) => {
// ваш код здесь
});
pay.click();
Ожидаемый результат
Событие: click
Элемент: pay
Тег: BUTTON
Подсказка 1
Тип — event.type, элемент — event.target, у него есть свойства id и tagName.
Подсказка 2
Шаблонная строка: console.log(`Событие: ${event.type}`); — три таких вызова.
ЗАДАЧА 2.2
Реакция на клавиши
средняя
В поле #msg «нажимают» три клавиши. Обработчик keydown должен реагировать так: Enter → «Отправка», Escape → «Очистка», любая другая → «Нажата клавиша: » и её название.
В кнопке #order лежит значок <b id="icon">. Подпишитесь на клик по кнопке и выведите две строки: тег target и тег currentTarget. Код дважды «щёлкнет»: сначала по значку, потом по кнопке. Сравните, что изменилось.
Можно редактировать · Ctrl + Enter
document.body.innerHTML = `<button id="order"><b id="icon">🚕</b> Заказать такси</button>`;
const order = document.querySelector("#order");
const icon = document.querySelector("#icon");
// ваш код здесь: обработчик на order
icon.click();
console.log("---");
order.click();
Ожидаемый результат
target: B
currentTarget: BUTTON
---
target: BUTTON
currentTarget: BUTTON
Подсказка 1
Обработчик вешается на order — не на icon. Клик по значку «поднимется» к кнопке сам, об этом подробно в разделе 05.
Событий в браузере десятки, но в повседневной работе вы будете пользоваться пятью-семью. Вот те, что встречаются в каждом втором проекте:
Событие
Когда срабатывает
Где используют
click
Клик мышью, касание пальцем, Enter на кнопке
Кнопки, карточки, меню
dblclick, contextmenu
Двойной клик, правая кнопка мыши
Редактирование по двойному клику, своё меню
mouseover / mouseout
Мышь зашла на элемент / ушла с него
Подсказки, подсветка
keydown / keyup
Клавиша нажата / отпущена
Горячие клавиши, Enter, Escape
input
Каждое изменение текста в поле
Живой поиск, счётчик символов
change
Значение «зафиксировано»: выбрали пункт, поставили галочку, ушли из поля
Select, checkbox, radio
focus / blur
Поле получило фокус / потеряло его
Подсветка поля, проверка при уходе
submit
Форма отправляется (кнопка или Enter)
Все формы — заявки, вход, поиск
DOMContentLoaded
HTML полностью прочитан и построен
Старт скрипта, который подключили в <head>
Аналогия из жизни
Вы пишете сообщение в Telegram. Событие input — это «собеседник печатает…»: оно приходит на каждую букву. А change — это «сообщение отправлено»: пришло один раз, когда вы закончили. Для живого поиска нужен input, для выбора города в списке — change.
input: значение поля на каждую букву
Текст из поля читаем через event.target.value (или input.value). Это всегда строка, даже если пользователь ввёл цифры. Чтобы сымитировать ввод в коде, ставим value и отправляем событие input:
Можно редактировать · Ctrl + Enter
document.body.innerHTML = `<input id="name">`;
const name = document.querySelector("#name");
name.addEventListener("input", (e) => {
console.log("Сейчас в поле:", e.target.value);
});
for (const text of ["Ф", "Фа", "Фар", "Фарход"]) {
name.value = text;
name.dispatchEvent(newEvent("input"));
}
change, checkbox и select
У флажка читаем checked (true/false), у выпадающего списка — value выбранного пункта:
Нажмите ▶ Запустить и печатайте в поле. Список фильтруется на каждую букву — это событие input в чистом виде.
Живой поиск по городам · Ctrl + Enter
<inputid="search"placeholder="Найти город…"><smallid="info">Введите хотя бы букву</small><ulid="cities"><li>Душанбе</li><li>Худжанд</li><li>Бохтар</li><li>Куляб</li><li>Истаравшан</li><li>Хорог</li></ul>
Частая ошибка: путают input и change у текстового поля
У текстового поля change срабатывает только когда вы ушли из поля или нажали Enter. Если повесить на него живой поиск, результат появится «с опозданием» — и пользователь решит, что сайт зависает. Для поиска и счётчиков берите input.
На каждое событие input в поле #q найдите города из массива cities, в названии которых есть введённый текст (без учёта регистра, лишние пробелы по краям — убрать). Выведите «Запрос «текст»: » и города через запятую. Если ничего не нашлось — «ничего не найдено». В выводе запрос пишется как ввёл пользователь, но без пробелов по краям.
Запрос «ху»: Худжанд
Запрос «ХО»: Хорог
Запрос «а»: Душанбе, Худжанд, Бохтар, Истаравшан
Запрос «щ»: ничего не найдено
Подсказка 1
Сначала приведите запрос в порядок: const query = e.target.value.trim();. Для сравнения используйте query.toLowerCase() и city.toLowerCase().includes(…), а выводить — исходный query.
Подсказка 2
const found = cities.filter(c => c.toLowerCase().includes(query.toLowerCase())); Потом found.length ? found.join(", ") : "ничего не найдено".
ЗАДАЧА 3.3
Итоговая цена с доставкой
средняя
Товар стоит 100 сомони. Доставка платная, если стоит галочка #delivery: по Душанбе 10 сомони, по Худжанду 25. Напишите функцию recalc, которая выводит «Итого: N сомони», и подпишите её на change и у списка городов, и у флажка. Код ниже сам «меняет» значения четыре раза.
Внутри recalc читайте текущее состояние прямо из элементов: city.value и delivery.checked. Стоимость доставки — fees[city.value].
Подсказка 2
const total = price + (delivery.checked ? fees[city.value] : 0); Подписка: city.addEventListener("change", recalc); и такая же для delivery. Скобок после recalc нет!
04 Действие по умолчанию: preventDefault
У многих событий у браузера есть своё действие по умолчанию. Оно выполнится после ваших обработчиков, если его не остановить:
клик по ссылке <a href> — переход на другую страницу;
отправка формы — перезагрузка страницы с данными;
клик правой кнопкой — контекстное меню;
Ctrl + S — окно «Сохранить страницу»;
пробел на странице — прокрутка вниз.
Метод event.preventDefault() говорит браузеру: «Не надо, дальше я сам». Ваш код при этом продолжает работать как обычно.
Аналогия из жизни
Вы подходите к турникету на станции, а контролёр говорит: «Стойте, сначала покажите билет». Вы собирались пройти (действие по умолчанию), но вас остановили — и теперь решаете, пустить ли. preventDefault — это поднятая рука контролёра: проход отменён, но всё остальное в зале идёт своим чередом.
Отправка формы по умолчанию перезагружает страницу: всё, что JavaScript успел нарисовать, исчезает. Поэтому почти любая форма на современном сайте обрабатывается так: слушаем submit у формы (не click у кнопки!), делаем preventDefault() и работаем с данными сами.
Почему именно submit, а не клик по кнопке? Форму можно отправить клавишей Enter в поле — клика по кнопке при этом не будет, а submit сработает всегда. В наших задачах форму «отправляет» код: он вручную запускает событие submit командой form.dispatchEvent(new Event("submit", { cancelable: true })). Обработчик не заметит разницы, а страница при этом не перезагрузится.
Можно редактировать · Ctrl + Enter
document.body.innerHTML = `
<form id="order">
<input id="who" value="Мадина">
<button>Заказать</button>
</form>`;
const order = document.querySelector("#order");
const who = document.querySelector("#who");
order.addEventListener("submit", (e) => {
e.preventDefault(); // без этой строки страница бы перезагрузилась
console.log("Заказ для:", who.value);
});
order.dispatchEvent(newEvent("submit", { cancelable: true }));
who.value = "Фарход";
order.dispatchEvent(newEvent("submit", { cancelable: true }));
Попробуйте сами
Нажмите ▶ Запустить, введите имя и нажмите Enter или кнопку. Страница не перезагружается, имя добавляется в список — это работа preventDefault.
const join = document.querySelector("#join");
const who = document.querySelector("#who");
const people = document.querySelector("#people");
join.addEventListener("submit", (event) => {
event.preventDefault();
const name = who.value.trim();
if (name ==="") return; // пустое имя не добавляемconst li = document.createElement("li");
li.textContent = name;
people.append(li);
who.value = "";
console.log("Записано человек:", people.children.length);
});
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.
Не путать с stopPropagation
preventDefault()
stopPropagation()
Что останавливает
Действие браузера (переход, отправку…)
Дальнейший путь события вверх по элементам
Другие обработчики
Продолжают работать
Выше по цепочке не вызываются
О чём подробнее
Этот раздел
Раздел 05
Частая ошибка: preventDefault «не сработал»
Две типичные причины. Первая — вы повесили обработчик на click кнопки, а форму отправляют ещё и клавишей Enter: слушайте submit. Вторая — preventDefault вызван не во всех ветках: например, внутри if, а при ошибке валидации раньше сработал return. Сначала отмените действие, потом проверяйте.
Частая ошибка: отмена только в одной ветке · Ctrl + Enter
document.body.innerHTML = `<form id="f"><input id="i"></form>`;
const f = document.querySelector("#f");
const i = document.querySelector("#i");
f.addEventListener("submit", (e) => {
if (i.value ==="") {
console.log("Пусто!");
return; // ✖ вышли, не отменив отправку
}
e.preventDefault();
console.log("Отправка", i.value);
});
// имитируем отправку пустой формы и смотрим, отменили ли мы действие браузераconst ev = newEvent("submit", { cancelable: true });
f.dispatchEvent(ev);
console.log("Перезагрузка отменена?", ev.defaultPrevented);
Ссылка #promo ведёт на внешний сайт, а мы хотим остаться на странице. Подпишитесь на клик, отмените переход и выведите две строки: «Переход отменён» и значение event.defaultPrevented (оно должно быть true).
Можно редактировать · Ctrl + Enter
document.body.innerHTML = `<a id="promo" href="https://example.com">Скидки</a>`;
const promo = document.querySelector("#promo");
// ваш код здесь
promo.click();
Ожидаемый результат
Переход отменён
true
Подсказка 1
Обработчик клика получает event. Метод отмены — event.preventDefault().
Подсказка 2
Сначала вызовите preventDefault(), потом выведите console.log("Переход отменён") и console.log(event.defaultPrevented).
ЗАДАЧА 4.2
Форма заявки
средняя
Напишите обработчик submit для формы заявки на курс. Он отменяет перезагрузку и проверяет имя: если поле #name пустое (после trim()) — выводит «Введите имя», иначе — «Заявка: имя, телефон». Код ниже отправляет форму дважды.
Слушайте submit у формы apply, а не клик у кнопки. Первой строкой в обработчике — event.preventDefault().
Подсказка 2
const name = nameInput.value.trim(); Потом if (name === "") { console.log("Введите имя"); return; } и вывод заявки через шаблонную строку.
ЗАДАЧА 4.3
Горячие клавиши редактора
сложная
Вы делаете онлайн-редактор. Подпишитесь на keydown у document и обработайте комбинации: Ctrl + S → «Сохранено», Ctrl + Shift + S → «Сохранить как…», Ctrl + Z → «Отмена». У этих комбинаций есть действие браузера по умолчанию — его нужно отменить. Остальные клавиши (например, просто a) не трогаем. Подсказка: при нажатом Shift буква в event.key приходит заглавной.
Сохранено
Ctrl + s: действие по умолчанию отменено
Сохранить как…
Ctrl + Shift + S: действие по умолчанию отменено
a: действие по умолчанию работает
Отмена
Ctrl + z: действие по умолчанию отменено
Подсказка 1
У события есть булевы свойства event.ctrlKey и event.shiftKey. Приведите клавишу к одному регистру: const key = event.key.toLowerCase();.
Подсказка 2
Сначала проверьте самую «узкую» комбинацию: if (event.ctrlKey && event.shiftKey && key === "s") { … } else if (event.ctrlKey && key === "s") { … } else if (event.ctrlKey && key === "z") { … }. В каждой ветке — event.preventDefault().
05 Всплытие и перехват
Что произойдёт, если кнопка лежит внутри карточки, а карточка — внутри списка, и на каждом из них висит свой обработчик клика? Сработают все три — и в очень определённом порядке. Событие не «застревает» на элементе, по которому кликнули: оно путешествует по цепочке родителей.
<ul id="list"> — обработчик №3
<li class="card"> — обработчик №2
<button> — обработчик №1 (сюда кликнули)
1. погружение ↓ сверху вниз2. цель: button3. всплытие ↑ снизу вверх
Три фазы пути события. Обычные обработчики срабатывают на фазе всплытия: button → li → ul
Аналогия из жизни
Пузырёк воздуха в стакане чая всплывает со дна к поверхности и проходит все слои. Клик по кнопке «всплывает» так же: сначала сообщает о себе кнопке, потом карточке, потом списку, потом body, document. А ещё это похоже на офис в Душанбе: сотрудник сообщает новость начальнику отдела, тот — директору, тот — владельцу.
Погружение (capturing). Событие спускается от document к цели. Обработчики здесь срабатывают только если вы специально попросили: { capture: true }. Нужно редко.
Цель (target). Событие доходит до элемента, по которому кликнули.
Всплытие (bubbling). Событие поднимается обратно вверх до самого document. На этой фазе работают все обычные обработчики.
Можно редактировать · Ctrl + Enter
document.body.innerHTML = `
<ul id="list">
<li id="card"><button id="btn">В корзину</button></li>
</ul>`;
for (const id of ["btn", "card", "list"]) {
document.getElementById(id).addEventListener("click", (e) => {
console.log(`сработал ${e.currentTarget.id}, а кликнули по ${e.target.id}`);
});
}
document.getElementById("btn").click();
Обратите внимание на две вещи: порядок идёт снизу вверх, а target на всём пути остаётся одним и тем же — это тот, по кому кликнули. Меняется только currentTarget.
Посмотрите вживую
Нажмите ▶ Запустить и кликните по внутренней кнопке, потом по средней и внешней рамке. Включите флажок — и всплытие остановится на кнопке.
Путь события · Ctrl + Enter
<divid="outer"class="box">outer
<divid="middle"class="box">middle
<buttonid="inner">inner (нажмите)</button></div></div><label><inputtype="checkbox"id="stop"> stopPropagation на кнопке</label><buttonid="clear">Очистить журнал</button><olid="log"></ol>
const log = document.querySelector("#log");
const stop = document.querySelector("#stop");
functionwrite(text) {
const li = document.createElement("li");
li.textContent = text;
log.append(li);
console.log(text);
}
for (const id of ["outer", "middle", "inner"]) {
document.getElementById(id).addEventListener("click", (event) => {
write(`${event.currentTarget.id} (target: ${event.target.id})`);
if (id ==="inner"&& stop.checked) event.stopPropagation();
});
}
document.querySelector("#clear").addEventListener("click", (e) => {
log.innerHTML = "";
e.stopPropagation();
});
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.
stopPropagation: оборвать путь
event.stopPropagation() прекращает всплытие: родители событие не получат. Выглядит соблазнительно, но используйте осторожно. Остановленное событие перестанет доходить и до чужого кода — например, до счётчика аналитики на document, и вы будете долго искать, почему он «пропускает» клики. Лучше обычно проверить в обработчике родителя, куда именно кликнули: event.target === event.currentTarget.
Перехват: { capture: true }
Если третьим аргументом передать true или { capture: true }, обработчик сработает на фазе погружения — раньше, чем у самой цели. Так можно «встретить» событие до того, как оно дойдёт вниз.
Порядок: сначала перехват у wrap (спуск вниз), потом сама кнопка, потом всплытие у wrap.
Не всё всплывает
События focus и blur не всплывают (вместо них у родителя слушайте focusin / focusout), а mouseenter и mouseleave — тоже нет (берите mouseover / mouseout). Кроме того, события, созданные вручную, по умолчанию не всплывают: нужно явно написать new Event("input", { bubbles: true }).
Частая ошибка: ручное событие не всплывает · Ctrl + Enter
Три вложенных элемента, на каждом свой обработчик. Запишите в prediction идентификаторы в том порядке, в котором сработают обработчики после клика по самой внутренней кнопке #c. Консоль скажет только «верно» или «неверно».
Можно редактировать · Ctrl + Enter
document.body.innerHTML = `
<div id="a">
<div id="b">
<button id="c">Кнопка</button>
</div>
</div>`;
const actual = [];
for (const id of ["a", "b", "c"]) {
document.getElementById(id).addEventListener("click", () => actual.push(id));
}
document.getElementById("c").click();
const prediction = []; // например ["x", "y", "z"]
console.log(prediction.join(" ") === actual.join(" ") ? "Прогноз верный" : "Прогноз неверный");
Ожидаемый результат
Прогноз верный
Подсказка 1
Вспомните схему: событие стартует у цели и поднимается вверх. Кто здесь цель?
Подсказка 2
Порядок идёт от самого глубокого элемента к самому внешнему. Подсмотреть можно, временно добавив console.log(actual.join(" ")).
ЗАДАЧА 5.2
Перехватить раньше
средняя
У кнопки #btn и у её обёртки #wrap уже есть обычные обработчики. Допишите ещё один — на обёртке, но на фазе погружения: он должен выводить «Перехват: wrap» раньше всех. Ожидаемый порядок указан ниже.
Серый фон #overlay закрывает окно по клику. Внутри лежит само окно #modal. Баг: клик внутри окна тоже закрывает его, потому что событие всплывает до фона. Исправьте так, чтобы клик по окну выводил только «Клик внутри окна», а клик по фону — «Окно закрыто».
Запустите как есть: «Окно закрыто» выводится дважды. Первый раз — после клика по окну, из-за всплытия.
Подсказка 2
Два способа. Быстрый: в обработчике modal добавить параметр event и вызвать event.stopPropagation(). Аккуратнее: в обработчике overlay закрывать только если event.target === event.currentTarget.
ЗАДАЧА 5.4
Хлебные крошки клика
сложная
Для отладки аналитики нужно знать, где именно кликнули. Повесьте на #shop один обработчик, который по event.target поднимается вверх по parentElement до #shop включительно и выводит путь в формате «тег#id.класс < … < main#shop». Если у элемента нет id или класса — они не пишутся. Функция describe(el) уже заготовлена как место для вас.
Можно редактировать · Ctrl + Enter
document.body.innerHTML = `
<main id="shop">
<article class="card">
<h3 id="title">Дыня</h3>
<p class="info">Цена: <b id="price">12</b></p>
<button id="buy">Купить</button>
</article>
</main>`;
const shop = document.querySelector("#shop");
// тег в нижнем регистре + "#id", если есть + ".класс", если естьfunctiondescribe(el) {
// ваш код здесь
}
// ваш код здесь: обработчик клика на shopfor (const id of ["price", "title", "buy", "shop"]) {
document.getElementById(id).click();
}
describe: el.tagName.toLowerCase() + (el.id ? "#" + el.id : "") + (el.className ? "." + el.className : ""). Проверьте функцию отдельно, вызвав её для пары элементов.
Подсказка 2
Цикл подъёма: let el = event.target; const path = []; while (true) { path.push(describe(el)); if (el === shop) break; el = el.parentElement; }, затем path.join(" < ").
06 Делегирование событий
Допустим, у вас список из 50 дел, и у каждого — кнопка «Удалить». Первая мысль: пройти циклом и повесить обработчик на каждую кнопку. Это работает, но есть три проблемы:
Много обработчиков. 50 кнопок — 50 слушателей в памяти. А если строк 5000?
Новые элементы «глухие». Пользователь добавил дело — у новой кнопки обработчика нет, потому что цикл отработал раньше.
Лишняя работа. Каждый элемент нужно подписывать и, при удалении, отписывать.
Решение использует всплытие из прошлого раздела. Клик по любой кнопке всё равно всплывёт до общего родителя — списка. Значит, достаточно одного обработчика на родителе: он узнаёт, кто именно был целью, и решает, что делать. Это называется делегированием — родитель принимает события «по доверенности» за всех детей.
Аналогия из жизни
В MakeFirstStep нет охранника у двери каждой аудитории — есть один администратор на ресепшене. Все вопросы идут к нему: «Где аудитория 3?», «Кто сегодня ведёт?». Когда открывают новую аудиторию, нанимать нового охранника не нужно — администратор уже в курсе. Так и с делегированием: один «администратор» (родитель) обслуживает всех, в том числе тех, кто появится позже.
Два подхода рядом
Обработчик на каждый элемент
JavaScript
for (const btn of list.querySelectorAll("button")) {
btn.addEventListener("click", remove);
}
// новые кнопки — без обработчика
Один на родителе
JavaScript
list.addEventListener("click", (event) => {
const btn = event.target.closest("button");
if (!btn) return;
remove(btn);
});
// работает и для будущих кнопок
Новый инструмент: closest
Проблема: event.target — это самый глубокий элемент, по которому кликнули. Если в кнопке лежит значок <span>, целью будет значок, а не кнопка. Нужно «подняться» от цели к нужному элементу. Для этого есть метод element.closest(selector): он проверяет сам элемент, затем родителя, затем родителя родителя — и возвращает первый подходящий под CSS-селектор. Если такого нет — null.
Можно редактировать · Ctrl + Enter
document.body.innerHTML = `
<ul id="list">
<li class="item"><button class="del"><span class="icon">✕</span></button></li>
</ul>`;
const icon = document.querySelector(".icon");
console.log(icon.closest("button").className); // del — поднялись на 1 уровень
console.log(icon.closest("li").className); // item — на 2 уровня
console.log(icon.closest(".icon") === icon); // true — сначала проверяется сам элемент
console.log(icon.closest("table")); // null — такого предка нет
Ещё один инструмент: data-атрибуты
Как узнать, какую именно кнопку нажали — «удалить» или «в избранное»? И в какой строке? Не по тексту же кнопки угадывать. Для таких меток в HTML есть атрибуты data-*: вы сами придумываете имя, а в JS читаете через element.dataset.
Можно редактировать · Ctrl + Enter
document.body.innerHTML = `<li data-id="42" data-user-name="Мадина"><button data-action="remove">✕</button></li>`;
const li = document.querySelector("li");
const btn = document.querySelector("button");
console.log(li.dataset.id); // "42" — всегда строка!
console.log(li.dataset.userName); // data-user-name → userName (camelCase)
console.log(btn.dataset.action); // "remove"
console.log(Number(li.dataset.id) + 1); // 43 — число делаем сами
Шаблон делегирования
Один обработчик на общего родителя — список, таблицу, меню.
Ничего не нашли — выходим:if (!btn) return; (клик мимо кнопок).
Проверьте, что кнопка внутри вашего списка:if (!list.contains(btn)) return; Нужна, когда списки вложены друг в друга.
Поднимитесь к строке:const li = btn.closest("li");
Решайте по dataset:if (btn.dataset.action === "remove") li.remove();
Можно редактировать · Ctrl + Enter
document.body.innerHTML = `
<ul id="list">
<li data-id="1">Нон <button data-action="remove"><span>✕</span></button></li>
<li data-id="2">Чой <button data-action="remove"><span>✕</span></button></li>
</ul>`;
const list = document.querySelector("#list");
list.addEventListener("click", (event) => {
const btn = event.target.closest("button[data-action]");
if (!btn || !list.contains(btn)) return;
const li = btn.closest("li");
if (btn.dataset.action ==="remove") {
console.log("Удаляю №" + li.dataset.id);
li.remove();
}
});
list.querySelector("span").click(); // кликнули по значку внутри кнопки — работает!
console.log("Осталось:", list.children.length);
// добавим новый пункт позже — обработчик не нуженconst newLi = document.createElement("li");
newLi.dataset.id = 3;
newLi.innerHTML = `Лагман <button data-action="remove"><span>✕</span></button>`;
list.append(newLi);
newLi.querySelector("button").click();
console.log("Осталось:", list.children.length);
Попробуйте сами
Нажмите ▶ Запустить. Удалите пару пунктов, затем добавьте новый кнопкой «Добавить пункт» — и убедитесь, что у нового кнопка «✕» работает без единой лишней строки подписки.
const menu = document.querySelector("#menu");
const dishes = ["Манту", "Самбуса", "Шурпо", "Кабоб"];
let next = 0;
// ОДИН обработчик на весь список
menu.addEventListener("click", (event) => {
const btn = event.target.closest("button[data-action]");
if (!btn) return;
const li = btn.closest("li");
console.log("Убрали:", li.querySelector(".name").textContent);
li.remove();
});
document.querySelector("#add").addEventListener("click", () => {
const li = document.createElement("li");
li.innerHTML = `<span class="name"></span> <button data-action="remove">✕</button>`;
li.querySelector(".name").textContent = dishes[next++ % dishes.length];
menu.append(li);
});
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.
Частые ошибки делегирования
1. Используют event.target вместо closest. Клик по значку внутри кнопки даёт цель-значок — и проверка target.tagName === "BUTTON" не срабатывает.
2. Делегируют событие, которое не всплывает.focus, blur, mouseenter до родителя не дойдут — берите focusin, focusout, mouseover.
3. Забывают if (!btn) return. Клик по пустому месту списка тоже вызывает обработчик, и btn будет null.
Частая ошибка: клик по значку внутри кнопки не замечен · Ctrl + Enter
document.body.innerHTML = `
<ul id="list">
<li>Нон <button><span id="icon">✕</span></button></li>
</ul>`;
const list = document.querySelector("#list");
list.addEventListener("click", (event) => {
if (event.target.tagName ==="BUTTON") { // ✖ цель — это SPAN
console.log("Кнопка нажата");
} else {
console.log("Цель:", event.target.tagName); // мы окажемся здесь
}
});
document.querySelector("#icon").click();
Бывает и обратное: делегирование — не всегда лучшее решение. Если элемент один (например, кнопка «Войти»), просто повесьте обработчик на него. Делегирование нужно для списков, таблиц, меню — там, где элементов много или они добавляются и удаляются.
Меню столовой — список #menu с тремя блюдами. Повесьте один обработчик клика на сам список. Он выводит «Выбрано: название» только если целью был пункт <li>. Клик по самому списку (мимо пунктов) должен игнорироваться.
Можно редактировать · Ctrl + Enter
document.body.innerHTML = `<ul id="menu"><li>Оши палав</li><li>Лагман</li><li>Манту</li></ul>`;
const menu = document.querySelector("#menu");
// ваш код здесь: один обработчик на menufor (const li of menu.children) {
li.click();
}
menu.click(); // мимо пунктов — реакции быть не должно
Ожидаемый результат
Выбрано: Оши палав
Выбрано: Лагман
Выбрано: Манту
Подсказка 1
Элемент, по которому кликнули, — event.target. Проверьте его тег: event.target.tagName === "LI". Для клика по самому списку target будет UL.
В списке три блюда, у каждого кнопки data-action="like" и data-action="remove". Напишите один обработчик на #list через closest и dataset. Для «like» выводите «Нравится: название (№id)», для «remove» — «Удалено: название (№id)» и удаляйте пункт. Клик не по кнопке игнорируйте. Название лежит в .name, id — в data-id у <li>.
Начало обработчика: const btn = event.target.closest("button[data-action]"); if (!btn) return; const li = btn.closest("li");
Подсказка 2
Название: li.querySelector(".name").textContent, id: li.dataset.id, действие: btn.dataset.action. Ветки if … else if по действию; для удаления — li.remove() после вывода строки.
ЗАДАЧА 6.3
Новый пункт не отвечает
средняя
В меню на каждый пункт повешен свой обработчик. Приложение позже добавило пункт «Самбуса» — и у него обработчика нет, а в консоли ожидаются три строки. Перепишите подписку: один обработчик на #menu вместо цикла. Код ниже строки «приложение добавляет пункт» не меняйте.
Можно редактировать · Ctrl + Enter
document.body.innerHTML = `<ul id="menu"><li>Нон</li><li>Чой</li></ul>`;
const menu = document.querySelector("#menu");
// Сейчас: по обработчику на каждый пункт. Замените на ОДИН обработчик на menufor (const li of menu.querySelectorAll("li")) {
li.addEventListener("click", () => console.log("Заказано: " + li.textContent));
}
// --- приложение добавляет пункт (не меняйте) ---const extra = document.createElement("li");
extra.textContent = "Самбуса";
menu.append(extra);
for (const li of menu.children) {
li.click();
}
Ожидаемый результат
Заказано: Нон
Заказано: Чой
Заказано: Самбуса
Подсказка 1
Удалите цикл for с подписками. Вместо него — одна строка menu.addEventListener("click", …).
Подсказка 2
В обработчике: const li = event.target.closest("li"); if (!li) return; console.log("Заказано: " + li.textContent);
ЗАДАЧА 6.4
Корзина с «+» и «−»
сложная
Корзина магазина. У каждой строки <li> есть цена в data-price, количество в .qty и три кнопки: inc (+1), dec (−1; если стало 0 — строка удаляется) и del (удалить строку сразу). Один обработчик на #cart. После каждого действия вывод: «Итого: N сомони», а если корзина пуста — «Корзина пуста». Функция press уже готова.
Можно редактировать · Ctrl + Enter
document.body.innerHTML = `
<ul id="cart">
<li data-price="4"><span class="name">Нон</span>
<button data-action="dec">−</button> <b class="qty">1</b> <button data-action="inc">+</button>
<button data-action="del">✕</button></li>
<li data-price="18"><span class="name">Чой</span>
<button data-action="dec">−</button> <b class="qty">2</b> <button data-action="inc">+</button>
<button data-action="del">✕</button></li>
</ul>`;
const cart = document.querySelector("#cart");
functionprintTotal() {
// ваш код здесь: сумма по всем строкам или «Корзина пуста»
}
// ваш код здесь: один обработчик на cartconst press = (name, action) => {
const li = [...cart.children].find((x) => x.querySelector(".name").textContent === name);
li.querySelector(`[data-action="${action}"]`).click();
};
press("Нон", "inc");
press("Чой", "dec");
press("Чой", "dec");
press("Нон", "del");
Разделите работу: printTotal() просто проходит по cart.children и суммирует Number(li.dataset.price) * Number(li.querySelector(".qty").textContent) — она ничего не знает про кнопки.
Подсказка 2
В обработчике: closest("button[data-action]"), затем li = btn.closest("li"), qty — элемент li.querySelector(".qty"). Для dec: qty.textContent = Number(qty.textContent) - 1; if (Number(qty.textContent) === 0) li.remove();. В конце каждого клика — printTotal().
07 ИИ-блок: как описать баг
«Кнопка не работает» — самая частая фраза начинающих программистов. И самая бесполезная: ни человек, ни ИИ не поймут, что именно сломалось. События — отличная тренировка, потому что баги в них всегда одного типа: «ожидал, что при клике будет X, а происходит Y (или ничего)». Научитесь описывать такой баг — и получите точный ответ от ИИ, а ещё чаще найдёте причину сами, пока пишете описание.
Правила курса про ИИ — напоминаем
1) Сначала 15 минут пробуете сами. 2) Не вставляете код, который не можете объяснить строчка за строчкой — на проверке ДЗ преподаватель вправе спросить про любую строку. 3) В промпте всегда: контекст → задача → ограничения → формат ответа → проверка.
Слабый и сильный баг-репорт
у меня не работает кнопка удалить в списке, почему
Контекст: учусь JavaScript, пишу список покупок на чистом JS (DOM, события).
Баг: ожидаю, что клик по кнопке «✕» удаляет строку из списка. Получаю: удаляется только первая строка, новые пункты не удаляются совсем. Ошибок в консоли нет.
Минимальный код (HTML + JS, 12 строк):
[код]
Что уже пробовал: вывел console.log(event.target) — при клике по значку внутри кнопки там SPAN.
Формат ответа: 3 гипотезы причины по вероятности, к каждой — как проверить. Код целиком не переписывай.
Проверка: после ответа я сам проверю каждую гипотезу в консоли.
Как сделать минимальный пример
Скопируйте проблемный кусок в отдельный файл или в песочницу на этой странице.
Выкиньте всё лишнее: стили, соседние компоненты, функции, которые к багу не относятся. После каждого удаления проверяйте — баг ещё воспроизводится?
Замените реальные данные на 2–3 простых элемента.
Запишите три вещи: что вы делаете (кликаю на …), что ожидали, что получили. Ошибку из консоли копируйте дословно, не пересказывайте.
Ещё раз прочитайте. Часто баг находится прямо на шаге 2. Это известный приём «резиновой уточки»: когда объясняешь проблему вслух, сам замечаешь ошибку.
Шаблон баг-репорта
Контекст: [что за проект, какая тема, что уже знаю].
Ожидал: [что должно происходить].
Получил: [что происходит на самом деле + точный текст ошибки из консоли].
Минимальный код: [не больше 20 строк].
Что пробовал: [1–2 шага отладки и что они показали].
Формат ответа: 3 гипотезы по вероятности, как проверить каждую. Код целиком не переписывай.
Проверка: я проверю гипотезы по очереди и напишу, какая подтвердилась.
Объяснить делегирование на своём коде
Это строка из библиотеки промптов курса. Вставьте свой код списка и попросите объяснение — но проверьте, что понимаете, почему работает event.target:
Объясни делегирование событий на этом примере: [код]. Покажи минимальную версию через один обработчик на родителе. Объясни простыми словами, что такое event.target и зачем здесь closest. Ответ — по шагам, не длиннее 15 строк.
Проверка ответа: закройте ответ ИИ и своими словами объясните соседу по парте, как клик по кнопке доходит до списка. Не получается — читайте разделы 05–06 ещё раз.
Типичные ошибки ИИ в событиях
Что видите в ответе ИИ
В чём проблема
Как исправить
onclick="..." прямо в HTML
Смешивает разметку и код, сложно поддерживать
addEventListener в JS-файле
Цикл с подпиской на каждый li / кнопку
Новые элементы останутся без обработчика
Делегирование на родителе
if (e.target.tagName === "BUTTON")
Не сработает на значке внутри кнопки
e.target.closest("button")
list.innerHTML += "<li>…</li>" в обработчике
Пересоздаёт весь список: теряются состояния и подписки на элементах
createElement + append
Форма: click на кнопке «Отправить»
Enter в поле отправит форму без клика
submit у формы + preventDefault()
e.stopPropagation() «на всякий случай»
Ломает чужие обработчики выше (аналитика, закрытие меню)
Проверка e.target === e.currentTarget
btn.addEventListener("click", handler())
Функция вызывается сразу, а не по клику
Без скобок: handler
Практика с ИИ
ЗАДАЧА 7.1
Баг: клик по значку
средняя
Список избранного должен выводить «В избранное: название» при клике на звёздочку. Звёздочка — это <span class="icon"> внутри кнопки. Сейчас ничего не выводится. Сначала найдите причину сами (15 минут!). Потом — в комментарии над кодом — составьте для ИИ баг-репорт по шаблону (ожидал / получил / минимальный код) и сравните с тем, что ИИ предположит. Добейтесь ожидаемого вывода.
Можно редактировать · Ctrl + Enter
document.body.innerHTML = `
<ul id="menu">
<li><span class="name">Дыня</span> <button class="fav"><span class="icon">★</span></button></li>
<li><span class="name">Гранат</span> <button class="fav"><span class="icon">★</span></button></li>
</ul>`;
const menu = document.querySelector("#menu");
menu.addEventListener("click", (event) => {
if (event.target.tagName ==="BUTTON") {
const name = event.target.parentElement.querySelector(".name").textContent;
console.log("В избранное: " + name);
}
});
// пользователь кликает по значку ★ внутри кнопкиfor (const icon of document.querySelectorAll(".icon")) {
icon.click();
}
Ожидаемый результат
В избранное: Дыня
В избранное: Гранат
Подсказка 1
Добавьте в обработчик console.log(event.target.tagName). Что вы видите при клике на значок? Равно ли это "BUTTON"?
Подсказка 2
Замените проверку на const btn = event.target.closest(".fav"); if (!btn) return;, а название ищите от кнопки: btn.closest("li").querySelector(".name").textContent.
ЗАДАЧА 7.2
Соберите баг-репорт
лёгкая
Ситуация: скрипт подключён в <head>, а кнопка стоит в <body>. В консоли ошибка TypeError: Cannot read properties of null (reading 'addEventListener'). Напишите баг-репорт для ИИ в переменной report (это текст, никуда он не отправляется). Нужны блоки «Ожидал:», «Получил:», «Что пробовал:», точный текст ошибки и минимальный код с addEventListener. Длина — не больше 600 символов. Код сам проверит ваш текст. Внутри текста не используйте обратные кавычки.
✔ Есть «Ожидал:»
✔ Есть «Получил:»
✔ Есть точный текст ошибки
✔ Есть минимальный код
✔ Есть «Что пробовал:»
✔ Не длиннее 600 символов
Подсказка 1
Скелет: «Ожидал: … Получил: … Минимальный код: … Что пробовал: …». Каждый блок — на новой строке прямо внутри report.
Подсказка 2
Не пересказывайте ошибку — вставьте её дословно. В минимальном коде достаточно двух строк: const btn = document.querySelector("#buy"); и btn.addEventListener("click", () => console.log("Куплено"));. Подумайте, что у вас получится, если скрипт запускается раньше, чем браузер прочитал <body>.
08 Практика на уроке: список с кнопками
Собираем «Покупки на базар» — список, где у каждого пункта две кнопки: ♥ в избранное и ✕ удалить. Обработчик один, на весь список. Идём тремя шагами, после каждого нажимайте ▶ Запустить и проверяйте руками. Не копируйте — набирайте сами, так запоминается в разы лучше.
Разведка. Один обработчик на список. Узнаём, по какой кнопке и в какой строке кликнули.
Действия. ♥ переключает класс fav у строки, ✕ удаляет строку.
Счётчик. После каждого действия обновляем «Всего: N, избранных: M». Новые пункты работают сразу.
Шаг 1. Разведка
Пока ничего не меняем — только печатаем в консоль, что поняли из клика. Кликайте по ♥, по ✕, по названию и мимо кнопок.
Шаг 1 · Разведка · Ctrl + Enter
<h4>Покупки на базар</h4><ulid="list"><li><spanclass="name">Морковь</span><buttondata-action="fav"title="В избранное">♥</button><buttondata-action="remove"title="Удалить">✕</button></li><li><spanclass="name">Рис</span><buttondata-action="fav"title="В избранное">♥</button><buttondata-action="remove"title="Удалить">✕</button></li><li><spanclass="name">Лук</span><buttondata-action="fav"title="В избранное">♥</button><buttondata-action="remove"title="Удалить">✕</button></li></ul><pid="count"></p>
const list = document.querySelector("#list");
list.addEventListener("click", (event) => {
const btn = event.target.closest("button[data-action]");
if (!btn) return; // клик мимо кнопокconst li = btn.closest("li");
if (btn.dataset.action ==="fav") {
li.classList.toggle("fav"); // есть класс — уберёт, нет — добавит
} elseif (btn.dataset.action ==="remove") {
li.remove();
}
});
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.
Шаг 3. Счётчик и новые пункты
Добавляем функцию updateCount и вызываем её после каждого изменения — и один раз на старте. Кнопка «Добавить» создаёт пункт функцией makeItem. Обратите внимание: ни одной новой подписки на новые пункты — делегирование уже всё умеет.
Шаг 3 · Счётчик · Ctrl + Enter
<h4>Покупки на базар</h4><buttonid="add">+ Добавить пункт</button><ulid="list"><li><spanclass="name">Морковь</span><buttondata-action="fav"title="В избранное">♥</button><buttondata-action="remove"title="Удалить">✕</button></li><li><spanclass="name">Рис</span><buttondata-action="fav"title="В избранное">♥</button><buttondata-action="remove"title="Удалить">✕</button></li><li><spanclass="name">Лук</span><buttondata-action="fav"title="В избранное">♥</button><buttondata-action="remove"title="Удалить">✕</button></li></ul><pid="count"></p>
const list = document.querySelector("#list");
const count = document.querySelector("#count");
const extras = ["Зира", "Помидоры", "Масло", "Чой"];
let next = 0;
functionupdateCount() {
const total = list.children.length;
const favs = list.querySelectorAll("li.fav").length;
count.textContent = `Всего: ${total}, избранных: ${favs}`;
}
functionmakeItem(text) {
const li = document.createElement("li");
li.innerHTML = `<span class="name"></span>
<button data-action="fav" title="В избранное">♥</button>
<button data-action="remove" title="Удалить">✕</button>`;
li.querySelector(".name").textContent = text; // текст — только через textContentreturn li;
}
list.addEventListener("click", (event) => {
const btn = event.target.closest("button[data-action]");
if (!btn) return;
const li = btn.closest("li");
if (btn.dataset.action ==="fav") li.classList.toggle("fav");
elseif (btn.dataset.action ==="remove") li.remove();
updateCount();
});
document.querySelector("#add").addEventListener("click", () => {
list.append(makeItem(extras[next++ % extras.length]));
updateCount();
});
updateCount();
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.
Что запомнить из практики
Обработчик отвечает только за «что нажали». Общая функция updateCount отвечает только за «пересчитать и показать». Такое разделение делает код читаемым — и именно оно ждёт вас в больших проектах (урок 11 — состояние приложения).
Практика: соберите сами
ЗАДАЧА 8.1
Список с избранным и счётчиком
средняя
Повторите шаг 3 без подсказок, но уже сами. Нужны: функция updateCount (пишет в #count «Всего: N, избранных: M»), один обработчик клика на #list (♥ переключает класс fav, ✕ удаляет строку) и вызов updateCount() в нужных местах. Блок «автотест» внизу сам «кликает» по кнопкам, в том числе по новому пункту, добавленному позже, — не меняйте его.
Счётчик: list.children.length — всего строк, list.querySelectorAll("li.fav").length — избранных. Текст — шаблонной строкой в count.textContent.
Подсказка 2
Обработчик: const btn = event.target.closest("button[data-action]"); if (!btn) return;, дальше const li = btn.closest("li"); и две ветки по btn.dataset.action. В конце обработчика — updateCount().
09 Домашнее задание: интерактивный список
Допишите свой «интерактивный компонент»: список с добавлением, удалением и фильтром по кнопкам. Тему выберите сами: покупки, задачи на неделю, фильмы к просмотру, любимые места Душанбе. Результат этого ДЗ пригодится в мини-проекте на уроке 12.
Что должно получиться
Форма добавления. Поле и кнопка; пункт добавляется и по клику, и по Enter (событие submit, preventDefault). После добавления поле очищается.
Проверки. Пустое имя (после trim()) и дубликаты (без учёта регистра) не добавляются.
Кнопки в каждой строке: ♥ — в избранное (класс fav), ✕ — удалить. Всё через один обработчик на списке: closest + data-action.
Фильтр. Две кнопки «Все» и «Избранные» (тоже делегированием на контейнере кнопок). Выбранная кнопка подсвечена. Фильтр продолжает работать после добавления, удаления и смены ♥.
Счётчик «Всего: N, избранных: M».
Чистота. Нет onclick="…" в HTML. Нет подписок на каждый li. Текст пользователя вставляется через textContent, а не innerHTML.
Ниже — стартовая заготовка-песочница с автотестом: он повторяет типичные проверки (добавление, пустой ввод, дубликат, избранное, фильтр, удаление). Когда в консоли всё сойдётся — отправляйте свою версию с вашей темой и красивыми стилями.
Заполните место «ваш код здесь»: форма, один обработчик на список, один — на кнопки фильтра, функции applyFilter и updateCount. Автотест внизу не меняйте.
applyFilter: для каждого li — li.hidden = currentFilter === "fav" && !li.classList.contains("fav");. Не забудьте вызывать applyFilter() после ♥, ✕ и добавления: иначе в фильтре «Избранные» снятое сердечко не спрячет строку.
Что сдать
Папку с index.html, style.css, script.js — ссылкой на репозиторий GitHub или архивом в Telegram-группу. Название темы напишите в сообщении.
Скриншот работающего списка (с добавленными пунктами, включённым фильтром).
Файл prompts.md (или раздел в README) с двумя вещами: (1) вашим баг-репортом для ИИ по шаблону из раздела 07 — по настоящему багу, который встретился вам; (2) одним абзацем: что подсказал ИИ и что из этого вы не взяли и почему.
Как оценим (0–10)
Критерий
Баллы
Что смотрим
Форма
2
submit + preventDefault, Enter работает, пустое и дубликат отсекаются, поле очищается
Делегирование
3
Один обработчик на список, closest, data-action; новые пункты работают без дополнительных подписок
Фильтр
2
«Все» / «Избранные», подсветка активной кнопки, фильтр живёт после изменений
Счётчик
1
Всегда показывает верные числа
Чистота кода
1
Нет inline-обработчиков, textContent вместо innerHTML для ввода, понятные имена, нет повторов
ИИ-часть
1
Баг-репорт по шаблону и честный разбор ответа ИИ
Бонус (по желанию, без баллов): двойной клик по названию меняет его, клавиша Escape очищает поле, Delete удаляет последнюю строку.
Напоминание
На проверке ДЗ преподаватель может попросить объяснить любую строку. Если вы не можете объяснить, как клик по ✕ доходит до обработчика на списке, — перечитайте разделы 05 и 06, прежде чем сдавать.
Шпаргалка урока
Событие — сигнал от браузера. Обработчик — функция, которую мы передаём (без скобок!): el.addEventListener("click", handler).
Обработчиков может быть много, они срабатывают в порядке добавления. Снять: removeEventListener (нужна та же функция). Один раз: { once: true }. Свойство onclick хранит только один обработчик — не используем.
Частые события: click, input (каждая буква), change (значение зафиксировано), keydown, submit, DOMContentLoaded. У флажка читаем checked, у поля и списка — value (строка).
Из кода: el.click(), el.dispatchEvent(new Event("input", { bubbles: true })), form.dispatchEvent(new Event("submit", { cancelable: true })). Ручные события по умолчанию не всплывают.
event.preventDefault() отменяет действие браузера: переход по ссылке, перезагрузку при submit, Ctrl + S. Формы слушаем по submit, а не по клику на кнопку.
Всплытие: событие идёт от цели вверх по родителям (button → li → ul → body). target не меняется, currentTarget меняется. Погружение — { capture: true }, нужно редко.
stopPropagation() прерывает всплытие — используйте осторожно; чаще достаточно проверить event.target === event.currentTarget.
Делегирование: один обработчик на родителе + event.target.closest("селектор") + if (!el) return. Работает для элементов, добавленных позже. Метки в HTML — data-action, data-id, читаем el.dataset.action.
Не всплывают: focus, blur, mouseenter, mouseleave — у родителя слушайте focusin, focusout, mouseover, mouseout.
Пользовательский текст в DOM — через textContent, не через innerHTML.
С ИИ: баг-репорт = ожидал / получил (ошибка дословно) / минимальный код (до 20 строк) / что пробовал / формат ответа. Проверяйте гипотезы по одной.