service-worker工作原理浅析

1,968 阅读13分钟

前言

之前有写过一篇文章,对service-worker做了简单的介绍:Service Worker初探

这篇文章主要是做补充,对service-worker工作原理做一个更深入的解析~

前提条件

Service Worker 出于安全性和其实现原理,在使用的时候有一定的前提条件。

  • 由于 Service Worker 要求 HTTPS 的环境,我们通常可以借助于 github page 进行学习调试。当然一般浏览器允许调试 Service Worker 的时候 host 为 localhost 或者 127.0.0.1 也是 ok 的。

  • Service Worker 的缓存机制是依赖 Cache API 实现的

  • 依赖 HTML5 fetch API

  • 依赖 Promise 实现

生命周期

  • 注册
  • 安装
  • 激活

其实在注册后,service-worker详细的生命周期是分为几种状态:

安装中, 安装后, 激活中, 激活后, 废弃。

  • 安装中( installing ):这个状态发生在 Service Worker 注册之后,表示开始安装,触发 install 事件回调指定一些静态资源进行离线缓存。 install 事件回调中有两个方法:

    event.waitUntil():传入一个 Promise 为参数,等到该 Promise 为 resolve 状态为止。

    self.skipWaiting():self 是当前 context 的 global 变量,执行该方法表示强制当前处在 waiting 状态的 Service Worker 进入 activate 状态。

  • 安装后( installed ):Service Worker 已经完成了安装,并且等待其他的 Service Worker 线程被关闭。

  • 激活中( activating ):在这个状态下没有被其他的 Service Worker 控制的客户端,允许当前的 worker 完成安装,并且清除了其他的 worker 以及关联缓存的旧缓存资源,等待新的 Service Worker 线程被激活。

    activate 回调中有两个方法:

    event.waitUntil():传入一个 Promise 为参数,等到该 Promise 为 resolve 状态为止。使用这个方法的作用是扩展了事件的生命周期(下面的激活状态同理),在服务工作线程中,延长事件的寿命从而阻止浏览器在事件中的异步操作完成之前终止服务工作线程。ExtendableEvent.waitUntil()

    self.clients.claim():在 activate 事件回调中执行该方法表示取得页面的控制权, 这样之后打开页面都会使用版本更新的缓存。旧的 Service Worker 脚本不再控制着页面,之后会被停止。

  • 激活后( activated ):在这个状态会处理 activate 事件回调 (提供了更新缓存策略的机会)。并可以处理功能性的事件 fetch (请求)、sync (后台同步)、push (推送)。

  • 废弃状态 ( redundant ):这个状态表示一个 Service Worker 的生命周期结束。

    这里特别说明一下,进入废弃 (redundant) 状态的原因可能为这几种:

  • 安装 (install) 失败

  • 激活 (activating) 失败

  • 新版本的 Service Worker 替换了它并成为激活状态

换言之,同一个页面/客户端,只能被一个service-woker控制,新的worker会把旧的替换。

service Worker 支持的所有事件

  • install:Service Worker 安装成功后被触发的事件,在事件处理函数中可以添加需要缓存的文件

  • activate:当 Service Worker 安装完成后并进入激活状态,会触发 activate 事件。通过监听 activate 事件你可以做一些预处理,如对旧版本的更新、对无用缓存的清理等。

  • message:Service Worker 运行于独立 context 中,无法直接访问当前页面主线程的 DOM 等信息,但是通过 postMessage API,可以实现他们之间的消息传递,这样主线程就可以接受 Service Worker 的指令操作 DOM。

