Как мы улучшили производительность SmashingMag
Tags: performance, webdev, javascript, css
Published: 2021-01-27 22:14:42

Вольный перевод [статьи](https://www.smashingmagazine.com/2021/01/smashingmag-performance-case-study/) Виталия Фридмана

> **Кратко.** В этой статье мы подробно рассмотрим некоторые изменения, которые мы внесли на сайте - работающем на JAMStack с React - для оптимизации веб-производительности и улучшения показателей Core Web Vitals. С некоторыми ошибками, которые мы сделали, и некоторыми неожиданными изменениями, которые помогли улучшить все метрики по всем направлениям.

---

**Каждая история веб-производительности похожа, не так ли?** Она всегда начинается с долгожданного капитального ремонта сайта. День, когда проект, полностью доработанный и тщательно оптимизированный, запускается, получает высокий рейтинг и поднимается выше показателей производительности в Lighthouse и WebPageTest. В воздухе царит праздник и искреннее чувство выполненного долга, что прекрасно отражается в ретвитах, комментариях, новостях и тредах Slack.

Тем не менее, со временем азарт постепенно утихает, и появляются срочные правки, столь необходимые фичи и новые бизнес-требования. И внезапно, прежде чем вы это узнаете, кодовая база становится немного **перегруженной и фрагментированной**, сторонние скрипты должны загружаться немного раньше, и новый блестящий динамический контент попадает в DOM через бэкдоры еще более сторонних скриптов и их незваных гостей.

Мы тоже были там в Smashing. Мало кто знает об этом, но мы очень небольшая команда из 12 человек, многие из которых работают неполный рабочий день и большинство из которых обычно “носят разные шляпы” (имеют много разных ролей и работ) в день. Хотя производительность была нашей целью уже [почти десять лет](https://www.smashingmagazine.com/2014/09/improving-smashing-magazine-performance-case-study/), у нас никогда не было специальной команды по производительности.

После последнего редизайна в конце 2017 года это были [Ilya Pukhalski](https://twitter.com/pukhalski) в области JavaScript (неполный рабочий день), [Michael Riethmueller](https://twitter.com/MikeRiethmuller) в области CSS (несколько часов в неделю), и ваш покорный слуга, играя в интеллектуальные игры с critical CSS и пытаясь жонглировать слишком многими вещами.

![Стартовая точка](https://dev-to-uploads.s3.amazonaws.com/i/fo77j3itpc60mxlgk7ff.jpeg)

Вот с чего мы начали. С оценкой Lighthouse где-то между 40 и 60, мы решили (в очередной раз) взяться за производительность. (Источник изображения: Lighthouse Metrics) (превью в большом разрешении)

Так получилось, что мы потеряли производительность в повседневной рутине. Мы разрабатывали и создавали вещи, настраивали новые продукты, реорганизовали компоненты и публиковали статьи. Таким образом, к концу 2020 года ситуация немного вышла из-под контроля, и **желтовато-красные баллы** Lighthouse постепенно начали появляться по всем направлениям. Нам нужно было это исправить.

## Вот где мы были 
Некоторые из вас могут знать, что мы [работаем на JAMStack](https://www.smashingmagazine.com/2017/03/a-little-surprise-is-waiting-for-you-here/), где все статьи и страницы хранятся в виде Markdown файлов, Sass файлы скомпилированы в CSS, JavaScript разделен на чанки с помощью Webpack и Hugo создает статические страницы, которые мы затем поставляем из Edge CDN. Еще в 2017 году мы создали весь сайт с помощью Preact, но затем в 2019 году перешли на React - и использовали его вместе с несколькими API для поиска, комментариев, аутентификации и покупок.

Весь сайт построен [с учетом прогрессивных улучшений](https://www.sarasoueidan.com/work/smashing-magazine/), а это означает, что вы, дорогой читатель, можете полностью прочитать каждую статью Smashing без необходимости загружать приложение вообще. Это тоже не очень удивительно - в конце концов, опубликованная статья не сильно меняется с годами, в то время как динамические элементы, такие как аутентификация членства и покупка, требуют, чтобы приложение работало.

Вся сборка для деплоя около 2500 статей в реальном времени в настоящий момент занимает около **6 минут**. Сам по себе процесс сборки со временем тоже превратился в чудовище, со вставками critical CSS, код сплиттинга от Webpack, динамическими вставками рекламных и фича панелей, (ре)генерацией RSS и, в конечном итоге, A/B-тестированием.

В начале 2020 года мы начали **большой рефакторинг** CSS layout компонентов. Мы никогда не использовали CSS-in-JS или styled-components, а вместо этого использовали старую добрую компонентную систему Sass-модулей, которая компилировалась в CSS. Еще в 2017 году весь layout был построен с помощью Flexbox и переделан с помощью CSS Grid и CSS Custom Properties в середине 2019 года. Однако некоторые страницы нуждались в особом уходе из-за появления новых рекламных роликов и новых продуктовых панелей. Итак, пока layout работал, он работал не очень хорошо, и его было довольно сложно поддерживать.

Кроме того, шапку с основной навигацией пришлось изменить, чтобы разместить больше элементов, которые мы хотели отображать динамически. Плюс, мы хотели провести рефакторинг некоторых часто используемых компонентов, которые используются на сайте, и использованный там CSS также нуждался в некоторой доработке - блок рассылки был наиболее заметным “преступником”. Мы начали с рефакторинга некоторых компонентов с помощью utility-first CSS, но так и не дошли до того, чтобы он постепенно использовался на всем сайте.

Серьезной проблемой был **большой JavaScript бандл**, который - что неудивительно - блокировал основной поток на сотни миллисекунд. Большой JavaScript бандл может показаться странным в журнале, который просто публикует статьи, но на самом деле за кулисами происходит множество скриптов.

У нас есть различные состояния компонентов для аутентифицированных и не аутентифицированных клиентов. После того, как вы войдете в систему, мы хотим, чтобы все продукты отображались в окончательной цене, а когда вы добавляете книгу в корзину, мы хотим, чтобы корзина оставалась доступной одним нажатием кнопки - независимо от того, на какой странице вы находитесь. Реклама должна появляться быстро, не вызывая резких **сдвигов в макете**, и то же самое можно сказать о собственных продуктовых панелях, которые подчеркивают наши продукты. Плюс service worker, который кэширует все статические ресурсы и отдает их для повторных просмотров вместе с кешированными версиями статей, которые читатель уже посетил.

Итак, все эти скрипты должны были произойти в *какой-то* момент, и это портило опыт чтения, даже несмотря на то, что скрипт приходил довольно поздно. Откровенно говоря, мы кропотливо работали над сайтом и новыми компонентами, не уделяя пристального внимания производительности (и нам нужно было помнить еще несколько вещей на 2020 год). Перелом наступил неожиданно. Гарри Робертс провел свой (отличный) [мастер-класс по веб-производительности](https://smashingconf.com/online-workshops/workshops/harry-roberts-oct) в качестве онлайн-семинара с нами, и на протяжении всего семинара он использовал Smashing в качестве примера, выделяя проблемы, которые у нас были, и предлагая решения этих проблем, а также полезные инструменты и рекомендации.

На протяжении всего семинара я старательно делал заметки и пересматривал кодовую базу. Во время семинара наши Lighthouse набрали **60–68 баллов на главной странице** и около **40–60 на страницах статей** - и, очевидно, хуже на мобильных устройствах. Когда семинар закончился, мы приступили к работе.

## Выявление узких мест
Мы часто склонны полагаться на конкретные баллы, чтобы понять, насколько хорошо мы работаем, но слишком часто отдельные баллы не дают полной картины. Как красноречиво [заметил](https://medium.com/firebase-developers/how-fast-should-your-site-load-cfb14be48e8b) David East в своей статье, производительность в Интернете - не единичная ценность; она распределена. Даже если пользовательский опыт в вебе тщательно оптимизирован по производительности, то он не может быть просто быстрым. Для некоторых посетителей может быть быстро, но в итоге будет медленнее (или медленно) для некоторых других.

Причин тому множество, но самая важная из них - огромная разница в сети и устройствах по всему миру. Чаще всего мы не можем реально повлиять на эти вещи, поэтому мы должны убедиться, что наш опыт учитывает их.

По сути, наша задача состоит в том, чтобы увеличить долю быстрого опыта и уменьшить долю медленного опыта. Но для этого нам нужно получить правильное представление о том, что такое распределение на самом деле. На сегодняшний день инструменты аналитики и инструменты мониторинга производительности могут предоставлять эти данные при необходимости, но мы специально изучили [CrUX](https://developers.google.com/web/updates/2018/08/chrome-ux-report-dashboard), Chrome User Experience Report. CrUX генерирует обзор распределения производительности с течением времени с трафиком, собранным от пользователей Chrome. Большая часть этих данных относится к Core Web Vitals, о котором Google объявил еще в 2020 году, и которые также вносят свой вклад в Lighthouse и публикуются в нем.

![Распределение производительности для Largest Contentful Paint в 2020 году](https://dev-to-uploads.s3.amazonaws.com/i/xmfwf8gvwc21rx3f8oab.png)

Распределение производительности для Largest Contentful Paint в 2020 году. С мая по сентябрь производительность сильно упала.

Мы заметили, что по всем направлениям наша производительность резко снизилась в течение года, особенно в августе и сентябре. Как только мы увидели эти графики, мы смогли вернуться к некоторым PR, которые мы тогда использовали, чтобы изучить, что же произошло на самом деле.

Не потребовалось много времени, чтобы понять, что примерно в это же время мы запустили новую панель навигации. Эта панель навигации, используемая на всех страницах, полагалась на JavaScript для отображения элементов навигации в меню при нажатии или клике, но на самом деле ее JavaScript часть была включена в пакет app.js. Чтобы улучшить Time To Interactive, мы решили **извлечь скрипт навигации** из бандла и поставлять его встроенным (inline).

Примерно в то же время мы переключились с (устаревшего) вручную созданного **critical CSS** на автоматизированную систему, которая генерировала critical CSS для каждого шаблона - домашней страницы, статьи, страницы продукта, события, доски объявлений и т.д. - и встраивала critical CSS во время сборки. Однако мы еще не осознавали, насколько тяжелее был автоматически сгенерированный critical CSS. Пришлось изучить подробнее.


И примерно в то же время мы настраивали загрузку веб-шрифтов, пытаясь более агрессивно продвигать веб-шрифты с помощью аттрибутов, таких как preload. И походу, это негативно сказывается на наших усилиях по повышению производительности, поскольку веб-шрифты задерживают рендеринг контента, имея приоритет перед полным файлом CSS.

Сейчас одной из распространенных причин регрессии является высокая стоимость JavaScript, поэтому мы также изучили [Webpack Bundle Analyzer](https://github.com/webpack-contrib/webpack-bundle-analyzer) и [Simon Hearne’s request map](https://domainmap.webperf.tools/render/210117_DiET_da4e353c05161c3af07149482eb39373/), чтобы получить визуальное представление о наших JavaScript зависимостях. Поначалу всё выглядело вполне здоровым.

![Карта запросов](https://dev-to-uploads.s3.amazonaws.com/i/rhe0jbtkjiexwd0zf2gr.jpeg)

Ничего особенного: карта запросов поначалу не казалась излишней. 

Несколько запросов поступало в CDN, службу согласия на использование файлов cookie Cookiebot, Google Analytics, а также в наши внутренние службы для обслуживания продуктовых панелей и настраиваемой рекламы. Не было похоже, что было много узких мест - пока мы не присмотрелись более внимательно.

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

Фактически, поскольку мы публикуем довольно много статей с тяжелым кодом и тяжелым дизайном на SmashingMag, за эти годы мы накопили буквально тысячи статей, содержащих тяжелые GIF-файлы, фрагменты кода с синтаксической подсветкой, встраивание CodePen, встроенные видео/аудио и вложенные потоки бесконечных комментариев.

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

Все это не отображалось на карте, которую мы создали для довольно легкой страницы статьи на диаграмме выше. Поэтому мы выбрали самые тяжелые страницы, которые у нас были - [всемогущую домашнюю страницу](https://www.smashingmagazine.com/), [самую длинную](https://www.smashingmagazine.com/2021/01/front-end-performance-2021-free-pdf-checklist/), ту, [которая содержит много видео](https://www.smashingmagazine.com/2020/02/design-mobile-apps-one-hand-usage/) и ту, [которая встраивала много CodePen](https://www.smashingmagazine.com/native-css-masonry-layout-css-grid/), - и решили оптимизировать их, насколько это было возможно. В конце концов, если они быстрые, то страницы с одним встраиванием CodePen тоже должны быть быстрее.

С учетом этих страниц карта выглядела немного иначе. Обратите внимание на огромную толстую стрелку, ведущую к проигрывателю Vimeo и Vimeo CDN, с 78 запросами, исходящими из статьи Smashing.

![Карта запросов сложных страниц](https://dev-to-uploads.s3.amazonaws.com/i/0niixetdkbojxyjl8kvu.png)

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

Чтобы изучить влияние на основной поток, мы глубоко погрузились в Performance панель в DevTools. В частности, мы искали задачи длительностью более 50 мс (выделены красным прямоугольником в правом верхнем углу) и задачи, содержащие стили пересчета (фиолетовая полоса). Первый будет указывать на дорогостоящее выполнение JavaScript, а второй - на недействительность стиля, вызванную динамической инъекцией контента в DOM и неоптимальным CSS. Это дало нам несколько действенных указателей, с чего начать. Например, мы быстро обнаружили, что загрузка нашего веб-шрифта требует значительных затрат на перерисовку, в то время как JavaScript чанки все еще достаточно тяжелые, чтобы блокировать основной поток.

![Performance панель](https://dev-to-uploads.s3.amazonaws.com/i/7dmbzrq21a0o18rr8e8s.jpeg)

Изучение панели «Performance» в DevTools. Было несколько длинных задач, которые занимали более 50 мс и блокировали основной поток.

В качестве основы мы очень внимательно изучили [Core Web Vitals](https://web.dev/vitals/), пытаясь убедиться, что у нас хорошие результаты по всем из них. Мы решили сосредоточиться именно на медленных мобильных устройствах - с медленным 3G, 400мс RTT и скоростью передачи 400 кбит/с, просто чтобы быть пессимистичными. Неудивительно, что Lighthouse тоже не очень доволен нашим сайтом, выставляя сплошные красные баллы для самых тяжелых статей и без устали жалующийся на неиспользуемые JavaScript, CSS, изображения вне экрана и их размеры.

![Lighthouse оценка](https://dev-to-uploads.s3.amazonaws.com/i/okniibup4dqm62q1sw17.jpeg)

Когда перед нами были некоторые данные, мы могли сосредоточиться на оптимизации трех самых тяжелых страниц статей, уделяя особое внимание critical (и non-critical) CSS, бандлу JavaScript, длинным задачам, загрузке веб-шрифтов, сдвигам макета (layout shift) и вставкой сторонних скриптов. Позже мы также пересмотрим кодовую базу, чтобы удалить устаревший код и использовать новые современные функции браузера. Казалось, впереди много работы, и действительно, мы были очень заняты в ближайшие месяцы.

## Улучшение порядка ассетов в <head>
По иронии судьбы, самое первое, что мы рассмотрели, даже не было тесно связано со всеми задачами, которые мы определили выше. На семинаре по производительности Гарри потратил значительное количество времени, объясняя порядок ассетов в <head> каждой страницы, подчеркнув, что для быстрой доставки критически важного контента необходимо быть очень стратегическим и внимательным к тому, как активы упорядочены в исходном коде.

То, что critical CSS полезен для веб-производительности, не должно стать большим открытием. Тем не менее, было немного удивительно, **насколько сильно отличается** порядок всех других ресурсов - аттрибуты ресурсов, предварительной загрузки веб-шрифтов, синхронных и асинхронных скриптов, полных CSS и метаданных.

Мы перевернули весь <head> вверх дном, поместив critical CSS перед всеми асинхронными скриптами и всеми предварительно загруженными ресурсами, такими как шрифты, изображения и т.д. Мы разбили ресурсы, к которым мы будем использовать preconnect или preload, поэтому важные изображения, выделение синтаксиса и встраивание видео будут запрашиваться раньше только для определенных типов статей и страниц.

В общем, мы тщательно организовали порядок в <head>, сократили количество предварительно загруженных ресурсов, которые конкурировали за пропускную способность, и сосредоточились на правильной настройке critical CSS. Если вы хотите глубже погрузиться в некоторые важные аспекты порядка <head>, Гарри выделяет их в статье о [CSS and Network Performance](https://csswizardry.com/2018/11/css-and-network-performance/). Одно только это изменение принесло нам около 3–4 очков Lighthouse по всем направлениям.

## Переход от автоматизированного critical CSS обратно к ручному critical CSS
Однако перемещение тегов <head> было простой частью истории. Более сложным было создание и управление [critical CSS](https://web.dev/extract-critical-css/) файлами. Еще в 2017 году мы вручную создали critical CSS для каждого шаблона, собрав все стили, необходимые для рендеринга **первых 1000 пикселей в высоту** по всей ширине экрана. Это, конечно, была громоздкая и немного скучная задача, не говоря уже о проблемах с поддержкой для соединения целого семейства critical CSS файлов и целого CSS файла.

Поэтому мы рассмотрели варианты **автоматизации этого процесса** как часть процедуры сборки. На самом деле недостатка в инструментах не было, поэтому мы протестировали несколько и решили провести несколько тестов. Нам удалось довольно быстро их настроить и запустить. Результат казался достаточно хорошим для автоматизированного процесса, поэтому после нескольких настроек конфигурации мы подключили его и отправили в продакшн. Это произошло примерно в июле – августе прошлого года, что хорошо видно по всплеску и падению производительности в данных CrUX выше. Мы продолжали идти вперед и назад с конфигурацией, часто возникали проблемы с простыми вещами, такими как добавление определенных стилей или удаление других. Например, стили запроса согласия на использование файлов cookie, которые на самом деле не отображаются на странице, если сценарий файлов cookie не инициализирован.

В октябре мы внесли некоторые важные изменения в макет сайта, и при рассмотрении critical CSS мы снова столкнулись с теми же проблемами - полученный результат был довольно подробным и не совсем тем, что мы хотели. Итак, в качестве эксперимента в конце октября мы объединили все свои сильные стороны, чтобы пересмотреть наш подход к critical CSS и изучить, насколько меньше будет **critical CSS, созданный вручную**. Мы сделали глубокий вдох и потратили несколько дней на инструмент покрытия кода на ключевых страницах. Мы сгруппировали CSS правила вручную и удалили дубликаты и устаревший код в обоих местах - в critical и основном CSS. Это действительно была столь необходимая очистка, поскольку многие стили, которые были написаны еще в 2017–2018 годах, со временем устарели.

В результате мы получили три важных CSS файла, созданных вручную, и еще три файла, которые в настоящее время находятся в разработке:

* critical-homepage-manual.css (8.2 KB, Brotlified)
* critical-article-manual.css (8 KB, Brotlified)
* critical-articles-manual.css (6 KB, Brotlified)
* critical-books-manual.css (предстоит сделать)
* critical-events-manual.css (предстоит сделать)
* critical-job-board-manual.css (предстоит сделать)

Файлы встроены в head каждого шаблона, и на данный момент они дублируются в монолитном CSS бандле, который содержит все, что когда-либо использовалось (или больше не используется) на сайте. В настоящий момент мы планируем разбить CSS бандл на несколько  CSS пакетов, чтобы читатель журнала не загружал стили с доски объявлений или страниц книги, но затем, перейдя на эти страницы, получил бы быстрый рендеринг с critical CSS и асинхронно получил остальной CSS для этой страницы - только на этой странице.

Надо признать, что созданные вручную critical CSS файлы не были намного меньше по размеру: мы **уменьшили размер critical CSS файлов примерно на 14%**. Тем не менее, они включили все, что нам нужно, в правильном порядке от начала до конца, без дублирования и переопределения стилей. Это казалось шагом в правильном направлении и дало нам прибавку к Lighthouse еще на 3–4 балла. Мы прогрессировали.

## Изменение загрузки веб-шрифтов
Теперь, когда font-display всегда под рукой, загрузка шрифтов, похоже, была проблемой в прошлом. К сожалению, в нашем случае это не совсем так. Вы, дорогие читатели, похоже, читаете несколько статей в Smashing Magazine. Вы также часто возвращаетесь на сайт, чтобы прочитать еще одну статью - возможно, через несколько часов или дней, или, возможно, через неделю. Одна из проблем, с которыми мы столкнулись с font-display, используемым на сайте, заключалась в том, что для читателей, которые часто переключались между статьями, мы заметили множество вспышек между резервным шрифтом и веб-шрифтом (что обычно не должно происходить, если шрифты правильно кешируются).

Не очень удобно для пользователей, поэтому мы рассмотрели варианты. В Smashing мы используем **два основных шрифта** - Mija для заголовков и Elena для основного текста. У Mija два weight (Regular и Bold), а у Elena - три weight (Regular, Italic, Bold). Мы убрали Elena Bold Italic курсив много лет назад во время редизайна только потому, что использовали его всего на нескольких страницах. Мы подгруппировали другие шрифты, удалив неиспользуемые символы и диапазоны Unicode.

Наши статьи в основном состоят из текста, поэтому мы обнаружили, что большую часть времени на сайте Largest Contentful Paint - это либо первый абзац текста в статье, либо фотография автора. Это означает, что нам нужно позаботиться о том, чтобы первый абзац быстро отображался в резервном шрифте, при этом плавно переходя на веб-шрифт с минимальными reflows.

Внимательно посмотрите на начальную загрузку главной страницы (замедленная в три раза):

https://vimeo.com/502567945

При поиске решения перед нами стояли четыре основные цели: 

1. При первом же посещении немедленно рендерить текст с использованием резервного шрифта; 
2. Заматчить метрики резервных шрифтов и веб-шрифтов, чтобы минимизировать сдвиги макета; 
3. Асинхронно загрузить все веб-шрифты и применить их все сразу (максимум 1 reflow); 
4. При последующих посещениях рендерить весь текст непосредственно в веб-шрифтах (без мигания или reflow).

Изначально мы действительно пытались использовать font-display:swap на font-face. Казалось, что это самый простой вариант, однако, как упоминалось выше, некоторые читатели посетят несколько страниц, поэтому в результате мы получили много мерцания с шестью шрифтами, которые мы отображали по всему сайту. Кроме того, с помощью только font-display мы не могли группировать запросы или перерисовывать (repaint).

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

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

Так что же тогда?

С 2017 года мы используем [Two-Stage-Render approach подход](https://www.zachleat.com/web/comprehensive-webfonts/#foft) для загрузки веб-шрифтов, который в основном описывает два этапа рендеринга: один с минимальным подмножеством веб-шрифтов, а другой - с полным семейством весов шрифтов. В свое время мы создали минимальные подмножества Mija Bold и Elena Regular, которые были наиболее часто используемыми weight на сайте. Оба подмножества включают только латинские символы, знаки препинания, числа и несколько специальных символов. Эти шрифты (ElenaInitial.woff2 и MijaInitial.woff2) были очень маленькими по размеру - часто всего около 10–15 КБ. Мы поставляем их на первом этапе рендеринга шрифтов, отображая всю страницу в этих двух шрифтах.

![CLS](https://dev-to-uploads.s3.amazonaws.com/i/bit02y14op60qq92vzc8.gif)

CLS вызвано мерцанием веб-шрифтов (тени под изображениями авторов перемещаются из-за изменения шрифта).

Мы делаем это с помощью Font Loading API, который дает нам информацию о том, какие шрифты успешно загружены, а какие еще нет. За кулисами это происходит путем добавления класса .wf-loaded-stage1 в body со стилями, отображающими содержимое в этих шрифтах:

```css
.wf-loaded-stage1 article,
.wf-loaded-stage1 promo-box,
.wf-loaded-stage1 comments {
    font-family: ElenaInitial,sans-serif;
}

.wf-loaded-stage1 h1,
.wf-loaded-stage1 h2,
.wf-loaded-stage1 .btn {
    font-family: MijaInitial,sans-serif;
}
```

Поскольку файлы шрифтов довольно маленькие, мы надеемся, что они довольно быстро проходят через сеть. Затем, когда читатель действительно может начать читать статью, мы загружаем полные weight шрифтов асинхронно и добавляем .wf-loaded-stage2 в body:

```css
.wf-loaded-stage2 article,
.wf-loaded-stage2 promo-box,
.wf-loaded-stage2 comments {
    font-family: Elena,sans-serif;
}

.wf-loaded-stage2 h1,
.wf-loaded-stage2 h2,
.wf-loaded-stage2 .btn {
    font-family: Mija,sans-serif;
}
```

Поэтому при загрузке страницы читатели сначала быстро получат небольшой набор веб-шрифтов, а затем мы переключимся на все семейство шрифтов. Теперь по умолчанию переключение между резервными шрифтами и веб-шрифтами происходит случайным образом, в зависимости от того, что сначала идет по сети. Когда вы начали читать статью, это может показаться довольно неприятным. Поэтому вместо того, чтобы оставлять браузеру решать, когда переключать шрифты, **мы группируем repaint’ы**, сводя к минимуму влияние reflow.

```js
/* Loading web fonts with Font Loading API to avoid multiple repaints. With help by Irina Lipovaya. */
/* Credit to initial work by Zach Leatherman: https://noti.st/zachleat/KNaZEg/the-five-whys-of-web-font-loading-performance#sWkN4u4 */

// If the Font Loading API is supported...
// (If not, we stick to fallback fonts)
if ("fonts" in document) {

    // Create new FontFace objects, one for each font
    let ElenaRegular = new FontFace(
        "Elena",
        "url(/fonts/ElenaWebRegular/ElenaWebRegular.woff2) format('woff2')"
    );
    let ElenaBold = new FontFace(
        "Elena",
        "url(/fonts/ElenaWebBold/ElenaWebBold.woff2) format('woff2')",
        {
            weight: "700"
        }
    );
    let ElenaItalic = new FontFace(
        "Elena",
        "url(/fonts/ElenaWebRegularItalic/ElenaWebRegularItalic.woff2) format('woff2')",
        {
            style: "italic"
        }
    );
    let MijaBold = new FontFace(
        "Mija",
        "url(/fonts/MijaBold/Mija_Bold-webfont.woff2) format('woff2')",
        {
            weight: "700"
        }
    );

    // Load all the fonts but render them at once
    // if they have successfully loaded
    let loadedFonts = Promise.all([
        ElenaRegular.load(),
        ElenaBold.load(),
        ElenaItalic.load(),
        MijaBold.load()
    ]).then(result => {
        result.forEach(font => document.fonts.add(font));
        document.documentElement.classList.add('wf-loaded-stage2');

        // Used for repeat views
        sessionStorage.foutFontsStage2Loaded = true;
    }).catch(error => {
        throw new Error(`Error caught: ${error}`);
    });

}
```

Однако, что если первое небольшое подмножество шрифтов не подгружается через сеть быстро? Мы заметили, что это, кажется, происходит чаще, чем нам хотелось бы. В этом случае, по истечении таймаута в 3 секунды, современные браузеры возвращаются к системному шрифту (в нашем стеке шрифтов это будет Arial), а затем переключаются на ElenaInitial или MijaInitial, чтобы потом переключиться на полную Elena или Mija соответственно. Это произвело слишком много вспышек в нашем тестировании. Сначала мы думали убрать первую стадию рендеринга только для медленных сетей (через Network Information API), но потом решили убрать его совсем.

Итак, в октябре мы полностью удалили подмножества вместе с промежуточным этапом. Как только все веса шрифтов Elena и Mija успешно загружены клиентом и готовы к применению, мы запускаем этап 2 и перерисовываем все сразу. А чтобы сделать reflow еще менее заметной, мы потратили немного времени на **подбор резервных шрифтов и веб-шрифтов**. В основном это означало применение немного разных размеров шрифта и высоты строк для элементов, нарисованных в первой видимой части страницы.

Для этого мы использовали [font-style-matcher](https://meowni.ca/font-style-matcher/) и (кхм, кхм) несколько магических чисел. Это также причина, по которой мы изначально использовали -apple-system и Arial в качестве глобальных резервных шрифтов; San Francisco (который рендерился через -apple-system) казался немного лучше, чем Arial, но если он недоступен, мы решили использовать Arial только потому, что он [широко распространен в большинстве операционных систем](http://fontfamily.io/Arial).

В CSS выглядит так:

```css
.article__summary {
    font-family: -apple-system,Arial,BlinkMacSystemFont,Roboto Slab,Droid Serif,Segoe UI,Ubuntu,Cantarell,Georgia,sans-serif;
    font-style: italic;

    /* Warning: magic numbers ahead! */
    /* San Francisco Italic and Arial Italic have larger x-height, compared to Elena */
    font-size: 0.9213em;
    line-height: 1.487em;
}

.wf-loaded-stage2 .article__summary {
    font-family: Elena,sans-serif;
    font-size: 1em; /* Original font-size for Elena Italic */
    line-height: 1.55em; /* Original line-height for Elena Italic */
}
```

Это сработало довольно хорошо. Мы действительно отображаем текст немедленно, и веб-шрифты появляются на экране сгруппированными, в идеале вызывая ровно один reflow в первом отображении и никаких reflow в последующих отображениях.

После загрузки шрифтов мы сохраняем их в **кеше сервис-воркера**. При последующих посещениях мы сначала проверяем, есть ли уже шрифты в кеше. Если это так, мы извлекаем их из кеша сервис-воркера и немедленно применяем. А если нет, мы начинаем все сначала с fallback-web-font-switcheroo.

Это решение сократило количество reflow до минимума (до одного) при относительно быстрых соединениях, а также постоянно и надежно сохраняло шрифты в кеше. В будущем мы искренне надеемся заменить магические числа на [f-mode](https://simonhearne.com/2021/layout-shifts-webfonts/#reduce-layout-shift-with-f-mods). Возможно, [Zach Leatherman будет гордиться этим](https://www.zachleat.com/web/comprehensive-webfonts/).

## Выявление и разбиение монолитного JS
Когда мы изучили основной поток на Performance панели DevTools, мы точно знали, что нам нужно делать. Было восемь длинных задач, которые занимали от 70 до 580мс, блокируя интерфейс и делая его невосприимчивым. В общем, это были скрипты, которые стоили больше всего:

- uc.js, a скрипт подскази cookie (70мс)
- пересчет стилей, вызванный входящим файлом full.css (176 мс) (critical CSS не содержит стилей ниже 1000 пикселей во всех окнах просмотра)
- рекламные скрипты, запускаемые при загрузке, для управления панелями, корзиной покупок и т.д. + перерасчет стилей (276 мс)
- переключение веб-шрифтов, пересчет стилей (290 мс)
- анализ app.js (580мс)

В первую очередь мы сосредоточились на самых вредных, так сказать, самых длинных долгих задачах.

![Performance панель](https://dev-to-uploads.s3.amazonaws.com/i/votpunucy1xgtuq3v0df.png)

Внизу Devtools показывает недействительность стиля - переключение шрифта затронуло 549 элементов, которые пришлось перекрашивать (repaint). Не говоря уже о сдвигах макета (layout shifts), которые это вызывало.

Первый возник из-за дорогостоящих пересчетов макета, вызванных сменой шрифтов (с резервного шрифта на веб-шрифт), что вызвало более 290мс дополнительной работы (на быстром ноутбуке и быстром соединении). Убрав первый этап только из загрузки шрифтов, мы смогли вернуть назад примерно 80мс. Однако этого было недостаточно, потому что бюджет превышал 50мс. Итак, мы начали копать глубже.

Основная причина, по которой произошли перерасчеты, заключалась просто в огромных различиях между резервными шрифтами и веб-шрифтами. **Сопоставив высоту строки и размеры для резервных шрифтов и веб-шрифтов**, мы смогли избежать многих ситуаций, когда строка текста переносилась на новую строку в резервном шрифте, но затем становилась немного меньше и помещалась на предыдущей строке, вызывая серьезные изменения в геометрии всей страницы и, как следствие, большие сдвиги в макете. Мы также играли с letter-spacing и word-spacing, но это не дало хороших результатов.

Благодаря этим изменениям мы смогли сократить еще на 50–80мс, но не смогли уменьшить его ниже 120мс без отображения содержимого в резервном шрифте и последующего отображения содержимого в веб-шрифте. Очевидно, это должно сильно повлиять только на посетителей, впервые посещающих сайт, поскольку последующие просмотры страниц будут отображаться с использованием шрифтов, полученных непосредственно из кеша сервис-воркера, без дорогостоящих reflow из-за переключения шрифтов.

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

Гораздо более серьезной проблемой был JavaScript бандл, который мы поставляли, занимая основной поток колоссальные 580мс. Большая часть этого времени была потрачена на загрузку app.js, который содержит React, Redux, Lodash и загрузчик модулей Webpack. Единственный способ улучшить производительность этого массивного зверя - разбить его на более мелкие чанки. Поэтому мы решили сделать именно это.

С помощью Webpack мы разделили монолитный бандл **на более мелкие чанки с разделением кода**, примерно по 30КБ на фрагмент. Мы сделали некоторую очистку package.json и обновили версию для всех продакшн зависимостей, подправили настройку browserlistrc для работы с двумя последними версиями браузера, обновили до Webpack и Babel до последних версий, перешли на Terser для минификации и использовали ES2017 (+browserlistrc) как target для компиляции скрипта.

Мы также использовали [BabelEsmPlugin](https://github.com/prateekbh/babel-esm-plugin) для генерации современных версий существующих зависимостей. Наконец, мы добавили prefetch ссылки в header для всех необходимых чанков скрипта и провели рефакторинг сервис-воркера, перейдя на Workbox с помощью Webpack ([workbox-webpack-plugin](https://developers.google.com/web/tools/workbox/modules/workbox-webpack-plugin)).

![JS чанки](https://dev-to-uploads.s3.amazonaws.com/i/ptibt0kw7x2ywglps2f0.jpeg)

JavaScript чанки в действии, каждый из которых выполняется не более 40мс в основном потоке.

Помните, когда мы перешли на новую навигацию в середине 2020 года, просто чтобы в результате увидеть огромное снижение производительности? Причина этого была довольно простой. Если раньше навигация представляла собой простой статический HTML и немного CSS, то с новой навигацией нам потребовалось немного JavaScript, чтобы действовать при открытии и закрытии меню на мобильных устройствах и на компьютерах. Это вызывало **злостные щелчки**, когда вы щелкали по меню навигации, и ничего не происходило, и, конечно же, это приводило к штрафу в Time-To-Interactive в Lighthouse.

Мы удалили скрипт из бандла и **выделили его как отдельный скрипт**. Кроме того, мы сделали то же самое для других автономных скриптов, которые использовались редко - для подсветки синтаксиса, таблиц, встраивания видео и встраивания кода - и удалили их из основного бандла; вместо этого мы загружаем их только при необходимости.

![Выделение nav.js](https://dev-to-uploads.s3.amazonaws.com/i/rnl3oocy4ru9x69r32ob.jpeg)

Обратите внимание, что вызов функции для nav.js происходит после выполнения монолитного бандла app.js. Это не совсем верно.

Однако в течение нескольких месяцев мы не замечали, что, хотя мы удалили скрипт навигации из бандла, он загружался после того, как был подгружен весь пакет app.js, что на самом деле не помогло Time-To-Interactive (см. Изображение выше ). Мы исправили это, предварительно загрузив (preload) nav.js и отложив его выполнение в порядке появления в DOM, и нам удалось сэкономить еще 100мс с помощью одной только этой операции. К концу, когда все было готово, мы смогли довести задачу до 220мс.

![Приоритет скрипта nav.js](https://dev-to-uploads.s3.amazonaws.com/i/7fou7aue5mwed2e2ab0u.jpeg)

Установив приоритет скрипта nav.js, мы смогли сократить длительную задачу почти на 200мс.

Нам удалось добиться некоторых улучшений, но нам еще предстоит пройти долгий путь, и в нашем списке дел есть дальнейшая оптимизация React и Webpack. На данный момент у нас все еще есть **три основные длинные задачи** - переключение шрифта (120мс), выполнение app.js (220 мс) и пересчет стилей из-за размера полного CSS (140мс). Для нас это означает чистку и разбиение монолитного CSS.

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

## Разбираемся с сторонними скриптами
К счастью, след наших сторонних скриптов (и влияние сторонних скриптов их друзей) с самого начала не было значительным. Но когда эти сторонние скрипты накапливаются, они значительно снижают производительность. Это особенно касается сценариев **встраивания видео**, а также подсветки синтаксиса, рекламных сценариев, сценариев промо-панелей и любых внешних встроенных iframe.

Очевидно, что мы откладываем (defer) загрузку всех этих скриптов после события DOMContentLoaded, но как только они наконец “выходят на сцену”, они вызывают довольно много работы в основном потоке. Это особенно заметно на страницах статей, которые, очевидно, составляют подавляющее большинство контента на сайте.

Первое, что мы сделали, - это **выделили надлежащее место для всех ресурсов**, которые вводятся в DOM после первоначального рендеринга страницы. Имеем в виду ширину (width) и высоту (height) всех рекламных изображений и стили фрагментов кода. Мы обнаружили, что из-за того, что все скрипты были отложены, новые стили перетирали существующие стили, вызывая массовые сдвиги в макете для каждого отображаемого фрагмента кода. Мы исправили это, добавив необходимые стили в critical CSS на страницах статей.

Мы восстановили стратегию **оптимизации изображений** (предпочтительно AVIF или WebP - хотя работа еще продолжается). Все изображения ниже порога (threshold) высоты 1000 пикселей изначально загружаются лениво (с <img loading = lazy>), тогда как изображения наверху имеют приоритет (<img loading = eager>). То же самое касается всех сторонних встраиваний.

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

На момент написания мы готовим [фасады](https://web.dev/third-party-facades/) для встраивания кода и видео. Кроме того, все изображения, которые находятся за пределами экрана, получат атрибут [decoding = async](https://developer.mozilla.org/en-US/docs/Web/API/HTMLImageElement/decoding), поэтому браузер может свободно распоряжаться, когда и как загружать изображения за пределами экрана, асинхронно и параллельно.

![Diagnostics CSS](https://dev-to-uploads.s3.amazonaws.com/i/dlt7mkndkp53yct35vku.png)

[Diagnostics CSS](https://gist.github.com/tkadlec/683b26344cde774170b94c0fcf0088b4): выделение изображений, у которых нет атрибутов width/height или которые представлены в устаревших форматах

Чтобы гарантировать, что наши изображения всегда включают атрибуты width и height, мы также изменили сниппет от [Harry Roberts](https://twitter.com/csswizardry/status/1346477682544951296) и [diagnostics CSS от Tim Kadlec](https://gist.github.com/tkadlec/683b26344cde774170b94c0fcf0088b4), чтобы выделять всякий раз, когда изображение не поставляется должным образом. Он используется в разработке и редактировании, но явно не в проде.

Одним из методов, который мы часто использовали для отслеживания того, что именно происходит при загрузке страницы, была slow-motion loading.

Во-первых, мы добавили простую строку кода в diagnostics CSS, который обеспечивает заметный контур для всех элементов на странице.

```css
* {
  outline: 3px solid red
}
```

![Alt Text](https://dev-to-uploads.s3.amazonaws.com/i/zxpyzori2hbpgluabsfn.png)

Быстрый трюк, позволяющий проверить стабильность макета, добавив * {outline: 3px red} и наблюдая за полями, пока браузер рендерит страницу.

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

Вот запись загрузки страницы по быстрому соединению:
https://vimeo.com/502962963

А вот запись проигрываемой записи для изучения того, что происходит с макетом:
https://vimeo.com/502964702

Проведя аудит изменений макета таким образом, мы смогли быстро заметить, что на странице не совсем правильно и где происходят огромные затраты на пересчет. Как вы, наверное, заметили, настройка line-height и font-size в заголовках может иметь большое значение, чтобы избежать больших сдвигов.

Только с этими простыми изменениями мы смогли повысить показатель производительности на целых **25 баллов Lighthouse** за статью с [самым большим объемом видео](https://www.smashingmagazine.com/2020/02/design-mobile-apps-one-hand-usage/) и получить несколько баллов за встраивание кода.

## Улучшение опыта
Мы старались придерживаться стратегии практически во всем, от загрузки веб-шрифтов до поставки critical CSS. Однако мы приложили все усилия, чтобы использовать некоторые из новых технологий, которые стали доступны в прошлом году.

Мы планируем использовать AVIF по умолчанию для обслуживания изображений в SmashingMag, но мы еще не достигли этого, так как какие-то из наших изображений обслуживаются из Cloudinary (который уже имеет бета-поддержку AVIF), но многие из них еще напрямую из нашего CDN, где у нас пока еще нет логики для генерации AVIF на лету. На данный момент это ручной процесс.

Мы лениво подгружаем и рендерим некоторые компоненты страницы с помощью [content-visibility: auto](https://web.dev/content-visibility/). Например, футер, раздел комментариев, а также панели, находящиеся ниже первого порогового значения высоты в 1000 пикселей, отображаются позже, после визуализации видимой части каждой страницы.

Мы немного поигрались со link rel="prefetch" и даже link rel="prerender" (NoPush prefetch) с некоторыми частями страницы, которые, скорее всего, будут использоваться для дальнейшей навигации - например, для prefetch ресурсов для первой статьи на первой полосе (все еще обсуждаются).

Мы также **предварительно загружаем изображения авторов**, чтобы уменьшить размер самой большой Contentful Paint и некоторые ключевые ресурсы, которые используются на каждой странице, например изображения танцующих кошек (для навигации) и тени, используемые для всех изображений авторов. Однако все они предварительно загружаются только в том случае, если читатель оказывается на большом экране (> 800 пикселей), хотя мы смотрим в сторону Network Information API, чтобы быть более точными.

Мы также **уменьшили размер полного CSS** и всех critical CSS файлов, удалив устаревший код, реорганизовав ряд компонентов и удалив трюк с text-shadow, который мы использовали для достижения идеального подчеркивания с помощью комбинации text-decoration-skip-ink и text-decoration-thickness (наконец-то!).

## Работа, которую нужно сделать
Мы потратили довольно много времени на работу над всеми мелкими и крупными изменениями на сайте. Мы заметили довольно значительные улучшения на настольных компьютерах и довольно заметный рост на мобильных устройствах. На момент написания наши статьи набирают в среднем от 90 до 100 баллов Lighthouse на настольных компьютерах и около **65–80 на мобильных устройствах**.

Причина плохой оценки на мобильных устройствах - это явно плохие Time to Interactive и Total Blocking time из-за загрузки приложения и размера полного файла CSS. Так что там еще есть над чем поработать.

Что касается следующих шагов, мы в настоящее время изучаем **дальнейшее уменьшение размера CSS** и, в частности, разбиваем его на модули, аналогично JavaScript, загружая некоторые части CSS (например, оформление заказа или доску объявлений или книги/электронные книги) только тогда, когда необходимо.

Мы также изучаем варианты дальнейших экспериментов с бандлом на мобильных устройствах, чтобы уменьшить влияние app.js на производительность, хотя на данный момент это кажется нетривиальным. Наконец, мы рассмотрим альтернативы нашему решению для подсказок cookie, перестроим наши контейнеры с помощью CSS clamp(), заменим технику padding-bottom ratio на aspect-ratio и рассмотрим возможность поставки как можно большего количества изображений в AVIF.

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

Хотя мы очень стремимся к повышению производительности, мы также работаем над улучшением доступности и содержания сайта. Так что если вы заметите что-то не совсем правильное или что-то, что мы могли бы сделать для дальнейшего улучшения Smashing Magazine, сообщите нам об этом в комментариях к этой статье.

Наконец, если вы хотите быть в курсе подобных статей, [подпишитесь на нашу рассылку новостей по электронной почте](https://www.smashingmagazine.com/the-smashing-newsletter), чтобы получать полезные советы, полезные советы, инструменты и статьи, а также сезонную подборку Smashing cats.

Перевел: [Ариф Балаев](https://twitter.com/BalaevArif)