MakeFirstStep· Frontend + ИИ

Урок 9 · DOM и интерфейс

События

Страница, которая только показывает данные, — это плакат. Живой сайт реагирует: кнопка «В корзину» добавляет товар, поле поиска сразу фильтрует список, форма заявки не перезагружает страницу. За каждой такой реакцией стоит событие. Сегодня вы научитесь «слушать» клики, ввод и отправку форм — и сделаете настоящий интерактивный список, как в приложениях, которыми пользуетесь каждый день.

  • Подписываться на события через 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");

function onBuy() {
  console.log("Спасибо за покупку!");
}

buy.addEventListener("click", onBuy);
console.log("Обработчик навешен, клика ещё не было");

buy.click();   // программный клик — как будто нажали мышкой
buy.click();

Обратите внимание: обработчик не выполняется в момент addEventListener. Он просто запоминается и ждёт. Каждый клик вызывает его заново.

Попробуйте по-настоящему

Нажмите ▶ Запустить, а потом кликайте по кнопке в предпросмотре. Попробуйте поменять текст в обработчике или сделать так, чтобы счётчик рос сразу на 5.

Счётчик кликов · Ctrl + Enter
<button id="like">❤ Нравится</button>
<p>Лайков: <b id="count">0</b></p>
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.

Три способа повесить обработчик

СпособКак выглядитКогда использовать
Атрибут в HTML<button onclick="buy()">Не используем: смешивает разметку и код, неудобно проверять
Свойствоbtn.onclick = function () {…}Только в простых примерах: можно повесить лишь один обработчик
addEventListenerbtn.addEventListener("click", fn)Всегда. Обработчиков может быть сколько угодно, их можно снимать

Главная разница видна, когда обработчиков несколько. Свойство onclick хранит одну функцию — следующее присваивание затирает предыдущее. А addEventListener складывает их в список, и они срабатывают в порядке добавления:

Можно редактировать · Ctrl + Enter
document.body.innerHTML = `<button id="b">Кнопка</button>`;
const b = document.querySelector("#b");

b.onclick = () => console.log("onclick №1");
b.onclick = () => console.log("onclick №2");   // затёр первый!

b.addEventListener("click", () => console.log("слушатель №1"));
b.addEventListener("click", () => console.log("слушатель №2"));

b.click();
Частая ошибка: скобки после имени функции

В addEventListener передают саму функцию, а не результат её вызова. Скобки () вызывают функцию прямо сейчас — и обработчиком становится то, что она вернула (обычно undefined).

Частая ошибка: функция вызвалась сразу, а не по клику · Ctrl + Enter
document.body.innerHTML = `<button id="b">Кнопка</button>`;
const b = document.querySelector("#b");

