我们向我们敬佩的网页构建者提出了同样的问题...

人们可以做些什么来使他们的网站变得更好?

感谢我们 2021 年的主要赞助商。他们是使这个网站成为可能的重要组成部分。

在您的网站中添加服务工作者

如果您还没有添加服务工作者,那么 2022 年您可以为您的网站做的一件最好的事情就是在网站中添加服务工作者。服务工作者赋予您的网站超级大国。今天,我想向您展示它们可以做的一些令人惊叹的事情,并为您提供一个“按图索骥”的样板,您可以使用它立即在您的网站上开始使用它们。

什么是服务工作者?

服务工作者是一种特殊的 JavaScript 文件,它充当您网站的中间件。来自网站的任何请求以及它收到的任何响应,都会首先通过服务工作者文件。服务工作者还可以访问一个特殊的缓存,它们可以在其中本地保存响应和资源。

这些功能共同使您能够……

  • 从本地缓存而不是网络提供频繁访问的资源,从而减少数据使用量并提高性能。
  • 当访问者离线时,提供访问关键信息(甚至整个网站或应用程序)的权限。
  • 预取重要的资源和 API 响应,以便在用户需要时准备好它们。
  • 提供对 HTTP 错误的响应的备用资源。

简而言之,服务工作者使您能够构建更快、更具弹性的 Web 体验。

与普通的 JavaScript 文件不同,服务工作者无法访问 DOM。它们还在自己的线程上运行,因此不会阻止其他 JavaScript 运行。服务工作者旨在完全异步。

安全

由于服务工作者会拦截您网站或应用程序的每个请求和响应,因此它们有一些重要的安全限制。

服务工作者遵循同源策略。

您不能从 CDN 或第三方运行您的服务工作者。它必须与要运行它的域位于同一域。

服务工作者仅在安装了 SSL 证书的网站上有效。

许多网络主机免费或以少量费用提供 SSL 证书。如果您熟悉命令行,还可以使用 Let’s Encrypt 免费安装一个。

对于 localhost 测试,SSL 证书要求有一个例外,但您不能从 file:// 协议运行您的服务工作者。您需要运行一个本地服务器。

将服务工作者添加到您的网站或 Web 应用程序

要使用服务工作者,我们首先需要注册它与浏览器。您可以使用 navigator.serviceWorker.register() 方法注册服务工作者。将服务工作者文件的路径作为参数传递。

navigator.serviceWorker.register('sw.js');

您可以在外部 JavaScript 文件中运行它,但我更喜欢直接在 HTML 中的内联 script 元素中运行它,以便它尽快运行。

与其他类型的 JavaScript 文件不同,服务工作者仅适用于它们所在的目录(及其任何子目录)。位于 /js/sw.js 的服务工作者文件仅适用于 /js 目录中的文件。因此,您应该将服务工作者文件放在网站的根目录中。

虽然服务工作者具有出色的浏览器支持,但最好在运行注册脚本之前确保浏览器支持它们。

if (navigator && navigator.serviceWorker) {
  navigator.serviceWorker.register('sw.js');
}

服务工作者安装后,浏览器可以激活它。通常,这只会发生在...

  • 当前没有活动的服务工作者,或
  • 用户刷新页面。

服务工作者在激活之前不会运行或拦截请求。

在服务工作者中监听请求

服务工作者激活后,它就可以开始拦截请求并运行其他任务。我们可以使用 self.addEventListener()fetch 事件监听请求。

// Listen for request events
self.addEventListener('fetch', function (event) {
  // Do stuff...
});

在事件监听器中,event.request 属性是请求对象本身。为了方便起见,我们可以将其保存到 request 变量中。

Chromium 浏览器的一些版本存在一个错误,如果页面在新标签页中打开,就会抛出错误。幸运的是,Paul Irish 给出了一个简单的解决方法,我会在我的所有服务工作者中都包含它,以防万一。

// Listen for request events
self.addEventListener('fetch', function (event) {

  // Get the request
  let request = event.request;

  // Bug fix
  // https://stackoverflow.com/a/49719964
  if (event.request.cache === 'only-if-cached' && event.request.mode !== 'same-origin') return;

});

