Как делать11 октября 2026 г.7 мин чтения
JavaScript с нуляШаг 6 из 9

JavaScript в браузере: DOM и события

Коротко

DOM — это дерево объектов, в которое браузер превращает HTML: JavaScript находит в нём элементы через document.querySelector, меняет их текст (textContent), классы (classList) и атрибуты и создаёт новые через createElement. На действия пользователя подписываются методом addEventListener; событие всплывает от элемента к родителям, поэтому один обработчик на списке может обслуживать все его кнопки — это называется делегированием. Форму обрабатывают в событии submit: event.preventDefault() отменяет перезагрузку, new FormData(form) собирает значения полей.

Пошаговый урокУровень: НачальныйJavaScriptПрактика: ~45–90 мин

После материала вы сможете

  • найти элементы через querySelector и querySelectorAll и объяснить, когда возвращается null
  • изменить текст, классы и атрибуты элемента, создать и удалить элемент, не используя innerHTML для пользовательского ввода
  • подписаться на события через addEventListener, объяснить всплытие и разницу target и currentTarget
  • обработать форму через submit, preventDefault и FormData и обслужить кнопки списка одним делегированным обработчиком

Проверено на

DOM Living StandardJavaScript (ES2024)Microsoft Edge 155 + Playwright 1.64ESLint 10.12

Сверено с документацией: 11 октября 2026 г.

Содержание
  1. 01DOM: дерево документа
  2. 02Поиск элементов: querySelector и querySelectorAll
  3. 03Изменение страницы: textContent, classList, атрибуты, новые элементы
  4. 04События: addEventListener и объект события
  5. 05Всплытие и делегирование
  6. 06Формы: submit, preventDefault и FormData
  7. 07Практика: список задач без фреймворка
  8. 08Упражнения и что дальше

DOM: дерево документа

Когда браузер загружает HTML, он строит в памяти DOM (Document Object Model) — дерево узлов, где каждый тег становится объектом. MDN называет DOM программным интерфейсом документа: через него скрипт может менять структуру, стили и содержимое страницы. Сам HTML-файл при этом не меняется — меняется его живая копия в браузере.

basics.html
<!DOCTYPE html>
<html lang="ru">
<head>
  <meta charset="utf-8">
  <title>DOM: первые шаги</title>
  <script src="select.js" defer></script>
</head>
<body>
  <h1>Задачи на сегодня</h1>
  <ul id="task-list">
    <li class="task">Купить зёрна</li>
    <li class="task done">Почистить кофемашину</li>
  </ul>
  <p id="status" hidden>Все задачи выполнены</p>
  <button type="button" id="add">Добавить задачу</button>
</body>
</html>
Учебная страница: на ней выполняются примеры из следующих разделов.
  • Корень дерева — document. Его потомки — html, затем head и body, затем всё, что внутри.
  • У ul#task-list два дочерних элемента li, у каждого li — текстовый узел с названием задачи.
  • Скрипт подключён в head с атрибутом defer: по MDN, такой скрипт выполняется после разбора всего HTML и в порядке подключения. Без defer скрипт в head выполнился бы раньше, чем браузер дошёл до body, и не нашёл бы ни одного элемента.

Код ниже запускайте в консоли DevTools на этой странице или подключайте вместо select.js. Вывод в комментариях // → — реальный, из Edge 155.

Поиск элементов: querySelector и querySelectorAll

Оба метода принимают CSS-селектор — тот же, что вы пишете в стилях. querySelector возвращает первый подходящий элемент или null, если такого нет. querySelectorAll возвращает список NodeList со всеми подходящими элементами; по MDN, он статический: если потом добавить на страницу ещё один .task, уже полученный список не изменится.

select.js
// Первый подходящий элемент — или null, если такого нет
const title = document.querySelector("h1");
console.log(title.textContent);                    // → Задачи на сегодня
console.log(document.querySelector(".missing"));   // → null

// Все подходящие элементы — статический список NodeList
const tasks = document.querySelectorAll("#task-list .task");
console.log(tasks.length);                         // → 2
tasks.forEach((task) => console.log(task.textContent)); // → Купить зёрна, затем Почистить кофемашину

// От элемента можно идти по дереву вверх и вниз
const list = tasks[0].parentElement;
console.log(list.id, list.children.length);        // → task-list 2
console.log(document.querySelector(".done").textContent); // → Почистить кофемашину
Поиск по тегу, классу и id, обход списка и переход к родителю.

"h1"

Что найдёт
Элемент по имени тега

"#task-list"

Что найдёт
Элемент с id="task-list"

".task"

Что найдёт
Элементы с классом task

