[Перевод] Ускоряем фронтенд. Когда много запросов к серверу — это хорошо
В этой статье описываются некоторые методы ускорения загрузки фронтенд-приложений, чтобы реализовать отзывчивый, быстрый пользовательский интерфейс.
Мы обсудим общую архитектуру фронтенда, как обеспечить предварительную загрузку необходимых ресурсов и увеличить вероятность того, что они в кэше. Немного порассуждаем, как отдавать ресурсы с бэкенда и когда можно ограничиться статическими страницами вместо интерактивного клиентского приложения.
Процесс загрузки разделим на три этапа. Для каждого этапа сформулируем общие стратегии повышения производительности:
- Начальная отрисовка: сколько времени требуется, чтобы пользователь увидел хоть что-то
- Сократить запросы, блокирующие рендеринг
- Избегать последовательных цепочек
- Повторно использовать соединения с сервером
- Сервис-воркеры для мгновенного рендеринга
- Сократить запросы, блокирующие рендеринг
- Загрузка приложения: сколько времени требуется, чтобы пользователь смог использовать приложение
- Разбить пакет приложения на части. Загружать только необходимые ресурсы. Максимизация попаданий в кэш
- Проверить, что хэши частей пакета не меняются без необходимости
- Запрашивать данные для страницы до загрузки всего приложения
- Не блокировать рендеринг загрузкой второстепенных данных
- Рассмотреть рендеринг на стороне сервера
- Разбить пакет приложения на части. Загружать только необходимые ресурсы. Максимизация попаданий в кэш
- Следующая страница: сколько времени требуется для перехода на следующую страницу
- Запрашивать дополнительный код до того, как он понадобится
- Кэшировать и повторно использовать данные на клиенте
- Запрашивать дополнительный код до того, как он понадобится
До момента начальной отрисовки пользователь вообще ничего не видит на экране. Что нам нужно для этой отрисовки? Как минимум, загрузить HTML-документ, а в большинстве случаев ещё и дополнительные ресурсы, такие как файлы CSS и JavaScript. Как только они доступны, браузер может приступить к какому-то рендерингу.
На протяжении всей статьи приведены диаграммы WebPageTest. Последовательность запросов для вашего сайта, вероятно, будет выглядеть примерно так.
HTML-документ загружает кучу дополнительных файлов, и страница отрисовывается после их загрузки. Обратите внимание, что файлы CSS загружаются параллельно друг другу, поэтому каждый дополнительный запрос не добавляет значительной задержки.
(Примечание: на скриншоте пример gov.uk, где теперь включен HTTP/2, так что домен с ресурсами может повторно использовать существующее соединение. Подробнее о серверных соединениях см. ниже).
Сократить запросы, блокирующие рендеринг
Таблицы стилей и (по умолчанию) скрипты блокируют отрисовку любого контента под ними.
Есть несколько вариантов, как это исправить:
- Перенести теги script в нижнюю часть body
- Загружать скрипты в асинхронном режиме с помощью
async
- Если JS или CSS должны загружаться последовательно, то лучше встроить их небольшими сниппетами
Избегайте цепочек с последовательными запросами, блокирующими рендеринг
Задержка с отрисовкой сайта не обязательно связана с большим количеством запросов, которые блокируют рендеринг. Более важным является размер каждого ресурса, а также время начала его загрузки. То есть момент, когда браузер вдруг понимает, что вот этот ресурс нужно загрузить.
Если браузер обнаруживает необходимость загрузить файл только после завершения другого запроса, то налицо цепочка запросов. Она может сформироваться по разным причинам:
- Правила
@import
в CSS - Веб-шрифты, на которые ссылается файл CSS
- Загружаемые JavaScript или теги скриптов
Взгляните на этот пример:
Один из CSS-файлов на этом сайте загружает шрифт Google через правило @import
. Это означает, что браузеру приходится по очереди выполнять следующим запросы:
- Документ HTML
- CSS приложения
- CSS для Google Fonts
- Файл Woff от Google Font (не показан на диаграмме)
Чтобы исправить это, сначала переместите запрос Google Fonts CSS из тега @import
в ссылку в HTML-документе. Так мы сокращаем цепь на одно звено.
Чтобы ещё больше ускорить процесс, вставьте Google Fonts CSS напрямую в свой файл HTML или CSS.
(Имейте в виду, что ответ CSS от сервера Google Fonts зависит от строчки user agent. Если сделать запрос с помощью IE8, то CSS сошлётся на файл EOT, браузер IE11 получит файл woff, а современные браузеры получат файл woff2. Если вы согласны, что старые браузеры ограничатся системными шрифтами, то можете просто скопировать и вставить к себе содержимое файла CSS).
Даже после начала рендеринга пользователь вряд ли сможет взаимодействовать со страницей, потому что для отображения текста нужно загрузить шрифт. Это дополнительная сетевая задержка, которой хотелось бы избежать. Здесь полезен параметр swap, он позволяет использовать font-display
с Google Fonts, а шрифты хранить локально.
Иногда цепочку запросов устранить невозможно. В таких случаях можете рассмотреть тег preload или preconnect. Например, веб-сайт из примера выше может подключиться к fonts.googleapis.com
ещё до того, как поступит фактический запрос CSS.
Повторное использование соединений с сервером для ускорения запросов
Чтобы установить новое соединение с сервером, обычно требуется три обмена пакетами между браузером и сервером:
- Поиск DNS
- Установление TCP-соединения
- Установление SSL-соединения
После установления соединения требуется по крайней мере ещё один обмен пакетами, чтобы отправить запрос и получить ответ.
Диаграмма ниже показывает, что мы инициируем соединения с четырьмя различными серверами: hostgator.com
, optimizely.com
, googletagmanager.com
, и googelapis.com
.
Однако последующие запросы к серверу могут повторно использовать существующее соединение. Загрузка base.css
или index1.css
происходит быстрее, потому что они размещены на том же сервере hostgator.com
, с которым ранее уже установлено соединение.
Уменьшите размер файлов и используйте CDN
Вы контролируете два фактора, которые влияют на время выполнения запроса: это размер файлов с ресурсами и расположение серверов.
Отправляйте пользователю как можно меньше данных и убедитесь, что они сжаты (например, с помощью brotli или gzip).
У сетей доставки контента (CDN) серверы по всему миру. Вместо подключения к центральному серверу пользователь может подключиться к серверу CDN, который находится ближе. Таким образом, обмен пакетами пройдёт гораздо быстрее. Это особенно хорошо подходит для статических ресурсов, таких как CSS, JavaScript и изображения, поскольку их легко распространять через CDN.
Устраняем сетевую задержку с помощью сервис-воркеров
Сервис-воркеры (Service Workers) позволяют перехватывать запросы перед их отправкой в сеть. Это означает, что ответ приходит практически мгновенно!
Конечно, это работает только в том случае, если вам действительно не требуется получение данных из сети. Ответ уже должен быть кэширован, поэтому выгода появится только со второй загрузки приложения.
Сервис-воркер ниже кэширует HTML и CSS, необходимые для визуализации страницы. Когда приложение снова загружается, оно пытается само выдать кэшированные ресурсы — и обращается в сеть только если они недоступны.
self.addEventListener("install", async e => {
caches.open("v1").then(function (cache) {
return cache.addAll(["/app", "/app.css"]);
});
});
self.addEventListener("fetch", event => {
event.respondWith(
caches.match(event.request).then(cachedResponse => {
return cachedResponse || fetch(event.request);
})
);
});
В этом руководстве подробно рассказано об использовании сервис-воркеров для предварительной загрузки и кэширования ресурсов.
Итак, пользователь что-то видит на экране. Какие необходимы дальнейшие действия, чтобы он мог использовать приложение?
- Загрузить код приложения (JS и CSS)
- Загрузить необходимые данные для страницы
- Загрузить дополнительных данные и изображения
Обратите внимание, что не только загрузка данных из сети может задержать рендеринг. Как только ваш код загружен, браузер должен проанализировать, скомпилировать и выполнить его.
Загружайте только необходимый код и максимизируйте количество обращений в кэш
«Разбить пакет» означает загружать только код, необходимый для текущей страницы, а не всего приложения. Это также означает, что части пакета можно кэшировать, даже если другие части изменились и нуждаются в перезагрузке.
Как правило, код разбивается на такие части:
- Код для конкретной страницы (page-specific)
- Общий код приложения
- Сторонние модули, которые редко меняются (отлично подходит для кэширования!)
Webpack может автоматически произвести такую оптимизацию, разбить код и уменьшить общий вес загрузки. Разбивка кода на куски производится с помощью объекта optimization.splitChunks. Выделите среду выполнения (рантайм) в отдельный файл: так вы сможете извлечь выгоду из долгосрочного кэширования. Иван Акулов написал подробное руководство по разбивке пакета на отдельные файлы и кэшированию в Webpack.
Автоматически выделить код для конкретной страницы невозможно. Нужно вручную определить те части, которые можно загружать отдельно. Часто это определённый путь или набор страниц. Используйте динамический импорт для ленивой загрузки этого кода.
Разбивка общего пакета на части увеличит количество запросов для загрузки вашего приложения. Но это не большая проблема, если запросы выполняются параллельно, особенно если сайт загружается по протоколу HTTP/2. Можете убедиться в этом по трём первым запросам на следующей диаграмме:
Однако на диаграмме также видны два последовательных запроса. Эти фрагменты нужны только для этой конкретной страницы и они загружаются динамически через import()
.
Вы можете попробовать исправить проблему, вставив тег предварительной загрузки preload.
Но мы видим, что общее время загрузки страницы увеличилось.
Предварительная загрузка ресурсов иногда контрпродуктивна, так как задерживает загрузку более важных файлов. Почитайте статью Энди Дэвиса о предварительной загрузке шрифтов и о том, как эта процедура блокирует начало рендеринга страницы.
Загрузка данных для страницы
Вероятно, ваше приложение должно показывать какие-то данные. Вот несколько советов, которые можно использовать для ранней загрузки этих данных без лишних задержек при рендеринге.
Не ждите полной загрузки пакета, прежде чем начать загружать данные
Здесь частный случай цепочки последовательных запросов: вы загружаете весь пакет приложения, а затем этот код запрашивает нужные данные для страницы.
Есть два способа избежать этого:
- Встроить данные в HTML-документ
- Запустить запрос данных с помощью встроенного скрипта внутри документа
Внедрение данных в HTML гарантирует, что приложение не будет ждать их загрузки. Это также снижает сложность системы, так как вам не нужно обрабатывать состояние загрузки.
Однако это не очень хорошая идея, если такой приём задержит первоначальный рендеринг.
В этом случае, а также если вы отдаёте кэшированный HTML-документ через сервис-воркера, в качестве альтернативы можно использовать встроенный скрипт, который загрузит эти данные. Можете сделать его доступным как глобальный объект, вот такой промис:
window.userDataPromise = fetch("/me")
Если данные готовы, а такой ситуации приложение может немедленно начать рендеринг или подождать, пока они будут готовы.
При использовании обоих методов нужно заранее знать, какие данные будет загружать страница, прежде чем приложение начнёт рендеринг. Как правило, это очевидно для данных, связанных с пользователем (имя пользователя, уведомления и т. д.), но сложнее с контентом, который специфичен для конкретной страницы. Возможно, есть смысл выделить самые важные страницы и написать для них собственную логику.
Не блокируйте рендеринг в ожидании несущественных данных
Иногда для генерации данных требуется запустить медленную сложную логику на бэкенде. В таких случаях можете попробовать сначала загрузить более простую версию данных, если этого достаточно, чтобы приложение стало функциональным и интерактивным.
Например, инструмент аналитики перед загрузкой данных может сначала загрузить список всех диаграмм. Это позволяет пользователю сразу же искать интересующую его диаграмму, а также помогает распределить бэкенд-запросы по разным серверам.
Избегайте цепочек с последовательными запросами данных
Это может противоречить предыдущему пункту о том, что несущественные данные лучше вынести в отдельный запрос. Поэтому следует уточнить: избегайте цепочек с последовательными запросами данных, если каждый завершённый запрос не приводит к тому, что пользователю показывается больше информации.
Вместо того, чтобы сначала делать запрос, какой пользователь вошёл в систему, а затем запрашивать список его групп, сразу верните список групп вместе с информацией о пользователе в первом запросе. Для этого можно использовать GraphQL, но и конечная точка user?includeTeams=true
тоже отлично работает.
Рендеринг на стороне сервера
Рендеринг на стороне сервера означает предварительный рендеринг приложения, так что по запросу клиента выдаётся полностраничный HTML. Клиент видит страницу полностью отрисованной, не дожидаясь загрузки дополнительного кода или данных!
Поскольку сервер отправляет клиенту просто статический HTML, приложение не является интерактивным в этот момент. Нужно загрузить само приложение, запустить логику рендеринга, затем подключить к DOM необходимые прослушиватели событий.
Используйте серверный рендеринг в том случае, если просмотр неинтерактивного контента имеет ценность сам по себе. Также неплохо кэшировать отрендеренный HTML на сервере и сразу выдавать его всем пользователям без задержки. Например, серверный рендеринг отлично подходит при использовании React для вывода сообщений в блоге.
В этой статье Михаила Янашека рассказывается, как сочетать сервис-воркеры и рендеринг на стороне сервера.
В какой-то момент пользователь собирается нажать кнопку и перейти на следующую страницу. С момента открытия начальной страницы вы управляете тем, что происходит в браузере, поэтому можете подготовиться к следующему взаимодействию.
Предварительная загрузка ресурсов
Если заранее загрузить код, необходимый для следующей страницы, то исчезает задержка при запуске навигации пользователем. Используйте теги prefetch или webpackPrefetch
для динамического импорта:
import(
/* webpackPrefetch: true, webpackChunkName: "todo-list" */ "./TodoList"
)
Учитывайте, какую нагрузку вы возлагаете на пользователя по трафику и полосе пропускания, особенно если он подключён по мобильному соединению. Если же человек загрузил мобильную версию сайта и у него активен режим сохранения данных, то разумно менее агрессивно выполнять предварительную загрузку.
Стратегически продумайте, какие части приложения раньше понадобятся пользователю.
Повторное использование уже загруженных данных
Кэшируйте данные локально в своём приложении и используйте их, чтобы избежать будущих запросов. Если пользователь переходит из списка своих групп на страницу «Отредактировать группу», вы можете произвести переход мгновенно, повторно используя ранее загруженные данные о группе.
Обратите внимание, что это не сработает, если объект часто редактируется другими пользователями, а загруженные данные могут устареть. В этих случаях есть вариант сначала показать существующие данные только для чтения, одновременно выполняя запрос на обновлённые данные.
В этой статье перечислен ряд факторов, которые могут замедлить вашу страницу на разных этапах процесса загрузки. Инструменты вроде Chrome DevTools, WebPageTest и Lighthouse помогут выяснить, какие из этих факторов влияют на работу конкретно вашего приложения.
На практике редко оптимизация идёт сразу по всем направлениям. Надо узнать, что оказывает наибольшее влияние на пользователей, и сосредоточиться на этом.
Пока я писал статью, то понял одну важную вещь: у меня было укоренившееся убеждение, что много отдельных запросов к серверу — это плохо для производительности. Так было в прошлом, когда каждый запрос требовал отдельного соединения, а браузеры разрешали лишь несколько соединений на домен. Но с HTTP/2 и современными браузерами это уже не так.
Есть веские аргументы в пользу разбиения приложения на части (с умножением запросов). Это позволяет загружать только необходимые ресурсы и лучше использовать кэш, поскольку придётся повторно загружать лишь изменённые файлы.