通过数据流加快多页应用的速度

如今,网站或网页应用(如果您愿意)往往会采用以下两种导航方案之一:

  • 浏览器默认提供的导航架构 - 也就是说,您在浏览器的地址栏中输入网址,导航请求会返回文档作为响应。然后点击某个链接,会卸载当前文档(即 ad infinitum)的另一个文档。
  • 单页应用模式,涉及初始导航请求以加载应用 Shell,并依赖 JavaScript 将客户端渲染的标记填充到应用 Shell,其中包含每个“导航”的后端 API 内容。

每种方法的优点都受到其支持者的宣传:

  • 默认情况下,浏览器提供的导航架构具有弹性,因为路线不需要 JavaScript 即可访问。通过 JavaScript 在客户端呈现标记的过程也可能需要耗费大量资源,这意味着低端设备最终可能会出现内容延迟的情况,因为该设备阻止了处理提供内容的脚本。
  • 另一方面,单页应用 (SPA) 可以在初始加载后提供更快的导航速度。您无需依赖浏览器为全新的文档卸载文档(并在每次导航时重复此操作),这样就能提供更快、更“类似应用”的内容即使这需要 JavaScript 才能正常运行也无妨。

在这篇博文中,我们将讨论第三种方法,该方法可在上述两种方法之间取得平衡:依赖 Service Worker 来预缓存网站的常用元素(例如页眉和页脚标记),并使用数据流向客户端尽可能快速地提供 HTML 响应,同时仍使用浏览器的默认导航架构。

为什么要在 Service Worker 中流式传输 HTML 响应?

流式传输是网络浏览器在发出请求时已经做的事情。这在导航请求的上下文中非常重要,因为它可确保浏览器在开始解析文档标记并呈现网页之前不会等待完整的响应,而不会受到阻止。

描述非流式 HTML 与流式 HTML 的示意图。在前一种情况下,在标记有效负载到达之前不会对其进行处理。对于后者,系统会在标记以区块的形式从网络到达时,以增量方式对其进行处理。

对于 Service Worker,流式传输略有不同,因为它使用 JavaScript Streams API。Service Worker 执行的最重要的任务是拦截并响应请求(包括导航请求)。

这些请求可以通过多种方式与缓存交互,但标记的常见缓存模式是优先使用来自网络的响应,如果具有较旧的副本,则回退到缓存。如果缓存中没有可用响应,则可以选择提供通用后备响应

这是一种经过时间测试的标记模式,效果很好,虽然它有助于提高离线访问的可靠性,但是对于依赖网络优先或仅限网络策略的导航请求而言,它并没有带来任何固有的性能优势。这正是流式传输的用武之地,我们将探索如何在 Workbox Service Worker 中使用由 Streams API 提供支持的 workbox-streams 模块,以加快多页网站上的导航请求。

对典型网页进行分解

从结构上讲,网站的每个网页上通常都有共同的元素。页面元素的典型排列方式通常如下所示:

  • 标题。
  • 内容。
  • 页脚。

web.dev 为例,常见元素的细分如下所示:

web.dev 网站上常见元素详解。划出的公共区域标记为“标题”“内容”和“页脚”。

识别网页各个部分的目的是确定无需访问网络即可预缓存和检索的内容,即所有网页通用的页眉和页脚标记,以及我们始终首先访问网页的部分,即本例中的内容。

如果我们知道如何划分页面的各个部分并确定常见元素,就可以编写一个 Service Worker,使其始终立即从缓存中检索页眉和页脚标记,而从网络请求内容。

然后,通过 workbox-streams 使用 Streams API,我们可以将所有这些部分拼接在一起,即时响应导航请求,同时从网络请求最少数量的标记。

构建流式传输 Service Worker

在 Service Worker 中流式传输部分内容时,会涉及到许多动态部分,但随着您不断深入,我们将从如何构建网站开始,详细介绍该过程的每个步骤。

将网站细分为多个部分

在开始编写流处理 Service Worker 之前,您需要完成三项工作:

  1. 创建一个仅包含网站标头标记的文件。
  2. 创建一个仅包含您网站的页脚标记的文件。
  3. 将每个网页的主要内容提取到单独的文件中,或者将后端设置为根据 HTTP 请求标头有条件地只提供网页内容。

如您所料,最后一步是最难的,特别是当您的网站是静态的时。如果您属于这种情况,则需要为每个网页生成两个版本:一个版本包含完整网页标记,另一个版本只包含内容。

组建流处理 Service Worker

如果您尚未安装 workbox-streams 模块,则除了当前已安装的 Workbox 模块之外,您还需要安装此模块。对于此特定示例,这涉及以下软件包:

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

在这里,下一步是创建新的 Service Worker,并预缓存页眉和页脚部分。

预缓存部分内容

首先,在项目根目录中创建一个名为 sw.js(或您喜欢的任何文件名)的 Service Worker。在此教程中,您将从以下内容开始:

// 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. 在使用 injectManifest 方法的 __WB_MANIFEST 占位符中预缓存静态资源。

流式响应

使 Service Worker 流式传输串联响应是整个工作中最重要的部分。即便如此,与必须自行完成所有这些操作相比,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 以包含内容部分内容,并指定一个自定义插件,用于处理是否针对不支持导航预加载(因此不会发送 Service-Worker-Navigation-Preload 标头)的浏览器设置 X-Content-Mode 请求标头。此插件还可确定是发送内容部分的最后一个缓存版本,还是发送离线后备网页(如果未存储当前请求的缓存版本)。
  2. workbox-streams 中的 strategy 方法(此处的别名为 composeStrategies)用于将预缓存的页眉和页脚部分与从网络请求的内容部分串联起来。
  3. 通过 registerRoute 为导航请求配置整个架构。

有了此逻辑,我们就可以设置流式响应。但是,您可能需要在后端执行一些操作,以确保来自网络的内容是可与预缓存的部分合并的部分页面。

如果您的网站有后端

您应该记得,启用导航预加载后,浏览器会发送值为 trueService-Worker-Navigation-Preload 标头。不过,在上面的代码示例中,当浏览器不支持导航预加载时,我们发送了一个自定义标头 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 渲染程序函数在检索为部分标记时,可能仅在条件中包含特定标记,我们稍后将对此进行介绍。

注意事项

在部署 Service Worker 以流式传输并将部分拼接在一起之前,您必须考虑一些事项。尽管以这种方式使用 Service Worker 确实不会从根本上改变浏览器的默认导航行为,但您可能需要解决一些问题。

在导航时更新页面元素

这种方法最棘手的部分是需要在客户端上更新某些内容。例如,预缓存标题标记意味着网页的 <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>

这只是您决定使用此 Service Worker 设置时可能需要执行的操作的一个示例。例如,对于包含用户信息的更复杂的应用,您可能需要在 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> 元素,以从 HTML 中移除 slow 类,从而去掉加载消息:

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

提供后备响应

假设您为部分内容采用网络优先策略。如果用户在离线状态下访问他们已经访问过的网页,则也能覆盖他们。不过,如果用户访问的是自己尚未访问过的网页,将一无所获。为避免这种情况,您需要提供后备响应。

之前的代码示例演示了实现回退响应所需的代码。该流程需要两个步骤:

  1. 预缓存离线回退响应。
  2. 在插件中为网络优先策略设置 handlerDidError 回调,以检查网页的上次访问版本的缓存。如果该网页从未访问过,您需要使用 workbox-precaching 模块中的 matchPrecache 方法从预缓存中检索回退响应。

缓存和 CDN

如果您在 Service Worker 中使用此流处理模式,请评估以下情况是否适用于您的情况:

  • 使用 CDN 或任何其他类型的中间/公共缓存。
  • 您已使用非零 max-age 和/或 s-maxage 指令与 public 指令一起指定 Cache-Control 标头。

如果您同时符合这两种情况,中间缓存可能会保留对导航请求的响应。不过请注意,当您使用此格式时,可能会为任何给定网址提供两个不同的响应:

  • 包含标头、内容和页脚标记的完整响应。
  • 仅包含内容的部分响应。

由于 Service Worker 可能从 CDN 缓存提取完整响应,并将该响应与您预缓存的页眉和页脚标记合并,因此这可能导致一些不良行为,从而导致页眉和页脚标记加倍。

为解决此问题,您需要依赖 Vary 标头,该标头会将可缓存的响应键控到请求中存在的一个或多个标头,从而影响缓存行为。由于我们根据 Service-Worker-Navigation-Preload 和自定义 X-Content-Mode 请求标头来更改导航请求的响应,因此需要在响应中指定此 Vary 标头:

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

有了此标头,浏览器将区分导航请求的完整响应和部分响应,避免像任何中间缓存一样,避免标头和页脚标记重复造成的问题。

结果

大多数加载时间性能建议可以归结为“向他们展示您获得的内容”- 不要退缩,不要等到一切就绪后再向用户显示任何内容。

<ph type="x-smartling-placeholder"></ph> Jake Archibald 在《Fun Hacks for Fast Content》中快速学习

浏览器在处理对导航请求的响应方面表现出色,即使对于大型的 HTML 响应正文也是如此。默认情况下,浏览器会分块逐步流式传输和处理标记,以避免耗时较长的任务,从而提高启动性能。

在使用流处理 Service Worker 模式时,这对我们有利。从一开始,无论何时从 Service Worker 缓存响应请求,响应的开头几乎都是即时到来的。将预缓存的页眉和页脚标记与来自网络的响应拼接在一起时,您会获得一些显著的性能优势:

  • 首字节时间 (TTFB) 通常会大幅缩短,因为对导航请求的响应的第一个字节是即时的。
  • First Contentful Paint (FCP) 的速度会非常快,因为预缓存的标头标记会包含对缓存的样式表的引用,这意味着网页可以非常快速地绘制。
  • 在某些情况下,Largest Contentful Paint (LCP) 也可能会更快,尤其是在预缓存标头部分提供最大的屏幕上元素时。即便如此,只要尽快从 Service Worker 缓存中提供内容,与较小的标记负载搭配使用,就可能改善 LCP。

流式多页架构的设置和迭代可能有些棘手,但其复杂性通常比理论上 SPA 更繁重。主要优势在于,您并不是要替换浏览器的默认导航架构,而是要对其加以改进

更棒的是,Workbox 不仅使此架构成为可能,而且比您自行实现它更容易。在您自己的网站上试一试,看看您的多页网站为现场用户带来了多少速度。

资源