Service Worker 实践

Service Worker 的生命周期

Service Worker 脚本通过 navigator.serviceWorker.register 方法注册到页面,之后可以脱离页面在浏览器后台运行:

1
2
3
4
5
6
7
8
9
10
if (navigator.serviceWorker) {
navigator.serviceWorker.register('/service-worker.js')
.then(function(registration) {
console.log('service worker 注册成功');
})
.catch(function (err) {
console.log('servcie worker 注册失败');
});
}

出于安全原因, Service Worker 脚本的作用范围不能超出脚本文件所在的路径。比如地址是 /sw-test/sw.js 的脚本只能控制 /sw-test/ 下的页面。

Service Worker 从注册开始需要先 install, 如果 install 成功, 接下来需要 activate, 然后才能接管页面。但是如果页面被先前的 Service Worker 控制着, 那么它会停留在 installed(waiting) 这个阶段等到页面重新打开才会接管页面, 或者可以通过调用 self.skipWaiting() 方法跳过等待。所以一个 Service Worker 脚本的生命周期有这样一些阶段(从左往右):

1
Parsed -> Installing -> Inatalled(Waiting) -> Activeting -> Activated -> Redundant
  • Parsed: 注册完成, 脚本解析成功, 尚未安装

  • Installing: 对应 Service Worker 脚本 install 事件执行, 如果事件里有 event.waitUntil() 则会等待传入的 Promise 完成才会成功

  • Inatalled(Waiting): 页面被旧的 Service Worker 脚本控制, 所以当前的脚本尚未激活。可以通过 self.skipWaiting() 激活新的 Service Worker

  • Activeting: 对应 Service Worker 脚本 activate 事件执行, 如果事件里有 event.waitUntil() 则会等待这个 Promise 完成才会成功。这时可以调用 self.clients.claim() 接管页面

  • Activated: 激活成功, 可以处理 fetch, message 等事件

  • Redundant: 安装失败, 或者激活失败, 或者被新的 Service Worker 替代掉

Service Worker 脚本最常用的功能是截获请求和缓存资源文件, 这些行为可以绑定在下面这些事件上:

  • install 事件中, 抓取资源进行缓存

  • activete 事件中, 遍历缓存, 清除过期的资源

  • fetch 事件中, 拦截请求, 查询缓存或者网络, 返回请求的资源

缓存策略

处在 activated 状态的 Service Worker 可以拦截作用范围下的页面的网络请求, 由 Service Worker 监听 fetch 事件来决定请求如何响应。Service Worker 可以访问 Cache API, Fetch API 等接口, 获取数据和完成响应。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const PRECACHE = 'zqd-cache-v0.1.4';
const RUNTIME = 'zqd-runtime';
self.addEventListener('fetch', event => {
// Skip cross-origin requests, like those for Google Analytics.
if (event.request.url.startsWith(self.location.origin)) {
event.respondWith(
caches.match(event.request).then(cachedResponse => {
if (cachedResponse) {
return cachedResponse;
}
return caches.open(RUNTIME).then(cache => {
return fetch(event.request).then(response => {
// Put a copy of the response in the runtime cache.
return cache.put(event.request, response.clone()).then(() => {
return response;
});
});
});
})
);
}
});

有一些常用的资源缓存的策略, 比如在 sw-toolbox 当中定义的有几种:

  • 网络优先: 从网络获取, 失败或者超时再尝试从缓存读取

  • 缓存优先: 从缓存获取, 缓存插叙不到再尝试从网络抓取

  • 最快: 同时查询缓存和网络, 返回最先拿到的

  • 仅限网络: 仅从网络获取

  • 仅限缓存: 仅从缓存获取

借助 Fetch API 和 Cache API 可以编写出复杂的策略用来区分不同类型或者页面的资源的处理方式。详细的解释可以看比如 Service workers explained

官方工具

除了前面提到的手工编写 Service Worker 脚本, Google 提供了 sw-toolbox 和 sw-precache 两个工具方便快速生成 service-worker.js 文件:

  • sw-precache 可以用来生成配置使 PWA 在安装时进行静态资源的缓存

  • sw-toolbox 提供了动态缓存使用的通用策略, 这些动态的资源不合适用 sw-precache 预先缓存。同时它提供了一套类似 Express.js 路由的语法, 用于编写策略

结尾

除了拦截请求之外, Service Worker 还能用来做后台通知, Mock 请求, 离线统计等等工作。可以浏览 GoogleChrome/samples 了解更多的功能和写法。

SW 缓存更新思考:

  1. 将 Service Worker 缓存版本号放在 html 页面,页面不做 SW 缓存,只做协商缓存

  2. 将 SW 版本号存在 cookie/localStorage 等本地存储中, html 有更新,SW 版本号同时更新,比对当前版本号和本地存储中的版本号,若不一致,则更新 Service Worker(or: 版本号放在 sw.js 中?sw.js 做协商缓存?)

  3. 这种更新方式,本地存储的版本号有被篡改的风险,应该仔细考量…

1
2
3
4
5
6
const version = 'wzj-h5-cache-v0.0.1';
navigator.serviceWorker.register('/sw.js').then(reg => {
if (localStorage.getItem('sw_version') !== version) {
reg.update().then(() => localStorage.setItem('sw_version', version));
}
});
  1. 降级方案

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    /**
    * 要注意的有几点:
    * 1. 降级一定要注销掉 SW ,而不是简单地不安装。这是因为降级前可能已经有用户访问过网站,
    * 导致 SW 被安装,不注销的话降级开关对这部分用户是不起作用的。
    * 2. 降级开关需要有即时性,因此服务器和 SW 都不应该缓存该接口。
    * 3. 出现问题并降级后,可能影响问题的排查,因此可以考虑加入对用户隐蔽
    * 的 debug 模式(如 url 传入特定字段),debug 模式中忽略降级接口。
    */
    if (支持SW) {
    fetch(开关接口)
    .then(() => {
    if (降级) {
    // 注销所有已安装的 SW
    } else {
    // 注册 SW
    }
    })
    }

参考资料