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

如今,网站(如果您愿意,也可以称之为 Web 应用)倾向于使用以下两种导航架构之一:

  • 导航方案浏览器默认提供,也就是说,您在浏览器的地址栏中输入网址,导航请求会返回文档作为响应。然后,您点击一个链接,这会为另一个文档取消加载当前文档,即无限下载
  • 单页应用模式,涉及到加载应用 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

有了此标头,浏览器就能区分导航请求的完整响应和部分响应,从而避免标头和页脚标记双倍出现的问题,与任何中间缓存一样。

结果

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

Jake Archibald 探讨 Fun Hacks for Faster Content

在处理对导航请求的响应方面,浏览器非常擅长,即使对于大型 HTML 响应正文也是如此。默认情况下,浏览器会分块逐步流式传输和处理标记,避免执行耗时较长的任务,这有利于提升启动性能。

当我们使用流式 Service Worker 模式时,这对我们十分有利。只要您从一开始就响应来自 Service Worker 缓存的请求,响应的起点几乎就会立即到达。如果将预缓存的页眉和页脚标记与网络的响应拼接在一起,您将获得一些显著的性能优势:

  • 首字节时间 (TTFB) 通常会大幅减少,因为对导航请求的响应的第一个字节是即时的。
  • First Contentful Paint (FCP) 的处理速度会非常快,因为预缓存的标头标记将会包含对缓存样式表的引用,这意味着网页的渲染速度会非常快。
  • 在某些情况下,Largest Contentful Paint (LCP) 速度也可能会更快,尤其是在预缓存标头部分提供最大的屏幕上元素时。即便如此,若只是尽快从 Service Worker 缓存中提供内容,同时采用较小的标记载荷,可能会产生更好的 LCP。

流式多页架构的设置和迭代可能有点棘手,但从理论上讲,涉及的复杂性通常不如 SPA 更繁琐。这样做的主要好处在于,您不是用它来替换浏览器的默认导航架构,而是在对其进行改进

更棒的是,Workbox 不仅能让您实现此架构,而且比您自行实现此架构更容易。您自己在自己的网站上试一试,看看网站中的多页用户能提升到什么速度。

资源