服务工作者激活后,每个请求都会通过它发送,并通过 fetch 事件被拦截。

服务工作者策略

服务工作者安装并激活后,您可以拦截请求和响应,并以各种方式处理它们。在您的服务工作者中可以使用两种主要策略。

  1. 网络优先。使用网络优先方法,您将请求传递给网络。如果找不到请求,或者没有网络连接,则您会在服务工作者缓存中查找请求。
  2. 离线优先。使用离线优先方法,您首先检查服务工作者缓存中是否有请求的资源。如果找不到,则将请求发送到网络。

网络优先离线优先方法协同工作。您可能会根据被请求的资源类型来混合使用这些方法。

离线优先非常适合那些不经常更改的大型资源:CSS、JavaScript、图像和字体。网络优先更适合经常更新的资源,如 HTML 和 API 请求。

缓存资源的策略

如何将资源放入浏览器的缓存中?您通常会使用两种不同的方法,具体取决于资源的类型。

  1. 在安装时预缓存。每个网站和 Web 应用程序都有一组核心资源,这些资源几乎在每个页面上都会使用:CSS、JavaScript、徽标、favicon 和字体。您可以在 install 事件期间预缓存它们,并在每次请求它们时使用离线优先方法提供它们。
  2. 在您浏览时缓存。您的网站或应用程序可能有一些资源,不会在每次访问或被每个访问者访问;例如博客文章和文章中包含的图像。对于这些资源,您可能希望在访问者访问它们时实时缓存它们。

然后,您可以提供这些缓存的资源,无论是默认情况下还是作为备用,具体取决于您的方法。

在您的服务工作者中实现网络优先和离线优先策略

在服务工作者中的 fetch 事件中,request.headers.get('Accept') 方法返回内容的 MIME 类型。我们可以使用它来确定请求是针对哪种类型的文件。 MDN 有一个常见的文件及其 MIME 类型列表。 例如,HTML 文件的 MIME 类型为 text/html

我们可以将要查找的文件类型作为参数传递到 String.includes() 方法中,并使用 if 语句根据文件类型以不同的方式进行响应。

// Listen for request events
self.addEventListener('fetch', function (event) {

  // Get the request
  let request = event.request;

  // Bug fix
  // https://stackoverflow.com/a/49719964
  if (event.request.cache === 'only-if-cached' && event.request.mode !== 'same-origin') return;

  // HTML files
  // Network-first
  if (request.headers.get('Accept').includes('text/html')) {
    // Handle HTML files...
    return;
  }

  // CSS & JavaScript
  // Offline-first
  if (request.headers.get('Accept').includes('text/css') || request.headers.get('Accept').includes('text/javascript')) {
    // Handle CSS and JavaScript files...
    return;
  }

  // Images
  // Offline-first
  if (request.headers.get('Accept').includes('image')) {
    // Handle images...
  }

});

网络优先

在每个 if 语句中,我们使用 event.respondWith() 方法来修改发送回浏览器的响应。

对于使用网络优先方法的资产,我们使用 fetch() 方法,传入 request,来传递 HTML 文件的请求。如果成功返回,我们将在回调函数中return response。这与根本没有服务工作者时的行为相同。

如果出现错误,我们可以使用 Promise.catch() 来修改响应,而不是显示默认的浏览器错误消息。我们可以使用 caches.match() 方法来查找该页面,并return它,而不是网络response

// Send the request to the network first
// If it's not found, look in the cache
event.respondWith(
  fetch(request).then(function (response) {
    return response;
  }).catch(function (error) {
    return caches.match(request).then(function (response) {
      return response;
    });
  })
);

离线优先

对于使用离线优先方法的资产,我们将首先使用 caches.match() 方法检查浏览器缓存。如果找到匹配项,我们将return它。否则,我们将使用 fetch() 方法将request传递给网络。

// Check the cache first
// If it's not found, send the request to the network
event.respondWith(
  caches.match(request).then(function (response) {
    return response || fetch(request).then(function (response) {
      return response;
    });
  })
);

预缓存核心资产

在服务工作者中的 install 事件监听器中,我们可以使用 caches.open() 方法打开服务工作者缓存。我们将要使用的缓存名称 app 作为参数传入。

缓存的作用域和限制在您的域内。其他网站无法访问它,如果它们具有相同名称的缓存,则内容将完全分开。

caches.open() 方法返回一个 Promise。如果已经存在具有此名称的缓存,则 Promise 将解析它。如果没有,它将首先创建缓存,然后解析。

// Listen for the install event
self.addEventListener('install', function (event) {
  event.waitUntil(caches.open('app'));
});

接下来,我们可以将then() 方法链接到我们的caches.open() 方法,并使用回调函数。

为了将文件添加到缓存中,我们需要请求它们,我们可以使用 new Request() 构造函数来做到这一点。我们可以使用 cache.add() 方法将文件添加到服务工作者缓存中。然后,我们return cache 对象。

我们希望 install 事件在缓存完文件之前等待完成,因此让我们将代码包装在 event.waitUntil() 方法中

// Listen for the install event
self.addEventListener('install', function (event) {

  // Cache the offline.html page
  event.waitUntil(caches.open('app').then(function (cache) {
    cache.add(new Request('offline.html'));
    return cache;
  }));

});

我发现用一个数组来保存所有核心文件的路径很有帮助。然后,在 install 事件监听器中,在我打开缓存之后,我可以遍历每个项目并添加它。

let coreAssets = [
  '/css/main.css',
  '/js/main.js',
  '/img/logo.svg',
  '/img/favicon.ico'
];

// On install, cache some stuff
self.addEventListener('install', function (event) {

  // Cache core assets
  event.waitUntil(caches.open('app').then(function (cache) {
    for (let asset of coreAssets) {
      cache.add(new Request(asset));
    }
    return cache;
  }));

});

边浏览边缓存

您的网站或应用程序可能有一些资产,这些资产并非每次访问或每次访问者都会访问;例如博客文章和文章附带的图片。对于这些资产,您可能希望在访问者访问它们时实时缓存它们。在后续访问中,您可以直接从缓存中加载它们(使用离线优先方法),或者在网络出现故障时作为回退提供它们(使用网络优先方法)。

fetch() 方法返回成功的 response 时,我们可以使用 Response.clone() 方法来创建它的副本。

接下来,我们可以使用 caches.open() 方法打开我们的缓存。然后,我们将使用 cache.put() 方法将复制的响应保存到缓存中,传入 requestresponse 的副本作为参数。因为这是一个异步函数,所以我们将代码包装在 event.waitUntil() 方法中。这将防止事件在我们保存完副本到缓存之前结束。保存完副本后,我们可以像往常一样return response

/explanation 我们使用 cache.put() 而不是 cache.add(),因为我们已经有一个 response。使用 cache.add() 将会进行另一个网络调用。

// HTML files
// Network-first
if (request.headers.get('Accept').includes('text/html')) {
  event.respondWith(
    fetch(request).then(function (response) {

      // Create a copy of the response and save it to the cache
      let copy = response.clone();
      event.waitUntil(caches.open('app').then(function (cache) {
        return cache.put(request, copy);
      }));

      // Return the response
      return response;

  }).catch(function (error) {
      return caches.match(request).then(function (response) {
        return response;
      });
    })
  );
}

总结

我已经在 GitHub 上为您准备了一个可以复制粘贴的样板。将您的核心资产添加到 coreAssets 数组中,并在您的网站上注册它以开始使用。

如果您什么都不做,这将极大地提升您在 2022 年的网站性能。

但服务工作者还能做更多的事情。对于 API,还有更高级的缓存策略。如果访客断开了网络连接,您可以提供包含关键信息的离线页面。您可以在用户浏览时清理臃肿的缓存。

Jeremy Keith 的著作,Going Offline,是服务工作者的一个很好的入门读物。如果您想更上一层楼,深入研究渐进式 Web 应用,Jason Grigsby 的书 深入探讨了您可以使用的各种策略。

对于可以在一个小时内完成的务实深入学习,我还有关于服务工作者的课程和电子书,其中包含大量的代码示例和一个您可以实践的项目。