Раздел 7 · Браузер и Frontend

DOM: дерево, поиск, события

junior~40 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

DOM: дерево, поиск, события

Браузер не работает с HTML напрямую. Он парсит разметку и собирает живое дерево объектов, DOM. С этого момента HTML только источник, а программа работает с деревом. Когда ты пишешь document.querySelector, ты лезешь в дерево, не в исходник.

Этот урок про три базовых навыка: понимать DOM как структуру, искать в нём узлы, реагировать на события. Через шесть уроков мы заменим всё это на React, но React это абстракция над тем же DOM. Без понимания нижнего слоя верхний будет магией.

1. DOM это не HTML

DOM строится парсером HTML по правилам спецификации. Парсер может закрывать незакрытые теги, нормализовывать атрибуты, разворачивать shorthand. Это значит, твой view-source и реальное DOM-дерево могут отличаться.

<table>
  <tr>
    <td>cell</td>
  </tr>
</table>

Браузер втихую обернёт это в <tbody>. В DOM путь будет table > tbody > tr > td, хотя в HTML никакого tbody нет. Это коварство. Когда querySelector('table > tr') ничего не находит, причина тут.

Базовая иерархия классов DOM:

  • EventTarget, всё, что может слушать события.
  • Node, любой узел в дереве (текст, элемент, документ).
  • Element, узел-тег (HTMLElement, SVGElement и так далее).
  • Document, корень. У него специальные методы: createElement, getElementById, body, head.

Текст внутри тега это отдельный узел типа Text. Поэтому element.childNodes может содержать Text-узлы с пробелами и переносами между тегами. Если нужны только теги, используй element.children.

2. Поиск узлов

Шесть методов покрывают 99 процентов задач:

document.getElementById('root');
document.getElementsByClassName('btn');     // живой HTMLCollection
document.getElementsByTagName('a');         // живой HTMLCollection
document.querySelector('.btn.primary');     // первый матч
document.querySelectorAll('.btn');          // статический NodeList
element.closest('form');                    // первый предок (или сам)

Различия, которые ловят на собеседованиях:

  • getElementsBy* возвращают живой HTMLCollection: если ты удалишь элемент в цикле, индексы прыгнут.
  • querySelectorAll возвращает статический NodeList: снимок на момент вызова. Безопасно итерировать for...of.
  • closest(selector) идёт вверх по дереву от текущего узла (включая сам). Используется в делегировании событий (см. §4).

Шпаргалка по выбору метода:

ХочуИспользую
один узел по idgetElementById (быстрее всех)
первый матч по CSS-селекторуquerySelector
все матчиquerySelectorAll, потом Array.from или for...of
ближайший предок-формаclosest('form')
прямые дети, не потомкиelement.children (HTMLCollection)

В виджете кликни по узлу, увидишь путь от body. Введи .message или nav a, увидишь все совпадения. Парсер селекторов внутри урезан (тег, класс, id, потомок через пробел), но даёт идею: селектор это просто фильтр по дереву.

3. Манипуляции

После поиска приходит изменение. Новый API короткий.

const ol = document.querySelector('.message-list');

const li = document.createElement('li');
li.className = 'message';
li.textContent = 'привет';

ol.append(li);                          // в конец
ol.prepend(li);                         // в начало
ol.insertAdjacentHTML('beforeend', '<li>ещё</li>');

const old = ol.querySelector('.message');
old.replaceWith(li);                    // заменить целиком

const clone = li.cloneNode(true);       // глубокая копия
const shallow = li.cloneNode(false);    // только тег, без потомков

li.remove();                            // удалить из дерева

Полезные правила:

  • textContent это безопасно (даже если внутри <script> в строке, он не выполнится).
  • innerHTML = userInput это XSS-вектор. Используй textContent или createElement + textContent. insertAdjacentHTML тоже опасен.
  • replaceWith, before, after, prepend, append принимают несколько аргументов сразу, в том числе строки (станут текстовыми узлами).
  • el.dataset.foo = 'bar' соответствует атрибуту data-foo. Удобно для маркировки.

4. События: три фазы и делегирование

Событие проходит через DOM по строгой последовательности: capture, target, bubble. На любой фазе можно повесить listener.

const form = document.querySelector('form');
const button = form.querySelector('button');

form.addEventListener('click', () => console.log('form click (bubble)'));
form.addEventListener('click', () => console.log('form click (capture)'), { capture: true });
button.addEventListener('click', () => console.log('button click'));

// click по button: capture(form) -> target(button) -> bubble(form)

В виджете нажми “click inner”. Видна последовательность фаз для трёх вложенных слоёв. Поставь stopPropagation на middle, цепочка оборвётся. Включи preventDefault, действие по умолчанию (например, submit формы) не сработает, но handler-ы продолжат идти.

Важные различия:

  • event.target, узел, на котором событие возникло.
  • event.currentTarget, узел, на котором висит этот listener. В bubble-фазе они разные.
  • event.stopPropagation(), останавливает дальнейшее распространение по фазам. Остальные listener-ы на текущем узле сработают.
  • event.stopImmediatePropagation(), останавливает всё, включая другие listener-ы на этом же узле.
  • event.preventDefault(), отменяет действие по умолчанию (submit формы, переход по ссылке, чекбокс). Цепочку не останавливает.

Делегирование

Если в списке тысяча кнопок, не вешай тысячу listener-ов. Повесь один на родителя и в handler-е используй closest:

const list = document.querySelector('.message-list');

list.addEventListener('click', (event) => {
  const target = event.target;
  if (!(target instanceof Element)) return;
  const deleteBtn = target.closest('.message-delete');
  if (deleteBtn === null) return;
  const message = deleteBtn.closest('.message');
  message?.remove();
});

Плюсы делегирования:

  • один listener вместо N;
  • работает для будущих элементов, добавленных динамически;
  • легче снимать (removeEventListener один раз).

Минусы: handler сработает для всех click-ов внутри родителя, нужно аккуратно фильтровать через closest или matches.

Passive listeners

Для scroll-, touch- и wheel-событий браузер по умолчанию ждёт, не вызовешь ли ты preventDefault, и только потом скроллит. Это замедляет страницу. Если ты точно не вызовешь preventDefault, передай { passive: true }:

window.addEventListener('scroll', onScroll, { passive: true });

Современные браузеры считают passive: true дефолтом для document/window scroll-listener-ов, но в коде это всё равно стоит писать явно: меньше сюрпризов при code review.

дальше после входа

Урок целиком открыт всем, кто вошёл. Войди по своему email, чтобы читать дальше.

Вход пока по приглашению. Если хочешь учиться, напиши Vassiliy.