Продуманный front-end. Правильная архитектура для быстрых сайтов

Автор оригинала: DebugBear
  • Перевод
Привет, Хабр!

Мы давно обходили вниманием тему браузеров, CSS и accessibility и решили вернуться к ней с переводом сегодняшнего обзорного материала (оригинал — февраль 2020). Особенно интересует ваше мнение об упомянутой здесь технологии серверного рендеринга, а также о том, насколько назрела необходимость в полноценной книге по HTTP/2 — впрочем, давайте обо всем по порядку.

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

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

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

Обзор


Разобьем процесс загрузки приложения на три отдельных этапа:

  1. Первичный рендеринг – сколько времени пройдет, прежде, чем пользователь что-то увидит?
  2. Загрузка приложения – сколько времени пройдет, прежде, чем пользователь увидит приложение?
  3. Следующая страница – сколько времени требуется для перехода на следующую страницу?

Первичный рендеринг


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

В этом посте я буду использовать каскадные диаграммы WebPageTest. Каскад запросов для вашего сайта будет выглядеть примерно так.



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

Сокращаем количество запросов, блокирующих рендеринг


Таблицы стилей и (по умолчанию) скриптовые элементы не позволяют отобразить какой-либо контент, расположенный под ними.

Существует несколько способов, позволяющих это исправить:

  • Ставим скриптовые теги в самом низу тега body
  • Асинхронно загружать скрипты при помощи async
  • Внутристрочно записывать небольшие фрагменты JS или CSS, если их требуется загружать синхронно

Избегайте образования цепочек запросов, блокирующих рендеринг


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

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

  • Наличие правил @import в CSS
  • Использование веб-шрифтов, ссылки на которые стоят в CSS-файле
  • Ссылка для инъекции JavaScript или скриптовые теги

Рассмотрим такой пример:



В одном из CSS-файлов на этом сайте содержится правило @import, предназначенное для загрузки шрифта Google. Таким образом, браузер должен выполнять следующие запросы один за другим, в таком порядке:

  1. Document HTML
  2. Application CSS
  3. Google Fonts CSS
  4. Файл Google Font Woff (не показан в каскаде)

Чтобы это исправить, сначала переместим запрос к Google Fonts CSS из @import к ссылочному тегу в HTML-документе. Так мы укоротим цепочку на одно звено.

Чтобы добиться еще более значительного ускорения, встроим файл Google Fonts CSS прямо в ваш HTML или в ваш CSS-файл.

(Помните, что CSS-отклик от Google Fonts зависит от пользовательского агента. Если сделать запрос при помощи IE8, то CSS сошлется на файл EOT (внедряемый OpenType), IE11 получит woff-файл, а современные браузеры — woff2. Но, если вас устраивает работать так, как со сравнительно старыми браузерами, использующими системные шрифты, то можно просто скопировать и вставить содержимое CSS-файла.)

Даже после того, как начнется рендеринг страницы, пользователь, возможно, ничего не сможет с ней поделать, так как не отобразится никакого текста до полной загрузки шрифта. Этого можно избежать при помощи свойства font-display swap, которое теперь используется в Google Fonts по умолчанию.

Иногда полностью избавиться от цепочки запросов не удается. В таких случаях попробуйте использовать тег preload или preconnect. Например, сайт, показанный выше, может подключиться к fonts.googleapis.com до того, как фактически будет сделан запрос к CSS.

Переиспользуем серверные соединения для ускорения запросов


Как правило, для установления нового серверного соединения требуется 3 прохода туда-обратно между браузером и сервером:

  1. Поиск DNS
  2. Установление TCP-соединения
  3. Установление SSL-соединения

Как только соединение установлено, требуется еще как минимум 1 проход туда и обратно: отправить запрос и загрузить отклик.

Как показано в нижеприведенном каскаде, соединения инициируются к четырем разным серверам: hostgator.com, optimizely.com, googletagmanager.com и googelapis.com.

Однако при последующих запросах к затронутому серверу можно заново пользоваться уже имеющимся соединением. Поэтому base.css или index1.css загружаются быстро, так как они тоже расположены на hostgator.com.



Уменьшаем размер файла и используем сети доставки контента (CDN)


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

Отправляйте пользователю минимально необходимое количество данных, причем, позаботьтесь об их сжатии (напр., при помощи brotli или gzip).

