Привет! Сегодня поговорим про оптимизации, что влияет на метрики, расскажу про best practices и случаи из жизни :)
Выделю три основные направления возможных оптимизаций:
- размеры бандлов и их доставка
- загрузка и отрисовка страницы
- взаимодейстивие со страницей, отзывчивость, UX
Про бандлы и доставку:
Кажется очевидным, но почему-то часто об этом забывают:
- включите сжатие gzip/brotli для ресурсов
- используйте в вебпаке хеш от контента в имени файла, и включите кеширование на подольше (у нас на месяц)
Тоже очевидно, но всё же, убедитесь, что вебпак собирает бандлы в продакшен-режиме с включенной опцией minimize:
Следующим шагом можно настроить в вебпаке разбивку на чанки. Чтобы это сделать, неплохо для начала проанализировать, из чего состоит итоговый бандл. Например, с помощью webpack-bundle-analyzer
npmjs.com/package/webpac…
Распространён подход, когда в один чанк (или несколько) выносят зависимости node_modules, а в другой код приложения. Это кажется не очень оптимальным, поскольку при таком подходе при первой загрузке приложения пользователь всё равно скачает всё.
Лучше, изучив состав бандла через webpack-bundle-analyzer, выделить в отдельный чанк общие зависимости для всех роутов приложения (например, реакт, роутер, redux, и UI-kit), а зависимости, используемые в 1-2 страницах, включить в состав чанка этой страницы
Для генерации отдельного чанка для каждого роута отлично подходит React.lazy() в сочетании с react-router ru.reactjs.org/docs/code-spli…
Дать файлам чанков человеко-читаемые имена поможет волшебный комментарий для вебпака:
Хорошим способом сократить объем кода, скачиваемого при первой загрузке React приложения, будет импортировать с lazy() все компоненты, с которыми пользователь не взаимодействует сразу: модалки, скрытые компоненты, и подобное.
Некоторые npm пакеты могут существенно увеличить объем бандла кодом, который никогда не будет использован. Например, moment.js по-умолчанию включает все возможные локали, а lodash — все хелперы.
Исключить лишние локали из moment.js можно через moment-locales-webpack-plugin,
github.com/iamakulov/mome…, IgnorePlugin или ContextReplacementPlugin github.com/jmblog/how-to-…
Про загрузку только необходимых функций lodash, можно почитать тут: azavea.com/blog/2019/03/0…
Про загрузку, отрисовку и взаимодейстивие со страницей продолжу завтра
Про загрузку и отрисовку страницы:
Чтобы разобраться в том, как оптимизировать отрисовку, нужно понимать, как браузер рендерит страницу.
Пара ссылок:
1)developers.google.com/web/fundamenta…,
2)medium.com/jspoint/how-th…
Блокируют отрисовку страницы:
- Стили, подключенные без атрибута media,
- стили, подключенные с атрибутом media, который совпадает с текущими параметрами устройства,
- скрипты без атрибута async/defer.
Стили и скрипты будут загружены, даже если они не блокируют отрисовку.
Чтобы немного упростить браузеру задачу отрисовки:
- разделите CSS на файлы так, чтобы один файл — один media query (плагин: npmjs.com/package/postcs…),
- подключайте скрипты с async/defer, и проверяйте в них событие DOMContentLoaded
Пользователь, заходя на страницу, видит только какую-то её часть. Важно сделать так, чтобы эта видимая часть отобразилась как можно быстрее. Для этого можно выделить стили, необходимые для отрисовки первого экрана, и заинлайнить их. Плагин для вебпака:
github.com/GoogleChromeLa…
Применение critical css позитивно скажется на метриках, например, Largest contentful paint
На контентные метрики (Largest contentful paint / First meaningful paint) влияет и то, как подключаются и используются шрифты.
Мы однажды обнаружили, что на одном из сервисов загружался, но не использовался, кастомный шрифт. После его удаления, метрика FMP улучшилась на 76%!
Ещё немного про шрифты:
- формата woff2 достаточно в большинстве случаев (caniuse.com/?search=woff2);
- подключайте шрифт с preload;
- используйте font-display: swap;
Про изображения:
- указывайте width/height, так браузер заранее зарезервирует место для картинок
- для контентных картинок — атрибут loading="lazy" (caniuse.com/loading-lazy-a…) для нативной загрузки по скроллу (полифил: github.com/mfranzke/loadi…)
Для контентных картинок можно использовать тег picture (developer.mozilla.org/en-US/docs/Web…), чтобы, например, загружать более легкие изображения на маленьких экранах. Плагин: responsive-loader (npmjs.com/package/respon…).
Совет от кэпа: убедитесь, что картинки минимизованы и имеют адекватные размеры! Особенно, если картинки могут загружать пользователи. В одном нашем сервисе мы сэкономили 10мб просто на том, что привели изображения к ширине 1024px и качеству 85% ¯_(ツ)_/¯
Кулстори про клиентский и серверный рендеринг. Есть мнение, что рендерить страницу на сервере быстрее, чем на клиенте. Поэтому мы выпилили реакт на одной из статичных страниц, и переписали её на чистом html, который собирается через Django на бэке (Django там уже был) ->
Постепенно к странице появлялись всё новые требования: сначала кастомизации в зависимости от тарифного плана и страны пользователя, а через год — от нескольких десятков параметров. Настройки делали на бэке условиями в темплейте, в итоге time to first byte составил 2 сек
Поддерживать это стало очень сложно, решили снова переписать на реакте. Страница стала отдаваться за 200мс, но выросли TTI и CLS (август-сентябрь на графике):
Тогда придумали к̶о̶с̶т̶ы̶л̶ь изящное решение: для видимой части сверстали скелетон (штуку как на картинке) и заинлайнили его стили в <style>. Поместили этот код внутри тега, куда рендерится реакт, чтобы он заменялся на реактом на готовую страницу с контентом.
Если нужно передать через шаблон данные с сервера на клиент (например, initial state для redux store), лучше завернуть их в JSON.parse(), так браузер сможет распарсить их быстрее, и это тоже улучшит TTI:
joreteg.com/blog/improving…
Про взаимодействие и UX.
Если использовать скелетон, а не спиннер, то пользователям кажется, что страница загружается быстрее:
uxdesign.cc/what-you-shoul…
Полезно изучить, как пользователи переходят по страницам сайта, и использовать это знание для предварительной загрузки ресурсов. Допустим, с главной 80% идут на регистрацию, тогда можно заранее загрузить ресурсы и в фоне отрендерить эту страницу.
caniuse.com/link-rel-prere…
С rel="prerender" нужно работать осторожно, так как это ускоряет следующую страницу для части пользователей за счёт загрузки доп ресурсов на текущей для всех. Не рекомендуется делать более одной ссылки с rel="prerender" на странице.
Используюя rel="preload" можно скачать и закешировать ресурсы, которые скоро понадобятся. В отличие от rel="prerender", они не будут исполнены (только скачаются).
caniuse.com/link-rel-prelo…
Guess.js — экспериментальная библиотека, которая на основе данных гугл-аналитики предсказывает, на какую страницу дальше пойдёт пользователь, и динамически делает prerender/preload.
github.com/guess-js/guess
Для Webpack есть preload-webpack-plugin, он решает проблему добавления ссылок на ресурсы с хешем в имени:
npmjs.com/package/@vue/p…
Совсем немного про отзывчивость интерфейса:
Если в течение ~100мс после действия (клик по кнопке, нажатие на меню, фокусировка в инпуте и начало ввода текста и тд) пользователь не получил визуального подтверждения, ему кажется, что интерфейс тормозит
Поэтому важно не блокировать интерфейс при выполнении запросов к API и других операциях, и максимально быстро показать, что что-то произошло.
Есть паттерн Optimistic UI, согласно которому предполагается, что операция завершилась успехом, и показывает результат сразу по взаимодействию, ещё до того, как выполнен запрос/пришли данные. uxplanet.org/optimistic-100…
Особенно хорошо такой подход работает в случае с атомарными независимыми друг от друга операциями
Как реализовать Optimistic UI с Apollo GraphQL:
apollographql.com/docs/react/per…
Как реализовать Optimistic UI с React Query:
react-query.tanstack.com/guides/optimis…