ЯдроКодаподготовка к экзаменам
Учебная платформа

Загружаем материалы

Подготавливаем материалы и навигацию по разделу.

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

Источники