DOM и события: работа с деревом, делегирование, всплытие, наблюдатели
Есть распространённая иллюзия: «DOM — это HTML, который я написал». Нет. HTML — текст,
который парсер прочитал один раз при загрузке. DOM — это живой объектный интерфейс к
дереву документа, реализованный внутри renderer-процесса на C++, а из JavaScript видимый
через тонкую обёртку. Каждое обращение el.style.width = '10px' — не запись в поле объекта,
а вызов через границу двух миров, который может дёрнуть пересчёт стилей всего документа.
Отсюда две практические истины, вокруг которых крутится вся статья.
- DOM дорог не потому, что «медленный», а потому, что он синхронен с рендерингом.
Читая
offsetHeight, вы заставляете браузер бросить всё и посчитать геометрию — прямо сейчас, посреди вашего цикла. - События — это не колбэки на элементах, а протокол распространения по дереву. Пока вы
думаете «повесил обработчик на кнопку», вы не сможете объяснить, почему попап закрывается
от клика внутри себя, почему
focusне ловится делегатом и почемуstopPropagation()в стороннем виджете сломал вашу аналитику.
Про то, как браузер вообще строит дерево и рисует кадр, — в Как работает браузер. Здесь мы работаем с уже построенным деревом руками.
Что такое DOM на самом деле
DOM — спецификация WHATWG (https://dom.spec.whatwg.org/), описывающая иерархию классов. Знать её структуру полезно: половина «магии» — просто наследование.
Три вывода из диаграммы, которые экономят часы отладки:
EventTarget— корень всего. Слушателя вешают не только на элемент, но и наwindow,document,XMLHttpRequest,AbortSignal,MediaQueryList,WebSocket,ServiceWorker— везде один и тот жеaddEventListenerс одинаковыми опциями.Node≠Element.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);
});
Подводные камни делегирования.
- Невсплывающие события (таблица выше) делегируются только через
capture: trueили через всплывающие аналоги. event.target— всегда элемент, но часто вложенный:<span>или<svg>-иконка внутри кнопки. Всегдаclosest()и работа с найденнымmatch, никогдаtarget === btn.- Клик по
<button disabled>не генерирует события вовсе, а<fieldset disabled>глушит вложенные поля. Не рассчитывайте «повесим и проверимdisabled» — проверять будет нечего. - Делегат на
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 раз в секунду.
во вьюпорте?"} Q1 -->|да| IO["IntersectionObserver
ленивая загрузка, бесконечный скролл,
показ рекламы, sticky-детект"] Q1 -->|нет| Q2{"Изменение размера
элемента?"} Q2 -->|да| Q2a{"Нужно менять только стили?"} Q2a -->|да| CQ["CSS @container
— без JS вообще"] Q2a -->|нет, нужна логика| RO["ResizeObserver
перерисовка canvas, чартов,
пересчёт числа колонок"] Q2 -->|нет| Q3{"Изменение самого DOM
чужим кодом?"} Q3 -->|да| MO["MutationObserver
интеграция со сторонним виджетом,
полифилы, автотесты"] Q3 -->|нет| Q4{"Метрики производительности?"} Q4 -->|да| PO["PerformanceObserver
LCP, CLS, INP, long tasks, LoAF"] Q4 -->|нет| EV["Обычное событие или
реактивное состояние фреймворка"]
IntersectionObserver
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' }); // точные физические пиксели
ResizeObserver loop completed with undelivered notifications— не мистика: вы в колбэке изменили размер наблюдаемого элемента, браузер пересчитал и снова позвал колбэк. Спецификация обрывает цикл и шлётerrorвwindow. Лечение: не менять размеры того, за чем наблюдаете, либо гейтить изменение проверкой «а изменилось ли реально».- Колбэк вызывается до отрисовки кадра (шаг 3 конвейера, см.
Как работает браузер) — поэтому обновление
размеров из него не даёт мигания, в отличие от
resize+setTimeout. - Если результат — только 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: основы и Хуках и паттернах.
Типичные ошибки
innerHTML +=в цикле. Пересобирает поддерево целиком, теряет слушатели и состояние полей,O(n²)по времени. ИспользуйтеinsertAdjacentHTML('beforeend', …)или фрагмент.- Чтение геометрии внутри цикла записи. Forced reflow на каждой итерации; разделяйте фазы.
addEventListenerсо стрелкой и попытка её снять. Ссылки нет — слушатель бессмертен. Держите ссылку или используйтеsignal.stopPropagation()«на всякий случай». Ломает делегатов, аналитику и закрытие попапов.- Обработчик
scroll/touchmoveбезpassive: true. Прямая потеря отзывчивости скролла, Lighthouse отдельно об этом ругается. event.target === buttonвместоclosest(). Сломается, как только внутрь кнопки положат<span>или иконку.MutationObserverвместо нормального состояния. Наблюдение за собственным DOM — архитектурный запах.- Наблюдатели без
disconnect()и слушатели без снятия. Детачнутые узлы копятся, вкладка распухает за час работы. - Проверка «клик внутри» через
contains(e.target)при наличии порталов/Shadow DOM.composedPath()решает. - Полагаться на
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.
Источники
- DOM Standard, WHATWG — первоисточник по узлам и событиям
- UI Events, W3C — модель распространения, порядок событий
- MDN: Event delegation,
composedPath() - Paul Irish: What forces layout / reflow
- web.dev: Long Animation Frames API, Optimize INP
- web.dev: IntersectionObserver v2, ResizeObserver
- Chrome DevTools: Memory, Console utilities API
- DOMPurify, Sanitizer API explainer
- React: событийная система с версии 17
- web-vitals — метрики с атрибуцией
Что дальше
Мы писали код, который браузер выполняет напрямую. В реальном проекте между вашим исходником и браузером стоит сборщик: он превращает модули в бандлы, выкидывает мёртвый код, режет приложение на чанки и поднимает dev-сервер с мгновенным HMR. Без понимания этого слоя невозможно ни ускорить загрузку, ни разобраться, почему «в дев-режиме работает, а в проде нет».
Сборка фронтенда: Vite, бандлеры, tree-shaking, code splitting, dev-server