Service Worker 有几个重要的功能性的的事件,这些功能性的事件支撑和实现了 Service Worker 的特性。

  • fetch (请求):当浏览器在当前指定的 scope 下发起请求时,会触发 fetch 事件,并得到传有 response 参数的回调函数,回调中就可以做各种代理缓存的事情了。

  • push (推送):push 事件是为推送准备的。不过首先需要了解一下 Notification API 和 PUSH API。通过 PUSH API,当订阅了推送服务后,可以使用推送方式唤醒 Service Worker 以响应来自系统消息传递服务的消息,即使用户已经关闭了页面。

  • sync (后台同步):sync 事件由 background sync (后台同步)发出。background sync 配合 Service Worker 推出的 API,用于为 Service Worker 提供一个可以实现注册和监听同步处理的方法。但它还不在 W3C Web API 标准中。在 Chrome 中这也只是一个实验性功能,需要访问 chrome://flags/#enable-experimental-web-platform-features ,开启该功能,然后重启生效。

注册

要安装 Service Worker, 我们需要通过在 js 主线程(常规的页面里的 js )注册 Service Worker 来启动安装,这个过程将会通知浏览器我们的 Service Worker 线程的 javaScript 文件在什么地方呆着。

先来感受一段代码

if ('serviceWorker' in navigator) {
    window.addEventListener('load', function () {
        navigator.serviceWorker.register('/sw.js', {scope: '/'})
            .then(function (registration) {
 
                // 注册成功
                console.log('ServiceWorker registration successful with scope: ', registration.scope);
            })
            .catch(function (err) {
 
                // 注册失败:(
                console.log('ServiceWorker registration failed: ', err);
            });
    });
}
  • 这段代码首先是要判断 Service Worker API 的可用情况,支持的话咱们才继续谈实现,否则免谈了。

  • 如果支持的话,在页面 onload 的时候注册位于 /sw.js 的 Service Worker。

  • 每次页面加载成功后,就会调用 register() 方法,浏览器将会判断 Service Worker 线程是否已注册并做出相应的处理。

  • register 方法的 scope 参数是可选的,用于指定你想让 Service Worker 控制的内容的子目录。本 demo 中服务工作线程文件位于根网域, 这意味着服务工作线程的作用域将是整个来源。

  • 关于 register 方法的 scope 参数,需要说明一下:Service Worker 线程将接收 scope 指定网域目录上所有事项的 fetch 事件,如果我们的 Service Worker 的 javaScript 文件在 /a/b/sw.js, 不传 scope 值的情况下, scope 的值就是 /a/b。

  • scope 的值的意义在于,如果 scope 的值为 /a/b, 那么 Service Worker 线程只能捕获到 path 为 /a/b 开头的( /a/b/page1, /a/b/page2,...)页面的 fetch 事件。通过 scope 的意义我们也能看出 Service Worker 不是服务单个页面的,所以在 Service Worker 的 js 逻辑中全局变量需要慎用。

  • then() 函数链式调用我们的 promise,当 promise resolve 的时候,里面的代码就会执行。

  • 最后面我们链了一个 catch() 函数,当 promise rejected 才会执行。

代码执行完成之后,我们这就注册了一个 Service Worker,它工作在 worker context,所以没有访问 DOM 的权限。在正常的页面之外运行 Service Worker 的代码来控制它们的加载。

安装

在你的 Service Worker 注册成功之后呢,我们的浏览器中已经有了一个属于你自己 web App 的 worker context 啦, 在此时,浏览器就会马不停蹄的尝试为你的站点里面的页面安装并激活它,并且在这里可以把静态资源的缓存给办了。

install 事件我们会绑定在 Service Worker 文件中,在 Service Worker 安装成功后,install 事件被触发。

install 事件一般是被用来填充你的浏览器的离线缓存能力。为了达成这个目的,我们使用了 Service Worker 新的标志性的存储 cache API — 一个 Service Worker 上的全局对象,它使我们可以存储网络响应发来的资源,并且根据它们的请求来生成key。这个 API 和浏览器的标准的缓存工作原理很相似,但是是只对应你的站点的域的。它会一直持久存在,直到你告诉它不再存储,你拥有全部的控制权。