"#task-list .task"

Что найдёт
Элементы .task внутри #task-list

"button[data-action]"

Что найдёт
Кнопки с атрибутом data-action

"li:not(.done)"

Что найдёт
Элементы li без класса done

Изменение страницы: textContent, classList, атрибуты, новые элементы

change.js
const list = document.querySelector("#task-list");
const [first, second] = list.querySelectorAll(".task");

// Текст
first.textContent = "Купить зёрна для эспрессо";
console.log(first.textContent);                    // → Купить зёрна для эспрессо

// Классы
first.classList.add("urgent");
second.classList.remove("done");
console.log(first.className);                      // → task urgent
console.log(second.classList.toggle("done"));      // → true
console.log(second.classList.contains("done"));    // → true

// Атрибуты и data-атрибуты
first.setAttribute("title", "Срок — сегодня");
first.dataset.priority = "high";                   // в HTML: data-priority="high"
console.log(first.outerHTML); // → <li class="task urgent" title="Срок — сегодня" data-priority="high">Купить зёрна для эспрессо</li>

// Свойство hidden показывает и скрывает элемент
const statusNote = document.querySelector("#status");
statusNote.hidden = false;

// Новый элемент: создать, заполнить, вставить
const item = document.createElement("li");
item.className = "task";
item.textContent = "Позвонить мастеру";
list.append(item);
console.log(list.children.length);                 // → 3

// textContent не разбирает HTML — разметка из ввода остаётся текстом
const userInput = "<b>Срочно</b>";
item.textContent = userInput;
console.log(item.children.length);                 // → 0
item.innerHTML = userInput;                        // так с вводом пользователя нельзя
console.log(item.children.length);                 // → 1

item.remove();
console.log(list.children.length);                 // → 2
Текст, классы, атрибуты, hidden, создание и удаление элементов.

Прочитать или заменить текст

Свойство или метод
element.textContent

Добавить, убрать, переключить класс

Свойство или метод
classList.add(), remove(), toggle(), contains()

Атрибут

Свойство или метод
setAttribute(), getAttribute(), removeAttribute()

Свои данные в разметке

Свойство или метод
data-* в HTML ↔ element.dataset в JS

Показать или скрыть

Свойство или метод
element.hidden = true / false

Создать и вставить

Свойство или метод
document.createElement(), затем append()

Удалить

Свойство или метод
element.remove()

Обратите внимание на classList.toggle: он не только переключает класс, но и возвращает true, если класс теперь есть, — это пригодится в практике. Стили по-прежнему живут в CSS: скрипт добавляет класс done, а как выглядит выполненная задача, решает правило .done в стилях.

События: addEventListener и объект события

Событие — сигнал браузера о том, что что-то произошло: клик, ввод символа, отправка формы, загрузка страницы. Чтобы отреагировать, на элемент вешают обработчик — функцию, которую браузер вызовет при событии. Функции и колбэки разобраны в уроке о функциях; здесь колбэк просто передаётся вторым аргументом addEventListener.

events.js
const button = document.querySelector("#add");
let clicks = 0;

button.addEventListener("click", (event) => {
  clicks++;
  console.log(event.type, event.target.id, clicks); // → click add 1, после второго клика — click add 2
});

// Обработчик срабатывает один раз и удаляется сам
button.addEventListener("click", () => console.log("первый клик"), { once: true });
Обработчик клика и обработчик с опцией once. Подключите вместо select.js и дважды нажмите кнопку.

Браузер передаёт обработчику объект события. В нём type — тип события, target — элемент, на котором оно произошло, currentTarget — элемент, на котором висит обработчик, и метод preventDefault(), отменяющий действие браузера по умолчанию. Опция { once: true } удаляет обработчик после первого срабатывания — в нашей проверке «первый клик» напечатался один раз на два клика.

click

Когда происходит
Клик мышью, касание, а для кнопок — ещё Enter и пробел

input

Когда происходит
Каждое изменение значения поля при вводе

change

Когда происходит
Значение поля зафиксировано: выбран пункт, снят фокус

submit

Когда происходит
Отправка формы — кнопкой или Enter в поле; срабатывает на form, а не на кнопке

keydown

Когда происходит
Нажатие клавиши

DOMContentLoaded

Когда происходит
HTML разобран, скрипты с defer выполнены

Чтобы снять обработчик, нужен removeEventListener с той же самой функцией. Анонимная стрелочная функция, записанная прямо в вызове, снаружи недоступна, поэтому обработчик, который придётся снимать, сохраняйте в переменную.

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

