Back

Трюки оптимизации производительности фронтенда, о которых мы забыли

Трюки оптимизации производительности фронтенда, о которых мы забыли

Самый верный способ замедлить современный фронтенд — это предположить, что фреймворк сам позаботится о производительности. Большинство низкоуровневых техник, которые определяли скорость сайтов десять лет назад — явное указание размеров изображений, отложенная загрузка сторонних скриптов, директивы font-display, ручные preconnect-подсказки — по-прежнему актуальны, но теперь между вами и платформой стоит фреймворк, и его абстракции дают течь именно там, где Lighthouse с удовольствием выставит предупреждения. В 2019 году ButterCMS писал, что «браузеры скоро поддержат ленивую загрузку нативно». Это будущее наступило, стало базовым стандартом — и тихо превратилось в то, о чём мы перестали думать. В этой статье мы разберём фундаментальные принципы производительности фронтенда, которые мы делегировали фреймворкам, и производственные сбои, которые возникают, когда это делегирование ломается.

Ключевые выводы

  • Нативный атрибут loading="lazy" поддерживается всеми основными браузерами начиная с Safari 15.4 (март 2022 года), поэтому любая обёртка на основе Intersection Observer, написанная до этой даты, — мёртвый код, увеличивающий размер бандла.
  • Google Fonts умеет добавлять font-display: swap в отдаваемый CSS, однако пользовательские блоки @font-face в вашем собственном CSS не наследуют это поведение — каждый из них потенциально вызывает мигание невидимого текста на медленных соединениях.
  • setTimeout(fn, 0) запускается в следующем слоте очереди задач и может выполниться прямо во время пользовательского взаимодействия; requestIdleCallback ждёт подлинного периода простоя, что делает его правильным примитивом для несрочной работы.
  • Используйте defer для скриптов, работающих с DOM, и async только для по-настоящему независимых скриптов — потому что async-скрипты выполняются в порядке получения от сети, а не в порядке их расположения в документе.
  • После того как в марте 2024 года INP заменил FID в качестве метрики Core Web Vitals, неограниченные обработчики scroll и resize, блокирующие основной поток, стали сигналом ранжирования, а не просто проблемой плавности.

Явное указание width/height у изображений по-прежнему предотвращает сдвиг макета

В React-приложениях, где размеры изображений приходят из API во время выполнения, браузер не имеет зарезервированного пространства для их размещения, поэтому каждое изображение, загружающееся после первой отрисовки, потенциально вызывает сдвиг макета — вне зависимости от того, корректно ли компонент изображений вашего фреймворка обрабатывает статические ресурсы. Cumulative Layout Shift — одна из метрик Core Web Vitals, определённых Google в документации web.dev по CLS, и видимый сбой здесь вполне конкретен: страница «прыгает» при загрузке изображения, и нажатие пользователя попадает не на ту кнопку.

Абстракция, дающая течь, — это компонент изображений фреймворка. <Image> в Next.js резервирует пространство, когда вы передаёте width и height, но ничего не делает для обычных тегов <img> в MDX-контенте, HTML, отрендеренном CMS, или любой разметке, которую компонент не обрабатывает. Современные браузеры вычисляют неявное значение aspect-ratio из атрибутов width и height (MDN описывает это поведение), поэтому размеры резервируют пространство даже тогда, когда CSS переопределяет отображаемый размер.

// До: размеры приходят во время выполнения, пространство не резервируется
<img src={product.imageUrl} alt={product.name} />

// После: атрибуты задают блок с соотношением сторон до загрузки изображения
<img
  src={product.imageUrl}
  alt={product.name}
  width={product.width}
  height={product.height}
  style={{ width: '100%', height: 'auto' }}
/>

Если API не возвращает размеры, задайте aspect-ratio на контейнере. В любом случае пространство должно существовать до того, как придут байты.

font-display: swap и preconnect для пользовательских шрифтов

Каждый блок @font-face без font-display: swap — это потенциальное мигание невидимого текста (FOIT) на медленных соединениях, когда абзац остаётся пустым на всё время загрузки шрифта. Дескриптор font-display управляет этим напрямую: значение swap немедленно отображает резервный текст и заменяет его пользовательским шрифтом после загрузки, вызывая мигание неоформленного текста (FOUT) вместо FOIT, как описано в справочнике MDN по font-display.

Утечка абстракции здесь — следствие делегирования. Google Fonts умеет добавлять font-display: swap в отдаваемый CSS, когда URL таблицы стилей содержит соответствующий параметр display, поэтому команды, использующие хостируемую таблицу стилей, никогда об этом не думают — а затем пишут собственные блоки @font-face для фирменных шрифтов, которые не наследуют это поведение. Самостоятельно размещённый шрифт без этого дескриптора отдаёт FOIT каждому посетителю с холодным кэшем.

Самостоятельный хостинг также устраняет preconnect, который неявно подразумевался при использовании таблицы стилей Google. Руководство web.dev по ранним сетевым соединениям рекомендует устанавливать preconnect к источнику шрифтов, чтобы DNS-, TCP- и TLS-рукопожатия завершились до того, как URL шрифта будет обнаружен в CSS.

@font-face {
  font-family: "BrandSans";
  src: url("/fonts/brand-sans.woff2") format("woff2");
  font-display: swap; /* резервный текст отображается немедленно, без FOIT */
}
<link rel="preconnect" href="https://fonts.cdn.example.com" crossorigin />

Проверьте каждый блок @font-face, написанный вручную. Привычка использовать хостируемый CSS скрывает те, которые требуют исправления.

preconnect и dns-prefetch для сторонних источников

Бандлеры и фреймворки обрабатывают preconnect для своих CDN-источников, но сторонние аналитические эндпоинты, CDN для изображений и сервисы A/B-тестирования невидимы для шага сборки — их DNS-запросы происходят в момент обращения, если только вы не добавите <link rel="preconnect"> вручную. ButterCMS точно описал этот механизм ещё в 2019 году: preconnect говорит браузеру выполнить DNS-запрос, начальное соединение и TLS-согласование «как можно раньше, а не тогда, когда тег script будет обнаружен».

Стоимость DNS- и TLS-рукопожатий никуда не делась; фреймворк просто перестал напоминать вам о ней. Эндпоинт Segment, источник Cloudinary или сторонний менеджер тегов — каждый из них требует отдельной установки соединения, которая блокирует ресурс за ним. Используйте preconnect для источников, к которым вы точно обратитесь в начале загрузки, и dns-prefetch как более лёгкую подсказку для источников, к которым вы можете обратиться, — поскольку preconnect открывает полное соединение, за которое вы платите вне зависимости от того, используется оно или нет. web.dev рассматривает компромисс между этими двумя подсказками.

<!-- Критически важный сторонний источник: открываем полное соединение сейчас -->
<link rel="preconnect" href="https://cdn.imagecdn.example" crossorigin />

<!-- Вероятный, но не гарантированный источник: только разрешаем DNS -->
<link rel="dns-prefetch" href="https://analytics.example.com" />

Размещайте эти теги в начале <head>, перед скриптами и таблицами стилей, которые инициируют запросы.

Нативный loading="lazy" заменил вашу обёртку на Intersection Observer

Нативный loading="lazy" поддерживается всеми основными браузерами с момента выхода Safari 15.4 в марте 2022 года — любая обёртка на основе Intersection Observer, написанная до этой даты, теперь является мёртвым кодом, увеличивающим размер бандла и поверхность поддержки. Chrome добавил поддержку в версии 77 (август 2019 года), Firefox — в версии 75 (апрель 2020 года), согласно таблице совместимости браузеров в справочнике MDN по элементу img.

Утечка здесь историческая, а не специфичная для фреймворка. В годы до того, как атрибут стал базовым стандартом, кодовые базы накапливали хуки useLazyImage и компоненты <LazyImage>, которые до сих пор поставляются в продакшн — запуская по одному наблюдателю на изображение, удерживая refs и вызывая повторный рендеринг при пересечении — чтобы делать то, что браузер теперь делает нативно и вне основного потока. Тот же атрибут работает для iframe, что важно для встроенных карт и видеоплееров ниже линии сгиба.

// До: самодельный наблюдатель, который платформа сделала избыточным
function LazyImage({ src, alt }) {
  const ref = useRef(null);
  const [visible, setVisible] = useState(false);
  useEffect(() => {
    const io = new IntersectionObserver(([e]) => {
      if (e.isIntersecting) setVisible(true);
    });
    io.observe(ref.current);
    return () => io.disconnect();
  }, []);
  return <img ref={ref} src={visible ? src : undefined} alt={alt} />;
}

// После: браузер обрабатывает это сам, вне основного потока
<img src={src} alt={alt} loading="lazy" width={800} height={600} />;

Не забывайте указывать явные размеры — изображения с ленивой загрузкой вызывают сдвиг макета ничуть не реже, чем загружаемые сразу.

defer и async для сторонних скриптов

Практическое правило выбора: используйте defer для любого скрипта, который читает или изменяет DOM, и async только для по-настоящему самодостаточных скриптов — потому что async-скрипты выполняются в порядке получения от сети, а не в порядке расположения в документе, и два async-скрипта с зависимостью между ними будут конкурировать. Спецификация HTML Living Standard для элемента script определяет, что defer-скрипты выполняются после завершения парсинга в порядке документа, тогда как async-скрипты запускаются сразу после завершения загрузки.

Утечка здесь социальная, а не техническая: кто-то вставляет сниппет аналитики от вендора в <head> точно так, как показано в инструкции вендора по копированию, без каких-либо атрибутов, и обычный <script> блокирует парсинг до тех пор, пока не скачается и не выполнится. Видимый сбой — задержка взаимодействия. Когда сторонние скрипты блокируют взаимодействие, записи сессий показывают многократные нажатия на один и тот же элемент управления — классический паттерн «rage-click».

АтрибутВремя выполненияГарантия порядкаПрименение
нетНемедленно блокирует парсерПорядок документаПочти никогда
asyncСразу после загрузкиПорядок получения от сетиНезависимая аналитика
deferПосле завершения парсингаПорядок документаВсё, что работает с DOM
<!-- До: блокирует парсер, задерживает первую отрисовку и взаимодействие -->
<script src="https://vendor.example/analytics.js"></script>

<!-- После: независимый скрипт, никогда не блокирует парсер -->
<script src="https://vendor.example/analytics.js" async></script>

requestIdleCallback вместо setTimeout(fn, 0)

setTimeout(fn, 0) планирует работу в следующем слоте очереди задач, который может оказаться прямо в середине пользовательского взаимодействия; requestIdleCallback ждёт подлинного периода простоя, что делает его правильным примитивом для инициализации аналитики, предварительной гидратации и пакетной отправки телеметрии. Это различие задокументировано в справочнике MDN по requestIdleCallback: колбэк срабатывает в периоды простоя браузера и получает дедлайн, который можно проверить перед выполнением следующей части работы.

Это примитив, который большинство команд так и не освоили — setTimeout(fn, 0) стал рефлекторным идиомом «сделать это позже», хотя на самом деле он не уступает управление пользователю. После того как в марте 2024 года INP заменил FID в качестве метрики Core Web Vitals (согласно анонсу INP на web.dev), работа в основном потоке, выполняемая во время взаимодействия, — это уже не просто проблема плавности, а сигнал ранжирования. requestIdleCallback поддерживается в Chrome и Firefox, но не в Safari, поэтому используйте определение возможностей и предусмотрите запасной вариант.

function whenIdle(fn) {
  if ("requestIdleCallback" in window) {
    requestIdleCallback(fn, { timeout: 2000 });
  } else {
    setTimeout(fn, 0); // запасной вариант для Safari
  }
}

// Откладываем несрочную работу за пределы пути взаимодействия
whenIdle(() => initAnalytics());

Параметр timeout гарантирует, что работа в конечном счёте выполнится, даже если браузер так и не перейдёт в состояние простоя.

Debounce и throttle для scroll, resize и input

Неограниченные обработчики scroll, resize и input, блокирующие основной поток, теперь являются сигналом ранжирования, а не просто проблемой плавности — каждый задержанный ими кадр потенциально нарушает метрику INP. Паттерн сломался потому, что useEffect делает подключение необработанного слушателя тривиальным: три строки кода, никакого ограничения частоты, и обработчик, срабатывающий на каждом кадре прокрутки.

Debounce запускает функцию после того, как активность прекращается — это правильный подход для полей поиска и работы по завершении изменения размера. Throttle ограничивает частоту вызовов — это правильный подход для отслеживания позиции прокрутки, которое должно обновляться во время жеста. Справочник MDN по событию scroll отмечает, что события scroll могут срабатывать с высокой частотой, и рекомендует применять throttle для ресурсоёмких обработчиков.

useEffect(() => {
  let ticking = false;
  function onScroll() {
    if (ticking) return;
    ticking = true;
    requestAnimationFrame(() => {
      updateScrollPosition(window.scrollY);
      ticking = false;
    });
  }
  window.addEventListener("scroll", onScroll, { passive: true });
  return () => window.removeEventListener("scroll", onScroll);
}, []);

Блокировка через requestAnimationFrame ограничивает частоту до одного обновления за кадр, а { passive: true } сообщает браузеру, что обработчик не будет вызывать preventDefault, позволяя выполнять прокрутку без ожидания вашего JavaScript.

Накопительный эффект

Каждая техника в этой статье — это знание о платформе, которое мы переложили на умолчания фреймворка и перестали проверять. Ни одна из них не является новой — в этом и суть. По отдельности отсутствующий font-display или не отложенный тег обходятся в миллисекунды; вместе они и есть разница между приложением, которое ощущается быстрым, и тем, которое кажется тяжёлым, несмотря на современный инструментарий. Следующий конкретный шаг: откройте DevTools, проверьте написанные вручную блоки @font-face, сторонние теги <script> и слушателей в useEffect по правилам, описанным выше, и удалите обёртку на Intersection Observer, которую браузер сделал избыточной.

Часто задаваемые вопросы

Используйте debounce, когда вас интересует только конечное состояние после завершения активности — например, отправка поискового запроса после того, как пользователь перестал печатать, или пересчёт макета после завершения изменения размера. Используйте throttle, когда вам нужны обновления во время непрерывного жеста с ограниченной частотой — например, при отслеживании позиции прокрутки. Debounce ждёт паузы; throttle ограничивает частоту, пока событие продолжает срабатывать.

Да. Атрибут loading применяется как к элементам img, так и к iframe, поэтому встроенные карты, видеоплееры и сторонние виджеты ниже линии сгиба могут откладывать загрузку нативно, без обёртки на Intersection Observer. Поддержка браузерами соответствует срокам внедрения для изображений и достигла базового уровня в Chrome, Firefox и Safari в тот же период. Не забывайте указывать явные width и height для предотвращения сдвига макета, поскольку элементы с ленивой загрузкой вызывают его ничуть не реже, чем загружаемые сразу.

Они будут конкурировать и могут выполниться в неправильном порядке. Async-скрипты запускаются сразу после завершения загрузки каждого из них, в порядке получения от сети, а не в порядке документа, поэтому скрипт, зависящий от другого async-скрипта, может выполниться первым и завершиться ошибкой. Решение — использовать defer для обоих скриптов, что гарантирует выполнение после завершения парсинга и в порядке документа, либо загружать зависимость перед зависимым скриптом в едином бандле.

setTimeout с нулевой задержкой планирует работу в следующем слоте очереди задач, который браузер может выполнить немедленно, в том числе в середине пользовательского взаимодействия, — то есть он фактически не уступает управление пользователю. requestIdleCallback ждёт подлинного периода простоя и передаёт дедлайн, который можно проверить перед продолжением работы. Поскольку INP стал метрикой Core Web Vitals в марте 2024 года, это различие приобрело особое значение: работа, выполняемая во время взаимодействия, теперь является сигналом ранжирования.

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data. Check our GitHub repo and join the thousands of developers in our community.

OpenReplay