Более быстрые многостраничные приложения с потоками

В наши дни веб-сайты (или веб- приложения , если хотите) обычно используют одну из двух схем навигации:

  • Браузеры предоставляют схему навигации по умолчанию, то есть вы вводите URL-адрес в адресную строку браузера, и запрос навигации возвращает документ в качестве ответа. Затем вы нажимаете на ссылку, которая выгружает текущий документ для другого, до бесконечности .
  • Шаблон одностраничного приложения, который включает в себя первоначальный запрос навигации для загрузки оболочки приложения и использует JavaScript для заполнения оболочки приложения разметкой, отображаемой клиентом, содержимым из внутреннего API для каждой «навигации».

Преимущества каждого подхода рекламировались их сторонниками:

  • Схема навигации, предоставляемая браузерами по умолчанию, является устойчивой, поскольку для доступа к маршрутам не требуется JavaScript. Клиентский рендеринг разметки с помощью JavaScript также может быть потенциально дорогостоящим процессом, а это означает, что устройства более низкого уровня могут оказаться в ситуации, когда контент задерживается, поскольку устройство заблокировано при обработке сценариев, предоставляющих контент.
  • С другой стороны, одностраничные приложения (SPA) могут обеспечить более быструю навигацию после начальной загрузки. Вместо того, чтобы полагаться на то, что браузер выгрузит документ для совершенно нового документа (и повторять это для каждой навигации), они могут предложить то, что кажется более быстрым и более «похожим на приложение», даже если для этого требуется JavaScript.

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

Зачем передавать HTML-ответы в сервис-воркер?

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

Диаграмма, показывающая непотоковый HTML и потоковый HTML. В первом случае вся полезная нагрузка разметки не обрабатывается до ее поступления. В последнем случае разметка обрабатывается постепенно по мере ее поступления из сети.

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

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

Это проверенный временем шаблон разметки, который хорошо работает, но, хотя он повышает надежность с точки зрения автономного доступа, он не дает каких-либо преимуществ в производительности для навигационных запросов, которые полагаются на стратегию «сначала сеть» или «только сеть». Вот тут-то и пригодится потоковая передача, и мы рассмотрим, как использовать модуль workbox-streams на базе Streams API в вашем сервисном работнике Workbox для ускорения запросов навигации на вашем многостраничном веб-сайте.

Разбивка типичной веб-страницы

Структурно говоря, веб-сайты, как правило, имеют общие элементы, существующие на каждой странице. Типичное расположение элементов страницы часто выглядит примерно так:

  • Заголовок.
  • Содержание.
  • Нижний колонтитул.

Если использовать в качестве примера web.dev , то разбивка общих элементов выглядит следующим образом:

Разбивка общих элементов на веб-сайте web.dev. Очерченные общие области отмечены «заголовком», «содержанием» и «нижним колонтитулом».

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

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

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

Создание работника потокового сервиса

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

Сегментация вашего сайта на части

Прежде чем вы сможете начать писать обработчик службы потоковой передачи, вам необходимо сделать три вещи:

  1. Создайте файл, содержащий только разметку заголовка вашего сайта.
  2. Создайте файл, содержащий только разметку нижнего колонтитула вашего сайта.
  3. Вытащите основное содержимое каждой страницы в отдельный файл или настройте серверную часть на условное обслуживание только содержимого страницы на основе заголовка HTTP-запроса.

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

Составление работника потоковой службы

Если вы еще не установили модуль workbox-streams , вам необходимо сделать это в дополнение к любым модулям Workbox, которые вы сейчас установили. В этом конкретном примере это включает в себя следующие пакеты:

npm i workbox-navigation-preload workbox-strategies workbox-routing workbox-precaching workbox-streams --save

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

Предварительное кэширование частичных данных

Первое, что вам нужно сделать, это создать сервис-воркера в корне вашего проекта с именем sw.js (или с любым другим именем файла, которое вы предпочитаете). В нем вы начнете со следующего:

// sw.js
import * as navigationPreload from 'workbox-navigation-preload';
import {NetworkFirst} from 'workbox-strategies';
import {registerRoute} from 'workbox-routing';
import {matchPrecache, precacheAndRoute} from 'workbox-precaching';
import {strategy as composeStrategies} from 'workbox-streams';

// Enable navigation preload for supporting browsers
navigationPreload.enable();

// Precache partials and some static assets
// using the InjectManifest method.
precacheAndRoute([
  // The header partial:
  {
    url: '/partial-header.php',
    revision: __PARTIAL_HEADER_HASH__
  },
  // The footer partial:
  {
    url: '/partial-footer.php',
    revision: __PARTIAL_FOOTER_HASH__
  },
  // The offline fallback:
  {
    url: '/offline.php',
    revision: __OFFLINE_FALLBACK_HASH__
  },
  ...self.__WB_MANIFEST
]);

