Frontend-разработка DOM и события: работа с деревом, делегирование, всплытие, наблюдатели
0%

DOM и события: работа с деревом, делегирование, всплытие, наблюдатели

DOM и события: работа с деревом, делегирование, всплытие, наблюдатели

Есть распространённая иллюзия: «DOM — это HTML, который я написал». Нет. HTML — текст, который парсер прочитал один раз при загрузке. DOM — это живой объектный интерфейс к дереву документа, реализованный внутри renderer-процесса на C++, а из JavaScript видимый через тонкую обёртку. Каждое обращение el.style.width = '10px' — не запись в поле объекта, а вызов через границу двух миров, который может дёрнуть пересчёт стилей всего документа.

Отсюда две практические истины, вокруг которых крутится вся статья.

  1. DOM дорог не потому, что «медленный», а потому, что он синхронен с рендерингом. Читая offsetHeight, вы заставляете браузер бросить всё и посчитать геометрию — прямо сейчас, посреди вашего цикла.
  2. События — это не колбэки на элементах, а протокол распространения по дереву. Пока вы думаете «повесил обработчик на кнопку», вы не сможете объяснить, почему попап закрывается от клика внутри себя, почему focus не ловится делегатом и почему stopPropagation() в стороннем виджете сломал вашу аналитику.

Про то, как браузер вообще строит дерево и рисует кадр, — в Как работает браузер. Здесь мы работаем с уже построенным деревом руками.

Что такое DOM на самом деле

