Поговорим об архитектуре?
Я собираюсь затронуть важную, но потенциально неправильно понимаемую тему: архитектуру, которую вы используете для своего веб-приложения, и, в частности, то, как ваши архитектурные решения вступают в игру, когда вы создаете прогрессивное веб-приложение.
«Архитектура» может звучать расплывчато, и может быть не сразу понятно, почему это важно. Один из способов продумать архитектуру – задать себе следующие вопросы: "Какой HTML-код загружается, когда пользователь посещает страницу на моем сайте? А что загружается, когда они переходят на другую страницу?
Ответы на эти вопросы не всегда очевидны, а когда вы начинаете думать о прогрессивных веб-приложениях, они могут стать ещё сложнее. Поэтому моя цель – познакомить вас с одной из возможных архитектур, которую я считаю эффективной. В этой статье я буду называть решения, которые я принял, «моим подходом» к созданию прогрессивного веб-приложения.
Вы можете использовать мой подход при создании собственного PWA, но в то же время всегда есть другие допустимые альтернативы. Надеюсь, что, увидев, как все части сочетаются друг с другом, вы вдохновитесь и почувствуете, что можете настроить это в соответствии со своими потребностями.
Stack Overflow PWA
В качестве примера для этой статьи я создал PWA Stack Overflow. Я много времени провожу на сайте Stack Overflow, читая и публикуя материалы. Мне хотелось создать веб-приложение, которое упростило бы поиск часто задаваемых вопросов по определенной теме. Он создан на основе общедоступного Stack Exchange API. Это ПО с открытым исходным кодом. Подробную информацию о нем можно найти на странице проекта в GitHub.
Многостраничные приложения (MPA)
Прежде чем перейти к деталям, давайте определим некоторые термины и объясним части базовой технологии. Сначала я расскажу о том, что я называю "многостраничными приложениями" (MPA).
MPA – это модное название традиционной архитектуры, используемой с самого начала существования интернета. Каждый раз, когда пользователь переходит по новому URL, браузер постепенно обрабатывает HTML-код, относящийся к этой странице. При этом не предпринимается попыток сохранить состояние страницы или контент между переходами. Каждый раз, когда вы переходите на новую страницу, вы начинаете с чистого листа.
Это отличается от модели одностраничного приложения (SPA) для создания веб-приложений, в которой браузер выполняет код JavaScript, чтобы обновить существующую страницу, когда пользователь переходит в новый раздел. Обе модели одинаково хороши, но в этой статье я хотел бы рассмотреть концепции PWA в контексте многостраничного приложения.
Стабильно высокая скорость
Вы уже слышали от меня (и не только) фразу "прогрессивное веб-приложение" (PWA). Возможно, вы уже знакомы с некоторыми справочными материалами, размещенными на этом сайте.
PWA – это веб-приложение, которое обеспечивает первоклассное удобство для пользователей и действительно заслуживает места на главном экране. Аббревиатура FIRE (Fast, Integrated, Reliable, Engaging) описывает все атрибуты, которые нужно учитывать при создании PWA.
В этой статье мы сосредоточимся на двух из них: быстроте и надежности.
Быстро. Хотя понятие "быстро" может трактоваться по-разному в зависимости от контекста, я расскажу о преимуществах загрузки как можно меньшего объема данных из сети.
Надежность. Но одной скорости недостаточно. Чтобы веб-приложение было похоже на PWA, оно должно быть надежным. Он должен быть достаточно устойчивым, чтобы всегда что-то загружать, даже если это просто настраиваемая страница ошибки, независимо от состояния сети.
Стабильно высокая скорость. Наконец, я немного перефразирую определение PWA и посмотрю, что значит создать что-то стабильно быстрое. Недостаточно быть быстрым и надежным только в сети с низкой задержкой. Надежность и быстрота означает, что скорость работы веб-приложения не зависит от качества подключения к интернету.
Вспомогательные технологии: Service Workers + Cache Storage API
Прогрессивные веб-приложения отличаются высокой скоростью и устойчивостью. К счастью, веб-платформа предлагает некоторые строительные блоки, позволяющие сделать такую производительность реальностью. Я имею в виду сервис-воркеры и Cache Storage API.
Вы можете создать сервис-воркер, который будет прослушивать входящие запросы, передавать некоторые из них в сеть и сохранять копию ответа для дальнейшего использования с помощью Cache Storage API.
Когда веб-приложение в следующий раз сделает такой же запрос, его сервисный работник сможет проверить свои кеши и просто вернуть ранее кешированный ответ.
Чтобы обеспечить высокую скорость работы, нужно по возможности избегать использования сети.
Изоморфный JavaScript
Ещё одна концепция, о которой я хочу рассказать, – это так называемый изоморфный, или универсальный, JavaScript. Проще говоря, это идея о том, что один и тот же код JavaScript может использоваться в разных средах выполнения. Когда я создавал свое PWA, я хотел использовать один и тот же код JavaScript на сервере и в сервис-воркере.
Существует множество способов совместного использования кода, но я решил использовать модули ES в качестве окончательного исходного кода. Затем я транспилировал и объединил эти модули для сервера и сервисного работника, используя комбинацию Babel и Rollup. В моем проекте файлы с расширением имени файла .mjs – это код, который находится в модуле ES.
Сервер
Помня об этих концепциях и терминологии, давайте углубимся в то, как я на самом деле создал свое PWA Stack Overflow. Начнем с бэкенд-сервера. Я объясню, как он вписывается в общую архитектуру.
Я искал сочетание динамического бэкенда со статическим хостингом и решил использовать платформу Firebase.
Firebase Cloud Functions автоматически запускает среду на основе Node при поступлении запроса и интегрируется с популярным фреймворком Express HTTP, с которым я уже был знаком. Кроме того, он предлагает готовый хостинг для всех статических ресурсов моего сайта. Давайте посмотрим, как сервер обрабатывает запросы.
Когда браузер отправляет навигационный запрос на наш сервер, выполняется следующая последовательность действий:
Сервер маршрутизирует запрос на основе URL и использует логику шаблонов для создания полного HTML-документа. Я использую комбинацию данных из Stack Exchange API, а также частичные HTML-фрагменты, которые сервер хранит локально. Как только сервис-воркер определит, как отвечать, он может начать передавать HTML обратно в веб-приложение.
В этой картине есть две части, которые стоит изучить более подробно: маршрутизация и шаблонизация.
Составление маршрутов
Для маршрутизации я использовал встроенный синтаксис фреймворка Express. Он достаточно гибок, чтобы соответствовать простым префиксам URL, а также URL, которые включают параметры как часть пути. Здесь я создаю сопоставление между названиями маршрутов и базовым шаблоном Express, с которым они должны совпадать.
const routes = new Map([
['about', '/about'],
['questions', '/questions/:questionId'],
['index', '/'],
]);
export default routes;
Затем я могу ссылаться на это сопоставление непосредственно из кода сервера. При совпадении с заданным шаблоном Express соответствующий обработчик отвечает логикой шаблонизации, характерной для совпадающего маршрута.
import routes from './lib/routes.mjs';
app.get(routes.get('index'), async (req, res) => {
// Templating logic.
});
Шаблоны на стороне сервера
Как выглядит логика шаблонов? Я выбрал подход, который последовательно собирал фрагменты HTML один за другим. Эта модель хорошо подходит для потоковой передачи.
Сервер сразу же отправляет обратно шаблон HTML, и браузер может сразу же отобразить эту частичную страницу. По мере того, как сервер собирает остальные источники данных, он передает их в браузер до тех пор, пока документ не будет завершен.
Чтобы понять, что я имею в виду, посмотрите на код Express для одного из наших маршрутов:
app.get(routes.get('index'), async (req, res) => {
res.write(headPartial + navbarPartial);
const tag = req.query.tag || DEFAULT_TAG;
const data = await requestData(...);
res.write(templates.index(tag, data.items));
res.write(footPartial);
res.end();
});
Используя метод write() объекта response и ссылаясь на локально сохраненные частичные шаблоны, я могу немедленно запустить поток ответов, не блокируя внешний источник данных. Браузер берет этот исходный HTML-код и сразу же отображает понятный интерфейс и сообщение о загрузке.
Следующая часть страницы использует данные из Stack Exchange API. Чтобы получить эти данные, нашему серверу нужно сделать сетевой запрос. Веб-приложение не может отображать ничего другого, пока не получит ответ и не обработает его, но, по крайней мере, пользователи не смотрят на пустой экран во время ожидания.
Получив ответ от Stack Exchange API, веб-приложение вызывает специальную функцию шаблонизации, чтобы преобразовать данные из API в соответствующий HTML-код.
Язык шаблонов
Шаблонизация может быть на удивление спорной темой, и то, что я выбрал, – это лишь один из многих подходов. Вы можете использовать собственное решение, особенно если у вас есть связи с существующей платформой шаблонов.
В моем случае было достаточно использовать шаблонные литералы JavaScript, а часть логики вынести во вспомогательные функции. Одно из преимуществ создания MPA заключается в том, что вам не нужно отслеживать обновления состояния и повторно отображать HTML, поэтому базовый подход, который создавал статический HTML, сработал для меня.
Вот пример того, как я создаю шаблон динамической HTML-части индекса моего веб-приложения. Как и в случае с маршрутами, логика шаблонов хранится в модуле ES, который можно импортировать как на сервер, так и в сервис-воркер.
export function index(tag, items) {
const title = `<h3>Top "${escape(tag)}" Questions</h3>`;
const form = `<form method="GET">...</form>`;
const questionCards = items
.map(item =>
questionCard({
id: item.question_id,
title: item.title,
})
)
.join('');
const questions = `<div id="questions">${questionCards}</div>`;
return title + form + questions;
}
Эти функции шаблонов написаны на чистом JavaScript, и при необходимости логику можно разбить на более мелкие вспомогательные функции. В этом примере я передаю каждый из элементов, возвращенных в ответе API, в одну из таких функций, которая создает стандартный элемент HTML со всеми заданными атрибутами.
function questionCard({id, title}) {
return `<a class="card"
href="/questions/${id}"
data-cache-url="${questionUrl(id)}">${title}</a>`;
}
Обратите внимание на атрибут данных, который я добавляю к каждой ссылке, data-cache-url, заданный для URL API Stack Exchange, который мне нужен для отображения соответствующего вопроса. Помните об этом. Я вернусь к этому позже.
Вернемся к обработчику маршрутов. После завершения работы с шаблонами я передаю в браузер последнюю часть HTML-кода страницы и завершаю поток. Это сигнал для браузера о том, что прогрессивная отрисовка завершена.
app.get(routes.get('index'), async (req, res) => {
res.write(headPartial + navbarPartial);
const tag = req.query.tag || DEFAULT_TAG;
const data = await requestData(...);
res.write(templates.index(tag, data.items));
res.write(footPartial);
res.end();
});
Вот и все, что я хотел рассказать о настройке сервера. Пользователи, которые впервые посещают мое веб-приложение, всегда получают ответ от сервера, но когда посетитель возвращается в мое веб-приложение, мой сервис-воркер начинает отвечать. Давайте погрузимся в это.
Сервис-воркер
Эта диаграмма должна выглядеть знакомо: многие из тех же частей, которые я ранее рассмотрел, здесь расположены в несколько ином порядке. Давайте рассмотрим поток запросов с учетом сервис-воркера.
Наш сервисный работник обрабатывает входящий запрос навигации для заданного URL-адреса и, как и мой сервер, использует комбинацию логики маршрутизации и шаблонов, чтобы определить, как ответить.
Подход остается прежним, но используются другие низкоуровневые примитивы, такие как fetch() и Cache Storage API. Я использую эти источники данных для создания HTML-ответа, который сервисный работник передает обратно в веб-приложение.
Workbox
Вместо того чтобы начинать с нуля с низкоуровневыми примитивами, я собираюсь создать свой сервисный воркер на основе набора высокоуровневых библиотек под названием Workbox. Он обеспечивает прочную основу для логики кэширования, маршрутизации и генерации ответов любого сервис-воркера.
Составление маршрутов
Как и в случае с кодом на стороне сервера, сервисному работнику необходимо знать, как сопоставить входящий запрос с соответствующей логикой ответа.
Я решил преобразовать каждый маршрут Express в соответствующее регулярное выражение, используя полезную библиотеку regexparam. После этого я могу воспользоваться встроенной поддержкой Workbox для маршрутизации регулярных выражений.
После импорта модуля с регулярными выражениями я регистрирую каждое регулярное выражение в маршрутизаторе Workbox. Внутри каждого маршрута я могу предоставить пользовательскую логику шаблонов для генерации ответа. Шаблонизация в сервисном работнике немного сложнее, чем на моем внутреннем сервере, но Workbox помогает с большей частью тяжелой работы.
import regExpRoutes from './regexp-routes.mjs';
workbox.routing.registerRoute(
regExpRoutes.get('index')
// Templating logic.
);
Кеширование статических объектов
Важная часть работы с шаблонами – обеспечить локальную доступность частичных HTML-шаблонов через Cache Storage API и их обновление при развертывании изменений в веб-приложении. Обслуживание кеша вручную может привести к ошибкам, поэтому я использую Workbox для предварительного кеширования в процессе сборки.
Я указываю Workbox, какие URL-адреса нужно предварительно кешировать, используя файл конфигурации, указывающий на каталог, содержащий все мои локальные ресурсы, а также набор шаблонов для сопоставления. Этот файл автоматически считывается интерфейсом командной строки Workbox, который запускается каждый раз, когда я перестраиваю сайт.
module.exports = {
globDirectory: 'build',
globPatterns: ['**/*.{html,js,svg}'],
// Other options...
};
Workbox делает снимок содержимого каждого файла и автоматически вставляет этот список URL и версий в мой окончательный файл сервис-воркера. Теперь у Workbox есть все необходимое, чтобы предварительно кешированные файлы всегда были доступны и обновлялись. В результате вы получите файл service-worker.js, который содержит примерно следующее:
workbox.precaching.precacheAndRoute([
{
url: 'partials/about.html',
revision: '518747aad9d7e',
},
{
url: 'partials/foot.html',
revision: '69bf746a9ecc6',
},
// etc.
]);
Для тех, кто использует более сложный процесс сборки, Workbox имеет как плагин webpack, так и общий модуль узла, в дополнение к своему интерфейсу командной строки.
Потоковая передача
Затем я хочу, чтобы сервисный работник немедленно передал этот предварительно кэшированный частичный HTML-код обратно в веб-приложение. Это важная часть "надежной скорости" – я всегда сразу вижу на экране что-то полезное. К счастью, это можно сделать с помощью Streams API в сервис-воркере.
Возможно, вы уже слышали о Streams API. Мой коллега Джейк Арчибальд (Jake Archibald) уже много лет поет ему дифирамбы. Он сделал смелый прогноз, что 2016 год станет годом веб-трансляций. Streams API сегодня так же хорош, как и два года назад, но с одним важным отличием.
Тогда Streams API поддерживался только в Chrome, а сейчас он поддерживается в большинстве браузеров. В целом ситуация положительная, и при наличии соответствующего резервного кода ничто не мешает вам использовать потоки в сервис-воркере уже сегодня.
Возможно, вас останавливает только одно – вы не до конца понимаете, как работает Streams API. Он предоставляет мощный набор примитивов, и разработчики, которым удобно его использовать, могут создавать сложные потоки данных, например:
const stream = new ReadableStream({
pull(controller) {
return sources[0]
.then(r => r.read())
.then(result => {
if (result.done) {
sources.shift();
if (sources.length === 0) return controller.close();
return this.pull(controller);
} else {
controller.enqueue(result.value);
}
});
},
});
Но не все могут понять, что именно делает этот код. Вместо того, чтобы разбираться в этой логике, давайте поговорим о моем подходе. к потоковой передаче сервисных работников.
Я использую новый высокоуровневый контейнер workbox-streams.
С его помощью я могу передавать его в сочетании источников потоковой передачи, как из кэшей, так и из данных времени выполнения, которые могут поступать из сети. Workbox координирует отдельные источники и объединяет их в один потоковый ответ.
Кроме того, Workbox автоматически определяет, поддерживается ли Streams API, и, если нет, создает эквивалентный ответ без потоковой передачи. Это значит, что вам не нужно беспокоиться о резервных вариантах, поскольку потоки поддерживаются почти всеми браузерами.
Кеширование во время выполнения
Давайте посмотрим, как мой сервис-воркер обрабатывает данные времени выполнения из Stack Exchange API. Я использую встроенную в Workbox поддержку стратегии кеширования stale-while-revalidate, а также срок действия, чтобы хранилище веб-приложения не росло бесконечно.
Я настроил две стратегии в Workbox для обработки различных источников, которые будут составлять потоковый ответ. Workbox позволяет нам сделать то, что в противном случае потребовало бы сотен строк рукописного кода, с помощью нескольких вызовов функций и конфигурации.
const cacheStrategy = workbox.strategies.cacheFirst({
cacheName: workbox.core.cacheNames.precache,
});
const apiStrategy = workbox.strategies.staleWhileRevalidate({
cacheName: API_CACHE_NAME,
plugins: [new workbox.expiration.Plugin({maxEntries: 50})],
});
Первая стратегия считывает данные, которые были предварительно кешированы, например наши частичные HTML-шаблоны.
Другая стратегия реализует логику кеширования stale-while-revalidate, а также удаление из кеша наименее востребованных элементов, когда их количество достигает 50.
Теперь, когда у меня есть эти стратегии, осталось только сообщить Workbox, как использовать их для создания полного потокового ответа. Я передаю массив источников в виде функций, и каждая из этих функций будет выполнена немедленно. Workbox берет результат из каждого источника и последовательно передает его в веб-приложение, задерживая только в том случае, если следующая функция в массиве еще не завершена.
workbox.streams.strategy([
() => cacheStrategy.makeRequest({request: '/head.html'}),
() => cacheStrategy.makeRequest({request: '/navbar.html'}),
async ({event, url}) => {
const tag = url.searchParams.get('tag') || DEFAULT_TAG;
const listResponse = await apiStrategy.makeRequest(...);
const data = await listResponse.json();
return templates.index(tag, data.items);
},
() => cacheStrategy.makeRequest({request: '/foot.html'}),
]);
Первые два источника – это предварительно кэшированные частичные шаблоны, считываемые непосредственно из Cache Storage API, поэтому они всегда будут доступны немедленно. Это гарантирует, что наша реализация сервисного работника будет надежно и быстро отвечать на запросы, как и мой серверный код.
Следующая функция источника получает данные из Stack Exchange API и обрабатывает ответ в HTML, который ожидает веб-приложение.
Стратегия stale-while-revalidate означает, что если у меня есть ранее кэшированный ответ для этого вызова API, я смогу немедленно передать его на страницу, обновив запись кэша «в фоновом режиме» для следующего запроса.
Наконец, я передаю кешированную копию нижнего колонтитула и закрываю конечные HTML-теги, чтобы завершить ответ.
Общий код обеспечивает синхронизацию
Вы заметите, что некоторые части кода сервисного работника выглядят знакомо. Частичная логика HTML и шаблонов, используемая моим сервис-воркером, идентична той, которую использует мой обработчик на стороне сервера. Такой обмен кодом гарантирует, что пользователи получат последовательный опыт, независимо от того, посещают ли они мое веб-приложение впервые или возврат на страницу, отображаемую сервис-воркером. В этом и заключается прелесть изоморфного JavaScript.
Динамические прогрессивные улучшения
Мы рассмотрели сервер и сервис-воркер для моего PWA, но осталась ещё одна деталь: небольшой фрагмент кода JavaScript, который выполняется на каждой странице после ее полной загрузки.
Этот код постепенно улучшает пользовательский опыт, но не является критическим – веб-приложение все равно будет работать, если оно не запущено.
Метаданные страниц
В моем приложении используется клиентский код JavaScript, который обновляет метаданные страницы на основе ответа API. Поскольку я использую один и тот же фрагмент кешированного HTML-кода для каждой страницы, в заголовке документа веб-приложения появляются общие теги. Но благодаря координации между шаблонами и клиентским кодом я могу обновлять заголовок окна, используя метаданные на уровне страницы.
В код шаблона я включаю тег script, содержащий правильно экранированную строку.
const metadataScript = `<script>
self._title = '${escape(item.title)}';
</script>`;
Затем, когда страница загрузится, я считываю эту строку и обновляю заголовок документа.
if (self._title) {
document.title = unescape(self._title);
}
Если вы хотите обновить другие метаданные на уровне страницы в своем веб-приложении, вы можете использовать тот же подход.
Офлайн-интерфейс
Ещё одно прогрессивное улучшение, которое я добавил, используется для привлечения внимания к нашим возможностям работы в автономном режиме. Я создал надежное прогрессивное веб-приложение и хочу, чтобы пользователи знали, что даже в офлайн-режиме они могут загружать ранее посещенные страницы.
Сначала я использую Cache Storage API, чтобы получить список всех ранее кешированных запросов к API, и преобразую его в список URL.
Помните специальные атрибуты данных, о которых я говорил? Каждый из них содержит URL для запроса к API, необходимого для показа вопроса. Я могу сопоставить эти атрибуты данных со списком кешированных URL и создать массив всех ссылок на вопросы, которые не совпадают.
Когда браузер переходит в офлайн-режим, я просматриваю список некешированных ссылок и затемняю те, которые не будут работать. Помните, что это всего лишь визуальная подсказка для пользователя о том, чего ему следует ожидать от этих страниц. На самом деле я не отключаю ссылки и не мешаю пользователю переходить по ним.
const apiCache = await caches.open(API_CACHE_NAME);
const cachedRequests = await apiCache.keys();
const cachedUrls = cachedRequests.map(request => request.url);
const cards = document.querySelectorAll('.card');
const uncachedCards = [...cards].filter(card => {
return !cachedUrls.includes(card.dataset.cacheUrl);
});
const offlineHandler = () => {
for (const uncachedCard of uncachedCards) {
uncachedCard.style.opacity = '0.3';
}
};
const onlineHandler = () => {
for (const uncachedCard of uncachedCards) {
uncachedCard.style.opacity = '1.0';
}
};
window.addEventListener('online', onlineHandler);
window.addEventListener('offline', offlineHandler);
Случаи, требующие особого внимания
Мы рассмотрели мой подход к созданию многостраничного PWA. При разработке собственного подхода вам придется учитывать множество факторов, и в итоге вы можете сделать другой выбор, чем я. Такая гибкость – одно из главных преимуществ веб-разработки.
Есть несколько распространенных ошибок, с которыми вы можете столкнуться, принимая собственные архитектурные решения, и я хочу уберечь вас от некоторых проблем.
Не кешируйте HTML-код полностью
Не рекомендуем хранить в кеше полные HTML-документы. Во-первых, это пустая трата места. Если ваше веб-приложение использует одну и ту же базовую структуру HTML для каждой из своих страниц, вы в конечном итоге будете хранить копии одной и той же разметки снова и снова.
Но самое главное – если вы измените общую структуру HTML-кода сайта, то все кешированные страницы будут по-прежнему отображаться в старом формате. Представьте, как будет разочарован вернувшийся посетитель, увидев смесь старых и новых страниц.
Расхождение между сервером и сервис-воркером
Ещё одна проблема, которой следует избегать, связана с рассинхронизацией сервера и сервис-воркера. Мой подход заключался в использовании изоморфного JavaScript, чтобы один и тот же код выполнялся в обоих местах. В зависимости от архитектуры вашего сервера это не всегда возможно.
Какие бы архитектурные решения вы ни приняли, у вас должна быть стратегия запуска эквивалентного кода маршрутизации и шаблонов на вашем сервере и в сервис-воркере.
Худшие сценарии
Несогласованный макет или дизайн
Что произойдет, если вы проигнорируете эти подводные камни? Возможны различные сбои, но худший сценарий – когда вернувшийся пользователь видит кешированную страницу с устаревшим макетом, например с устаревшим текстом заголовка или с именами классов CSS, которые больше не действительны.
Худший сценарий: нарушенная маршрутизация
Кроме того, пользователь может перейти по URL, который обрабатывается сервером, но не сервис-воркером. Сайт, полный макетов зомби и тупиков, не является надежным PWA.
Секреты успеха
Но вы не одиноки! Следующие советы помогут вам избежать этих подводных камней:
Используйте библиотеки шаблонов и маршрутизации, которые поддерживают несколько языков
Попробуйте использовать библиотеки шаблонов и маршрутизации, в которых реализован JavaScript. Конечно, не у каждого разработчика есть возможность перейти на другой веб-сервер и язык шаблонов.
Однако ряд популярных фреймворков для шаблонизации и маршрутизации реализован на нескольких языках. Если вы найдете библиотеку, которая работает как с JavaScript, так и с языком вашего текущего сервера, вы будете на шаг ближе к синхронизации сервисного работника и сервера.
Используйте последовательные, а не вложенные шаблоны
Затем я рекомендую использовать серию последовательных шаблонов, которые можно транслировать один за другим. Если в последующих частях страницы используется более сложная логика шаблонов, это не страшно, главное – как можно быстрее передать начальную часть HTML-кода.
Кешируйте статический и динамический контент в сервисных скриптах
Для достижения наилучшей производительности вам следует предварительно кэшировать все критически важные статические ресурсы вашего сайта. Вам также следует настроить логику кеширования во время выполнения для обработки динамического контента, например запросов к API. Используя Workbox, вы можете создавать приложения на основе проверенных и готовых к работе стратегий, а не реализовывать все с нуля.
Блокируйте рекламу на уровне сети только в случае крайней необходимости
И в связи с этим вы должны блокировать только в сети, когда это невозможно. для потоковой передачи ответа из кеша. Если сразу показать кешированный ответ API, а не ждать новых данных, это может улучшить взаимодействие с пользователем.