// To be continued...

Этот код делает несколько вещей:

  1. Включает предварительную загрузку навигации для браузеров, которые ее поддерживают .
  2. Предварительно кэширует разметку верхнего и нижнего колонтитула. Это означает, что разметка верхнего и нижнего колонтитула для каждой страницы будет получена мгновенно, поскольку она не будет заблокирована сетью.
  3. Предварительно кэширует статические ресурсы в заполнителе __WB_MANIFEST , который использует метод injectManifest .

Потоковая передача ответов

Заставить вашего сервис-воркера передавать составные ответы в потоковом режиме — самая большая часть всех этих усилий. Несмотря на это, Workbox и его workbox-streams позволяют сделать это гораздо более лаконично, чем если бы вам приходилось делать все это самостоятельно:

// sw.js
import * as navigationPreload from 'workbox-navigation-preload';
import {NetworkFirst} from 'workbox-strategies';
import {registerRoute} from 'workbox-routing';
import {matchPrecache, precacheAndRoute} from 'workbox-precaching';
import {strategy as composeStrategies} from 'workbox-streams';

// ...
// Prior navigation preload and precaching code omitted...
// ...

// The strategy for retrieving content partials from the network:
const contentStrategy = new NetworkFirst({
  cacheName: 'content',
  plugins: [
    {
      // NOTE: This callback will never be run if navigation
      // preload is not supported, because the navigation
      // request is dispatched while the service worker is
      // booting up. This callback will only run if navigation
      // preload is _not_ supported.
      requestWillFetch: ({request}) => {
        const headers = new Headers();

        // If the browser doesn't support navigation preload, we need to
        // send a custom `X-Content-Mode` header for the back end to use
        // instead of the `Service-Worker-Navigation-Preload` header.
        headers.append('X-Content-Mode', 'partial');

        // Send the request with the new headers.
        // Note: if you're using a static site generator to generate
        // both full pages and content partials rather than a back end
        // (as this example assumes), you'll need to point to a new URL.
        return new Request(request.url, {
          method: 'GET',
          headers
        });
      },
      // What to do if the request fails.
      handlerDidError: async ({request}) => {
        return await matchPrecache('/offline.php');
      }
    }
  ]
});

// Concatenates precached partials with the content partial
// obtained from the network (or its fallback response).
const navigationHandler = composeStrategies([
  // Get the precached header markup.
  () => matchPrecache('/partial-header.php'),
  // Get the content partial from the network.
  ({event}) => contentStrategy.handle(event),
  // Get the precached footer markup.
  () => matchPrecache('/partial-footer.php')
]);

// Register the streaming route for all navigation requests.
registerRoute(({request}) => request.mode === 'navigate', navigationHandler);

// Your service worker can end here, or you can add more
// logic to suit your needs, such as runtime caching, etc.

Этот код состоит из трех основных частей, которые удовлетворяют следующим требованиям:

  1. Стратегия NetworkFirst используется для обработки запросов на части контента. Используя эту стратегию, указывается пользовательское имя кэша content , которое будет содержать части контента, а также пользовательский плагин, который определяет, следует ли устанавливать заголовок запроса X-Content-Mode для браузеров, которые не поддерживают предварительную загрузку навигации (и, следовательно, не поддерживают предварительную загрузку навигации). не отправляю заголовок Service-Worker-Navigation-Preload ). Этот плагин также определяет, следует ли отправлять последнюю кэшированную версию части содержимого или отправлять автономную резервную страницу в случае, если кешированная версия для текущего запроса не сохранена.
  2. Метод strategy в workbox-streams (здесь он называется composeStrategies ) используется для объединения предварительно кэшированных частей верхнего и нижнего колонтитула вместе с частью контента, запрошенной из сети.
  3. Вся схема настроена через registerRoute для навигационных запросов.

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

Если у вашего сайта есть серверная часть

Вы помните, что когда включена предварительная загрузка навигации, браузер отправляет заголовок Service-Worker-Navigation-Preload со значением true . Однако в приведенном выше примере кода мы отправили пользовательский заголовок X-Content-Mode в случае, если предварительная загрузка навигации не поддерживается в браузере. В серверной части вы можете изменить ответ в зависимости от наличия этих заголовков. В серверной части PHP это может выглядеть примерно так для данной страницы:

<?php
// Check if we need to render a content partial
$navPreloadSupported = isset($_SERVER['HTTP_SERVICE_WORKER_NAVIGATION_PRELOAD']) && $_SERVER['HTTP_SERVICE_WORKER_NAVIGATION_PRELOAD'] === 'true';
$partialContentMode = isset($_SERVER['HTTP_X_CONTENT_MODE']) && $_SERVER['HTTP_X_CONTENT_MODE'] === 'partial';
$isPartial = $navPreloadSupported || $partialContentMode;