function hello() {
  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");

function takeTicket() {
  console.log("Ваш талон выдан");
  b.removeEventListener("click", takeTicket);   // тот же элемент, тип и функция
}

b.addEventListener("click", takeTicket);
b.click();
b.click();   // тишина — обработчик снят
console.log("Второй клик ничего не сделал");

Для случая «сработать один раз» есть короткий путь — третий аргумент с настройками. Браузер сам снимет обработчик после первого срабатывания:

Можно редактировать · Ctrl + Enter
document.body.innerHTML = `<button id="b">Приз</button>`;
const b = document.querySelector("#b");

b.addEventListener("click", () => console.log("Вы получили приз!"), { once: true });

b.click();
b.click();
b.click();

Читать подробнее: learn.javascript.ru — Введение в браузерные события · MDN — addEventListener

Практика: закрепляем

Во всех задачах кнопку «нажимает» код — вам нужно написать обработчик.

ЗАДАЧА 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

Найди баг: приветствие раньше времени

лёгкая

Ожидалось, что «Салом, Мадина!» выведется два раза — по одному на каждый клик, и после строки «Кнопка готова». Сейчас всё иначе: найдите и исправьте одну ошибку.

Можно редактировать · Ctrl + Enter
document.body.innerHTML = `<button id="hi">Поздороваться</button>`;
const hi = document.querySelector("#hi");

function sayHello() {
  console.log("Салом, Мадина!");
}

hi.addEventListener("click", sayHello());

console.log("Кнопка готова");
hi.click();
hi.click();
Ожидаемый результат
Кнопка готова
Салом, Мадина!
Салом, Мадина!
Подсказка 1

Запустите как есть и посмотрите на порядок строк: приветствие появилось до клика. Значит, функция вызвалась в момент подписки.

Подсказка 2

Уберите круглые скобки после sayHello в addEventListener: браузер сам вызовет функцию при клике.

ЗАДАЧА 1.3

Предскажи порядок

лёгкая

Вот четыре обработчика на одной кнопке. Один из них затирает другой. Запишите в массив prediction, какие буквы и в каком порядке попадут в actual после одного клика. Не запускайте код сразу — сначала подумайте. Ответ не выводится на экран: консоль только сообщит, угадали ли вы.

Можно редактировать · Ctrl + Enter
document.body.innerHTML = `<button id="b">Кнопка</button>`;
const b = document.querySelector("#b");
const actual = [];

b.onclick = () => actual.push("A");
b.onclick = () => actual.push("B");
b.addEventListener("click", () => actual.push("C"));
b.addEventListener("click", () => actual.push("D"));
b.click();

const prediction = [];   // впишите буквы в строках, например ["X", "Y"]

console.log(prediction.join(" ") === actual.join(" ") ? "Прогноз верный" : "Прогноз неверный");
Ожидаемый результат
Прогноз верный
Подсказка 1

Что делает второе присваивание 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;

function onClick() {
  // ваш код здесь
}

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. В нём лежит всё, что известно о случившемся: какого оно типа, на каком элементе произошло, где была мышь, какая клавиша нажата.

Аналогия из жизни

Диспетчер маршрутки записывает в журнал: «что случилось (посадка), где (остановка «Шохмансур»), во сколько, кто сел». Объект события — такой же журнальный листок. Хотите узнать подробности — загляните в него.

Можно редактировать · Ctrl + Enter
document.body.innerHTML = `<button id="pay" class="btn">Оплатить</button>`;
const pay = document.querySelector("#pay");

pay.addEventListener("click", (event) => {
  console.log("Тип события:", event.type);
  console.log("Элемент:", event.target.id);
  console.log("Тег:", event.target.tagName);
  console.log("Это Event?", event instanceof Event);
});

pay.click();

Самые полезные свойства

СвойствоЧто даётПример
event.typeТип события строкой"click", "input"
event.targetЭлемент, на котором событие произошлоКнопка или значок внутри неё
event.currentTargetЭлемент, на котором висит ваш обработчикТот, у кого вызвали addEventListener
event.clientX / clientYКоординаты мыши в окне (у событий мыши)120, 45
event.keyКакая клавиша нажата (у клавиатурных событий)"Enter", "a", "Escape"
event.target.valueТекст в поле ввода (у input)"Душанбе"
event.preventDefault()Отменить действие браузера по умолчаниюРаздел 04

Мышь и клавиатура

Как «нажать клавишу» из кода? Создаём событие вручную и отправляем его элементу методом dispatchEvent. Так мы будем делать в задачах — для вашего обработчика это выглядит как настоящее нажатие.

Можно редактировать · Ctrl + Enter
document.body.innerHTML = `<input id="field" placeholder="Введите имя">`;
const field = document.querySelector("#field");

field.addEventListener("keydown", (e) => {
  console.log("Нажата клавиша:", e.key);
});

field.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter" }));
field.dispatchEvent(new KeyboardEvent("keydown", { key: "a" }));
Можно редактировать · Ctrl + Enter
document.body.innerHTML = `<div id="map" style="width:300px;height:200px">Карта</div>`;
const map = document.querySelector("#map");

map.addEventListener("click", (e) => {
  console.log(`Клик в точке (${e.clientX}, ${e.clientY})`);
});

map.dispatchEvent(new MouseEvent("click", { clientX: 120, clientY: 45 }));

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.

Читать подробнее: learn.javascript.ru — Объект события · MDN — Event.currentTarget · MDN — KeyboardEvent.key

Практика: закрепляем

ЗАДАЧА 2.1

Паспорт события

лёгкая

Допишите обработчик клика по кнопке #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 → «Очистка», любая другая → «Нажата клавиша: » и её название.

Можно редактировать · Ctrl + Enter
document.body.innerHTML = `<input id="msg">`;
const msg = document.querySelector("#msg");

// ваш код здесь

for (const key of ["a", "Enter", "Escape"]) {
  msg.dispatchEvent(new KeyboardEvent("keydown", { key }));
}
Ожидаемый результат
Нажата клавиша: a
Отправка
Очистка
Подсказка 1

Название клавиши — event.key. Удобно разобрать случаи через if … else if … else.

Подсказка 2

if (event.key === "Enter") { … } else if (event.key === "Escape") { … } else { console.log("Нажата клавиша: " + event.key); }

ЗАДАЧА 2.3

Кто сработал, а кто слушает

средняя

В кнопке #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.

Подсказка 2

order.addEventListener("click", (e) => { console.log("target: " + e.target.tagName); console.log("currentTarget: " + e.currentTarget.tagName); });

03 Какие бывают события

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

СобытиеКогда срабатываетГде используют
clickКлик мышью, касание пальцем, Enter на кнопкеКнопки, карточки, меню
dblclick, contextmenuДвойной клик, правая кнопка мышиРедактирование по двойному клику, своё меню
mouseover / mouseoutМышь зашла на элемент / ушла с негоПодсказки, подсветка
keydown / keyupКлавиша нажата / отпущенаГорячие клавиши, Enter, Escape
inputКаждое изменение текста в полеЖивой поиск, счётчик символов
changeЗначение «зафиксировано»: выбрали пункт, поставили галочку, ушли из поляSelect, checkbox, radio
focus / blurПоле получило фокус / потеряло егоПодсветка поля, проверка при уходе
submitФорма отправляется (кнопка или Enter)Все формы — заявки, вход, поиск
DOMContentLoadedHTML полностью прочитан и построенСтарт скрипта, который подключили в <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(new Event("input"));
}

