Наблюдение за DOM и производительность

Пятый, «операционный» выпуск серии. Четыре предыдущих построили интерфейс: логика (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.

Вы меняетеStyleLayout (reflow)PaintComposite
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 canvasResizeObserverразмер, а не позиция
Перехват вставленных узлов (редактор, 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).
  • IntersectionObserver lazy-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 для вашей целевой аудитории.

👤 Зарегистрируйтесь, чтобы добавить материал в закладки.