DOM в JavaScript: поиск, обход дерева и делегирование событий
Автор: Казачкин Даниил Михайлович · Обновлено
DOM представляет документ как дерево объектов, с которым JavaScript работает через браузерный API. Поиск элементов, изменение текста и обработка событий позволяют связать данные…
DOM представляет документ как дерево объектов, с которым JavaScript работает через браузерный API. Поиск элементов, изменение текста и обработка событий позволяют связать данные программы с доступным пользовательским интерфейсом.
Среда и полноценная страница
Сохраните первый блок в dom-demo.html и откройте файл в современном браузере. Внешних импортов и запросов нет, поэтому для этого конкретного примера сервер не требуется. Откройте консоль разработчика рядом со страницей. Здесь нужны HTML-элементы, функции, массивы и условия; объекты document и Element предоставляет браузер, а не сам стандарт ECMAScript. В обычном Node.js такого документа нет.
Сделаем список учебных действий с одной подпиской на контейнер. Каждая настоящая кнопка будет переключать состояние выполнения, а отдельная команда отключит обработку. Такой пример объединяет чтение дерева, безопасную запись текста, всплытие события и жизненный цикл подписки. Кнопки уже умеют получать фокус и активироваться клавиатурой; не приходится воспроизводить это поведение на обычном div.
<!doctype html>
<html lang="ru">
<meta charset="utf-8">
<title>План занятий</title>
<h1>План занятий</h1>
<ul id="lessons">
<li><button type="button" data-lesson="loops" aria-pressed="false"><span>Циклы</span></button></li>
<li><button type="button" data-lesson="arrays" aria-pressed="false"><span>Массивы</span></button></li>
</ul>
<p id="status" role="status">Выберите выполненный урок</p>
<button id="stop" type="button">Отключить обработку</button>
<script type="module">
const list = document.querySelector('#lessons');
const status = document.querySelector('#status');
const stop = document.querySelector('#stop');
const lifecycle = new AbortController();
console.log([...list.children].map((node) => node.tagName).join(','));
list.addEventListener('click', (event) => {
if (!(event.target instanceof Element)) return;
const button = event.target.closest('button[data-lesson]');
if (!button || !list.contains(button) || button.closest('ul') !== list) return;
const pressed = button.getAttribute('aria-pressed') !== 'true';
button.setAttribute('aria-pressed', String(pressed));
status.textContent = button.textContent.trim() + ': ' +
(pressed ? 'готово' : 'в работе');
}, { signal: lifecycle.signal });
stop.addEventListener('click', () => {
lifecycle.abort();
stop.disabled = true;
status.textContent = 'Обработка отключена';
}, { once: true });
</script>
</html>В консоли появится LI,LI. Нажатие на текст «Циклы» изменит сообщение на Циклы: готово, повторное — на Циклы: в работе. Нажмите Tab, переведите фокус на другую кнопку и активируйте её пробелом или Enter: должен сработать тот же сценарий. После отключения дальнейшие нажатия не меняют сообщение. Это проверка реального поведения, а не только наличия обработчика в исходном коде.
Узел не всегда является элементом
В DOM присутствуют элементы, текстовые узлы и комментарии. Пробелы и переносы строк между тегами тоже могут образовывать текстовые узлы. Поэтому firstChild не обязательно будет первым вложенным тегом. Для обхода именно элементов используйте children, firstElementChild, nextElementSibling и parentElement; для анализа всех узлов нужны соответствующие свойства семейства Node.
В основном примере list.children содержит элементы списка. Если использовать childNodes, число объектов может оказаться больше двух из-за форматирования HTML. Это не «лишние элементы, добавленные браузером», а разные уровни представления документа. Перед обходом определите, интересует ли вас текстовая структура или только элементы интерфейса.
Результат querySelectorAll — статический список найденных элементов: добавление новых узлов позже не обновляет уже полученный набор. Коллекция children живая и отражает изменение дочерних элементов. При удалении во время обхода живой коллекции индексы сдвигаются. Если нужен снимок перед изменением, создайте массив через Array.from или spread и работайте с ним.
Полный обход поддерева
Для посещения всех вложенных элементов можно использовать TreeWalker. Следующий блок самостоятельный: он создаёт небольшое отсоединённое дерево через DOM API и ничего не добавляет на страницу. Запустите его в браузерной консоли.
const tree = document.createElement('section');
const heading = document.createElement('h2');
const paragraph = document.createElement('p');
const emphasis = document.createElement('strong');
paragraph.append(emphasis);
tree.append(heading, paragraph);
const walker = document.createTreeWalker(tree, NodeFilter.SHOW_ELEMENT);
const names = [tree.tagName];
while (walker.nextNode()) names.push(walker.currentNode.nodeName);
console.log(names.join(','));Ожидается SECTION,H2,P,STRONG: сначала родитель, затем его потомки в порядке документа. Корень добавлен явно, потому что первый nextNode() переходит к следующему подходящему узлу. Фильтр SHOW_ELEMENT исключает текстовые узлы. Для анализа текста выберите другой фильтр; не смешивайте изменение структуры и сложный обход без заранее выбранного правила продвижения.
Поиск и безопасное изменение
querySelector возвращает первый подходящий элемент или null. В учебной странице разметка известна и скрипт расположен после неё; в переиспользуемой функции отсутствие контейнера нужно обработать явно. Если селектор строится из внешнего идентификатора, учитывайте синтаксис CSS и экранирование. Не вставляйте произвольную строку в сложный селектор без понимания её роли.
Для обычной подписи используйте textContent: значение становится текстом, а не исполняемой разметкой. Присваивание заменяет дочернее содержимое выбранного элемента, поэтому не направляйте его на контейнер с нужными кнопками и подписками. В примере сообщение обновляется в отдельном абзаце. Для новой структуры создавайте элементы через createElement и добавляйте их явно.
Перемещение существующего узла в другого родителя не создаёт копию. Это важно, когда одна и та же ссылка используется в двух местах: последний append переместит узел. Клонирование DOM имеет собственные правила и не равно клонированию объектов через structuredClone; обработчики, зарегистрированные через addEventListener, не следует считать автоматически перенесённым поведением копии.
Событие, цель и контейнер
event.target обозначает цель события с учётом правил DOM, а event.currentTarget — объект текущего обработчика. При клике по вложенному span цель может быть этим span, хотя нас интересует кнопка. closest поднимается от элемента к подходящему предку, включая сам элемент. Проверки принадлежности контейнеру ограничивают область действия обработчика.
Делегирование использует всплытие: одна подписка на списке обслуживает подходящие кнопки внутри. Оно также работает для кнопок, добавленных позже, без повторной регистрации на каждой. Но не все события всплывают одинаково, а Shadow DOM добавляет правила перенаправления цели. Для конкретного события изучите контракт, прежде чем копировать схему из примера с click.
preventDefault отменяет стандартное действие там, где это разрешено; stopPropagation ограничивает распространение события. Это разные операции. Нашим кнопкам с type="button" не нужно отменять отправку формы. Без необходимости останавливать всплытие тоже не следует: это может мешать другому коду наблюдать взаимодействие.
Практика: добавить урок без новой подписки
После запуска страницы выполните следующий блок в её консоли. Это продолжение основного примера, не отдельная страница. Создайте третий пункт программно и проверьте, что делегированный обработчик работает без изменений.
const item = document.createElement('li');
const button = document.createElement('button');
button.type = 'button';
button.dataset.lesson = 'objects';
button.setAttribute('aria-pressed', 'false');
button.textContent = 'Объекты';
item.append(button);
document.querySelector('#lessons').append(item);Нажатие новой кнопки должно показать Объекты: готово, если обработка ещё не отключена. Если ранее нажали кнопку отключения, обновите страницу перед практикой. Попробуйте сохранить результат querySelectorAll до добавления и сравнить его длину с новым запросом: старый снимок останется прежним. Так проверяются сразу два независимых механизма — всплытие и статическая коллекция.
Частые вопросы
Почему querySelector вернул null?
Проверьте селектор, документ и момент исполнения. Элемент мог ещё не появиться, находиться в другом корне или быть удалён. Не добавляйте произвольную задержку: свяжите код с созданием элемента или подходящим жизненным циклом.
Почему обработчик срабатывает дважды?
Проверьте повторную инициализацию и разные ссылки callback. Каждый новый анонимный обработчик является отдельной подпиской. Храните функцию очистки или объединяйте подписки сигналом жизненного цикла, как в примере.
Нужно ли удалять подписку при закрытии экрана?
Если её цель живёт дольше экрана и callback удерживает его состояние, да. Очистка должна соответствовать владельцу подписки. Удаление одной DOM-ветви не гарантирует исчезновения ссылок, сохранённых в глобальном обработчике или другом долгоживущем объекте.