change, checkbox и select

У флажка читаем checked (true/false), у выпадающего списка — value выбранного пункта:

Можно редактировать · Ctrl + Enter
document.body.innerHTML = `
  <label><input type="checkbox" id="sugar"> С сахаром</label>
  <select id="tea"><option>Чёрный</option><option>Зелёный</option></select>`;
const sugar = document.querySelector("#sugar");
const tea = document.querySelector("#tea");

sugar.addEventListener("change", (e) => console.log("Сахар:", e.target.checked));
tea.addEventListener("change", (e) => console.log("Чай:", e.target.value));

sugar.checked = true;
sugar.dispatchEvent(new Event("change"));
tea.value = "Зелёный";
tea.dispatchEvent(new Event("change"));

Живой пример: поиск и счётчик

Нажмите ▶ Запустить и печатайте в поле. Список фильтруется на каждую букву — это событие input в чистом виде.

Живой поиск по городам · Ctrl + Enter
<input id="search" placeholder="Найти город…">
<small id="info">Введите хотя бы букву</small>
<ul id="cities">
  <li>Душанбе</li><li>Худжанд</li><li>Бохтар</li>
  <li>Куляб</li><li>Истаравшан</li><li>Хорог</li>
</ul>
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.
Частая ошибка: путают input и change у текстового поля

У текстового поля change срабатывает только когда вы ушли из поля или нажали Enter. Если повесить на него живой поиск, результат появится «с опозданием» — и пользователь решит, что сайт зависает. Для поиска и счётчиков берите input.

Читать подробнее: learn.javascript.ru — Клавиатура: keydown и keyup · MDN — Справочник событий

Практика: закрепляем

ЗАДАЧА 3.1

Счётчик символов

лёгкая

Сделайте счётчик для поля «О себе», как в анкете MakeFirstStep. На каждое событие input выводите строку «Символов: N», где N — длина текста в поле.

Можно редактировать · Ctrl + Enter
document.body.innerHTML = `<textarea id="about"></textarea>`;
const about = document.querySelector("#about");

// ваш код здесь

for (const text of ["Привет", "Привет, Душанбе"]) {
  about.value = text;
  about.dispatchEvent(new Event("input"));
}
Ожидаемый результат
Символов: 6
Символов: 15
Подсказка 1

Текст из поля — event.target.value. Это строка, у строк есть свойство length.

Подсказка 2

about.addEventListener("input", (e) => console.log("Символов: " + e.target.value.length));

ЗАДАЧА 3.2

Поиск города на лету

средняя

На каждое событие input в поле #q найдите города из массива cities, в названии которых есть введённый текст (без учёта регистра, лишние пробелы по краям — убрать). Выведите «Запрос «текст»: » и города через запятую. Если ничего не нашлось — «ничего не найдено». В выводе запрос пишется как ввёл пользователь, но без пробелов по краям.

Можно редактировать · Ctrl + Enter
document.body.innerHTML = `<input id="q">`;
const q = document.querySelector("#q");
const cities = ["Душанбе", "Худжанд", "Бохтар", "Куляб", "Истаравшан", "Хорог"];

// ваш код здесь