// Figure out whether to render the header
if ($isPartial === false) {
  // Get the header include
  require_once($_SERVER['DOCUMENT_ROOT'] . '/includes/site-header.php');

  // Render the header
  siteHeader();
}

// Get the content include
require_once('./content.php');

// Render the content
content($isPartial);

// Figure out whether to render the footer
if ($isPartial === false) {
  // Get the footer include
  require_once($_SERVER['DOCUMENT_ROOT'] . '/includes/site-footer.php');

  // Render the footer
  siteFooter();
}
?>

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

Соображения

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

Обновление элементов страницы при навигации

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

Чтобы обойти это, можно поместить встроенный элемент <script> в часть содержимого, поступающую из сети, чтобы обновить несколько важных вещей:

<!-- The JSON below contains information about the current page. -->
<script id="page-data" type="application/json">'{"title":"Sand Wasp &mdash; World of Wasps","description":"Read all about the sand wasp in this tidy little post."}'</script>
<script>
  const pageData = JSON.parse(document.getElementById('page-data').textContent);

  // Update the page title
  document.title = pageData.title;
</script>
<article>
  <!-- Page content omitted... -->
</article>

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

Работа с медленными сетями

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

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

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

article:empty::before {
  text-align: center;
  content: 'Loading...';
}

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

.slow article:empty::before {
  text-align: center;
  content: 'Loading...';
}

Отсюда вы можете использовать JavaScript в части заголовка, чтобы прочитать эффективный тип соединения (по крайней мере, в браузерах Chromium), чтобы добавить slow класс к элементу <html> для выбранных типов соединения:

<script>
  const effectiveType = navigator?.connection?.effectiveType;

  if (effectiveType !== '4g') {
    document.documentElement.classList.add('slow');
  }
</script>

Это гарантирует, что эффективные типы соединений, более медленные, чем тип 4g , получат сообщение о загрузке. Затем в части контента вы можете поместить встроенный элемент <script> , чтобы удалить slow класс из HTML и избавиться от сообщения о загрузке:

<script>
  document.documentElement.classList.remove('slow');
</script>

Предоставление запасного ответа

Допустим, вы используете стратегию «сначала сеть» для частей контента. Если пользователь не в сети и переходит на страницу, на которой он уже был, он распространяется. Однако, если они перейдут на страницу, на которой еще не были, они ничего не получат. Чтобы избежать этого, вам потребуется предоставить резервный ответ.

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

  1. Предварительно кэшируйте автономный резервный ответ.
  2. Настройте обратный вызов handlerDidError в плагине для своей стратегии «сначала сеть», чтобы проверять кеш последней доступной версии страницы. Если к странице никогда не обращались, вам нужно будет использовать метод matchPrecache из модуля workbox-precaching , чтобы получить резервный ответ из предварительного кэша.

Кэширование и CDN

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

  • Вы используете CDN или любой другой промежуточный/общедоступный кеш.
  • Вы указали заголовок Cache-Control с ненулевыми директивами max-age и/или s-maxage в сочетании с директивой public .

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

  • Полный ответ, содержащий разметку заголовка, содержимого и нижнего колонтитула.
  • Частичный ответ, содержащий только контент.

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

Чтобы обойти эту проблему, вам нужно будет полагаться на заголовок Vary , который влияет на поведение кэширования, связывая кэшируемые ответы с одним или несколькими заголовками, которые присутствовали в запросе. Поскольку мы варьируем ответы на запросы навигации на основе заголовков запросов Service-Worker-Navigation-Preload и пользовательских X-Content-Mode , нам необходимо указать этот заголовок Vary в ответе:

Vary: Service-Worker-Navigation-Preload,X-Content-Mode

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

Исход

Большинство советов по повышению производительности во время загрузки сводятся к тому, чтобы «показать им, что у вас есть» — не сдерживайтесь, не ждите, пока у вас будет все, прежде чем показывать что-либо пользователю.

Джейк Арчибальд в Fun Hacks для более быстрого контента

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

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

  • Время до первого байта (TTFB) часто значительно сокращается, поскольку первый байт ответа на навигационный запрос является мгновенным.
  • Первая Contentful Paint (FCP) будет очень быстрой, поскольку предварительно кэшированная разметка заголовка будет содержать ссылку на кэшированную таблицу стилей, а это означает, что страница будет рисоваться очень и очень быстро.
  • В некоторых случаях Largest Contentful Paint (LCP) также может работать быстрее, особенно если самый большой элемент на экране предоставляется предварительно кэшированным частичным заголовком. Даже в этом случае простое предоставление чего-либо из кэша сервисного работника как можно скорее в сочетании с меньшими полезными нагрузками разметки может привести к улучшению LCP.

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

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

Ресурсы