localStorage 的用法和 Service Worker cache 的用法很相似,但是由于 localStorage 是同步的用法,service-worker被设计为完全异步,所以不允许在 Service Worker 中使用。 IndexedDB 也可以在 Service Worker 内做数据存储。

// 监听 service worker 的 install 事件
this.addEventListener('install', function (event) {
    // 如果监听到了 service worker 已经安装成功的话,就会调用 event.waitUntil 回调函数
    event.waitUntil(
        // 安装成功后操作 CacheStorage 缓存,使用之前需要先通过 caches.open() 打开对应缓存空间。
        caches.open('my-test-cache-v1').then(function (cache) {
            // 通过 cache 缓存对象的 addAll 方法添加 precache 缓存
            return cache.addAll([
                '/',
                '/index.html',
                '/main.css',
                '/main.js',
                '/image.jpg'
            ]);
        })
    );
});
  • 这里我们 新增了一个 install 事件监听器,接着在事件上接了一个 ExtendableEvent.waitUntil() 方法——这会确保 Service Worker 不会在 waitUntil() 里面的代码执行完毕之前安装完成。

  • 在 waitUntil() 内,我们使用了 caches.open() 方法来创建了一个叫做 v1 的新的缓存,将会是我们的站点资源缓存的第一个版本。它返回了一个创建缓存的 promise,当它 resolved 的时候,我们接着会调用在创建的缓存实例(Cache API)上的一个方法 addAll(),这个方法的参数是一个由一组相对于 origin 的 URL 组成的数组,这些 URL 就是你想缓存的资源的列表。

  • 如果 promise 被 rejected,安装就会失败,这个 worker 不会做任何事情。这也是可以的,因为你可以修复你的代码,在下次注册发生的时候,又可以进行尝试。

  • 当安装成功完成之后,Service Worker 就会激活。在第一次你的 Service Worker 注册/激活时,这并不会有什么不同。但是当 Service Worker 更新的时候 ,就不太一样了。

激活

安装完之后下一步即激活。该步骤是操作之前缓存资源的绝佳时机。

其实,这个时候并不意味我们之前操作的ServiceWorker会立马进入下一个阶段,除非之前没有新的ServiceWorker实例,如果之前已有ServiceWorker,这个版本只是对ServiceWorker进行了更新,那么需要满足如下任意一个条件,新的ServiceWorker才会进入下一个阶段:

  • 在新的ServiceWorker线程代码里,使用了self.skipWaiting()。(可防止出现等待情况,这意味着 Service Worker 在安装完后立即激活。)
  • 或者当用户关闭被旧sw控制的页面,因此释放了旧的sw时候
  • 或者指定的时间过去后,释放了之前的ServiceWorker

可以参考这里:谨慎处理 Service Worker 的更新

一旦激活,Service Worker 就可以开始控制在其作用域内的所有页面。一个有趣的事实即:注册了 Service Worker 的页面直到再次加载的时候才会被 Service Worker 进行处理。当 Service Worker 开始进行控制,它有以下几种状态:

处理来自页面的网络或者消息请求所触发的 fetchmessage 事件 中止以节约内存 以下即其生命周期:

active事件中通常做一些过期资源释放的工作:

// 如果当前浏览器没有激活的service worker或者已经激活的worker被解雇,
// 新的service worker进入active事件
self.addEventListener('activate', function(e) {
  console.log('Activate event');
  console.log('Promise all', Promise, Promise.all);
  // active事件中通常做一些过期资源释放的工作
  var cacheDeletePromises = caches.keys().then(cacheNames => {
    console.log('cacheNames', cacheNames, cacheNames.map);
    return Promise.all(cacheNames.map(name => {
      if (name !== 'my-test-cache-v1') { // 如果资源的key与当前需要缓存的key不同则释放资源
        console.log('caches.delete', caches.delete);
        var deletePromise = caches.delete(name);
        console.log('cache delete result: ', deletePromise);
        return deletePromise;
      } else {
        return Promise.resolve();
      }
    }));
  });

  console.log('cacheDeletePromises: ', cacheDeletePromises);
  e.waitUntil(
    Promise.all([cacheDeletePromises]
    )
  )
})