for (const text of ["ху", " ХО ", "а", "щ"]) {
  q.value = text;
  q.dispatchEvent(new Event("input"));
}
Ожидаемый результат
Запрос «ху»: Худжанд
Запрос «ХО»: Хорог
Запрос «а»: Душанбе, Худжанд, Бохтар, Истаравшан
Запрос «щ»: ничего не найдено
Подсказка 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 и у списка городов, и у флажка. Код ниже сам «меняет» значения четыре раза.

Можно редактировать · Ctrl + Enter
document.body.innerHTML = `
  <select id="city"><option>Душанбе</option><option>Худжанд</option></select>
  <label><input type="checkbox" id="delivery"> Доставка</label>`;
const city = document.querySelector("#city");
const delivery = document.querySelector("#delivery");
const price = 100;
const fees = { "Душанбе": 10, "Худжанд": 25 };

function recalc() {
  // ваш код здесь: посчитать и вывести «Итого: N сомони»
}

// ваш код здесь: подписки на change

const fire = (el) => el.dispatchEvent(new Event("change"));
city.value = "Худжанд"; fire(city);
delivery.checked = true; fire(delivery);
city.value = "Душанбе"; fire(city);
delivery.checked = false; fire(delivery);
Ожидаемый результат
Итого: 100 сомони
Итого: 125 сомони
Итого: 110 сомони
Итого: 100 сомони
Подсказка 1

Внутри 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 — это поднятая рука контролёра: проход отменён, но всё остальное в зале идёт своим чередом.

Можно редактировать · Ctrl + Enter
document.body.innerHTML = `<a id="promo" href="https://example.com/promo">Акция недели</a>`;
const promo = document.querySelector("#promo");

promo.addEventListener("click", (e) => {
  e.preventDefault();                       // остаёмся на странице
  console.log("Переход отменён:", e.defaultPrevented);
});

promo.click();

Главный случай: форма

Отправка формы по умолчанию перезагружает страницу: всё, что 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(new Event("submit", { cancelable: true }));
who.value = "Фарход";
order.dispatchEvent(new Event("submit", { cancelable: true }));

Попробуйте сами

Нажмите ▶ Запустить, введите имя и нажмите Enter или кнопку. Страница не перезагружается, имя добавляется в список — это работа preventDefault.

Форма без перезагрузки · Ctrl + Enter
<form id="join">
  <input id="who" placeholder="Ваше имя" autocomplete="off">
  <button>Записаться</button>
</form>
<ol id="people"></ol>
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.

Не путать с 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 = new Event("submit", { cancelable: true });
f.dispatchEvent(ev);
console.log("Перезагрузка отменена?", ev.defaultPrevented);

Читать подробнее: learn.javascript.ru — Действия браузера по умолчанию · MDN — Event.preventDefault

Практика: закрепляем

ЗАДАЧА 4.1

Ссылка-ловушка

лёгкая

Ссылка #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()) — выводит «Введите имя», иначе — «Заявка: имя, телефон». Код ниже отправляет форму дважды.

Можно редактировать · Ctrl + Enter
document.body.innerHTML = `
  <form id="apply">
    <input id="name">
    <input id="phone">
    <button>Отправить</button>
  </form>`;
const apply = document.querySelector("#apply");
const nameInput = document.querySelector("#name");
const phoneInput = document.querySelector("#phone");

// ваш код здесь

nameInput.value = "Мадина";
phoneInput.value = "900 11 22 33";
apply.dispatchEvent(new Event("submit", { cancelable: true }));

nameInput.value = "   ";
apply.dispatchEvent(new Event("submit", { cancelable: true }));
Ожидаемый результат
Заявка: Мадина, 900 11 22 33
Введите имя
Подсказка 1

Слушайте 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 + Enter
const press = (key, ctrl, shift) => {
  const ev = new KeyboardEvent("keydown", { key, ctrlKey: ctrl, shiftKey: shift, bubbles: true, cancelable: true });
  document.dispatchEvent(ev);
  const label = (ctrl ? "Ctrl + " : "") + (shift ? "Shift + " : "") + key;
  console.log(`${label}: действие по умолчанию ${ev.defaultPrevented ? "отменено" : "работает"}`);
};

// ваш код здесь: подписка на keydown у document

press("s", true, false);
press("S", true, true);
press("a", false, false);
press("z", true, false);
Ожидаемый результат
Сохранено
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. цель: button 3. всплытие ↑ снизу вверх
Три фазы пути события. Обычные обработчики срабатывают на фазе всплытия: button → li → ul
Аналогия из жизни