Клик по элементу — это ещё и клик по всем его родителям. MDN описывает это как всплытие (bubbling): событие сначала срабатывает на самом вложенном элементе, затем на его родителе и так до document. Повесим один и тот же обработчик на li, ul и body:

bubbling.js
function logClick(event) {
  console.log(`${event.currentTarget.tagName}: target = ${event.target.tagName}`);
}

document.body.addEventListener("click", logClick);
document.querySelector("#task-list").addEventListener("click", logClick);
document.querySelector(".task").addEventListener("click", logClick);

// Клик по первой задаче выведет:
// LI: target = LI
// UL: target = LI
// BODY: target = LI
Вывод в комментарии — реальный порядок срабатывания в Edge 155.

target на всех трёх уровнях — LI: это элемент, по которому кликнули. currentTarget меняется — это элемент, чей обработчик выполняется сейчас. Всплытие можно остановить вызовом event.stopPropagation(), но делать это стоит редко: обработчики выше по дереву, например аналитика или закрытие меню по клику снаружи, перестанут узнавать о событии.

Главная польза всплытия — делегирование. Вместо обработчика на каждой кнопке вешают один на общий контейнер и по event.target выясняют, по чему кликнули. Плюсы: обработчик один, сколько бы ни было элементов, и он работает для элементов, добавленных позже, — им не нужно ничего подключать. Метод closest(selector) поднимается от target вверх и находит ближайший подходящий элемент, поэтому клик по иконке внутри кнопки тоже будет засчитан кнопке.

  1. 01Клик по «Готово»
  2. 02button
  3. 03li
  4. 04ul: обработчик
  5. 05closest() → действие
Событие всплывает от кнопки к списку, где его ловит единственный обработчик.

Формы: submit, preventDefault и FormData

Форму обрабатывают в событии submit на самой форме, а не в click на кнопке: submit срабатывает и по клику, и по Enter в поле. По умолчанию браузер отправляет форму и перезагружает страницу. event.preventDefault() это отменяет, и дальше всё делает скрипт. new FormData(form) собирает значения всех полей по их атрибутам name — поэтому name в разметке обязателен.

app.js
form.addEventListener("submit", (event) => {
  event.preventDefault();                  // без перезагрузки страницы
  const data = new FormData(form);
  const title = String(data.get("title")).trim();
  if (title === "") return;                // строка из одних пробелов

  list.append(createTask(title));
  form.reset();
  input.focus();
  renderCounter();
});
Фрагмент app.js из практики ниже — обработчик отправки формы.
  • data.get("title") возвращает значение поля name="title". trim() убирает пробелы по краям, чтобы задача « » не попала в список.
  • Пустое поле не дойдёт даже до обработчика: у input есть атрибут required, и браузер сам не отправит форму. Строку из пробелов required пропускает — её отсекает наша проверка.
  • form.reset() очищает поля, input.focus() возвращает курсор, чтобы можно было сразу ввести следующую задачу.

Практика: список задач без фреймворка

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

