通过导航预加载加快 Service Worker 的运行速度

借助 Navigation 预加载,您可以通过并行发出请求来克服 Service Worker 的启动时间。

杰克·阿奇博尔德
Jake Archibald

浏览器支持

  • 59
  • 18
  • 99
  • 15.4

来源

摘要

问题

当您导航到使用 Service Worker 处理提取事件的站点时,浏览器会向 Service Worker 请求响应。这涉及启动 Service Worker(如果尚未运行)以及分派 fetch 事件。

启动时间取决于设备和条件。该时间通常为 50 毫秒左右。在移动设备上,更像是 250 毫秒。在极端情况(设备运行缓慢、CPU 遇到困难)下,可能会超过 500 毫秒。不过,由于 Service Worker 在浏览器确定的事件间隔时间期间保持唤醒状态,因此您只会偶尔出现这种延迟,例如当用户从新标签或其他网站导航到您的网站时。

如果您从缓存进行响应,启动时间将不是问题,因为跳过网络的好处大于启动延迟。但是,如果你通过网络回复...

软件启动
导航请求

网络请求因 Service Worker 的启动而延迟。

我们通过使用 V8 中的代码缓存跳过没有提取事件的 Service Worker推测性启动 Service Worker 以及其他优化方式,持续缩短启动时间。不过,启动时间始终大于零。

Facebook 注意到了该问题的影响,并提出了一种并行执行导航请求的方法:

软件启动
导航请求



我们说:“好的,看起来还不错”。

使用“Navigation 预加载”来修复问题

Navigation 预加载是一项功能,可让您说“Hey, 当用户发出 GET 导航请求时,在 Service Worker 启动时启动网络请求”。

启动延迟仍然存在,但它不会阻止网络请求,因此用户可以更快地获得内容。

下面的视频展示的是它的实际运用,其中使用 when 循环为 Service Worker 特意设置 500 毫秒的启动延迟时间:

这里是演示版。要获享导航预加载功能,您需要支持此模式的浏览器

启用导航预加载

addEventListener('activate', event => {
  event.waitUntil(async function() {
    // Feature-detect
    if (self.registration.navigationPreload) {
      // Enable navigation preloads!
      await self.registration.navigationPreload.enable();
    }
  }());
});

您可以随时调用 navigationPreload.enable(),也可以使用 navigationPreload.disable() 将其停用。不过,由于您的 fetch 事件需要使用它,因此最好在 Service Worker 的 activate 事件中启用/停用它。

使用预加载的响应

现在,浏览器将为导航执行预加载,但您仍然需要使用响应:

addEventListener('fetch', event => {
  event.respondWith(async function() {
    // Respond from the cache if we can
    const cachedResponse = await caches.match(event.request);
    if (cachedResponse) return cachedResponse;

    // Else, use the preloaded response, if it's there
    const response = await event.preloadResponse;
    if (response) return response;

    // Else try the network.
    return fetch(event.request);
  }());
});

在以下情况下,event.preloadResponse 是一个通过响应进行解析的 promise:

  • 导航预加载已启用。
  • 该请求是 GET 请求。
  • 该请求是一个导航请求(浏览器在加载网页(包括 iframe)时生成的请求)。

否则,event.preloadResponse 仍然存在,但它会通过 undefined 进行解析。

如果您的页面需要来自网络的数据,最快的方式是在 Service Worker 中请求这些数据,并创建单个流式响应,其中包含来自缓存的部分和来自网络的部分。

假设我们想显示一篇文章:

addEventListener('fetch', event => {
  const url = new URL(event.request.url);
  const includeURL = new URL(url);
  includeURL.pathname += 'include';

  if (isArticleURL(url)) {
    event.respondWith(async function() {
      // We're going to build a single request from multiple parts.
      const parts = [
        // The top of the page.
        caches.match('/article-top.include'),
        // The primary content
        fetch(includeURL)
          // A fallback if the network fails.
          .catch(() => caches.match('/article-offline.include')),
        // The bottom of the page
        caches.match('/article-bottom.include')
      ];

      // Merge them all together.
      const {done, response} = await mergeResponses(parts);

      // Wait until the stream is complete.
      event.waitUntil(done);

      // Return the merged response.
      return response;
    }());
  }
});

在上面的示例中,mergeResponses 是一个小函数,用于合并每个请求的数据流。这意味着我们可以在网络内容流式传输时显示缓存标头。

这种方式比“应用 Shell”模型更快,因为网络请求是随网页请求一起发出的,并且内容可以在没有主要黑客手段的情况下在线播放。

不过,对 includeURL 的请求将延迟 Service Worker 的启动时间。我们也可以使用导航预加载功能来解决此问题,但在本例中,我们不想预加载完整网页,而是想预加载包含内容。

为了支持这一点,每个预加载请求都会发送一个标头:

Service-Worker-Navigation-Preload: true

服务器可以使用此方法为导航预加载请求发送与常规导航请求不同的内容。只需记得添加 Vary: Service-Worker-Navigation-Preload 标头,以便缓存知道您的响应会有所不同。

现在,我们可以使用预加载请求:

// Try to use the preload
const networkContent = Promise.resolve(event.preloadResponse)
  // Else do a normal fetch
  .then(r => r || fetch(includeURL))
  // A fallback if the network fails.
  .catch(() => caches.match('/article-offline.include'));

const parts = [
  caches.match('/article-top.include'),
  networkContent,
  caches.match('/article-bottom')
];

更改标题

默认情况下,Service-Worker-Navigation-Preload 标头的值为 true,但您可以视需要将其设置为任何值:

navigator.serviceWorker.ready.then(registration => {
  return registration.navigationPreload.setHeaderValue(newValue);
}).then(() => {
  console.log('Done!');
});

例如,您可以将其设为您在本地缓存的最新博文的 ID,以便服务器仅返回较新的数据。

获取状态

您可以使用 getState 查找导航预加载的状态:

navigator.serviceWorker.ready.then(registration => {
  return registration.navigationPreload.getState();
}).then(state => {
  console.log(state.enabled); // boolean
  console.log(state.headerValue); // string
});

非常感谢 Matt Falkenhagen 和 Tsuyoshi Horo 对此功能所做的工作以及对本文的帮助。非常感谢标准化工作的参与者

全新可互操作系列课程的一部分