Пузырёк воздуха в стакане чая всплывает со дна к поверхности и проходит все слои. Клик по кнопке «всплывает» так же: сначала сообщает о себе кнопке, потом карточке, потом списку, потом body, document. А ещё это похоже на офис в Душанбе: сотрудник сообщает новость начальнику отдела, тот — директору, тот — владельцу.

  1. Погружение (capturing). Событие спускается от document к цели. Обработчики здесь срабатывают только если вы специально попросили: { capture: true }. Нужно редко.
  2. Цель (target). Событие доходит до элемента, по которому кликнули.
  3. Всплытие (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
<div id="outer" class="box">outer
  <div id="middle" class="box">middle
    <button id="inner">inner (нажмите)</button>
  </div>
</div>
<label><input type="checkbox" id="stop"> stopPropagation на кнопке</label>
<button id="clear">Очистить журнал</button>
<ol id="log"></ol>
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.

stopPropagation: оборвать путь

event.stopPropagation() прекращает всплытие: родители событие не получат. Выглядит соблазнительно, но используйте осторожно. Остановленное событие перестанет доходить и до чужого кода — например, до счётчика аналитики на document, и вы будете долго искать, почему он «пропускает» клики. Лучше обычно проверить в обработчике родителя, куда именно кликнули: event.target === event.currentTarget.

Перехват: { capture: true }

Если третьим аргументом передать true или { capture: true }, обработчик сработает на фазе погружения — раньше, чем у самой цели. Так можно «встретить» событие до того, как оно дойдёт вниз.

Можно редактировать · Ctrl + Enter
document.body.innerHTML = `<div id="wrap"><button id="btn">Кнопка</button></div>`;
const wrap = document.querySelector("#wrap");
const btn = document.querySelector("#btn");

wrap.addEventListener("click", () => console.log("wrap: всплытие"));
wrap.addEventListener("click", () => console.log("wrap: перехват"), { capture: true });
btn.addEventListener("click", () => console.log("button: цель"));

btn.click();

Порядок: сначала перехват у wrap (спуск вниз), потом сама кнопка, потом всплытие у wrap.

Не всё всплывает

События focus и blur не всплывают (вместо них у родителя слушайте focusin / focusout), а mouseenter и mouseleave — тоже нет (берите mouseover / mouseout). Кроме того, события, созданные вручную, по умолчанию не всплывают: нужно явно написать new Event("input", { bubbles: true }).

Частая ошибка: ручное событие не всплывает · Ctrl + Enter
document.body.innerHTML = `<div id="form"><input id="field"></div>`;
const form = document.querySelector("#form");
const field = document.querySelector("#field");

form.addEventListener("input", () => console.log("форма услышала ввод"));

field.dispatchEvent(new Event("input"));                      // ✖ не всплывает — тишина
console.log("---");
field.dispatchEvent(new Event("input", { bubbles: true }));   // ✔ всплывает

Читать подробнее: learn.javascript.ru — Всплытие и погружение · MDN — Event.stopPropagation

Практика: закрепляем

ЗАДАЧА 5.1

Предскажи путь клика

лёгкая

Три вложенных элемента, на каждом свой обработчик. Запишите в 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» раньше всех. Ожидаемый порядок указан ниже.

Можно редактировать · Ctrl + Enter
document.body.innerHTML = `<div id="wrap"><button id="btn">Купить</button></div>`;
const wrap = document.querySelector("#wrap");
const btn = document.querySelector("#btn");

btn.addEventListener("click", () => console.log("Кнопка"));
wrap.addEventListener("click", () => console.log("Всплытие: wrap"));

// ваш код здесь

btn.click();
Ожидаемый результат
Перехват: wrap
Кнопка
Всплытие: wrap
Подсказка 1

Фаза погружения включается третьим аргументом addEventListener.

Подсказка 2

wrap.addEventListener("click", () => console.log("Перехват: wrap"), { capture: true });

ЗАДАЧА 5.3

Модальное окно закрывается само

средняя

Серый фон #overlay закрывает окно по клику. Внутри лежит само окно #modal. Баг: клик внутри окна тоже закрывает его, потому что событие всплывает до фона. Исправьте так, чтобы клик по окну выводил только «Клик внутри окна», а клик по фону — «Окно закрыто».

Можно редактировать · Ctrl + Enter
document.body.innerHTML = `
  <div id="overlay">
    <div id="modal">Вы уверены?</div>
  </div>`;
const overlay = document.querySelector("#overlay");
const modal = document.querySelector("#modal");

modal.addEventListener("click", () => {
  console.log("Клик внутри окна");
});

overlay.addEventListener("click", () => {
  console.log("Окно закрыто");
});

modal.click();
overlay.click();
Ожидаемый результат
Клик внутри окна
Окно закрыто
Подсказка 1

Запустите как есть: «Окно закрыто» выводится дважды. Первый раз — после клика по окну, из-за всплытия.

Подсказка 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", если есть + ".класс", если есть
function describe(el) {
  // ваш код здесь
}

// ваш код здесь: обработчик клика на shop

for (const id of ["price", "title", "buy", "shop"]) {
  document.getElementById(id).click();
}
Ожидаемый результат
b#price < p.info < article.card < main#shop
h3#title < article.card < main#shop
button#buy < article.card < main#shop
main#shop
Подсказка 1

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 — число делаем сами

Шаблон делегирования

  1. Один обработчик на общего родителя — список, таблицу, меню.
  2. Найдите нужный элемент: const btn = event.target.closest("button[data-action]");
  3. Ничего не нашли — выходим: if (!btn) return; (клик мимо кнопок).
  4. Проверьте, что кнопка внутри вашего списка: if (!list.contains(btn)) return; Нужна, когда списки вложены друг в друга.
  5. Поднимитесь к строке: const li = btn.closest("li");
  6. Решайте по 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);

