DOM — это дерево объектов, в которое браузер превращает HTML: JavaScript находит в нём элементы через document.querySelector, меняет их текст (textContent), классы (classList) и атрибуты и создаёт новые через createElement. На действия пользователя подписываются методом addEventListener; событие всплывает от элемента к родителям, поэтому один обработчик на списке может обслуживать все его кнопки — это называется делегированием. Форму обрабатывают в событии submit: event.preventDefault() отменяет перезагрузку, new FormData(form) собирает значения полей.
После материала вы сможете
- найти элементы через
querySelectorиquerySelectorAllи объяснить, когда возвращаетсяnull - изменить текст, классы и атрибуты элемента, создать и удалить элемент, не используя
innerHTMLдля пользовательского ввода - подписаться на события через
addEventListener, объяснить всплытие и разницуtargetиcurrentTarget - обработать форму через
submit,preventDefaultиFormDataи обслужить кнопки списка одним делегированным обработчиком
Что знать заранее
Проверено на
DOM Living StandardJavaScript (ES2024)Microsoft Edge 155 + Playwright 1.64ESLint 10.12
Сверено с документацией: 11 октября 2026 г.
Содержание
- DOM: дерево документа
- Поиск элементов: querySelector и querySelectorAll
- Изменение страницы: textContent, classList, атрибуты, новые элементы
- События: addEventListener и объект события
- Всплытие и делегирование
- Формы: submit, preventDefault и FormData
- Практика: список задач без фреймворка
- Упражнения и что дальше
DOM: дерево документа
Когда браузер загружает HTML, он строит в памяти DOM (Document Object Model) — дерево узлов, где каждый тег становится объектом. MDN называет DOM программным интерфейсом документа: через него скрипт может менять структуру, стили и содержимое страницы. Сам 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, уже полученный список не изменится.
// Первый подходящий элемент — или 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); // → Почистить кофемашину"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, атрибуты, новые элементы
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Прочитать или заменить текст
- Свойство или метод
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.
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 });Браузер передаёт обработчику объект события. В нём type — тип события, target — элемент, на котором оно произошло, currentTarget — элемент, на котором висит обработчик, и метод preventDefault(), отменяющий действие браузера по умолчанию. Опция { once: true } удаляет обработчик после первого срабатывания — в нашей проверке «первый клик» напечатался один раз на два клика.
click
- Когда происходит
- Клик мышью, касание, а для кнопок — ещё Enter и пробел
input
- Когда происходит
- Каждое изменение значения поля при вводе
change
- Когда происходит
- Значение поля зафиксировано: выбран пункт, снят фокус
submit
- Когда происходит
- Отправка формы — кнопкой или Enter в поле; срабатывает на
form, а не на кнопке
keydown
- Когда происходит
- Нажатие клавиши
DOMContentLoaded
- Когда происходит
- HTML разобран, скрипты с
deferвыполнены
Чтобы снять обработчик, нужен removeEventListener с той же самой функцией. Анонимная стрелочная функция, записанная прямо в вызове, снаружи недоступна, поэтому обработчик, который придётся снимать, сохраняйте в переменную.
Всплытие и делегирование
Клик по элементу — это ещё и клик по всем его родителям. MDN описывает это как всплытие (bubbling): событие сначала срабатывает на самом вложенном элементе, затем на его родителе и так до document. Повесим один и тот же обработчик на li, ul и body:
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 = LItarget на всех трёх уровнях — LI: это элемент, по которому кликнули. currentTarget меняется — это элемент, чей обработчик выполняется сейчас. Всплытие можно остановить вызовом event.stopPropagation(), но делать это стоит редко: обработчики выше по дереву, например аналитика или закрытие меню по клику снаружи, перестанут узнавать о событии.
Главная польза всплытия — делегирование. Вместо обработчика на каждой кнопке вешают один на общий контейнер и по event.target выясняют, по чему кликнули. Плюсы: обработчик один, сколько бы ни было элементов, и он работает для элементов, добавленных позже, — им не нужно ничего подключать. Метод closest(selector) поднимается от target вверх и находит ближайший подходящий элемент, поэтому клик по иконке внутри кнопки тоже будет засчитан кнопке.
- 01Клик по «Готово»
- 02button
- 03li
- 04ul: обработчик
- 05closest() → действие
Формы: submit, preventDefault и FormData
Форму обрабатывают в событии submit на самой форме, а не в click на кнопке: submit срабатывает и по клику, и по Enter в поле. По умолчанию браузер отправляет форму и перезагружает страницу. event.preventDefault() это отменяет, и дальше всё делает скрипт. new FormData(form) собирает значения всех полей по их атрибутам name — поэтому name в разметке обязателен.
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();
});data.get("title")возвращает значение поляname="title".trim()убирает пробелы по краям, чтобы задача « » не попала в список.- Пустое поле не дойдёт даже до обработчика: у
inputесть атрибутrequired, и браузер сам не отправит форму. Строку из пробеловrequiredпропускает — её отсекает наша проверка. form.reset()очищает поля,input.focus()возвращает курсор, чтобы можно было сразу ввести следующую задачу.
Практика: список задач без фреймворка
Соберём мини-приложение: поле и кнопка добавляют задачу, кнопка «Готово» отмечает её выполненной, «Удалить» убирает, счётчик показывает, сколько осталось. Две кнопки у каждой задачи обслуживает один обработчик на списке.
<!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>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();Откройте index.html
Двойным кликом или через локальный сервер. Счётчик сразу покажет «Осталось задач: 0» —
renderCounter()вызывается при загрузке.Добавьте задачи
Введите текст и нажмите Enter или «Добавить». Страница не перезагружается, поле очищается и остаётся в фокусе.
Отметьте и удалите
«Готово» зачёркивает задачу и уменьшает счётчик, повторное нажатие снимает отметку. «Удалить» убирает строку.
Попробуйте сломать
Отправьте пустое поле, строку из пробелов и текст
<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.