Пятый, «операционный» выпуск серии. Четыре предыдущих построили интерфейс: логика (fetch и асинхронность в «Современном JavaScript»), данные («Fetch API»), представление («Современный CSS»), компонентная модель («Web Components») и живой канал («Потоки в браузере: SSE и WebSocket»). Про live-компонент мы даже писали: слушает SSE, обновляет textContent. Теперь честный вопрос: почему такая страница с чатом-лентой на 5 000 сообщений начинает тормозить, а карусель с картинками жжёт батарею и сдвигает вёрстку при загрузке?
Ответ лежит не в алгоритмах JS, а в том, как браузер превращает DOM в пиксели, и в том, как вы об этом узнаете. Эта статья — про два дела сразу:
- Наблюдать за DOM без setInterval —
IntersectionObserver,MutationObserver,ResizeObserver(три штатных наблюдателя) и делегирование событий. - Не ломать производительность — модель кадров (reflow/repaint/composite),
requestAnimationFrame, layout thrashing, виртуализация длинных списков и измерение результата через Core Web Vitals (LCP / CLS / INP).
Всё — стандартные API браузера, без библиотек. Точные пороги/единицы Core Web Vitals мы приводим из стабильной документации Google/MDN, а поведенческие тонкости движков (дефолт passive у touch-событий, content-visibility) сверяйте на caniuse для вашей аудитории — номера релизов там, где мы их не ставили, опущены сознательно.
Часть I. Как браузер рисует кадр1. Конвейер рендеринга
Каждый «тик» экрана (~16.7 мс при 60 Гц; 8.3 мс при 120 Гц) браузер проходит по стадиям:
JavaScript → RequestAnimationFrame → Style → Layout → Paint → Composite → GPU → пиксели
(вы) (ваш rAF-колбэк) (какие (геометрия (покрытие (сборка слоёв
стили) и позиции) пикселей) в картинку)
Стадии не всегда идут по полной: смена цвета текста (color) не трогает Layout, только Paint; смена transform/opacity может уйти прямо в Composite без Layout и Paint — именно поэтому анимации на transform/opacity дешевле, чем анимация width/left.
| Вы меняете | Style | Layout (reflow) | Paint | Composite |
|---|---|---|---|---|
color, background, visibility | ✔ | ✘ | ✔ | иногда |
width, height, top/left, padding, margin, контент | ✔ | ✔ | ✔ | ✔ |
transform, opacity | ✔ | ✘ | ✘ | ✔ (свой слой) |
filter, box-shadow анимируемые | ✔ | ✘ | ✔ | ✔ |
Главный вывод для производительности: избегайте Layout (reflow) там, где можно уйти в Composite (transform), и не заставляйте браузер считать геометрию синхронно (см. §2).
2. Forced synchronous layout (layout thrashing)
Браузер копит изменения стилей и применяет Layout пакетом, когда действительно нужен результат. Но есть набор свойств, которые требуют актуальной геометрии прямо сейчас — и их чтение форсирует Layout:
el.style.height = '200px'; // запись стиля
const h = el.offsetHeight; // ЧТЕНИЕ геометрии → браузер обязан сделать Layout СЕЙЧАС
Если такие read/write чередуются в цикле по 1000 элементов — вы делаете 1000 Layout вместо одного. Типичный read-триггер: offsetWidth/Height/Top/Left, clientWidth/Height, getBoundingClientRect(), getComputedStyle(), scrollTop/scrollHeight. Write-триггер: любой element.style.*, добавление/удаление класса, изменение текста/узла.
Правило: разделите фазы — сначала все чтения, потом все записи (см. §11, batching).
3. Paint-слой и GPU
transform: translateZ(0) / will-change: transform поднимают элемент в отдельный composited слой — анимация идёт без ре-пейнта соседней области. Но каждый слой — это видеопамять; бесконтрольный will-change «на всём» даёт обратный эффект (рост памяти, просадки). Правило: will-change ставят перед анимацией и снимают после; постоянный will-change почти всегда ошибка.
Часть II. Три наблюдателя вместо polling
Раньше «видно ли элемент / изменился ли блок» решали setInterval + getBoundingClientRect() в цикле. Это и polling-antin-pattern (см. часть про SSE, §1), и источник thrashing-а (чтение геометрии по таймеру). Теперь у браузера есть три штатных наблюдателя — они работают асинхронно и «спят», пока не сработали.
4. IntersectionObserver — видимость и lazy-load
Сообщает, когда элемент пересекается с областью наблюдения (viewport или заданным корнем). Идеален для: ленивой загрузки картинок/видео, «догрузки хвоста списка» (infinite scroll), отсчёта «пост просмотрен», паузы видео вне экрана, анимации появления.
const io = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
// «виден» (с учётом threshold)
const el = entry.target;
if (el.dataset.src) { el.src = el.dataset.src; delete el.dataset.src; } // lazy img
el.classList.add('in-view');
io.unobserve(el); // сработал — наблюдаем дальше только нужное
}
}
}, {
root: null, // viewport
rootMargin: '200px', // начать грузить за 200px до входа в экран
threshold: [0, 0.25, 0.5], // события на 0/25/50% видимости
});
document.querySelectorAll('img[data-src]').forEach((img) => io.observe(img));
// снимаете наблюдение при размонтировании компонента: io.disconnect();
Важно:
- callback батчится — придёт сразу за несколько элементов (не по одному), и выполняется после Layout, но до Paint; в нём безопасно читать геометрию (уже актуальную).
entry.intersectionRatio— доля;entry.isIntersecting— булево по текущему состоянию;entry.boundingClientRect/rootBounds— геометрия (read, но в этом колбэке бесплатная).- обязательно
unobserve/disconnect, когда наблюдение не нужно (см. §10 и часть про WC) — иначе observer держит ссылки и «не отпускает» удалённые узлы до GC. threshold: 0+rootMargin: '0px'сработает «чуть показался». Для «полностью виден» нуженthreshold: 1.- Для «показался на N секунд» (viewability-реклама) IO один не годится: он даёт пересечение, а не длительность — добавляйте таймер внутри
isIntersecting.
5. ResizeObserver — размеры блока
Сообщает, когда меняется размер наблюдаемого элемента (и изначально при подписке). Нужен для адаптивных компонентов без медиа-запросов по всему окну (контейнерные компоненты), для пересчёта canvas, для «влезает ли текст».
const ro = new ResizeObserver((entries) => {
for (const entry of entries) {
// entry.contentRect — новые внутренние размеры (CSS px)
drawChartTo(entry.target, entry.contentRect.width, entry.contentRect.height);
}
});
ro.observe(document.querySelector('#chart-box'));
// ro.unobserve(el); ro.disconnect();
Ключевая грабля: ResizeObserver loop error. Если внутри callback вы меняете размер того же (или родителя) элемента так, что ResizeObserver снова срабатывает — браузер бесконечный цикл прерывает с сообщением ResizeObserver loop completed with undelivered notifications. Это не всегда баг (часто это «размер устоялся»), но сигнал, что надо вынести запись в requestAnimationFrame и задать max-width, чтобы цикл сошёлся. Дефолт-«срабатывает на первый размер» при observe — не сюрприз, а фича: вы сразу получаете текущие размеры.
6. MutationObserver — изменения DOM
Самый «дорогой» из трёх: следит за добавленными/удалёнными/изменёнными узлами, атрибутами, текстом.
const mo = new MutationObserver((mutations) => {
for (const m of mutations) {
if (m.type === 'childList') {
m.addedNodes.forEach((n) => n.nodeType === 1 && hydrate(n));
// m.removedNodes — убрали
} else if (m.type === 'attributes' && m.attributeName === 'data-open') {
onToggle(m.target, m.target.hasAttribute('data-open'));
}
}
});
mo.observe(document.querySelector('#list'), {
childList: true, subtree: true, attributes: true,
attributeFilter: ['data-open'], // следить только за этим атрибутом
});
// mo.disconnect(); mo.takeRecords(); // takeRecords — забрать очередь, не вызывая callback
Правила, чтобы MutationObserver не убил страницу:
- сужайте опции:
attributeFilter,characterData:falseесли не нужен текст,subtree:trueтолько когда правда нужно всё дерево; - батчьте работу: мутаций прилетает пачкой; не делайте Layout-чтение на каждую мутацию;
- отключайте на время своей же правки (
disconnect()→ изменить DOM →observe()снова) — иначе вы ловите собственные изменения и рекурсируетесь; takeRecords()— сбросить накопленное перед размонтированием (избежать «позднего» callback на мёртвом компоненте).
7. Сравнение: что где брать
| Задача | API | Почему |
|---|---|---|
| Lazy-load картинки/видео, «показался» | IntersectionObserver | асинхронно, без чтения геометрии в цикле |
| Адаптив блока к контейнеру, resize canvas | ResizeObserver | размер, а не позиция |
| Перехват вставленных узлов (редактор, live-домен) | MutationObserver | мутации DOM |
| «Прокрутили до низа страницы» | scroll + debounce / IO на стопере | scroll без rAF = thrashing |
| Реальное время видимости поста | IO + document.visibilityState | и пересечение, и вкладка |
Часть III. Делегирование событий: один слушатель вместо тысяч
8. Модель
Навешивать addEventListener на каждую строку списка — память, время монтажа, и «новое» не получает слушателя. Вместо этого — один слушатель на предке, а цель находите через event.target.closest(selector):
// ОДИН слушатель на контейнере вместо N на кнопках:
list.addEventListener('click', (e) => {
const btn = e.target.closest('[data-action]'); // всплыли до кнопки с data-action
if (!btn || !list.contains(btn)) return; // не наше — игнор
const action = btn.dataset.action;
if (action === 'remove') rows.get(btn.closest('li')).remove();
});
closest идёт вверх по DOM, поэтому клик «вглубь» кнопки (по иконке внутри) всё равно найдёт нужную — это и есть суть делегирования.
9. Всплытие, перехват, passive, signal
- Фаза:
useCapture:trueловит на спуске вниз (сверху вниз), по умолчаниюfalse— на всплытии (bubble). Для делегирования обычно bubble-фаза. - Passive: для
touchstart/touchmove/wheelбраузер по умолчанию (в современных Chrome) ставитpassive:true— значитpreventDefault()там не сработает и вы не заблокируете скролл зря. Явно пишите{ passive: true }, еслиpreventDefaultне нужен — браузер перестанет ждать колбэк и уберёт «задержку» скролла. ЕслиpreventDefaultнужен (свой свайп) —{ passive: false }, но тогда вы в критическом пути жеста. - Снятие через
AbortController(чистый разбор из «Современного JavaScript» и «Fetch»): один контроллер на компонент, и все его слушатели снимаются разом:
const ac = new AbortController();
document.addEventListener('keydown', onKey, { signal: ac.signal });
window.addEventListener('resize', onResize, { signal: ac.signal, passive: true });
// в disconnectedCallback компонента: ac.abort(); // все слушатели с этим signal сняты
10. Делегирование и Shadow DOM
В open/closed shadow-дереве нативные события не выходят наружу через границу, а наружный e.target ретаргетится на хост. Отсюда два вывода (мост к «Web Components» и «Fetch» §XSS-событий):
- слушать «кнопку внутри компонента» снаружи через делегирование на document нельзя — она не всплывёт; нужен
CustomEvent(..., {bubbles:true, composed:true})изнутри, и тогда наружу придёт событие сtarget === <хост>; - если нужна истинная внутренняя цель —
event.composedPath()[0](первый элемент пути, уже сквозь shadow).
document.addEventListener('click', (e) => {
const insideShadow = e.composedPath().find((n) => n instanceof Element && n.shadowRoot);
// composedPath даёт массив целей от самой внутренней до window — единственный способ
// корректно обработать «клик внутри web-компонента» на глобальном слушателе.
});
Это же чинит классический паттерн «клик вне dropdown — закрыть»: проверяйте, что !event.composedPath().includes(dropdown), а не только event.target !== dropdown (иначе клик по внутреннему узлу ошибочно «закроет»).
Часть IV. requestAnimationFrame, batching и thrashing
11. Почему setTimeout(fn, 16) — плохо, а rAF — правильно
requestAnimationFrame синхронизирует ваш код с реальным началом кадра: браузер сам решит «перед Style/Layout/Paint выполни колбэк», учтёт, что вкладка скрыта (не жжёт CPU в фоне — в отличие от setInterval), и схлопнет несколько rAF-запросов до одного кадра.
let y = 0;
function frame() {
y += 2;
box.style.transform = `translateY(${y}px)`; // запись стиля в начале кадра — хорошо
if (y < 500) requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
Для «пропустить кадр, сделать работу» — requestIdleCallback (работа вне критического пути: аналитика, предзагрузка, сериализация). Он с таймаутом и не везде одинаково предсказуем — используйте для некритичного.
12. Layout thrashing → batched read/write
Классический антипаттерн (1000 Layout) и его чинка:
// ПЛОХО: read/write чередуются → Layout на каждой итерации
items.forEach((el) => {
el.style.height = el.offsetHeight + 'px'; // write… потом read → форс Layout
});
// ХОРОШО: фаза чтений, потом фаза записей
const heights = items.map((el) => el.offsetHeight); // только чтения (один Layout на батч)
items.forEach((el, i) => { el.style.height = heights[i] + 'px'; }); // только записи (один Layout на конец)
В реальном UI на одном кадре обычно так и делят: reads (измерить) → writes (применить). Библиотеки анимации это и делают внутри rAF: читают геометрию до, пишут после, не смешивая.
13. Тонкие рычаги
scroll/resizeбез throttle = падение FPS: обработчик навешивают сpassive:true+ флагом «дождаться rAF» (не делать Layout-чтения в самом scroll-колбэке).contain: layout paint size(мост к «Современный CSS») ограничивает влияние reflow одной подобластью — браузер не пересчитывает всю страницу из-за чужого блока.content-visibility: auto+contain-intrinsic-size— самый дешёвый способ ускорить длинные страницы: браузер не делает Layout/Paint для блоков вне экрана до появления, а их размер берёт изcontain-intrinsic-size, не ломая полосу прокрутки. Идеально для ленты/чат-истории:
.chat-row {
content-visibility: auto;
contain-intrinsic-size: auto 72px; /* «пока» считаем строку ~72px, автокорректирует */
}
Часть V. Виртуализация длинного списка своими руками
content-visibility (выше) — «дешёвый» способ. Настоящая виртуализация — в DOM только видимые строки + буфер; остальные «живут» в данных. Прокрутка пересчитывает, какой срез строк отрисовать. Два случая: фиксированная высота и динамическая.
14. Фиксированная высота (однородный список)
const ROW = 40; // px, фиксированная высота
const BUFFER = 10; // строк сверху/снизу сверх видимых
const viewport = document.querySelector('#viewport');
const scroller = document.querySelector('#scroller');
const total = rows.length; // например 50 000
// одна «прокладка» задаёт полную высоту скролла
scroller.style.height = (total * ROW) + 'px';
function render() {
const scrollTop = viewport.scrollTop; // read (в rAF!)
const start = Math.max(0, Math.floor(scrollTop / ROW) - BUFFER);
const count = Math.ceil(viewport.clientHeight / ROW) + BUFFER * 2;
const end = Math.min(total, start + count);
// окно позиционируем transform'ом (composite, без reflow соседей)
window.style.transform = `translateY(${start * ROW}px)`;
// рендерим ТОЛЬКО slice [start, end)
const frag = document.createDocumentFragment();
for (let i = start; i < end; i++) frag.append(makeRow(rows[i]));
window.replaceChildren(frag); // XSS-safe (см. «Fetch»)
}
viewport.addEventListener('scroll', () => requestAnimationFrame(render), { passive: true });
Схема: viewport (overflow: auto) → scroller (высота = all × ROW) → window (transform translateY, содержит лишь видимые makeRow). 50 000 элементов в данных, 20–40 в DOM.
15. Динамическая высота
Точной ROW нет. Рабочий безбиблиотечный вариант:
- храните измеренные высоты и префиксные суммы (или «оценку» + домер);
- по
scrollTopбинарным поиском находите стартовый индекс; - меряйте после рендера в batched-read фазе (см. §12) и корректируйте
content-visibility/отступы; - для «догружаемого хвоста» (infinite) — стопер с
IntersectionObserver(§4): виден стопер → загрузить следующую страницу (черезfetch+ отмену черезAbortControllerиз «Fetch»).
const sentinel = new IntersectionObserver((e) => {
if (e[0].isIntersecting) loadNextPage(); // дошёл до конца — дотяни страницу
}, { rootMargin: '300px' });
sentinel.observe(stopper);
Полноценный «windowing» с произвольной высотой руками делать не советую (тот самый ResizeObserver loop из §5 и ошибки прокрутки при ресайзе) — там честнее взять виртуализатор. Но фиксированную высоту §14 реально и дёшево держать руками, а content-visibility ( Часть IV ) вообще решает половину кейсов без JS.
Часть VI. Core Web Vitals: LCP, CLS, INP
Производительность измеряют не «ощущениями», а тремя field-метриками (реальные пользователи, не лаб. Pro). Пороги «good» (стабильная документация Google web.dev):
| Метрика | «Хорошо» | Что мерит |
|---|---|---|
| LCP (Largest Contentful Paint) | ≤ 2.5 с | скорость загрузки biggest-контента (hero, заголовок, видео) |
| CLS (Cumulative Layout Shift) | ≤ 0.1 | суммарный «прыжок» вёрстки (смещение не-стабильных блоков) |
| INP (Interaction to Next Paint) | ≤ 200 мс | насколько отзывчив UI на клик/ввод (заменил FID в 2024) |
INP — это про вас и этот выпуск: он меряет задержку обработки взаимодействия (input → ответный кадр). Долгий JS, layout thrashing и 5 000 DOM-узлов на экране — всё роняет INP. LCP — про «критический контент» (preload, <img fetchpriority=high>, серверный HTML). CLS — про стабильные размеры (зарезервированное место, width/height у картинок, content-visibility/contain-intrinsic-size).
16. Собираем метрики через PerformanceObserver
// Универсальный «снимок» метрик → отправить в аналитику
function report(name, value) {
// beacon: navigator.sendBeacon('/vitals', JSON.stringify({ name, value }));
console.log(name, value);
}
new PerformanceObserver((list) => {
for (const e of list.getEntries()) report('LCP', e.startTime);
}).observe({ type: 'largest-contentful-paint', buffered: true });
let cls = 0;
new PerformanceObserver((list) => {
for (const e of list.getEntries()) {
if (!e.hadRecentInput) cls += e.value; // сдвиги после клика не в зачёт
}
report('CLS', cls);
}).observe({ type: 'layout-shift', buffered: true });
// INP: максимум processing+delay по всем взаимодействиям (упрощённо)
let inp = 0;
new PerformanceObserver((list) => {
for (const e of list.getEntries()) {
const dur = Math.max(0, e.processingStart - e.startTime)
+ Math.max(0, e.processingEnd - e.processingStart);
inp = Math.max(inp, dur);
}
report('INP', inp);
}).observe({ type: 'event', durationThreshold: 16, buffered: true });
buffered:true — забрать записи, случившиеся до регистрации observer (иначе потеряете ранние LCP/CLS). hadRecentInput у layout-shift — сдвиг после клика «легитимен», не в CLS.
17. Как чинить типичные провалы
- Плохой LCP → предзагрузите hero-ресурс (
<link rel=preload>),fetchpriority="high"на картинке, критический CSS инлайн, не lazy-load above-the-fold. - Плохой CLS → всегда
width/heightу медиа и мест под рекламу (зарезервировано), шрифтыfont-display: optional/preload (бороться с FOUT-сдвигом), вставляйте баннеры внизу, а не толкайте контент вниз,content-visibility+contain-intrinsic-size. - Плохой INP → разбейте долгий JS (≤50 мс таск) на
rAF/scheduler.yield/requestIdleCallback; делегирование и виртуализация (§3/§5) снижают объём работы на клик; уберите синхронный layout в обработчиках (§12).
Часть VII. Практика: виртуальная лента с lazy-img, делегированием и наблюдателями
Компонент-лента чата/фида на WC из прошлого выпуска: фикс-высота-виртуализация (§14) + IntersectionObserver для картинок (§4) + делегирование кликов (§8) + AbortController-очистка (§9) + отрисовка safe (textContent, см. «Fetch»).
class FeedList extends HTMLElement {
static observedAttributes = ['src'];
#io = null; #ac = null; #rows = []; #raf = 0;
constructor() { super(); this.attachShadow({ mode: 'open' }); }
async connectedCallback() {
const url = this.getAttribute('src');
if (!url || this.#ac) return;
this.#ac = new AbortController();
const { signal } = this.#ac;
this.#mount(); // skeleton + контейнер (один раз)
try {
const r = await fetch(url, { signal });
if (!r.ok) throw new Error('HTTP ' + r.status);
this.#rows = await r.json(); // [{id, title, img}]
} catch (err) {
if (err.name === 'AbortError') return; // отменили при disconnect
this.#err(); return;
}
this.#setupVirtual();
// один слушатель на весь список (делегирование) + abort-снять при disconnect
this.shadowRoot.querySelector('#viewport')
.addEventListener('click', (e) => this.#onClick(e), { signal });
this.shadowRoot.querySelector('#viewport')
.addEventListener('scroll', () => this.#scheduleRender(), { passive: true, signal });
}
disconnectedCallback() {
this.#io?.disconnect(); // снять IntersectionObserver
this.#ac?.abort(); // снять слушатели и висящий fetch
this.#ac = null; this.#io = null;
}
#mount() {
this.shadowRoot.innerHTML = `
<style>
:host { display: block; height: 60vh; }
#viewport { overflow: auto; height: 100%; }
#scroller { position: relative; }
#window { position: absolute; inset: 0 auto auto 0; will-change: transform; }
.row { display: flex; gap: .5rem; height: 40px; align-items: center;
content-visibility: auto; contain-intrinsic-size: auto 40px; }
.row img { width: 40px; height: 40px; object-fit: cover; border-radius: 4px; }
</style>
<div id="viewport"><div id="scroller"><div id="window"></div></div></div>`;
this.#vp = this.shadowRoot.querySelector('#viewport');
this.#sc = this.shadowRoot.querySelector('#scroller');
this.#win = this.shadowRoot.querySelector('#window');
}
#setupVirtual() {
const ROW = 40, BUFFER = 10;
this._cfg = { ROW, BUFFER };
this.#sc.style.height = (this.#rows.length * ROW) + 'px';
// lazy-load картинок в видимом окне + 300px буфер
this.#io = new IntersectionObserver((entries) => {
for (const e of entries) {
if (e.isIntersecting) {
const img = e.target;
if (img.dataset.src) { img.src = img.dataset.src; delete img.dataset.src; }
this.#io.unobserve(img);
}
}
}, { root: this.#vp, rootMargin: '300px', threshold: 0 });
this.#render();
}
#scheduleRender() {
if (this.#raf) return; // не плодить кадры (см. §11)
this.#raf = requestAnimationFrame(() => { this.#raf = 0; this.#render(); });
}
#render() {
const { ROW, BUFFER } = this._cfg;
const top = this.#vp.scrollTop; // read (уже в rAF — нет thrashing)
const start = Math.max(0, Math.floor(top / ROW) - BUFFER);
const end = Math.min(this.#rows.length, start + Math.ceil(this.#vp.clientHeight / ROW) + BUFFER * 2);
this.#win.style.transform = `translateY(${start * ROW}px)`; // composite
const frag = document.createDocumentFragment();
for (let i = start; i < end; i++) {
const it = this.#rows[i];
const row = document.createElement('div'); row.className = 'row';
row.dataset.id = it.id; // данные — в dataset, не в строке-HTML
const img = document.createElement('img');
img.alt = ''; img.loading = 'lazy';
if (it.img) img.dataset.src = it.img; // IO подставит src (см. §4)
const txt = document.createElement('span');
txt.textContent = it.title; // XSS-safe: ни одного innerHTML с данными
row.append(img, txt);
frag.append(row);
}
this.#win.replaceChildren(frag); // только slice в DOM
frag.querySelectorAll?.('img[data-src]').forEach((img) => this.#io.observe(img));
}
// делегирование: один слушатель, цель по closest (см. §8)
#onClick(e) {
const row = e.target.closest('.row');
if (!row) return;
this.dispatchEvent(new CustomEvent('select', {
bubbles: true, composed: true, // иначе наружу сквозь shadow не выйдет (§10)
detail: { id: row.dataset.id },
}));
}
#err() { this.shadowRoot.querySelector('#window').textContent = 'Не удалось загрузить'; }
}
customElements.define('feed-list', FeedList);
Что здесь завязано на серию:
fetchсAbortControllerи снятием приdisconnect(мост к «Fetch» §15).- делегирование +
composed:trueсобытие наружу (мост к «Web Components» §16–17 и §10). IntersectionObserverlazy-img +loading="lazy"вдвойне; снятие наблюдателя.- виртуализация фикс-высоты (§14) +
content-visibility/contain-intrinsic-sizeна строках (мост к «CSS» и §13) → CLS держится, INP не падает (только ~40 узлов в DOM). rAF-склейка скролла (без thrashing §12).- safe-render через
textContent(мост к «XSS» и «Fetch»). signalвaddEventListener— вся подписка снимается однимabort().
Чек-лист: готовность и грабли
- Не читайте геометрию сразу после записи стиля (read→write→read) — это forced synchronous layout. Делайте батчи: все чтения, потом все записи.
- Плохие триггеры Layout:
offset*,client*,getBoundingClientRect(),getComputedStyle(),scrollTop/scrollHeight. Не зовите их вscroll-колбэке и в цикле по 1000 узлов. - Анимации — на
transform/opacity(Composite), а не наwidth/left/top(Layout).will-changeставить временно, не «навсегда на всём». IntersectionObserverснимайте (unobserve/disconnect) при размонтировании — иначе держит удалённые узлы.rootMarginдля опережающей загрузки.ResizeObserver loop completed with undelivered notifications— обычно значит вы меняете размер внутри колбэка; вынесите запись вrAF, задайте ограничение — цикл сойдётся.MutationObserver: сузьте (attributeFilter,subtreeтолько когда надо), отключайте на время своей правки,takeRecords()перед размонтированием.- Не навешивайте слушатель на каждую строку — делегируйте на контейнер +
closest. Снимайте черезAbortController.signal. touchstart/wheel:passive:true, если не нуженpreventDefault— иначе тормозите скролл.- Shadow DOM + делегирование: внутренние события не всплывают наружу; своя цель через
composedPath()[0]; наружу —CustomEvent({bubbles,composed:true}). requestAnimationFrameвместоsetInterval/setTimeoutдля визуала (учитывает скрытую вкладку, схлопывает кадры); для некритичной работы —requestIdleCallback.- Длинные списки:
content-visibility:auto+contain-intrinsic-size(дёшево), или виртуализация (только видимые + буфер;transformдля сдвига окна). Не держите 50 000 узлов в DOM. scroll/resize— throttle через «флаг + rAF» илиpassive, не делать Layout-чтение в самом обработчике.- Core Web Vitals: INP ≤ 200 мс требует разбивать долгий JS (< 50 мс таск). LCP ≤ 2.5 с — preload/
fetchpriority/не lazy-load above-the-fold. CLS ≤ 0.1 — резервировать размеры у медиа и баннеров. - Меряйте
PerformanceObserverсbuffered:true, а не только «в лаборатории»;hadRecentInputу layout-shift не в зачёт CLS. - WC-компонент: каждый observer/listener/timer/fetch, заведённый в
connectedCallback, снимается вdisconnectedCallback(одинAbortController+disconnect()наблюдателей). - Рендер чужих данных — только
textContent/insertAdjacentText; ниinnerHTMLиз строки с API (см. «Fetch»/«XSS»).
Материал носит образовательно-практический характер. API (
IntersectionObserver,ResizeObserver,MutationObserver,requestAnimationFrame,PerformanceObserver, Core Web Vitals с INP) соответствуют актуальным спецификациям/документации MDN и web.dev на момент написания; поведенческие особенности движков (дефолтpassive,content-visibility,scheduler.yield) и точные пороги метрик сверяйте на MDN и caniuse для вашей целевой аудитории.
👤 Зарегистрируйтесь, чтобы добавить материал в закладки.