Попробуйте сами

Нажмите ▶ Запустить. Удалите пару пунктов, затем добавьте новый кнопкой «Добавить пункт» — и убедитесь, что у нового кнопка «✕» работает без единой лишней строки подписки.

Меню чойхоны · Ctrl + Enter
<button id="add">Добавить пункт</button>
<ul id="menu">
  <li><span class="name">Оши палав</span> <button data-action="remove">✕</button></li>
  <li><span class="name">Лагман</span> <button data-action="remove">✕</button></li>
</ul>
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.
Частые ошибки делегирования

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();

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

Читать подробнее: learn.javascript.ru — Делегирование событий · MDN — Element.closest · MDN — data-атрибуты

Практика: закрепляем

ЗАДАЧА 6.1

Один слушатель на всё меню

лёгкая

Меню столовой — список #menu с тремя блюдами. Повесьте один обработчик клика на сам список. Он выводит «Выбрано: название» только если целью был пункт <li>. Клик по самому списку (мимо пунктов) должен игнорироваться.

Можно редактировать · Ctrl + Enter
document.body.innerHTML = `<ul id="menu"><li>Оши палав</li><li>Лагман</li><li>Манту</li></ul>`;
const menu = document.querySelector("#menu");

// ваш код здесь: один обработчик на menu

for (const li of menu.children) {
  li.click();
}
menu.click();   // мимо пунктов — реакции быть не должно
Ожидаемый результат
Выбрано: Оши палав
Выбрано: Лагман
Выбрано: Манту
Подсказка 1

Элемент, по которому кликнули, — event.target. Проверьте его тег: event.target.tagName === "LI". Для клика по самому списку target будет UL.

Подсказка 2

menu.addEventListener("click", (event) => { if (event.target.tagName !== "LI") return; console.log("Выбрано: " + event.target.textContent); });

ЗАДАЧА 6.2

Кнопки «нравится» и «удалить»

средняя

В списке три блюда, у каждого кнопки data-action="like" и data-action="remove". Напишите один обработчик на #list через closest и dataset. Для «like» выводите «Нравится: название (№id)», для «remove» — «Удалено: название (№id)» и удаляйте пункт. Клик не по кнопке игнорируйте. Название лежит в .name, id — в data-id у <li>.

Можно редактировать · Ctrl + Enter
document.body.innerHTML = `
  <ul id="list">
    <li data-id="1"><span class="name">Оши палав</span> <button data-action="like">♥</button> <button data-action="remove">✕</button></li>
    <li data-id="2"><span class="name">Лагман</span> <button data-action="like">♥</button> <button data-action="remove">✕</button></li>
    <li data-id="3"><span class="name">Манту</span> <button data-action="like">♥</button> <button data-action="remove">✕</button></li>
  </ul>`;
const list = document.querySelector("#list");

// ваш код здесь

const press = (id, action) =>
  document.querySelector(`li[data-id="${id}"] [data-action="${action}"]`).click();

press(2, "like");
press(3, "remove");
press(1, "like");
list.querySelector(".name").click();   // клик по названию — игнорируем
console.log("Осталось: " + list.children.length);
Ожидаемый результат
Нравится: Лагман (№2)
Удалено: Манту (№3)
Нравится: Оши палав (№1)
Осталось: 2
Подсказка 1

Начало обработчика: 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");