缓存运行时请求

该部分才是干货。在这里可以看到如何拦截请求然后返回已创建的缓存(以及创建新的缓存)。

当 Service Worker 安装完成之后,用户会导航到另一个页面或者刷新当前页面,Service Worker 将会收到 fetch 事件。这里有一个演示了如何返回缓存的静态资源或执行一个新的请求并缓存返回结果的过程的示例:

self.addEventListener('fetch', function(event) {
  event.respondWith(
    // 该方法查询请求然后返回 Service Worker 创建的任何缓存数据。
    caches.match(event.request)
      .then(function(response) {
        // 若有缓存,则返回
        if (response) {
          return response;
        }
        // 复制请求。请求是一个流且只能被使用一次。因为之前已经通过缓存使用过一次了,所以为了在浏览器中使用 fetch,需要复制下该请求。
        var fetchRequest = event.request.clone();
        
        // 没有找到缓存。所以我们需要执行 fetch 以发起请求并返回请求数据。
        return fetch(fetchRequest).then(
          function(response) {
            // 检测返回数据是否有效
            if(!response || response.status !== 200 || response.type !== 'basic') {
              return response;
            }

            // 复制返回数据,因为它也是流。因为我们想要浏览器和缓存一样使用返回数据,所以必须复制它。这样就有两个流
            var responseToCache = response.clone();

            caches.open(CACHE_NAME)
              .then(function(cache) {
                // 把请求添加到缓存中以备之后的查询用
                cache.put(event.request, responseToCache);
              });

            return response;
          }
        );
      })
    );
});

大概的流程如下:

  • event.respondWith() 会决定如何响应 fetch 事件。 caches.match() 查询请求然后返回之前创建的缓存中的任意缓存数据并返回 promise。
  • 如果有,则返回该缓存数据。
  • 否则,执行 fetch 。
  • 检查返回的状态码是否是 200。同时检查响应类型是否为 basic,即检查请求是否同域。当前场景不缓存第三方资源的请求。
  • 把返回数据添加到缓存中。

因为请求和响应都是流而流数据只能被使用一次,所以必须进行复制。而且由于缓存和浏览器都需要使用它们,所以必须进行复制。

更新 Service Worker

当用户访问网络应用的时候,浏览器会在后台试图重新下载包含 Service Worker 代码的 .js 文件。

如果下载下来的文件和当前的 Service Worker 代码文件有一丁点儿不同,浏览器会认为文件发生了改变并且会创建一个新的 Service Worker。

创建新的 Service Worker 的过程将会启动,然后触发 install 事件。然而,这时候,旧的 Service Worker 仍然控制着网络应用的页面意即新的 Service Worker 将会处于 waiting 状态。

一旦关闭网络应用当前打开的页面,旧的 Service Worker 将会被浏览器杀死而新的 Service Worker 就可以上位了。这时候将会触发 activate 事件。

为什么所有这一切是必须的呢?这是为了避免在不同选项卡中同时运行不同版本的的网络应用所造成的问题-一些在网页中实际存在的问题且有可能会产生新的 bug(比如当在浏览器中本地存储数据的时候却拥有不同的数据库结构)。

谨慎处理 Service Worker 的更新

HTTPS 要求

当处于开发阶段的时候,可以通过 localhost 来使用 Service Workers ,但当处于发布环境的时候,必须部署好 HTTPS(这也是使用 HTTPS 的最后一个原因了)。

可以利用 Service Worker劫持网络连接和伪造响应数据。如果不使用 HTTPS,网络应用会容易遭受中间人 攻击。

为了保证安全,必须通过 HTTPS 在页面上注册 Service Workers,这样就可以保证浏览器接收到的 Service Worker 没有在传输过程中被篡改。

参考文献: