likes
comments
collection
share

解析 Service Worker 工作方式

作者站长头像
站长
· 阅读数 15

在阅读本篇内容开始之前,请至少熟悉: Service Worker 指南

Service Worker 是一种运行在浏览器后台的脚本,它可以拦截网络请求、操作缓存、离线工作等。作为中间层控制浏览器和服务器之间的请求和响应,从而缓存页面内容,提高网页性能以及实现离线访问。

Web Worker 类似,两者的工作方式都是在单独的线程上工作,独立于主进程,并且在同一个混合进程中被执行,该进程在执行时机上优先于主进程。

Basic Concepts

service worker 基于 promise 实现。

service worker 允许我们独立于缓存处理请求,因此我们会先解析其生命周期,其次是如何进行缓存操作。

Js 在 service worker 中需要使用异步 API,就好像不能在 service worker 中不能使用 document、localStorage。

深入学习之前,先来回顾一些基本概念,我们注册一个 worker:

// register

if('serviceWorker' in navigator) {
  navigator.serviceWorker.register('./service-worker.js')
    .then(function(registration) {
      console.log('Service worker registered:', registration);
    })
    .catch(function(error) {
      console.log('Service worker registration failed:', error);
    });
}

当调用 register 时,所注册的 worker 将被下载。如果目标 worker 无法被解析、加载或者在初始化、执行时便抛出错误,那么该 worker 就会被丢弃。

我们特地修改文件为一个不存在的错误地址,尝试返回一个错误消息:

解析 Service Worker 工作方式

可以通过 DevTools Application 可以看到更多关于服务的信息。

解析 Service Worker 工作方式

下面的选项可以使得正处于被接管页面直接获取最新 service worker 版本,并会对缓存数据进行更新,而且会跳过等待阶段直接激活更新后的 service worker 服务,更新页面。

解析 Service Worker 工作方式

如果你的程序一直在等待 waitUntil 工作成功完成(也就是数据在此阶段缓存时出现问题),此时通常 Activate 阶段会一直无法转变到 Activated,可以点击通过 skipAwait 跳过这一过程(从服务成功搭建之后,一直无法出现这个状态,网上找了一个):

解析 Service Worker 工作方式

和目前版本的 chrome 工具展示效果有一些出入。

其所注册的默认范围相对脚本 URL 的 ./ 。换句话说就是:在 example.com/src/service-worker.js 中注册了服务,那么默认范围便是:example.com/src/

我们将 tab页、worker(shared workers)称为 clients。下文中的所有 client 都泛指这里的概念。服务线程只能控制范围内的 clients,一旦 client 被“控制”,相关的 fetch 操作就会经过受控的 worker。

最后呢,我们可以通过 navigator.serviceWorker.controller(return null | worker instance) 来检测当前 client 是否被控制。

大家回顾完这点概念之后便可以正式开始本文的阅读了。更多基本概念请查看文章开始位置指南。

Lifecycle

生命周期让我们可以在不同的阶段穿插不同行为的工作,使我们更容易把控对业务的需求。

install:

install 是 service worker 生命周期中的第一个被触发的事件,在初始化时便被调用(只会发生一次);

如果途中更新了 worker 脚本,浏览器便会认为这是一个新的 worker,此时会再次执行 install 事件,对缓存进行数据更新。

当修改了缓存名称从 v1 -> v2,同样依旧会重新触发 install,且此时将意味着不再在数据上做更新,而是会设置一个新的缓存结构。

通常在该事件上会调用 ExtendableEvent.waitUntil() 方法,确保 Service Worker 不会在 waitUntil() 里面的代码执行完毕之前 install 完成。

🌰 举个例子:

//  extendableEvent.waitUntil(promise); 
//  该方法会通知事件分发器该事件仍在继续,且可以用于检测任务是否成功
//  参数是一个 promise

addEventListener('install', event => {
  const preCache = async () => {
    const cache = await caches.open('static-v1');
    return cache.addAll([
      '/',
      '/about/',
      '/static/styles.css'
    ]);
  };
  event.waitUntil(preCache());
});
  

在 install 完成之前,waiUntil 会一直等待 preCache 回调的执行结束(成功或者失败)。

在回调内部,我们用它做了很多准备性东西。可以是CSS,图片,字体,JS,模板... 基本上任何你认为是静态的东西。

install 事件是我们在能够控制 client 之前通过调用 waitUntil 方法来缓存所需一切资源的机会。

这主要用于确保在服务工作线程安装以前,所有依赖的核心缓存都已经被成功载入。

Tip:这里不需要缓存一些不会立即使用的大型资源。

activate:

service worker 在 install 成功完成并转换为 activate 之前,不会接收到 fetch 和 push 等功能性事件。

当 worker 完成了 client 的准备性工作并处理了一些功能性事件之后,我们便会获得 ativate 激活事件。但也不意味着调用 register 的页面将被控制。

我们来看一个 demo:

<script>
    if ('serviceWorker' in navigator) {
        window.addEventListener('load', () => {
            navigator.serviceWorker.register('./sw.js')
                .then((reg) => {
                    console.log('Service worker registered.', reg)
                })
                .catch((err) => {
                    console.log('Service worker registration failed:', err)
            });
        });
    }

    setTimeout(() => {
        const img = new Image();
        img.src = './dog.svg';
        document.body.appendChild(img);
    }, 3000);
</script>

文件中,注册了一个 worker 并在 3s 之后加载一张狗的图片。

console.log("sw.js 文件被加载了!");
self.addEventListener("install", (event) => {
  console.log("V1 installing…");

  event.waitUntil(
    caches.open("static-v1").then((cache) => cache.add("/cat.svg"))
  );
});

self.addEventListener("activate", (event) => {
  console.log("V1 now ready to handle fetches!");
});

self.addEventListener("fetch", (event) => {
  const url = new URL(event.request.url);

  if (url.origin == location.origin && url.pathname == "./dog.svg") {
    event.respondWith(caches.match("./cat.svg"));
  }
});

在 worker 中,又缓存了一张猫的图片,在涉及 /dog.svg 的请求中提供它。

当我们第一查看页面时,看到的是一条狗,当刷新后会看到一只猫。

解析 Service Worker 工作方式

默认情况,如果页面在没有 service worker 的情况下加载,作为子资源也不会被加载。如果我们第二次加载演示(刷新页面),这时它将会被控制。页面和图像都将经历 fetch 事件,这时我们会看到一只猫。

专业上来说:如果你的 service worker 已经被 install,但是刷新页面时有一个新版本的可用,新版的 service worker 会在后 install,但是仍然不会被激活。当不再有任何已加载的页面在使用旧版的 service worker 的时候,新版本才会激活。一旦再也没有这样的已加载的页面,新的 service worker 就会被激活。

由于旧版本已经过时了,所以当下是清理过期不必要缓存的最理想的时间。

服务激活后,service worker 将会立即控制当前 tab 页,但只会控制 register 成功之后被打开的页面。也就是说,在激活之前的页面需要重新加载才可以被控制,因为在这之前页面无法在生命周期中被维护。

为了覆盖此默认行为并在页面打开的情境下,可以通过调用 clients.claim() 方法来控制 client。

TipClients 接口的 claim()  方法允许一个激活的 service worker 将自己设置为其 scope (en-US) 内所有 clients 的 controller . 这会在由此 service worker 控制的任何 clients 中触发 navigator.serviceWorker 上的 "controllerchange" 事件。

update the service worker

在一些场景下,会触发服务的更新:

  1. A navigation to an in-scope page.
  2. A functional events such as push and sync, unless there's been an update check within the previous 24 hours.
  3. Calling .register() only if the service worker URL has changed. However, you should avoid changing the worker URL。

Waiting:

在更新后的 service worker 成功 install 之后,服务会延迟激活,直到当前的 service worker 不在管控 client。这种状态便被称为:Waiting,这是浏览器在确保一次只有一个 service worker 在运行。

接上面的 demo 来说,如果我们随之将猫换做成了一匹马,可此时在页面上看到的将依旧会是一只猫的图片,因为此时 v2 的 worker 暂未被激活。

即使此时开启新的页签、刷新页面页并不足以使得新版本的 worker 来接管当前的 client。由于浏览器导航工作方式的缘故,我们在使用导航时,页面不会消失,直到收到响应头。

即使这样,如果响应具有新的响应头,在 worker 更新之前的页面也可能会保留。由于存在的这种重叠,所以当前的 service worker 在刷新期间依旧管控着 client。

要获取更新,便需要关闭所有受控于之前的 worker 的选项卡。此时再回到页面看到将会是替换后的那匹马。

其他剩余的生命周事件,大家可自行查阅,了解即可。

现在我们来看如何在不同的场景下让 service worker 进行工作。

cache

鉴于 HTTPS 缓存受 HTTPS 标头中指定的缓存指令的影响(为保证数据通信的安全性,Service worker 只能由 HTTPS 承载),Cache 接口可以通过 JavaScript 文件的形式,对不同的业务场景制定合适的缓存逻辑:

  1. 在首次请求时,缓存静态资源到本地,为后续请求从缓存中提供资源;
  2. 将页面静态资源存储在缓存中,仅在离线的情况下为页面提供服务;
  3. 从缓存中为某些资源请求提供缓存资源,缓存会在后台通过后续的网络请求更新数据;
  4. 将通过网络请求获取的一些信息与缓存中的应用程序逻辑结合,以提高感知性能。