// Сейчас: по обработчику на каждый пункт. Замените на ОДИН обработчик на menu
for (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");

function printTotal() {
  // ваш код здесь: сумма по всем строкам или «Корзина пуста»
}

// ваш код здесь: один обработчик на cart

const 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");
Ожидаемый результат
Итого: 44 сомони
Итого: 26 сомони
Итого: 8 сомони
Корзина пуста
Подсказка 1

Разделите работу: 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 гипотезы причины по вероятности, к каждой — как проверить. Код целиком не переписывай. Проверка: после ответа я сам проверю каждую гипотезу в консоли.

Как сделать минимальный пример

  1. Скопируйте проблемный кусок в отдельный файл или в песочницу на этой странице.
  2. Выкиньте всё лишнее: стили, соседние компоненты, функции, которые к багу не относятся. После каждого удаления проверяйте — баг ещё воспроизводится?
  3. Замените реальные данные на 2–3 простых элемента.
  4. Запишите три вещи: что вы делаете (кликаю на …), что ожидали, что получили. Ошибку из консоли копируйте дословно, не пересказывайте.
  5. Ещё раз прочитайте. Часто баг находится прямо на шаге 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 символов. Код сам проверит ваш текст. Внутри текста не используйте обратные кавычки.

Можно редактировать · Ctrl + Enter
const report = `
`;   // пишите баг-репорт между кавычками

const checks = [
  ["Есть «Ожидал:»", report.includes("Ожидал:")],
  ["Есть «Получил:»", report.includes("Получил:")],
  ["Есть точный текст ошибки", report.includes("Cannot read properties of null")],
  ["Есть минимальный код", report.includes("addEventListener")],
  ["Есть «Что пробовал:»", report.includes("Что пробовал:")],
  ["Не длиннее 600 символов", report.length <= 600]
];
for (const [name, ok] of checks) {
  console.log((ok ? "✔ " : "✖ ") + name);
}
Ожидаемый результат
✔ Есть «Ожидал:»
✔ Есть «Получил:»
✔ Есть точный текст ошибки
✔ Есть минимальный код
✔ Есть «Что пробовал:»
✔ Не длиннее 600 символов
Подсказка 1

Скелет: «Ожидал: … Получил: … Минимальный код: … Что пробовал: …». Каждый блок — на новой строке прямо внутри report.

Подсказка 2

Не пересказывайте ошибку — вставьте её дословно. В минимальном коде достаточно двух строк: const btn = document.querySelector("#buy"); и btn.addEventListener("click", () => console.log("Куплено"));. Подумайте, что у вас получится, если скрипт запускается раньше, чем браузер прочитал <body>.

08 Практика на уроке: список с кнопками

Собираем «Покупки на базар» — список, где у каждого пункта две кнопки: ♥ в избранное и ✕ удалить. Обработчик один, на весь список. Идём тремя шагами, после каждого нажимайте ▶ Запустить и проверяйте руками. Не копируйте — набирайте сами, так запоминается в разы лучше.

  1. Разведка. Один обработчик на список. Узнаём, по какой кнопке и в какой строке кликнули.
  2. Действия. ♥ переключает класс fav у строки, ✕ удаляет строку.
  3. Счётчик. После каждого действия обновляем «Всего: N, избранных: M». Новые пункты работают сразу.

Шаг 1. Разведка

Пока ничего не меняем — только печатаем в консоль, что поняли из клика. Кликайте по ♥, по ✕, по названию и мимо кнопок.

Шаг 1 · Разведка · Ctrl + Enter
<h4>Покупки на базар</h4>
<ul id="list">
  <li><span class="name">Морковь</span> <button data-action="fav" title="В избранное">♥</button> <button data-action="remove" title="Удалить">✕</button></li>
  <li><span class="name">Рис</span> <button data-action="fav" title="В избранное">♥</button> <button data-action="remove" title="Удалить">✕</button></li>
  <li><span class="name">Лук</span> <button data-action="fav" title="В избранное">♥</button> <button data-action="remove" title="Удалить">✕</button></li>
</ul>
<p id="count"></p>
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.

Шаг 2. Действия

Теперь «разведку» заменяем настоящим поведением. ♥ переключает класс fav у строки (classList.toggle — из прошлого урока), ✕ удаляет строку целиком.

Шаг 2 · Действия · Ctrl + Enter
<h4>Покупки на базар</h4>
<ul id="list">
  <li><span class="name">Морковь</span> <button data-action="fav" title="В избранное">♥</button> <button data-action="remove" title="Удалить">✕</button></li>
  <li><span class="name">Рис</span> <button data-action="fav" title="В избранное">♥</button> <button data-action="remove" title="Удалить">✕</button></li>
  <li><span class="name">Лук</span> <button data-action="fav" title="В избранное">♥</button> <button data-action="remove" title="Удалить">✕</button></li>
</ul>
<p id="count"></p>
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.

Шаг 3. Счётчик и новые пункты

Добавляем функцию updateCount и вызываем её после каждого изменения — и один раз на старте. Кнопка «Добавить» создаёт пункт функцией makeItem. Обратите внимание: ни одной новой подписки на новые пункты — делегирование уже всё умеет.

Шаг 3 · Счётчик · Ctrl + Enter
<h4>Покупки на базар</h4>
<button id="add">+ Добавить пункт</button>
<ul id="list">
  <li><span class="name">Морковь</span> <button data-action="fav" title="В избранное">♥</button> <button data-action="remove" title="Удалить">✕</button></li>
  <li><span class="name">Рис</span> <button data-action="fav" title="В избранное">♥</button> <button data-action="remove" title="Удалить">✕</button></li>
  <li><span class="name">Лук</span> <button data-action="fav" title="В избранное">♥</button> <button data-action="remove" title="Удалить">✕</button></li>
</ul>
<p id="count"></p>
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.
Что запомнить из практики

Обработчик отвечает только за «что нажали». Общая функция updateCount отвечает только за «пересчитать и показать». Такое разделение делает код читаемым — и именно оно ждёт вас в больших проектах (урок 11 — состояние приложения).

Практика: соберите сами

ЗАДАЧА 8.1

Список с избранным и счётчиком

средняя

Повторите шаг 3 без подсказок, но уже сами. Нужны: функция updateCount (пишет в #count «Всего: N, избранных: M»), один обработчик клика на #list (♥ переключает класс fav, ✕ удаляет строку) и вызов updateCount() в нужных местах. Блок «автотест» внизу сам «кликает» по кнопкам, в том числе по новому пункту, добавленному позже, — не меняйте его.

Практика · Список с избранным · Ctrl + Enter
<ul id="list">
  <li><span class="name">Морковь</span> <button data-action="fav">♥</button> <button data-action="remove">✕</button></li>
  <li><span class="name">Рис</span> <button data-action="fav">♥</button> <button data-action="remove">✕</button></li>
  <li><span class="name">Лук</span> <button data-action="fav">♥</button> <button data-action="remove">✕</button></li>
</ul>
<p id="count"></p>
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.
Ожидаемый результат
Всего: 3, избранных: 2
Всего: 2, избранных: 2
Всего: 2, избранных: 1
Всего: 2, избранных: 1
Всего: 3, избранных: 2
Подсказка 1

Счётчик: 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.

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

Практика: заготовка к ДЗ

ЗАДАЧА 9.1

Интерактивный список: добавить, удалить, отфильтровать

сложная

Заполните место «ваш код здесь»: форма, один обработчик на список, один — на кнопки фильтра, функции applyFilter и updateCount. Автотест внизу не меняйте.

ДЗ · Интерактивный список · Ctrl + Enter
<form id="add-form">
  <input id="new-item" placeholder="Что купить?" autocomplete="off">
  <button>Добавить</button>
</form>
<div id="filters">
  <button data-filter="all" class="on">Все</button>
  <button data-filter="fav">Избранные</button>
</div>
<ul id="list">
  <li><span class="name">Морковь</span> <button data-action="fav">♥</button> <button data-action="remove">✕</button></li>
  <li><span class="name">Рис</span> <button data-action="fav">♥</button> <button data-action="remove">✕</button></li>
</ul>
<p id="count"></p>
Предпросмотр
Нажмите «Запустить», чтобы увидеть результат.
Ожидаемый результат
Всего: 4
Избранных: 2
В фильтре «Избранные»: 2
После снятия: 1
После удаления: 3, видно 3
Поле очищено: true
Всего: 3, избранных: 1
Подсказка 1

Начните с формы: form.addEventListener("submit", (event) => { event.preventDefault(); … }). Дубликат: [...list.querySelectorAll(".name")].some(n => n.textContent.toLowerCase() === text.toLowerCase()).

Подсказка 2

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)

КритерийБаллыЧто смотрим
Форма2submit + 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 хранит только один обработчик — не используем.
  • Обработчик получает event: type, target (где случилось), currentTarget (где висит обработчик), key, clientX/Y, target.value.
  • Частые события: 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 строк) / что пробовал / формат ответа. Проверяйте гипотезы по одной.