DOM — спецификация WHATWG (https://dom.spec.whatwg.org/), описывающая иерархию классов. Знать её структуру полезно: половина «магии» — просто наследование.

Три вывода из диаграммы, которые экономят часы отладки:

  • EventTarget — корень всего. Слушателя вешают не только на элемент, но и на window, document, XMLHttpRequest, AbortSignal, MediaQueryList, WebSocket, ServiceWorker — везде один и тот же addEventListener с одинаковыми опциями.
  • NodeElement. childNodes возвращает и текстовые узлы, и комментарии; children — только элементы. Пробел и перенос строки в разметке — это Text-узел, из-за которого el.childNodes[0] внезапно не то, что вы ждали. Работайте с children, firstElementChild, nextElementSibling.
  • DocumentFragment — узел без документа. Он не отображается, вставки в него не вызывают ни стилей, ни layout. Это встроенный буфер для батчинга и основа <template>.

Рядом с DOM живут ещё два дерева, которые с ним часто путают: flat tree (DOM с раскрытыми shadow-корнями и слотами — по нему считаются стили) и accessibility tree (то, что видит скринридер; строится из DOM + ARIA, подробности — в руководстве по доступности).

Живые и статические коллекции

// HTMLCollection — ЖИВАЯ: это запрос, а не массив. Перевычисляется при обращении.
const live = document.getElementsByClassName('row');
// NodeList — СТАТИЧЕСКАЯ: снимок, сделанный в момент вызова.
const snap = document.querySelectorAll('.row');

console.log(live.length, snap.length); // 3 3
document.querySelector('ul').append(makeRow());
console.log(live.length, snap.length); // 4 3  ← live уже знает о новом узле

// Классический баг: удаляем класс и «теряем» половину элементов
const rows = document.getElementsByClassName('row');
for (let i = 0; i < rows.length; i++) {
  rows[i].classList.remove('row'); // rows.length уменьшается на каждой итерации,
}                                   // а i растёт → обработается ровно половина

// Правильно: зафиксировать снимок
for (const el of [...document.getElementsByClassName('row')]) el.classList.remove('row');

Живые: getElementsByTagName/ClassName, node.childNodes, element.children. Статические: querySelectorAll. По NodeList можно пройти for..of, по HTMLCollection — только через [...coll]. На практике: берите querySelectorAll и не экономьте, если речь не о десятках тысяч узлов.

Три метода, на которых держится вся работа с деревом:

btn.closest('[data-row]');        // ближайший предок ВКЛЮЧАЯ сам элемент — O(глубина)
btn.matches('button:disabled');   // булев тест — основа делегирования
parent.contains(node);            // node внутри parent (или === parent)

Для редких задач вроде подсветки текста есть document.createTreeWalker(root, NodeFilter.SHOW_TEXT, filter) — обход без рекурсии с возможностью отсечь целые поддеревья через NodeFilter.FILTER_REJECT.

Мутации: как менять дерево дёшево

Каждая вставка узла в живой документ инвалидирует стили и layout. Тысяча вставок — тысяча точек инвалидации. Собирайте изменения вне документа, во фрагменте.

// ❌ list.appendChild(li) внутри цикла — каждый раз трогаем живой документ
// ✅ собрали во фрагменте, вставили один раз
function renderRows(container, items) {
  const frag = document.createDocumentFragment();
  for (const item of items) {
    const li = document.createElement('li');
    li.className = 'row';
    li.dataset.id = item.id;              // → атрибут data-id
    li.textContent = item.title;          // безопасно: не парсит HTML
    frag.append(li);
  }
  // replaceChildren очищает контейнер и вставляет новое содержимое одной операцией.
  // Дети фрагмента переезжают в контейнер, сам фрагмент остаётся пустым.
  container.replaceChildren(frag);
}

Современные методы вставки удобнее старых: append()/prepend()/before()/after()/ replaceWith() принимают несколько аргументов и строки (строка станет текстовым узлом, не HTML). appendChild берёт ровно один Node.

el.append('Привет, ', strongNode, '!');   // три узла за раз, строка экранируется
el.replaceChildren();                     // самый быстрый способ очистить контейнер
el.insertAdjacentHTML('beforeend', html); // beforebegin | afterbegin | beforeend | afterend

insertAdjacentHTML принципиально отличается от innerHTML +=: последний сериализует всё содержимое в строку, стирает поддерево и парсит заново — теряются слушатели, состояние <input>, позиция каретки, проигрывание видео, а по времени это O(n²).

innerHTML и XSS

container.textContent = userInput;              // всегда безопасно
container.innerHTML = `<b>${userInput}</b>`;    // дыра

// Миф: «через innerHTML скрипт не выполнится». Верно только для <script>.
el.innerHTML = '<img src=x onerror="fetch(\'/steal?c=\'+document.cookie)">'; // выполнится
el.innerHTML = '<svg><animate onbegin=alert(1) attributeName=x dur=1s>';     // тоже

Правило: строим DOM узлами и textContent, а когда нужен HTML от пользователя — санитизируем. Sanitizer API (Element.setHTML) постепенно приезжает в браузеры; до полной поддержки — DOMPurify, де-факто стандарт с 2014 года:

if ('setHTML' in Element.prototype) el.setHTML(userHtml);
else el.innerHTML = DOMPurify.sanitize(userHtml, { USE_PROFILES: { html: true } });

<template> — инертная разметка

<template id="row-tpl">
  <li class="row"><span class="row__title"></span>
    <button class="row__del" type="button" aria-label="Удалить">×</button></li>
</template>
const tpl = document.getElementById('row-tpl');

function makeRow(item) {
  const node = tpl.content.cloneNode(true);   // клон DocumentFragment
  node.querySelector('.row').dataset.id = item.id;
  node.querySelector('.row__title').textContent = item.title; // никакого innerHTML
  return node;
}

Содержимое <template> лежит в отдельном DocumentFragment: картинки не грузятся, скрипты не выполняются, стили не применяются, пока вы не клонируете и не вставите. Это единственный способ хранить кусок разметки в HTML, не платя за него.

Почему тормозит: layout thrashing

Браузер ленив — он откладывает пересчёт стилей и геометрии до конца задачи. Но некоторые свойства требуют актуального ответа, и их чтение вызывает принудительную синхронную перекомпоновку (forced synchronous layout, она же reflow).

Свойства-триггеры (полный список — https://gist.github.com/paulirish/5d52fb081b3570c81e3a): offsetTop/Left/Width/Height, clientTop/Left/Width/Height, scrollTop/Left/Width/Height, getBoundingClientRect(), getComputedStyle(), offsetParent, innerText (да, именно innerText, в отличие от textContent), focus(), scrollIntoView().

// ❌ read-write-read-write: N принудительных layout
for (const box of boxes) {
  box.style.height = box.offsetHeight * 2 + 'px';
}

// ✅ разделяем фазы: все чтения, потом все записи → один layout
const heights = boxes.map((b) => b.offsetHeight);                    // read phase
requestAnimationFrame(() => {                                        // write phase в кадре
  boxes.forEach((b, i) => { b.style.height = heights[i] * 2 + 'px'; });
});

Как поймать в DevTools: панель Performance, запись взаимодействия, ищите в Main фиолетовые блоки Recalculate Style / Layout с красным треугольником и подписью «Forced reflow is a likely performance bottleneck». Клик по блоку показывает стек — точную строку, которая прочитала геометрию. Отдельно полезна галка Enable advanced paint instrumentation и панель Layers.

Ещё два рычага, снижающих стоимость самого layout: CSS-свойства contain: layout style paint и content-visibility: auto — они ограничивают область пересчёта поддеревом. Подробности в Вёрстке и Производительности фронтенда.

События: три фазы, а не «клик на кнопке»

Когда пользователь кликает, браузер определяет самый глубокий элемент под курсором — это target — и строит путь от window до него. Событие проходит путь дважды.

Путь события по дереву: перехват, цель, всплытие

el.onclick = handler;                        // ровно один обработчик, перезапишет прежний
el.addEventListener('click', handler);       // фаза всплытия (по умолчанию)
el.addEventListener('click', handler, true); // фаза перехвата, короткая форма

el.addEventListener('click', onClick, {
  capture: false,  // ловим на всплытии
  once: true,      // снять после первого вызова — браузер сделает это сам
  passive: true,   // обещаем не звать preventDefault → скролл не ждёт обработчик
  signal,          // AbortSignal — снятие пачкой
});
  • passive — самая недооценённая опция. Без неё браузер обязан дождаться завершения обработчика touchstart/wheel, чтобы узнать, не отменит ли тот скролл. Для touchstart, touchmove, wheel, mousewheel на window, document и body браузеры по умолчанию ставят passive: true (Chrome 56+), но на произвольном элементе — нет. Вызов preventDefault() внутри passive-обработчика игнорируется с предупреждением в консоли.
  • once избавляет от самораздевающихся обработчиков: не нужно хранить ссылку и звать removeEventListener внутри самого себя.
  • signal — самый удобный способ управлять жизненным циклом (см. ниже).

removeEventListener снимает слушателя только при совпадении трёх вещей: тип, та же ссылка на функцию, то же значение capture. Стрелочная функция, созданная прямо в вызове addEventListener, снята быть не может — ссылки на неё нет.

target vs currentTarget, три способа «остановить»

ul.addEventListener('click', (e) => {
  e.target;         // самый глубокий элемент под курсором — например <span> внутри кнопки
  e.currentTarget;  // элемент, на котором висит ЭТОТ слушатель — всегда ul
  e.eventPhase;     // 1 capture, 2 at target, 3 bubbling
  e.composedPath(); // массив узлов пути: [span, button, li, ul, body, html, document, window]

  e.preventDefault();          // отменить действие браузера (переход, отправку формы)
  e.stopPropagation();         // не пускать событие дальше по пути
  e.stopImmediatePropagation();// + не звать остальных слушателей ЭТОГО узла

  setTimeout(() => console.log(e.currentTarget)); // null! после диспетчеризации поле сбрасывается
});

Различайте: preventDefault() не останавливает распространение, а stopPropagation() не отменяет действие по умолчанию. Проверить отмену можно через e.defaultPrevented или по возвращаемому значению dispatchEvent() (false = кто-то вызвал preventDefault).

stopPropagation() — почти всегда сигнал плохого дизайна. Он ломает всё, что подписано выше: аналитику на document, закрытие модалок по клику снаружи, делегаты фреймворка. Если нужен «клик именно по мне» — проверяйте e.target.closest(...) в верхнем обработчике, а не глушите событие внизу.

Что не всплывает

Не всплывает Всплывающий аналог
focus, blur focusin, focusout
mouseenter, mouseleave mouseover, mouseout (с проверкой relatedTarget)
load, error на <img>, <script> нет — ловите в фазе перехвата на document
scroll на элементе нет; scroll на document доходит до window
resize только на window

Отсюда рецепт валидации формы «по уходу из поля»: form.addEventListener('blur', h, true) через перехват — либо, что лучше, form.addEventListener('focusout', h), потому что focusout всплывает штатно.

Делегирование событий

Идея: вместо N слушателей на N элементов — один слушатель на общем предке, который смотрит на event.target и решает, что делать.

Почему это не просто «оптимизация на слушателях»: делегат работает с элементами, которых ещё нет — перерисовали список, переподписываться не надо (для виртуализированных списков и бесконечного скролла это обязательное условие); он экономит память — 10 000 слушателей это 10 000 замыканий, каждое держит своё окружение; и он даёт одну точку для логирования и брейкпоинта.

/** Один обработчик на контейнер. Возвращает функцию отписки — удобно для useEffect. */
function delegate<K extends keyof HTMLElementEventMap>(
  root: HTMLElement | Document,
  type: K,
  selector: string,
  handler: (event: HTMLElementEventMap[K], match: HTMLElement) => void,
  options?: AddEventListenerOptions,
): () => void {
  const listener = (event: Event) => {
    // composedPath()[0] вместо event.target: корректно работает и с Shadow DOM,
    // где target «перетаргетится» на хост компонента.
    const start = event.composedPath()[0];
    if (!(start instanceof Element)) return;
    const match = start.closest<HTMLElement>(selector);
    // root.contains обязателен: closest мог уйти ВЫШЕ контейнера и найти чужой элемент.
    if (match && root.contains(match)) handler(event as HTMLElementEventMap[K], match);
  };
  root.addEventListener(type, listener as EventListener, options);
  return () => root.removeEventListener(type, listener as EventListener, options);
}

Дальше — «декларативные действия в разметке», приём, который прекрасно живёт и без фреймворка: на элементах <button type="button" data-action="delete"> внутри <li data-id="7">.

const actions = {
  delete: (el) => store.remove(el.closest('li').dataset.id),
  toggle: (el) => store.toggle(el.closest('li').dataset.id),
};

delegate(document.getElementById('todo'), 'click', '[data-action]', (e, el) => {
  actions[el.dataset.action]?.(el);
});

Подводные камни делегирования.

  1. Невсплывающие события (таблица выше) делегируются только через capture: true или через всплывающие аналоги.
  2. event.target — всегда элемент, но часто вложенный: <span> или <svg>-иконка внутри кнопки. Всегда closest() и работа с найденным match, никогда target === btn.
  3. Клик по <button disabled> не генерирует события вовсе, а <fieldset disabled> глушит вложенные поля. Не рассчитывайте «повесим и проверим disabled» — проверять будет нечего.
  4. Делегат на document конфликтует с чужим stopPropagation(). Аналитику вешайте в фазе перехвата на document: туда событие приходит раньше, чем кто-либо его заглушит.

Кастомные события: слабая связность без шины

Свои события — способ дать компоненту публичный API, не заставляя всех импортировать его модуль. Это тот же паттерн Observer, что и в паттернах проектирования, только транспортом служит само дерево.

interface CartAddDetail { sku: string; qty: number }

// Расширяем карту типов, чтобы addEventListener типизировался
declare global {
  interface HTMLElementEventMap { 'cart:add': CustomEvent<CartAddDetail> }
}

const evt = new CustomEvent<CartAddDetail>('cart:add', {
  detail: { sku, qty: 1 },
  bubbles: true,     // всплывает → ловится делегатом на document
  composed: true,    // пересекает границу Shadow DOM
  cancelable: true,  // имеет смысл preventDefault()
});

// dispatchEvent синхронен: к следующей строке все слушатели уже отработали
const notCancelled = button.dispatchEvent(evt);
if (notCancelled) cart.add(sku);

Соглашения, которые окупаются: префикс неймспейса (cart:add, а не add — чтобы не столкнуться с чужими), полезные данные строго в detail (другие поля события read-only), cancelable: true только если вы реально уважаете отмену.

Наблюдатели: подписка на состояние вместо опроса

Наблюдатели — асинхронные, батчащиеся API, которые сообщают об изменениях после того, как браузер посчитал их сам. Это принципиально дешевле, чем scroll-обработчик, вызывающий getBoundingClientRect() 60 раз в секунду.

IntersectionObserver

Схема работы IntersectionObserver: root, rootMargin, пороги

const io = new IntersectionObserver((entries, observer) => {
  for (const entry of entries) {
    if (!entry.isIntersecting) continue;
    const img = entry.target;
    img.src = img.dataset.src;
    observer.unobserve(img);        // отписываемся: работа сделана
  }
}, {
  root: null,                        // null = вьюпорт; иначе — прокручиваемый предок
  rootMargin: '0px 0px 400px 0px',   // грузим за 400px до появления
  threshold: 0,                      // сработать, как только пересеклись хоть на пиксель
});

document.querySelectorAll('img[data-src]').forEach((img) => io.observe(img));

Ключевые нюансы:

  • rootMargin расширяет root, а не target. Поэтому intersectionRatio считается относительно уже расширенного прямоугольника — элемент, «наполовину видимый пользователю», может дать ratio: 1. Если нужна честная видимость — не задавайте rootMargin или считайте геометрию сами из entry.intersectionRect и entry.rootBounds.
  • Единицы rootMargin — только px и %. em, rem, vh бросят исключение.
  • Колбэк вызывается и при подписке, с текущим состоянием: так вы узнаёте про элементы, уже видимые на старте. Для «виден и не перекрыт» есть v2 с trackVisibility: true и полем entry.isVisible — сделано против кликджекинга рекламы, стоит дорого.
  • Классический трюк со «sticky-заголовком, который узнал, что прилип»: невидимый sentinel-пиксель над заголовком, наблюдаем за ним.
// Бесконечный скролл: sentinel в конце списка + защита от повторного запроса
let loading = false;
new IntersectionObserver(async ([entry]) => {
  if (!entry.isIntersecting || loading) return;
  loading = true;
  try { list.append(renderRows(await api.nextPage())); } // делегат на списке продолжает работать
  finally { loading = false; }
}, { rootMargin: '0px 0px 600px 0px' }).observe(sentinel);

Для картинок сначала попробуйте нативное loading="lazy" + decoding="async" — оно бесплатно и лучше интегрировано с приоритезацией ресурсов. IntersectionObserver нужен там, где нативного механизма нет: видео, iframe со сторонним виджетом, тяжёлые чанки JS, счётчик показов.

ResizeObserver

const ro = new ResizeObserver((entries) => {
  for (const entry of entries) {
    // contentBoxSize — МАССИВ (фрагменты в многоколоночной вёрстке), почти всегда [0]
    const { inlineSize, blockSize } = entry.contentBoxSize[0];
    chart.resize(inlineSize, blockSize);
  }
});
ro.observe(container);                                   // box: 'content-box' по умолчанию
ro.observe(canvas, { box: 'device-pixel-content-box' }); // точные физические пиксели
  1. ResizeObserver loop completed with undelivered notifications — не мистика: вы в колбэке изменили размер наблюдаемого элемента, браузер пересчитал и снова позвал колбэк. Спецификация обрывает цикл и шлёт error в window. Лечение: не менять размеры того, за чем наблюдаете, либо гейтить изменение проверкой «а изменилось ли реально».
  2. Колбэк вызывается до отрисовки кадра (шаг 3 конвейера, см. Как работает браузер) — поэтому обновление размеров из него не даёт мигания, в отличие от resize + setTimeout.
  3. Если результат — только CSS, берите контейнерные запросы @container: декларативно, без главного потока, без сюрпризов при гидратации. См. Вёрстку.

MutationObserver

const mo = new MutationObserver((records) => {
  for (const r of records) {
    if (r.type === 'childList') for (const n of r.addedNodes) enhance(n); // чужой виджет
    if (r.type === 'attributes') console.log(r.attributeName, r.oldValue, '→', r.target);
  }
});

mo.observe(root, {
  childList: true, subtree: true,           // добавление/удаление детей на любой глубине
  attributes: true, attributeOldValue: true,
  attributeFilter: ['data-state', 'class'], // фильтр — существенно дешевле
});

const tail = mo.takeRecords();  // забрать накопленное, но ещё не доставленное
mo.disconnect();

MutationObserver — инструмент последней надежды: если вы наблюдаете за собственным DOM, значит, потеряли контроль над состоянием. Легитимные сценарии: интеграция со сторонним скриптом (чат, рекламный блок, виджет карт), полифилы, автотесты. Колбэк вызывается микрозадачей в конце текущей задачи, записи батчатся — сотня вставок даст один вызов.

PerformanceObserver: измеряем то, что чувствует пользователь

Все Core Web Vitals приходят именно через этот интерфейс.

// Long Animation Frames — API, который сразу показывает виновника длинного кадра
new PerformanceObserver((list) => {
  for (const frame of list.getEntries()) {
    if (frame.duration < 100) continue;
    for (const s of frame.scripts) {
      console.warn(`LoAF ${frame.duration.toFixed(0)}ms`,
        s.invoker,        // например: BUTTON#save.onclick
        s.sourceURL,      // файл и позиция
        `forcedLayout=${s.forcedStyleAndLayoutDuration.toFixed(1)}ms`);
    }
  }
}).observe({ type: 'long-animation-frame', buffered: true });

// INP: сырые взаимодействия дольше 200 мс — прямая метрика качества обработчиков
new PerformanceObserver((list) => {
  for (const e of list.getEntries()) {
    if (e.interactionId && e.duration > 200) console.warn('Медленный отклик', e.name, e.target);
  }
}).observe({ type: 'event', durationThreshold: 40, buffered: true });

Поле forcedStyleAndLayoutDuration — прямая цифра стоимости layout thrashing из вашего кода. В проде используйте библиотеку web-vitals (https://github.com/GoogleChrome/web-vitals) с атрибуцией: она отдаёт не только значение INP, но и селектор элемента и фазу (input delay / processing / presentation delay). Подробный разбор бюджетов — в Производительности фронтенда.

Жизненный цикл слушателя и утечки памяти

Ключевой механизм современного кода — AbortController. Один сигнал снимает произвольное число подписок, включая fetch.

function mountWidget(el) {
  const ac = new AbortController();
  const { signal } = ac;

  window.addEventListener('resize', onResize, { signal, passive: true });
  document.addEventListener('keydown', onKey, { signal });
  fetch('/api/data', { signal }).then(render).catch(ignoreAbort);

  const ro = new ResizeObserver(onResize);   // наблюдатели signal не понимают —
  ro.observe(el);                            // подписываемся на сам сигнал
  signal.addEventListener('abort', () => ro.disconnect(), { once: true });

  return () => ac.abort();   // одна строка снимает всё
}

Что реально течёт на практике.

  • Слушатель на document/window, который замыкает удалённый компонент. Узел уже вне документа, но document → слушатель → замыкание → узел → всё поддерево живо.
  • setInterval, который дёргает DOM удалённого виджета.
  • Наблюдатель без disconnect(). IntersectionObserver держит сильные ссылки на цели.
  • Массив/Map с DOM-узлами в кэше приложения. Лечится WeakMap/WeakSet — ключи-узлы не мешают сборке мусора.

Как ловить. DevTools → Memory → Heap snapshot, фильтр Detached — увидите Detached HTMLDivElement и цепочку удержания (Retainers). Отдельная панель Performance monitor показывает счётчики DOM Nodes, JS event listeners в реальном времени: покликайте по приложению туда-сюда 20 раз — если счётчики растут монотонно и не падают после форсированной сборки мусора, у вас утечка. В консоли помогают getEventListeners($0) и monitorEvents($0, ['click', 'focusin']).

Рецепты, которые постоянно нужны в проде

Клик снаружи (и почему обычно он написан неправильно)

// ❌ if (!popup.contains(e.target)) close() — работает до первого портала и Shadow DOM

// ✅ composedPath видит весь путь, включая shadow-деревья и порталы,
//    pointerdown в capture-фазе срабатывает раньше, чем кто-то заглушит событие
function onOutside(el, cb) {
  const handler = (e) => { if (!e.composedPath().includes(el)) cb(e); };
  document.addEventListener('pointerdown', handler, true);
  return () => document.removeEventListener('pointerdown', handler, true);
}

Но сначала посмотрите, не решает ли задачу платформа: <dialog> с showModal() даёт модальность, фокус-ловушку и Esc бесплатно, а атрибут popover — light dismiss по клику снаружи и корректный top layer. Об этом — в Семантическом HTML.

Drag с pointer-событиями

slider.addEventListener('pointerdown', (e) => {
  slider.setPointerCapture(e.pointerId); // все события этого указателя — наши, даже
  start = e.clientX;                     // если курсор ушёл далеко за пределы элемента
});
slider.addEventListener('pointermove', (e) => {
  if (slider.hasPointerCapture(e.pointerId)) move(e.clientX - start);
});
slider.addEventListener('pointerup', (e) => slider.releasePointerCapture(e.pointerId));

setPointerCapture убирает целый набор костылей: слушатели на document, проверки buttons, потерю drag при быстром движении мыши. Pointer Events покрывают мышь, тач и стилус одним кодом, а e.pointerType скажет, что именно это было.

rAF-троттлинг вместо debounce для скролла

function rafThrottle(fn) {
  let scheduled = false, lastArgs;
  return (...args) => {
    lastArgs = args;
    if (scheduled) return;
    scheduled = true;
    requestAnimationFrame(() => { scheduled = false; fn(...lastArgs); });
  };
}
// passive: true обязателен — иначе браузер ждёт обработчик перед прокруткой
window.addEventListener('scroll', rafThrottle(updateHeader), { passive: true });

Семантика разная: debounce — «один раз, когда перестанут дёргать» (поиск по вводу), throttle — «не чаще раза в N мс» (телеметрия), rafThrottle — «ровно раз на кадр» (всё визуальное). А для чисто визуальных эффектов при скролле современный ответ — scroll-driven animations в CSS (animation-timeline: view()): они живут в потоке композитора и не трогают JS вообще.

React и DOM: где абстракция протекает

React не вешает слушатели на ваши элементы. Он использует делегирование: с версии 17 все события прикрепляются к контейнеру корня (тому, что передан в createRoot), а не к document, как было в React 16. Это чинит вложенные React-приложения, но создаёт свои сюрпризы.

// 1. Смешение слоёв: нативный stopPropagation убивает React-обработчик — событие
//    не дойдёт до корневого контейнера, и onClick выше по дереву не вызовется.
node.addEventListener('click', (e) => e.stopPropagation());   // ❌

// 2. Порталы: событие всплывает по ДЕРЕВУ REACT, а не по DOM. <Modal> отрисован
//    в document.body, но onClick React-родителя всё равно сработает.

// 3. Правильная подписка на нативные события — с AbortController
useEffect(() => {
  const ac = new AbortController();
  window.addEventListener('keydown', onEscape, { signal: ac.signal });
  return () => ac.abort();
}, [onEscape]);

// 4. Наблюдатель на узле: ref-колбэк в React 19 умеет возвращать cleanup
<div ref={(node) => {
  if (!node) return;
  const ro = new ResizeObserver(([entry]) => setWidth(entry.contentRect.width));
  ro.observe(node);
  return () => ro.disconnect();
}} />

Ещё различия, о которые спотыкаются: onFocus/onBlur в React реализованы поверх нативных focusin/focusout и всплывают (в отличие от нативных), а onScroll с React 17 намеренно не всплывает — как в платформе. e.nativeEvent даёт доступ к настоящему событию. Часть событий (load, error на медиа, scroll) React не делегирует вовсе, а вешает напрямую — именно потому, что они не всплывают.

Подробнее о модели React — в React: основы и Хуках и паттернах.

Типичные ошибки

  1. innerHTML += в цикле. Пересобирает поддерево целиком, теряет слушатели и состояние полей, O(n²) по времени. Используйте insertAdjacentHTML('beforeend', …) или фрагмент.
  2. Чтение геометрии внутри цикла записи. Forced reflow на каждой итерации; разделяйте фазы.
  3. addEventListener со стрелкой и попытка её снять. Ссылки нет — слушатель бессмертен. Держите ссылку или используйте signal.
  4. stopPropagation() «на всякий случай». Ломает делегатов, аналитику и закрытие попапов.
  5. Обработчик scroll/touchmove без passive: true. Прямая потеря отзывчивости скролла, Lighthouse отдельно об этом ругается.
  6. event.target === button вместо closest(). Сломается, как только внутрь кнопки положат <span> или иконку.
  7. MutationObserver вместо нормального состояния. Наблюдение за собственным DOM — архитектурный запах.
  8. Наблюдатели без disconnect() и слушатели без снятия. Детачнутые узлы копятся, вкладка распухает за час работы.
  9. Проверка «клик внутри» через contains(e.target) при наличии порталов/Shadow DOM. composedPath() решает.
  10. Полагаться на e.currentTarget в асинхронном коде. После диспетчеризации поле null — сохраните нужное значение в переменную синхронно.

Мини-итог

  • DOM — не разметка, а API поверх живого дерева. Различайте Node и Element, живые коллекции и снимки; предпочитайте querySelectorAll и closest.
  • Мутации батчатся: DocumentFragment, replaceChildren, <template>. textContent — безопасен, innerHTML — нет, санитизация обязательна.
  • «Тормозит» почти всегда означает forced layout: чтение геометрии между записями. Разделяйте фазы чтения и записи, ищите «Forced reflow» в панели Performance.
  • Событие проходит путь дважды: перехват сверху вниз, всплытие снизу вверх. target — где случилось, currentTarget — кто слушает, composedPath() — весь путь.
  • Делегирование — не микрооптимизация, а способ жить с динамическим списком: один слушатель, closest() и data-action.
  • Наблюдатели заменяют опрос: IntersectionObserver — видимость, ResizeObserver — размеры (а лучше @container), MutationObserver — чужой DOM, PerformanceObserver — метрики.
  • Каждая подписка — потенциальная утечка. AbortController + disconnect() в cleanup, проверка счётчиков DOM Nodes и JS event listeners в Performance monitor.

Источники

Что дальше

Мы писали код, который браузер выполняет напрямую. В реальном проекте между вашим исходником и браузером стоит сборщик: он превращает модули в бандлы, выкидывает мёртвый код, режет приложение на чанки и поднимает dev-сервер с мгновенным HMR. Без понимания этого слоя невозможно ни ускорить загрузку, ни разобраться, почему «в дев-режиме работает, а в проде нет».

Сборка фронтенда: Vite, бандлеры, tree-shaking, code splitting, dev-server

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов