divmagic Make design
SimpleNowLiveFunMatterSimple
Улучшение производительности сайта за счет отправки большего количества CSS
Blogs›производительность›Улучшение производительности сайта за счет отправки большего количества CSS
производительность

Улучшение производительности сайта за счет отправки большего количества CSS

Улучшение производительности сайта за счет отправки большего количества CSS

В современной фронтенд-разработке укоренился инстинкт, который гласит, что самая быстрая страница отправляет меньше всего: меньше JavaScript, меньше шрифтов, меньше запросов. Поэтому, когда инженерная команда GitHub написала об улучшении производительности сайта за счет отправки большего количества CSS, это выглядело как противоречие. Исправление медленного, насыщенного JavaScript интерфейса заключалось не в том, чтобы удалить стилизацию из приложения. Нужно было вернуть ее обратно в виде простых, статических, кэшируемых таблиц стилей и удалить JavaScript, который существовал только для генерации стилей в браузере.

В этом различии и заключается суть всей статьи. Отправка «большего количества CSS» не означает увеличения таблицы стилей, раздувающей ваш критический путь. Это означает перенос генерации стилей из среды выполнения в сборку, а затем предоставление браузеру возможности делать то, в чем он действительно превосходен: один раз разобрать таблицу стилей, закэшировать ее и повторно использовать при каждой навигации.

Скрытая стоимость стилизации во время выполнения

Библиотеки CSS-in-JS решили реальные проблемы. Они предоставили авторам компонентов изоляцию (scoping), совместное размещение (colocation) и возможность интерполировать свойства (props) в стили без изобретения соглашения об именах. Платой за это стала архитектурная особенность: они перенесли проблему времени компиляции в браузер.

Генерация стилей — это работа JavaScript

Каждая страница, отрендеренная на сервере с использованием библиотеки CSS-in-JS времени выполнения, заставляет браузер выполнять одну и ту же работу дважды. Сервер сериализует стили в теги <style>, затем клиентская библиотека просыпается, обходит дерево компонентов, снова хэширует имена классов и согласует результат. Эта работа происходит в основном потоке, конкурируя с теми самыми этапами компоновки (layout) и отрисовки (paint), которые вы пытаетесь ускорить.

// Runtime CSS-in-JS: this stylesheet does not exist until hydration runs
const Button = styled.button`
  background: ${(p) => (p.primary ? "#1f883d" : "transparent")};
  border: 1px solid #d0d7de;
  padding: 6px 16px;
  border-radius: 6px;
  font: inherit;
`;

Тот же компонент, выраженный в виде таблицы стилей времени сборки, устраняет как зависимость от времени выполнения, так и работу по сериализации:

/* button.module.css, parsed once, cached forever */
.button {
  padding: 6px 16px;
  border: 1px solid #d0d7de;
  border-radius: 6px;
  font: inherit;
  background: transparent;
}
.buttonPrimary {
  background: #1f883d;
  color: #ffffff;
}
import styles from "./button.module.css";

export function Button({ primary, ...props }) {
  const className = primary
    ? `${styles.button} ${styles.buttonPrimary}`
    : styles.button;

  return <button className={className} {...props} />;
}

Здесь нет ничего нового. Что легко недооценить, так это то, какой объем бандла незаметно занимает зрелый стек CSS-in-JS: сама библиотека, утилиты для интерполяции свойств, провайдер тем, сериализатор и дублирующийся код фреймворка, который разрастается, когда каждый пакет зависит от своей собственной копии.

Он находится непосредственно на критическом пути рендеринга

<link rel="stylesheet"> блокирует рендеринг, но блокирует его один раз, и браузер может разбирать файл, пока поток HTML все еще поступает. CSS времени выполнения работает иначе: браузер не может даже начать построение стилей компонента, пока JavaScript не будет загружен, разобран, скомпилирован и выполнен. На телефоне среднего уровня это секунда или более работы основного потока, прежде чем единственная кнопка получит свой цвет фона.

Это фрагментирует ваш кэш

Сгенерированные имена классов меняются при каждом изменении исходного кода компонента, что означает, что таблица стилей становится недействительной почти при каждом развертывании. Разделите это на десятки бандлов уровня маршрутов, и вы получите процент попаданий в кэш, который выглядит здоровым на панели управления, но редко срабатывает при повторных посещениях. Статическая таблица стилей с хэшированным содержимым, напротив, может обслуживаться с Cache-Control: public, max-age=31536000, immutable и фактически никогда не загружаться дважды.

Это удваивает работу во время гидратации

Несоответствия гидратации между стилями, внедренными на сервере и сгенерированными на клиенте, являются классическим источником мерцания. Браузер отрисовывает один набор правил, затем клиентская библиотека заменяет их во время взаимодействия, вызывая пересчет стилей, а иногда и сдвиг макета. Статические имена классов, вычисленные во время сборки, идентичны на обеих сторонах, поэтому первая отрисовка является также и окончательной.

Main-thread work before first contentful paint

Что на самом деле изменил GitHub

Миграцию лучше всего понимать как пять архитектурных шагов, а не один рефакторинг. Ни один из них не требует отказа от компонентной модели, и ни один не требует полного переписывания.

web design, laptop, html, design, computer, internet, web, technology, website, development, office, business, web design, web design, web design, web design, web design, html, html, website, website, website, England, United Kingdom, Appledore

Дизайн-токены стали CSS-кастомными свойствами

Цвета, отступы, радиусы и типографские шкалы переместились в глобальный блок :root в качестве кастомных свойств. Компоненты ссылаются на var(--color-bg-default) вместо импорта JavaScript-объекта темы. Именно это делает статический CSS достаточно выразительным, чтобы заменить динамические стили в большинстве случаев, и позволяет использовать темизацию во время выполнения (темный режим, высокая контрастность) без JavaScript.

Стили компонентов компилируются заранее

Каждый компонент получил настоящую таблицу стилей, сгенерированную во время сборки и изолированную с помощью хэша или соглашения о модулях. На выходе получается обычный CSS, который любой браузер может разобрать со скоростью разбора.

Критический CSS встраивается в заголовок документа

В HTML встраиваются только правила, необходимые для первого видового экрана (viewport). Обычно это 8–14 КБ в сжатом виде, и это означает, что первая отрисовка никогда не ждет сетевого round trip для таблицы стилей, которая формирует видимую страницу.

<head>
  <!-- Critical: exactly what the first viewport needs, nothing more -->
  <style>
    /* reset + tokens + header + hero + above-the-fold grid */
  </style>

  <!-- Everything else: one hashed, immutable, long-cached file -->
  <link
    rel="stylesheet"
    href="/assets/app.8f3a1c2d.css"
    media="print"
    onload="this.media='all'"
  />
  <noscript>
    <link rel="stylesheet" href="/assets/app.8f3a1c2d.css" />
  </noscript>
</head>

Разметка, отрендеренная на сервере, содержит окончательные имена классов

Поскольку имена классов существуют на этапе сборки, HTML, возвращаемый сервером, уже стилизован. Нет второго прохода, нет этапа согласования и нет вспышки нестилизованного контента на медленных соединениях.

Слои каскада заменили хаки со специфичностью

Общая система дизайна живет или умирает благодаря предсказуемому порядку каскада. Объявление порядка слоев один раз, заранее, делает переопределения детерминированными и устраняет стимул прибегать к !important или еще более глубоким селекторам.

@layer reset, tokens, base, components, utilities;

@layer components {
  .card {
    background: var(--color-canvas-default);
    border: 1px solid var(--color-border-default);
    border-radius: var(--radius-2);
    padding: var(--space-4);
  }
}

@layer utilities {
  .mt-4 { margin-top: var(--space-4); }
}

Цифры: Что меняется, когда вы отправляете больше CSS

Перенос генерации стилей на этап сборки меняет форму того, что вы отправляете, а не только объем. Оставшиеся байты дешевы: они хорошо сжимаются, используются совместно на всех маршрутах и кэшируются на год.

Bytes shipped vs bytes executed

Архитектурная выгода проявляется сразу в четырех местах: меньше JavaScript для загрузки, меньше работы основного потока перед первой отрисовкой, кэшируемая таблица стилей, используемая совместно при навигации, и идентичная разметка на сервере и клиенте.

0
JavaScript bytes executed to apply a static stylesheet at runtime
~40%
of client-side JavaScript in a typical CSS-in-JS app is style generation code and its dependencies
ApproachJS shipped (gzip)CSS shipped (gzip)Runtime style workCacheable across deploys
Runtime CSS-in-JS~180 KB~12 KBHighNo
Single monolithic stylesheet~96 KB~88 KBNoneYes
Inline critical + split static sheets~78 KB~54 KBNoneYes
Utility-first CSS~85 KB~40 KBNoneYes

Самая быстрая таблица стилей — это та, которую браузер разбирает один раз, кэширует на год и никогда не генерирует снова.

Практическое руководство по миграции

Вам не нужна кодовая база GitHub, чтобы применить ту же логику. Миграция выполняется постепенно, и каждый шаг можно развертывать независимо.

html, css, responsive, site design, themplate design, website development, html code, layout sites, programming, code, html, css, programming, programming, programming, programming, programming, code

1. Инвентаризируйте то, что на самом деле рендерится во время выполнения

Откройте DevTools, загрузите страницу и посчитайте document.styleSheets. Если вы найдете десятки внедренных тегов <style> с хэшированными именами классов, вы нашли свою цель. Сверьтесь с панелью Performance: ищите длинные задачи между DOMContentLoaded и первой содержательной отрисовкой (first contentful paint).

60%
of total page bytes can often be moved off the critical path by deferring non-critical CSS

2. Извлеките CSS «над сгибом»

Начните с наименьшего возможного встроенного блока. Надежная эвристика — встроить сброс (reset), токены и правила, используемые для первых двух высот области просмотра. Все, что ниже сгиба, загружается из отложенной таблицы стилей.

3. Преобразуйте компоненты времени выполнения в статические классы

Работайте компонент за компонентом, начиная с наиболее посещаемых. Варианты, управляемые свойствами (prop-driven), становятся либо классами-модификаторами, либо кастомными свойствами, установленными на обертке. Сохраняйте API компонента идентичным, чтобы места вызова не менялись.

// Genuinely dynamic values still belong in an inline style, not a generated sheet
<div
  className={styles.progressBar}
  style={{ "--progress": `${percent}%` }}
  role="progressbar"
  aria-valuenow={percent}
/>
.progressBar {
  width: var(--progress, 0%);
  transition: width 200ms ease-out;
}

Этот паттерн является рабочей лошадкой всей миграции: структура стиля статична и кэшируема, в то время как значение остается динамическим и ничего не стоит обновить.

4. Установите порядок слоев заранее

Объявите порядок @layer в начале вашей входной таблицы стилей до того, как у вас возникнет проблема со специфичностью, а не после. Встраивание слоев в устаревший каскад задним числом гораздо болезненнее, чем начинать с них.

5. Хэшируйте имена файлов и кэшируйте неизменяемоХеширование контента в сочетании с immutable превращает «больше CSS» в «бесплатный CSS». Второй просмотр страницы в рамках сессии должен попадать в HTTP-кеш для каждого правила стиля на сайте.

6. Оставляйте по-настоящему динамические стили динамическими

Значения, определяемые измерениями, трансформацией и позицией прокрутки, должны быть во встроенных стилях, requestAnimationFrame или Web Animations API. Не стоит заново внедрять механизм стилей времени выполнения для решения проблемы, которую уже решают пользовательские свойства CSS.

Как измерить улучшение

Измеряйте одни и те же три показателя до и после: время отрисовки (paint timing), длительные задачи основного потока (main-thread long tasks) и количество таблиц стилей. Небольшой скрипт PerformanceObserver даст вам все три показателя без добавления зависимостей для мониторинга.

new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    console.log(entry.name, Math.round(entry.startTime));
  }
}).observe({ type: "paint", buffered: true });

new PerformanceObserver((list) => {
  const long = list.getEntries().filter((e) => e.duration > 50);
  console.log(`${long.length} long tasks after load`);
}).observe({ type: "longtask", buffered: true });

console.log("stylesheets:", document.styleSheets.length);

Ожидайте наибольших изменений во времени до первой отрисовки (time-to-first-paint) при повторных посещениях и на ограниченных мобильных профилях, где доминирует устраненная стоимость парсинга JavaScript. Largest contentful paint обычно улучшается более постепенно, от релиза к релизу.

p75 Largest Contentful Paint across releases

Ловушек, которых стоит избегать

Этот подход не лишен рисков, и большинство неудач происходит из-за слишком агрессивного его применения.

coding, computer, hacker, hacking, html, programmer, programming, script, scripting, source code, coding, coding, coding, coding, computer, computer, hacker, hacker, hacker, hacker, hacker, hacking, hacking, programming, programming

  • **Неполный критический CSS.**Если вы ошибетесь с определением первого вьюпорта, вы получите видимый рефлоу при загрузке отложенной таблицы стилей. Генерируйте критический блок автоматически на основе реального рендеринга, а не вручную. -**Встраивание всего подряд.**Встраивание полной таблицы стилей в каждый HTML-ответ сводит на нет кеширование и увеличивает размер документа. Встраивайте минимум, остальное откладывайте. -**Инстинкт «все в одном бандле».**Старый совет объединять весь CSS в один гигантский файл появился до HTTP/2. Теперь часто лучше разделять по маршрутам, при условии, что общие примитивы кешируются отдельно. -Смешивание двух систем. Слишком долгая параллельная работа CSS-in-JS времени выполнения и статических таблиц стилей удваивает поверхность стилей и превращает отладку каскада в кошмар. Установите срок миграции для каждого каталога компонентов.

Самая распространенная нетехническая ловушка — рассматривать это как переписывание «большим взрывом». Команды, которые добиваются успеха, конвертируют несколько высоконагруженных компонентов, измеряют разницу и используют эти данные для обоснования следующей партии.

Использование DivMagic для обратного проектирования реальной CSS-архитектуры

Одна из недооцененных частей этой миграции — это возможность увидеть, как зрелые продакшн-сайты на самом деле структурируют свои таблицы стилей. Когда вы изучаете сайт, который хорошо использует статический CSS, вы можете узнать больше за пять минут, чем за час чтения документации: как они выстраивают каскад, как они ограничивают токены, как они разделяют критический и отложенный CSS и как они называют модификаторы.

DivMagic создан именно для такого рабочего процесса. Вместо того чтобы вручную копировать стили элементов из DevTools по одному правилу, вы выбираете любой UI на любой странице и переносите результирующий HTML и CSS прямо в свой редактор, готовый к адаптации. Это действительно полезно в двух ситуациях: при прототипировании компонента на основе референсного дизайна и при изучении того, как высокопроизводительный сайт, которым вы восхищаетесь, организует свой каскад, чтобы вы могли отразить этот паттерн в своей собственной системе дизайна.

Это также сокращает скучную часть шага три, описанного выше. Когда вы конвертируете компонент времени выполнения в статические классы, наличие финальных вычисленных стилей в редактируемой форме, а не в виде разрозненных встроенных стилей и внедренных блоков <style>, устраняет большую часть работы по переносу.

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

  • Генерация стилей во время выполнения — это работа JavaScript в основном потоке, которая происходит после загрузки JavaScript, непосредственно на вашем критическом пути рендеринга.
  • Статический CSS парсится один раз, кешируется на год при хешировании контента и используется совместно на каждом маршруте и при каждой навигации.
  • Встраивайте только те правила, которые нужны первому вьюпорту; остальное откладывайте за неблокирующим <link> и долгой политикой кеширования.
  • Пользовательские свойства CSS в сочетании с @layer покрывают подавляющее большинство случаев, которые раньше требовали интерполяции стилей на основе пропсов.
  • Мигрируйте инкрементально, компонент за компонентом, и измеряйте время отрисовки p75 и длительные задачи, а не средние значения.

Отправка большего количества CSS звучит как регрессия, пока вы не осознаете, что меняете кешируемую, однократно парсимую таблицу стилей на мегабайты JavaScript, который перестраивает её при каждой загрузке страницы.

Этот урок хорошо обобщается и за пределами GitHub. Каждая абстракция, выполняемая в браузере, — это затраты, которые пользователь оплачивает на своем устройстве, в своей сети, каждый раз. Перенос генерации стилей на этап сборки — один из самых эффективных обменов, доступных для компонентного фронтенда: вы отправляете статический файл, который браузер уже умеет обрабатывать, и удаляете механизм, который раньше стоял у него на пути.

Начните создавать с помощью DivMagic сегодня

Присоединяйтесь к более чем 10 000 разработчиков, дизайнеров и владельцев бизнеса, чтобы копировать код с любого веб-сайта и использовать его в своих проектах.

Get DivMagic for 42% off

Limited time deal for 22:45