缓存策略使得程序离线机制提供了保障。

预缓存和运行时缓存

Service worker 和 Cache 实例之间的交互涉及两个不同的缓存概念:预缓存和运行时缓存。

预缓存:

在最初注册 Service Worker 时,浏览器会自动下载和安装新版本,并在安装过程中预缓存指定的资源。(通过在 Service Worker 安装阶段指定需要缓存的资源列表,将这些资源预先缓存到浏览器中,以便在后续离线访问或网络不佳时使用。)

由于是静态缓存,所以只能在 Service Worker 安装期间更新缓存,不能在运行时动态修改缓存内容。

这是一种提前缓存资产的过程,通常在service worker的安装过程中进行。通过预缓存,将关键静态资产和材料下载并存储在缓存实例中,供离线访问。

从而提高需要预缓存资产的后续页面的页面加载速度。

运行时缓存:

通过缓存与请求相对应的响应结果来实现,将某个请求的响应结果保存到浏览器缓存中,并在下次请求该资源时直接从缓存中获取,从而优化页面的响应速度和离线访问体验。

运行时缓存是动态缓存,可以在任何时候更新缓存数据,例如:在响应返回后将其添加到缓存中。

预缓存静态资源:

在上面我们说过,install 事件是我们在能够控制 client 之前通过调用 waitUntil 方法来缓存所需一切资源的机会。

通常的,工作中会通过预缓存网站上许多页面中使用的一些静态资源,如:图像、样式以及脚本等,以便于页面在加载过程中从缓存中获取资源从而加快加载时间,而不是在页面加载时从网络中获取。

当用户因为网络原因导致无法满足页面资源请求时,可以通过预缓存备份页面从而优化用户体验。

通过调用相应的 cache 以及 Cache 实例方法来扩展程序(缓存资源):

self.addEventListener('install', event => {

  function onInstall () {
    return caches.open('v1')
      .then(cache => cache.addAll([
        '/',
        '/js/ws.js',
        '/css/styles.css',
        '/images/img.gif'
      ])
    );
  }

  event.waitUntil(onInstall(event));
});

service worker 提供了 CacheStorage 接口,使得 caches 属性在线程中全局可用。

这里用到了两个方法:openaddAll;在上文中,通过调用 open 获取到 v1 cache 缓存(成功),那么就会返回解析为 cache 对象的 promise;addAll 同样返回一个 promise,当所有传递给它的项目都存储在缓存中时解析。

最后我们通过调用 waitUntil 使得整个服务等待,直到 cb 返回的 promise 成功解析。这样我们便可以确保 install 完成之前对所有这些预缓存项目进行排序。

service worker 处理 install 时间并预缓存了一些静态资源,但还不能离线使用它们。截至目前除了这些,我们没有做任何事情。

每当浏览器想要获取当前服务 scope 范围中的资源时,都可以通过 fetch 的方式响应提取,告诉浏览器尝试从网络中获取某些资源(确保关键内容总是最新的)。

self.addEventListener('fetch', event => {
  // … 
});

此外,在所有后续在此工作范围的每一个 fetch 都会触发这个事件,我们可以有选择的响应处理这些 fetch。

我们可以提供一些预处理逻辑,来评估请求以确定应该提供响应还是让浏览器断言默认处理。

self.addEventListener('fetch', event => {

  function shouldHandleFetch (event, opts) {
    // 
  }

  function onFetch (event, opts) {
    // …
  }

  if (shouldHandleFetch(event, config)) {
    onFetch(event, config);
  }
});

在 shouldHandleFetch 中,可以通过为 options 传递一个 config object,通过内部数据来判断某些请求是否是我们不想缓存的内容。

建立在以上的逻辑基础上,现在 onFetch 要怎样做处理呢?

一方面我们应该满足请求的怎样的资源:

function onFetch (event, opts) {
  var request      = event.request;
  var acceptHeader = request.headers.get('Accept');
  var resourceType = 'static';
  var cacheKey;

  if (acceptHeader.indexOf('text/html') !== -1) {
    resourceType = 'content';
  } else if (acceptHeader.indexOf('image') !== -1) {
    resourceType = 'image';
  }

  // {String} [static|image|content]
  cacheKey = resourceType;
  // … now do something
}

可以通过查看 HTTP Accept 标头来获取有关正在请求哪种资产的提示。帮助于我们弄清楚该如何处理它。

我们在这里将不同种类的资源放入不同的缓存中,以便于之后管理这些缓存。

另一方面我们需要做到如何满足这些要求,对其进行响应:

function onFetch (event, opts) {
  // 判断这是哪种数据资源
  if (resourceType === 'content') {
    event.respondWith(
      fetch(request)
        .then(response => addToCache(cacheKey, request, response))
        .catch(() => fetchFromCache(event))
        .catch(() => offlineResponse(opts))
    );
  } else {
    // 非 HTML 使用缓存优先策略
    event.respondWith(
      fetchFromCache(event)
        .catch(() => fetch(request))
        .then(response => addToCache(cacheKey, request, response))
        .catch(() => offlineResponse(resourceType, opts))
      );
  }
}

我们来看我想在响应 HTML 内容上 resourceType === ‘content’ 的请求方式,因为 HTML 内容是我们程序的核心内容并且经常变化,所以需要总是尝试从网络上获取最新的 HTML 文档资源。

当请求失败时,才会到缓存中获取资源。

如果请求成功,我们会继续将 HTML 文档的副本存储在缓存中:

function addToCache (cacheKey, request, response) {
  if (response.ok) {
    var copy = response.clone();
    caches.open(cacheKey).then( cache => {
      cache.put(request, copy);
    });
    return response;
  }
}

TipResponse 对象只能使用一次。我们可以通过克隆的方式对数据进行拷贝,从而可以对数据进行多次使用。

如果没有成功,便需要尝试从缓存中检索以前缓存后的 HTML 副本:

fetch(request)
  .then(response => addToCache(cacheKey, request, response))
  .catch(() => fetchFromCache(event))
  . ...
  
function fetchFromCache (event) {
  return caches.match(event.request).then(response => {
    if (!response) {
      // 未找到则触发外部的 catch 处理
      throw Error('${event.request.url} not found in cache');
    }
    return response;
  });
}

如果做到现在,但是缓存中并没有可以的响应内容,便可以返回一个离线页面,并告诉用户它们处于离线状态,无法满足它们的需求:

fetch(request)
  .then(response => addToCache(cacheKey, request, response))
  .catch(() => fetchFromCache(event))
  .catch(() => offlineResponse(opts))

function offlineResponse (resourceType, opts) {
  if (resourceType === 'image') {
    return new Response(opts.offlineImage,
      { headers: { 'Content-Type': 'image/svg+xml' } }
    );
  } else if (resourceType === 'content') {
    return caches.match(opts.offlinePage);
  }
  return undefined;
}

离线页面可以是任何你想展现的数据。

HTML 内容以外的资源的获取逻辑使用缓存优先策略。网站上的图像和其他静态内容很少改变,例如:用户头像,因此可以首先检查缓存,一定程度上可以避免网络请求的往返。

// respondWith()  方法阻止浏览器默认的 fetch 操作

event.respondWith(
  fetchFromCache(event)
    .catch(() => fetch(request))
    .then(response => addToCache(cacheKey, request, response))
    .catch(() => offlineResponse(resourceType, opts))
);

这里的意思类似于上面的逻辑:

  1. 首先尝试从缓存中检索资源;
  2. 如果失败,便尝试从网络上请求支援;
  3. 如果依旧失败,那么就需要提供备用离线页面资源。

这样我们的最基础的需求就做完了。我们在 install 期间对一些资源进行了缓存,以及对 fetch 进行健壮性处理,使得可以适应在不同的场景下响应合适的资源数据。

最后🥱🥱🥱

service worker 版本控制!

如果我们的网站上再也没有发生任何的改变,其实我们已经完成了。但是,service worker 需要不时更新。也许想要修改或添加更多可缓存的路径;也许想改进离线后的工作方式;又或者要修复一些问题,Hmm ~。

意料之中的有一些工具可以为我们做到这些事情:Workbox。这里涉及到很多 service worker 相关的文章,大家可以读一读。

当然我们可以自己来做这个事情。

我们可以为每一个版本提供以特殊的字符串来指示版本,在 fetch 阶段将其添加到 config 中,以便于针对不同的版本做资源部署。在之后每当需要部署新的版本时都去更新一个新的字符串来标识新的版本,也可以通过该标识来清理过期的缓存资源。

有时我们习惯将一些数据配置项抽离出一个文件,在这里你或许会想到将 config 抽离并通过 importScripts 来引入脚本(关于该 API 更多信息,请移步 WebWorker)。但是这里存在一个问题:

浏览器会通过字节数等手段对 service worker 文件以确定它们是否已更新 —— 这也是它知道何时重新触发下载和安装周期的方式。对上面外部导入 config 的更改方式,并不会对 service worker 本身造成任何更改,这就意味着对 config 的更改不会导致服务工作者更新。

最后大家可以思考这样一个问题:

用户在登录某 app 时会接收到一些推送,但看到时可能处于离线状态。此时用户对推送点击查看,我们应该给予怎样的信息,换句话说我们应该怎样做,可以使得用户看到的推送内容的完整性。

文章推荐:

  1. Learn PWA
  2. A Beginner’s Guide To Progressive Web Apps