Сети доставки контента (CDN) предоставляют сервера, расположенные в самых разных местах, поэтому велика вероятность, что какой-нибудь из них будет расположен поблизости от ваших пользователей. Вы можете подключать их не к вашему центральному серверу приложений, а к ближайшему серверу в сети CDN. Таким образом, путь данных на сервер и обратно значительно сократится. Это особенно удобно при работе со статическими ресурсами, например, CSS, JavaScript и изображениями, поскольку их легко распределять.

Минуем сеть при помощи сервис-воркеров


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



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

Сервис-воркер, показанный ниже, кэширует 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);
   })
 );
});

О предзагрузке и кэшировании ресурсов при помощи сервис-воркеров подробнее рассказано в этом руководстве.

Загрузка приложения


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

  1. Загрузка приложения (JS и CSS)
  2. Загрузка наиболее важных данных для страницы
  3. Загрузка дополнительных данных и изображений



Обратите внимание: рендеринг может замедляться не только из-за загрузки данных по сети. Когда код будет загружен, браузеру потребуется разобрать, скомпилировать и выполнить его.

Разделение бандла: загружаем только необходимый код и доводим до максимума попадания в кэш.

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

Как правило, код состоит из файлов трех различных типов:

  • Код, специфичный для данной страницы
  • Разделяемый код приложения
  • Сторонние модули, которые меняются редко (отлично подходят для кэширования!)

Webpack может автоматически разбивать разделяемый код, чтобы снизить общий вес загрузок, это делается при помощи optimization.splitChunks. Обязательно активируйте фрагмент, отвечающий за время исполнения (runtime chunk), так, чтобы хэши фрагментов оставались стабильными, и можно было с пользой применять долгосрочное кэширование. Иван Акулов написал подробное руководство о разделении и кэшировании кода Webpack.

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

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



Однако, в этом каскаде также показано 2 запроса, выполняемых последовательно. Эти фрагменты нужны только для данной страницы, и динамически загружаются при помощи вызова import().

Это можно исправить, вставив тег preload link, если вам известно, что эти фрагменты точно понадобятся.



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

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

Загрузка страничных данных


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

Не дожидайтесь бандлов, сразу начинайте загружать данные

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

Есть два способа этого избежать:

  1. Встраивать страничные данные в HTML-документ
  2. Начинать запрашивать данные через внутристрочный скрипт, находящийся внутри документа

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

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

В таком случае, либо при выдаче кэшированного HTML-документа при помощи сервис-воркера, можно встроить в HTML внутристрочный скрипт, который будет загружать эти данные. Его можно предоставить в виде глобального промиса, вот так:

window.userDataPromise = fetch("/me")

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

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

Не блокируйте рендеринг, пока дожидаетесь несущественных данных

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

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



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

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

Вместо того, чтобы сначала сделать запрос о том, что за пользователь вошел в систему, а потом запрашивать список групп, к которым относится этот пользователь, возвращайте список групп вместе с информацией о пользователе. Для этого можно использовать GraphQL, но собственная конечная точка user?includeTeams=true также отлично подойдет.

Рендеринг на стороне сервера

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

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

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

Почитайте эту статью Михала Янашека; в ней хорошо описано, как комбинировать сервис-воркеры с рендерингом на стороне сервера.

Следующая страница


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

Предварительная выборка ресурсов

Если вы предзагрузите код, нужный для отображения следующей страницы, это поможет избежать задержек при пользовательской навигации. Используйте теги prefetch link или webpackPrefetch для динамических импортов:

import(
    /* webpackPrefetch: true, webpackChunkName: "todo-list" */ "./TodoList"
)

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

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

Переиспользуйте те данные, что уже загружены

Локально кэшируйте данные Ajax в вашем приложении, чтобы впоследствии обойтись без лишних запросов. Если пользователь переходит к списку групп на странице «Редактировать группу», такой переход можно сделать мгновенным, повторно использовав данные, уже выбранные ранее.

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

Заключение


В этой статье мы рассмотрели ряд факторов, которые могут замедлять работу страницы на различных этапах процесса загрузки. Пользуйтесь такими инструментами как Chrome DevTools, WebPageTest и Lighthouse, чтобы определить, какие из советов актуальны в вашем приложении.

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

Работая над этой статьей, я осознал, что разделяю глубоко укоренившееся убеждение, будто множество отдельных запросов – это плохо с точки зрения производительности. Это было актуально в прошлом, когда для каждого запроса требовалось отдельное соединение, а браузеры допускали лишь несколько соединений на домен. Но такая проблема исчезла с появлением HTTP/2 и современных браузеров.

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

Комментарии 0

Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

Самое читаемое