index.html
<!DOCTYPE html>
<html lang="ru">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Список задач</title>
  <style>
    .done span { text-decoration: line-through; color: #6b7280; }
  </style>
  <script src="app.js" defer></script>
</head>
<body>
  <main>
    <h1>Список задач</h1>
    <form id="task-form">
      <label for="task-title">Новая задача</label>
      <input id="task-title" name="title" type="text" autocomplete="off" required>
      <button type="submit">Добавить</button>
    </form>
    <ul id="task-list"></ul>
    <p id="task-counter" aria-live="polite"></p>
  </main>
</body>
</html>
Разметка: форма с подписью, пустой список и счётчик. aria-live="polite" просит скринридер зачитывать изменения счётчика.
app.js
const form = document.querySelector("#task-form");
const input = document.querySelector("#task-title");
const list = document.querySelector("#task-list");
const counter = document.querySelector("#task-counter");

function renderCounter() {
  const left = list.querySelectorAll("li:not(.done)").length;
  counter.textContent = `Осталось задач: ${left}`;
}

function createTask(title) {
  const item = document.createElement("li");

  const text = document.createElement("span");
  text.textContent = title;                // ввод пользователя — только как текст

  const toggle = document.createElement("button");
  toggle.type = "button";
  toggle.dataset.action = "toggle";
  toggle.setAttribute("aria-pressed", "false");
  toggle.textContent = "Готово";

  const remove = document.createElement("button");
  remove.type = "button";
  remove.dataset.action = "remove";
  remove.textContent = "Удалить";

  item.append(text, " ", toggle, " ", remove);
  return item;
}

form.addEventListener("submit", (event) => {
  event.preventDefault();                  // без перезагрузки страницы
  const data = new FormData(form);
  const title = String(data.get("title")).trim();
  if (title === "") return;                // строка из одних пробелов

  list.append(createTask(title));
  form.reset();
  input.focus();
  renderCounter();
});

// Один обработчик на весь список — делегирование событий
list.addEventListener("click", (event) => {
  const button = event.target.closest("button[data-action]");
  if (!button) return;                     // клик мимо кнопок
  const item = button.closest("li");

  if (button.dataset.action === "toggle") {
    const isDone = item.classList.toggle("done");
    button.setAttribute("aria-pressed", String(isDone));
  }
  if (button.dataset.action === "remove") {
    item.remove();
  }
  renderCounter();
});

renderCounter();
Всё приложение: создание задачи, отправка формы, делегирование кликов и счётчик.
  1. Откройте index.html

    Двойным кликом или через локальный сервер. Счётчик сразу покажет «Осталось задач: 0» — renderCounter() вызывается при загрузке.

  2. Добавьте задачи

    Введите текст и нажмите Enter или «Добавить». Страница не перезагружается, поле очищается и остаётся в фокусе.

  3. Отметьте и удалите

    «Готово» зачёркивает задачу и уменьшает счётчик, повторное нажатие снимает отметку. «Удалить» убирает строку.

  4. Попробуйте сломать

    Отправьте пустое поле, строку из пробелов и текст <b>Срочно</b> — первые два не добавятся, третий появится как текст, а не жирным.

Мы прошли этот сценарий автоматически в Edge 155: три задачи добавлены, пустая и из пробелов отброшены, страница ни разу не перезагрузилась, <b> не превратился в элемент, кнопки работают и мышью, и пробелом с клавиатуры, а aria-pressed у «Готово» переключается вместе с классом. Через протокол DevTools мы проверили и само делегирование: обработчик click один — на ul, у кнопок собственных обработчиков нет.

Упражнения и что дальше

Пока все данные живут в самой странице. Следующий урок — асинхронный JavaScript: как загрузить список с сервера через fetch, показать загрузку и ошибку и не заблокировать интерфейс. А когда таких интерфейсов станет много, их удобнее собирать из компонентов — этим займёмся в уроках по React. Приём из этого урока пригодится и в рецепте debounce: обработчик input на поле поиска срабатывает на каждый символ.

Как проверено4 · пройденосверено 11 октября 2026 г.
  • запущено, работает

    select.js, change.js, events.js, bubbling.js на basics.html (RU и EN)

    Microsoft Edge 155 через Playwright 1.64: console.log сопоставлен со строкой исходника (location) и сверен с комментарием // → — select 6 строк, change 9, events 1 (+ once после двух кликов); bubbling — порядок LI → UL → BODY совпал с комментарием; ошибок в консоли нет; ESLint 10.12 (recommended, globals.browser) — 0 замечаний (в ходе проверки переменная status переименована в statusNote: конфликт с window.status)

  • запущено, работает

    index.html + app.js — список задач (RU и EN)

    Edge 155: добавление по Enter и кнопкой, пустая строка (required) и пробелы отброшены, ровно одна навигация (без перезагрузки), фокус и очистка поля, <b> как текст, toggle класса done и aria-pressed, Space с клавиатуры, удаление, клик мимо кнопок; CDP DOMDebugger.getEventListeners: 1 click-обработчик на ul, 0 на кнопках; работает из file://; html-validate 0 ошибок

  • запущено, работает

    сообщение TypeError для null

    Edge 155: TypeError: Cannot set properties of null (setting 'textContent')

  • запущено, работает

    решения упражнений (render-counter.js, clear-done.js, menu.html + menu.js)

    Edge 155: счётчик «все выполнены» и «осталось 0» для пустого; удаление отмеченных; фильтр 2/2/4 и aria-pressed, клик мимо кнопок без изменений; html-validate и ESLint без замечаний

«Запущено» — код выполнен во временном проекте или контейнере, вывод в статье взят из прогона. «Частично» — проверено с заглушками вместо внешних сервисов. «Не запускалось» — нужны устройство, аккаунт или ключ; шаги сверены с официальной документацией. Файлы проверочного проекта и логи запусков сохранены в архиве InterLAB.

Расскажите о задаче — мы изучим запрос и свяжемся с вами

Есть задача?
Обсудим и оценим — бесплатно.

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

  • Оценка объёма и рисков до старта
  • NDA — по вашему или нашему шаблону
  • Старт работ в течение недели

Не присылайте через форму пароли, ключи доступа и исходный код — безопасную передачу доступа обсудим отдельно.

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