ServiceWorker总结篇

Service Worker 是 Chrome 团队提出和力推的一个 WEB API,用于给 web 应用提供高级的可持续的后台处理能力。该 WEB API 标准起草于 2013 年,于 2014 年纳入 W3C WEB 标准草案,当前还在草案阶段。

基于 Service Worker API 的特性,结合 Fetch APICache API、Push API、postMessage API 和 Notification API,可以在基于浏览器的 web 应用中实现如离线缓存、消息推送、静默更新等 native 应用常见的功能,以给 web 应用提供更好更丰富的使用体验。因此,Service Worker也是PWA中最核心的部分。

本文内容分为以下模块:

概念

Service workers 本质上充当Web应用程序与浏览器之间的代理服务器,也可以在网络可用时作为浏览器和网络间的代理。它们旨在(除其他之外)使得能够创建有效的离线体验,拦截网络请求并基于网络是否可用以及更新的资源是否驻留在服务器上来采取适当的动作。他们还允许访问推送通知和后台同步API。 ——mdn

Service worker是一个注册在指定源和路径下的事件驱动worker。它采用JavaScript控制关联的页面或者网站,拦截并修改访问和资源请求,细粒度地缓存资源。你可以完全控制应用在特定情形(最常见的情形是网络不可用)下的表现。

Service worker运行在worker上下文,因此它不能访问DOM。相对于驱动应用的主JavaScript线程,它运行在其他线程中,所以不会造成阻塞。它设计为完全异步,同步API(如XHR和localStorage)不能在service worker中使用

出于安全考量,Service workers只能由HTTPS承载,毕竟修改网络请求的能力暴露给中间人攻击会非常危险。在Firefox浏览器的用户隐私模式,Service Worker不可用。

为了便于本地开发,localhost 也被浏览器认为是安全源。

主要作用

  • 解决web离线问题
  • 后台数据同步
  • 响应来自其它源的资源请求
  • 集中接收计算成本高的数据更新,比如地理位置和陀螺仪信息,这样多个页面就可以利用同一组数据
  • 在客户端进行CoffeeScript,LESS,CJS/AMD等模块编译和依赖管理(用于开发目的)
  • 后台服务钩子
  • 自定义模板用于特定URL模式
  • 性能增强,比如预取用户可能需要的资源,比如相册中的后面数张图片

未来service workers能够用来做更多使web平台接近原生应用的事。 值得关注的是,其他标准也能并且将会使用service worker,例如:

  • 后台同步:启动一个service worker即使没有用户访问特定站点,也可以更新缓存
  • 响应推送:启动一个service worker向用户发送一条信息通知新的内容可用
  • 对时间或日期作出响应
  • 进入地理围栏

Service Worker能做的事

  • 1.预缓存HTML、JavaScript和CSS文件,使其能在页面断网离线的时候也能展示
  • 2.当缓存名更新的时候清理之前的预缓存内容
  • 拦截网络请求,当缓存的响应内容有效时直接返回这个缓存响应
  • 如果没有缓存的响应,获取来自网络的响应并将其添加到缓存中,以供将来使用。

Service Worker不能做的事

  • 自动对预缓存资源进行版本处理。你必须手动更新缓存。
  • 破坏预缓存请求。cache.addAll()调用可能实现HTTP缓存的响应,这取决于您所使用的HTTP缓存头。如果您正在使用HTTP缓存和未版本化的资源源,它可以安全破坏预缓存请求。
  • 在运行时更新缓存内容。一旦一个条目被添加到运行时缓存,这是无限期使用,没有咨询网络来检查更新。如果您的运行时缓存用于资源可能会被更新,一个不同的策略,就像stale-while-revalidate可能更合适。
  • 在运行时清理缓存内容。运行时缓存将成长为新的资源的url请求。在这个例子中,只有5个不同的图像可能加载,所以缓存大小不是一个问题。如果您的web应用程序可能要求任意数量的独特的资源url,然后使用一个如sw-toolbox的封装库来提供缓存过期。

更多demo可见w3c-ServiceWorkersDemos

基本兼容情况

i-c1.png

关于AppCache和Service Worker对比

  • AppCache仅仅在离线的时候才能发挥用处(无法解决网络慢的用户体验问题),而SW不是,可以通过拦截请求,并且返回合适的数据。
  • AppCache无法支持当操作出错时终止操作。SW可以更细致地控制每一件事情。
  • AppCache的浏览器兼容性相比更好。见下图

i-c2.png

《Why Not AppCache++?》AppCache的问题

使用

注册

使用 ServiceWorkerContainer.register() 方法首次注册service worker。
如果注册成功,service worker就会被下载到客户端并尝试安装或激活(见后文),这将作用于整个域内用户可访问的URL,或者其特定子集。

如:

1
2
3
4
5
6
if ('serviceWorker' in navigator) {
navigator.serviceWorker
.register('/sw/sw.js', {scope: '/'})
.then(registration => console.log('ServiceWorker 注册成功!作用域为: ', registration.scope))
.catch(err => console.log('ServiceWorker 注册失败: ', err));
}

注意

  • service worker 只能抓取在 service worker scope 里从客户端发出的请求。
  • 最大的 scope 是 service worker 所在的地址
  • 如果你的 service worker 被激活在一个有 Service-Worker-Allowed header 的客户端,你可以为service worker 指定一个最大的 scope 的列表。
  • 在 Firefox, Service Worker APIs 在用户在 private browsing mode 下会被隐藏而且无法使用。

注册失败可能的原因

chrome 浏览器下,注册成功后,可以打开 chrome://serviceworker-internals/ 查看浏览器的 Service Worker 信息。

Service Worker 的注册路径决定了其 scope 默认作用范围。示例中 sw.js 是在 /sw/ 路径下,这使得该 Service Worker 默认只会收到 /sw/ 路径下的 fetch 事件。如果存放在网站的根路径下,则将会收到该网站的所有 fetch 事件。
如果希望改变它的作用域,可在第二个参数设置 scope 范围。示例中将其改为了根目录,即对整个站点生效。

另外应意识到这一点:Service Worker 没有页面作用域的概念,作用域范围内的所有页面请求都会被当前激活的 Service Worker 所监控。

下载、安装和激活

  • 1.service worker URL 通过 serviceWorkerContainer.register() 来获取和注册。
  • 2.如果注册成功,service worker 就在 ServiceWorkerGlobalScope 环境中运行; 这是一个特殊类型的 woker 上下文运行环境,与主运行线程(执行脚本)相独立,同时也没有访问 DOM 的能力。
  • 3.service worker 现在可以处理事件了。
  • 4.受 service worker 控制的页面打开后会尝试去安装 service worker。最先发送给 service worker 的事件是安装事件(在这个事件里可以开始进行填充 IndexDB和缓存站点资源)。这个流程同原生 APP 或者 Firefox OS APP 是一样的 — 让所有资源可离线访问。
  • 5.当 oninstall 事件的处理程序执行完毕后,可以认为 service worker 安装完成了。
    下一步是激活。当 service worker 安装完成后,会接收到一个激活事件(activate event)。 onactivate 主要用途是清理先前版本的service worker 脚本中使用的资源。
  • 6.Service Worker 现在可以控制页面了,但仅是在 register() 成功后的打开的页面。也就是说,页面起始于有没有 service worker ,且在页面的接下来生命周期内维持这个状态。所以,页面不得不重新加载以让 service worker 获得完全的控制。

生命周期

1
install -> installed -> actvating -> Active -> Activated -> Redundant

下图则展示了service worker的生命周期
sw-lifecycle.png

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

  • 安装(install)失败
  • 激活(activating)失败
  • 新版本的 Service Worker 替换了它并成为激活状态

下图展示了 service worker 所有支持的事件:
sw-events.png

安装和激活:填充你的缓存

在你的 service worker 注册之后,浏览器会尝试为你的页面或站点安装并激活它。

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

如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
this.addEventListener('install', function(event) {
event.waitUntil(
caches.open('v1').then(function(cache) {
return cache.addAll([
'/sw-test/',
'/sw-test/index.html',
'/sw-test/style.css',
'/sw-test/app.js',
'/sw-test/image-list.js',
'/sw-test/star-wars-logo.jpg',
'/sw-test/gallery/',
'/sw-test/gallery/bountyHunters.jpg',
'/sw-test/gallery/myLittleVader.jpg',
'/sw-test/gallery/snowTroopers.jpg'
]);
})
);
});

需要注意的是,只有 addAll 中数组的文件全部安装成功,Service Worker 才会认为安装完成。否则会认为安装失败,安装失败则进入 redundant (废弃)状态。所以这里应当尽量少地缓存资源(一般为离线时需要但联网时不会访问到的内容),以提升成功率。
安装成功后,即进入等待(waiting)或激活(active)状态。在激活状态可通过监听各种事件,实现更为复杂的逻辑需求。具体参见后文事件处理部分。

  • 这里我们 新增了一个 install 事件监听器,接着在事件上接了一个ExtendableEvent.waitUntil()方法——这会确保Service Worker 不会在 waitUntil() 里面的代码执行完毕之前安装完成。
  • waitUntil() 内,我们使用了 caches.open() 方法来创建了一个叫做 v1 的新的缓存,将会是我们的站点资源缓存的第一个版本。它返回了一个创建缓存的 promise,当它 resolved的时候,我们接着会调用在创建的缓存示例上的一个方法 addAll(),这个方法的参数是一个由一组相对于 origin 的 URL 组成的数组,这些 URL 就是你想缓存的资源的列表。
  • 如果 promise 被 rejected,安装就会失败,这个 worker 不会做任何事情。这也是可以的,因为你可以修复你的代码,在下次注册发生的时候,又可以进行尝试。
  • 当安装成功完成之后, service worker 就会激活。在第一次你的 service worker 注册/激活时,这并不会有什么不同。但是当 service worker 更新 (稍后查看 Updating your service worker 部分) 的时候 ,就不太一样了。

自定义请求的响应

现在你已经将你的站点资源缓存了,你需要告诉 service worker 让它用这些缓存内容来做点什么。有了 fetch 事件,这是很容易做到的。

sw-fetch.png

每次任何被 service worker 控制的资源被请求到时,都会触发 fetch 事件,这些资源包括了指定的 scope 内的文档,和这些文档内引用的其他任何资源(比如 index.html 发起了一个跨域的请求来嵌入一个图片,这个也会通过 service worker)。

你可以给 service worker 添加一个 fetch 的事件监听器,接着调用 event 上的 respondWith() 方法来劫持我们的 HTTP 响应,然后你用可以用自己的魔法来更新他们。

1
2
3
4
5
this.addEventListener('fetch', function(event) {
event.respondWith(
// magic goes here
);
});

我们可以用一个简单的例子开始,在任何情况下我们只是简单的响应这些缓存中的 url 和网络请求匹配的资源。

1
2
3
4
5
this.addEventListener('fetch', function(event) {
event.respondWith(
caches.match(event.request)
);
});

caches.match(event.request) 允许我们对网络请求的资源和 cache 里可获取的资源进行匹配,查看是否缓存中有相应的资源。这个匹配通过 url 和 vary header进行,就像正常的 http 请求一样。

失败请求的处理:在有 service worker cache 里匹配的资源时, caches.match(event.request) 是非常棒的。但是如果没有匹配资源呢?如果我们不提供任何错误处理,promise 就会 reject,同时也会出现一个网络错误。

1
2
3
4
5
6
7
self.addEventListener('fetch', function(event) {
event.respondWith(
caches.match(event.request).then(function(response) {
return response || fetch(event.request);
})
);
});

如果 promise reject了, catch()函数会执行默认的网络请求,意味着在网络可用的时候可以直接像服务器请求资源。

如果我们足够聪明的话,我们就不会只是从服务器请求资源,而且还会把请求到的资源保存到缓存中,以便将来离线时所用!这意味着如果其他额外的图片被加入到 Star Wars 图库里,我们的 app 会自动抓取它们。下面就是这个诀窍:

1
2
3
4
5
6
7
8
9
10
11
12
self.addEventListener('fetch', function(event) {
event.respondWith(
caches.match(event.request).then(function(resp) {
return resp || fetch(event.request).then(function(response) {
return caches.open('v1').then(function(cache) {
cache.put(event.request, response.clone());
return response;
});
});
})
);
});

这里我们用 fetch(event.request) 返回了默认的网络请求,它返回了一个 promise 。当网络请求的 promise 成功的时候,我们 通过执行一个函数用 caches.open('v1') 来抓取我们的缓存,它也返回了一个 promise。当这个 promise 成功的时候, cache.put() 被用来把这些资源加入缓存中。资源是从 event.request 抓取的,它的响应会被 response.clone()克隆一份然后被加入缓存。这个克隆被放到缓存中,它的原始响应则会返回给浏览器来给调用它的页面。

为什么要这样做?这是因为请求和响应流只能被读取一次。为了给浏览器返回响应以及把它缓存起来,我们不得不克隆一份。所以原始的会返回给浏览器,克隆的会发送到缓存中。它们都是读取了一次。

我们现在唯一的问题是当请求没有匹配到缓存中的任何资源的时候,以及网络不可用的时候,我们的请求依然会失败。让我们提供一个默认的回退方案以便不管发生了什么,用户至少能得到些东西:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
this.addEventListener('fetch', function(event) {
event.respondWith(
caches.match(event.request).then(function() {
return fetch(event.request).then(function(response) {
return caches.open('v1').then(function(cache) {
cache.put(event.request, response.clone());
return response;
});
});
}).catch(function() {
return caches.match('/sw-test/gallery/myLittleVader.jpg');
})
);
});

因为只有新图片会失败,我们已经选择了回退的图片,一切都依赖我们之前看到的 install 事件侦听器中的安装过程。

更新你的 service worker

如果你的 service worker 已经被安装,但是刷新页面时有一个新版本的可用,新版的 service worker 会在后台安装,但是还没激活。当不再有任何已加载的页面在使用旧版的 service worker 的时候,新版本才会激活。一旦再也没有更多的这样已加载的页面,新的 service worker 就会被激活。

你想把你的新版的 service worker 里的 install 事件监听器改成下面这样(注意新的版本号):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
self.addEventListener('install', function(event) {
event.waitUntil(
caches.open('v2').then(function(cache) {
return cache.addAll([
'/sw-test/',
'/sw-test/index.html',
'/sw-test/style.css',
'/sw-test/app.js',
'/sw-test/image-list.js',



// include other new resources for the new version...
]);
})
);
});

当安装发生的时候,前一个版本依然在响应请求,新的版本正在后台安装,我们调用了一个新的缓存 v2,所以前一个 v1 版本的缓存不会被扰乱。

当没有页面在使用当前的版本的时候,这个新的 service worker 就会激活并开始响应请求。

手动更新

其实在页面中,也可以手动来管理更新。参考如下示例:

1
2
3
4
5
6
7
const version = '1.0.1';

navigator.serviceWorker.register('/sw.js').then(reg => {
if (localStorage.getItem('sw_version') !== version) {
reg.update().then(() => localStorage.setItem('sw_version', version));
}
});

删除旧缓存

你还有个 activate 事件。当之前版本还在运行的时候,一般被用来做些会破坏它的事情,比如摆脱旧版的缓存。在避免占满太多磁盘空间清理一些不再需要的数据的时候也是非常有用的,每个浏览器都对 service worker 可以用的缓存空间有个硬性的限制。浏览器尽力管理磁盘空间,但它可能会删除整个域的缓存。浏览器通常会删除域下面的所有的数据。

传给 waitUntil() 的 promise 会阻塞其他的事件,直到它完成。所以你可以确保你的清理操作会在你的的第一次 fetch 事件之前会完成。

1
2
3
4
5
6
7
8
9
10
11
12
13
self.addEventListener('activate', function(event) {
var cacheWhitelist = ['v2'];

event.waitUntil(
caches.keys().then(function(keyList) {
return Promise.all(keyList.map(function(key) {
if (cacheWhitelist.indexOf(key) === -1) {
return caches.delete(key);
}
}));
})
);
});

相关事件

install 事件

当前脚本被安装时,会触发 install 事件,具体参考前文的 安装 部分的示例。

fetch 事件

当浏览器发起请求时,会触发 fetch 事件。

Service Worker 安装成功并进入激活状态后即运行于浏览器后台,可以通过 fetch 事件可以拦截到当前作用域范围内的 http/https 请求,并且给出自己的响应。结合 Fetch API ,可以简单方便地处理请求响应,实现对网络请求的控制。

activate 事件

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

push 事件

push 事件是为推送准备的。不过首先你需要了解一下 Notification API 和 PUSH API(相关链接见后文)。

通过 PUSH API,当订阅了推送服务后,可以使用推送方式唤醒 ServiceWorker 以响应来自系统消息传递服务的消息,即使用户已经关闭了页面。

推送的实现有两步:

不同浏览器需要用不同的推送消息服务器。以 Chrome 上使用 Google Cloud Messaging 作为推送服务为例,第一步是注册 applicationServerKey(通过 GCM 注册获取),并在页面上进行订阅或发起订阅。每一个会话会有一个独立的端点(endpoint),订阅对象的属性(PushSubscription.endpoint) 即为端点值。将端点发送给服务器后,服务器用这一值来发送消息给会话的激活的 Service Worker (通过 GCM 与浏览器客户端沟通)。

在页面上,使用 PushManager.subscribe() 来订阅推送服务。第二步比较简单,是在 Service Worker 中通过监听 push 事件对推送的消息作处理。

如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
// 向用户申请通知权限,用户可以选择允许或禁止
// Notification.requestPermission 只有在页面上才可执行,Service Worker 内部不可申请权限
Notification.requestPermission().then(grant => {
console.log(grant); // 如果获得权限,会得到 granted
if (Notification.permission === 'denied') {
// 用户拒绝了通知权限
console.log('Permission for Notifications was denied');
}
});

let reg;
const applicationServerKey = 'xxx'; // 应用服务器的公钥(base64 网址安全编码)
navigator.serviceWorker.ready.then(_reg => {
reg = _reg;
// 获取当前订阅的推送
return reg.pushManager.getSubscription();
})
.then(subscription => {
// 获取的结果没有任何订阅,发起一个订阅
if (!subscription) {
return reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: applicationServerKey
});
} else {
// 每一个会话会有一个独立的端点(endpoint),用于推送时后端识别
return console.log("已订阅 endpoint:", subscription.endpoint);
}
})
.then(subscription => {
if (!subscription) {
return;
}

// 订阅成功
console.log('订阅成功!', subscription.endpoint);

// 做更多的事情,如将订阅信息发送给后端,用于后端推送识别
// const key = subscription.getKey('p256dh');
// updateStatus(subscription.endpoint, key, 'subscribe');
})
.catch(function (e) {
// 订阅失败
console.log('Unable to subscribe to push.', e);
});

// 消息处理
self.addEventListener('push', function(event) {
// 读取 event.data 获取传递过来的数据,根据该数据做进一步的逻辑处理
const obj = event.data.json();

// 逻辑处理示例
if(Notification.permission === 'granted' && obj.action === 'subscribe') {
self.registration.showNotification("Hi:", {
body: '订阅成功 ~',
icon: '//lzw.me/images/avatar/lzwme-80x80.png',
tag: 'push'
});
}
});

sync 事件

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

后台同步功能允许你一次性或按间隔请求后台数据同步,即使用户没有打开网站,仅唤醒了 ServiceWorker,也会如此。

当你从页面请求执行此操作的权限,用户将收到提示。后台同步适合于: 非紧急更新,特别是那些需要定期进行的更新,每次更新都发送一个推送通知会显得太频繁,如在某个时间推送一篇特色文章或一条消息通知,这在 native 应用中非常常见。

如:
页面中注册

1
2
3
navigator.serviceWorker.ready.then(function(swRegistration) {
return swRegistration.sync.register('myFirstSync');
});

SW中监听

1
2
3
4
5
self.addEventListener('sync', function(event) {
if (event.tag === 'myFirstSync') {
event.waitUntil(doSomething());
}
});

message 事件(postMessage)

ServiceWorker 运行于独立的沙盒中,无法直接访问当前页面的 DOM 等信息,但是通过 postMessage API,可以实现他们之间的消息传递。

跨文档的 postMessage 消息传输,需要获取接收方的文档句柄。那么当需要将消息从页面传输给 ServiceWorker 或从 ServiceWorker 传输给页面时,如何获取对应的文档句柄?如下。

A. 页面发消息给 serviceWorker

在页面上通过 navigator.serviceWorker.controller 获得 ServiceWorker 的句柄。但只有 ServiceWorker 注册成功后该句柄才会存在。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function sendMsg(msg) {
const controller = navigator.serviceWorker.controller;

if (!controller) {
return;
}

controller.postMessage(msg, []);
}

// 在 serviceWorker 注册成功后,页面上即可通过 navigator.serviceWorker.controller 发送消息给它
navigator.serviceWorker
.register('/test/sw.js', {scope: '/test/'})
.then(registration => console.log('ServiceWorker 注册成功!作用域为: ', registration.scope))
.then(() => sendMsg('hello sw!'))
.catch(err => console.log('ServiceWorker 注册失败: ', err));

在 ServiceWorker 内部,可以通过监听 message 事件即可获得消息:

1
2
3
self.addEventListener('message', function(ev) {
console.log(ev.data);
});

B. ServiceWorker 发消息给页面

ServiceWorker 内部需要获取页面句柄,这个句柄要从 self.clients 上得到。

1
2
3
4
5
self.clients.matchAll().then(clientList => {
clientList.forEach(client => {
client.postMessage('Hi, I am send from Service worker!');
})
});

online/offline 事件

当网络状态发生变化时,会触发 online 或 offline 事件。结合这两个事件,可以与 Service Worker 结合实现更好的离线使用体验,例如当网络发生改变时,替换/隐藏需要在线状态才能使用的链接导航等。

如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
self.addEventListener('offline', function() {
Notification.requestPermission().then(grant => {
if (grant !== 'granted') {
return;
}

const notification = new Notification("Hi,网络不给力哟", {
body: '您的网络貌似离线了,不过在志文工作室里访问过的页面还可以继续打开~',
icon: '//lzw.me/images/avatar/lzwme-80x80.png'
});

notification.onclick = function() {
notification.close();
};
});
});

error 和 unhandledrejection 事件

当 JS 执行发生错误,会触发 error 事件;当 Promise 类型的回调发生 reject 却没有 catch 处理,会触发 unhandledrejection 事件。

对于这类事件,前端应当作埋点上报,以便于统计监控和及时发现处理。一般情况下上报的信息应从 error 中读取,主要包括错误堆栈相关信息以便定位。

如:

1
2
3
4
5
6
7
8
9
10
11
12
self.onerror = function(errorMessage, scriptURI, lineNumber, columnNumber, error) {
if (error) {
reportError(error);
} else {
reportError({
message: errorMessage,
script: scriptURI,
line: lineNumber,
column: columnNumber
});
}
}

监听unhandledrejection事件:

1
2
3
4
5
self.addEventListener('unhandledrejection', function(event) {
reportError({
message: event.reason
})
});

beforeinstallprompt 事件

当发生 Add to Homescreen (A2HS, 添加到主屏幕)行为的请求时,会触发该事件。它发生于页面中,与 Service Worker 并没有直接关系。

如果你的站点符合 A2HS 的条件(具体参见后文介绍),浏览器(chrome) 会根据默认的行为算法,来决定何时主动的向用户展示添加到首屏提示。另外,用户也可以通过 chrome 菜单中的 添加到主屏幕 选项主动添加。

可以在页面中通过监听 beforeinstallprompt 事件,决定是否屏蔽/延迟该行为,或者统计用户选择了允许还是拒绝。

如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
let deferredPrompt; // 用于缓存 beforeinstallprompt 的事件对象

window.addEventListener('beforeinstallprompt', function(event) {
// 阻止该行为,只需要返回 false
// event.preventDefault();
// deferredPrompt = event;
// return false;

// 统计用户的选择
event.userChoice.then(function(choiceResult) {
console.log(choiceResult.outcome); // 为 dismissed 或 accepted
if(choiceResult.outcome === 'dismissed') {
console.log('User cancelled home screen install');
} else {
console.log('User added to home screen');
}
});
});

// 在 beforeinstallprompt 事件中屏蔽了浏览器的默认行为,在页面中通过按钮让用户主动选择
document.getElementById('addToHomeScreen').addEventListener('click', function() {
if(deferredPrompt) {
deferredPrompt.prompt();
}
})

相关API

Cache

Cache 接口为缓存的 Request / Response 对象对提供存储机制,例如,作为ServiceWorker 生命周期的一部分。请注意,Cache 接口像 workers 一样,是暴露在 window 作用域下的。尽管它被定义在 service worker 的标准中, 但是它不必一定要配合 service worker 使用.

一个域可以有多个命名 Cache 对象。你需要在你的脚本 (例如,在 ServiceWorker 中)中处理缓存更新的方式。除非明确地更新缓存,否则缓存将不会被更新;除非删除,否则缓存数据不会过期。使用 CacheStorage.open(cacheName) 打开一个Cache 对象,再使用 Cache 对象的方法去处理缓存.

你需要定期地清理缓存条目,因为每个浏览器都硬性限制了一个域下缓存数据的大小。缓存配额使用估算值,可以使用 StorageEstimate API 获得。浏览器尽其所能去管理磁盘空间,但它有可能删除一个域下的缓存数据。浏览器要么自动删除特定域的全部缓存,要么全部保留。确保按名称安装版本缓存,并仅从可以安全操作的脚本版本中使用缓存。查看 Deleting old caches 获取更多信息.

Cache.match(request, options)

返回一个 Promise对象,resolve的结果是跟 Cache 对象匹配的第一个已经缓存的请求。

语法

1
2
3
cache.match(request,{options}).then(function(response) {
// 操作response
});

参数

  • request:想要在Cache对象中查找的Request对象。
  • options:(可选)一个为 match 操作设置选项的对象。有效的选项如下:
    • ignoreSearch: 一个 Boolean 值用来设置是否忽略url中的query部分。例如, 如果该参数设置为 true ,那么 http://foo.com/?value=bar中的 ?value=bar 部分就会在匹配中被忽略. 该选项默认为 false。
    • ignoreMethod: 一个 Boolean 值,如果设置为 true在匹配时就不会验证 Request 对象的http 方法 (通常只允许是 GET 或 HEAD 。) 该参数默认值为 false。
    • ignoreVary: 一个 Boolean 值,该值如果为 true 则匹配时不进行 VARY 部分的匹配。例如,如果一个URL匹配,此时无论Response对象是否包含VARY头部,都会认为是成功匹配。该参数默认为 false。
    • cacheName: 一个 DOMString ,代表一个具体的要被搜索的缓存。注意该选项被 Cache.match()方法忽略。

下面的例子在请求失败时提供特定的数据。 catch() 在 fetch() 的调用抛出异常时触发。在 catch() 语句中, match()用来返回正确的响应。

在这个例子中,我们决定只缓存通过GET取得的HTML文档. 如果 if() 条件是 false,那么这个fetch处理器就不会处理这个请求。如果还有其他的fetch处理器被注册,它们将有机会调用 event.respondWith() 如果没有fetch处理器调用 event.respondWith() ,该请求就会像没有 service worker 介入一样由浏览器处理。如果 fetch() 返回了有效的HTTP响应,相应码是4xx或5xx,那么catch() 就不会被调用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
self.addEventListener('fetch', function(event) {
// 我们只想在用GET方法请求HTML文档时调用 event.respondWith()。
if (event.request.method === 'GET' &&
event.request.headers.get('accept').indexOf('text/html') !== -1) {
console.log('Handling fetch event for', event.request.url);
event.respondWith(
fetch(event.request).catch(function(e) {
console.error('Fetch failed; returning offline page instead.', e);
return caches.open(OFFLINE_CACHE).then(function(cache) {
return cache.match(OFFLINE_URL);
});
})
);
}
});

Cache.matchAll(request, options)

返回一个Promise 对象,resolve的结果是跟Cache对象匹配的所有请求组成的数组。

Cache.match() 基本上与 Cache.matchAll() 相同,除了它 resolve 为 response[0] (即第一个匹配响应) 而不是 response (数组中所有匹配的响应)。

如:

1
2
3
4
5
6
7
caches.open('v1').then(function(cache) {
cache.matchAll('/images/').then(function(response) {
response.forEach(function(element, index, array) {
cache.delete(element);
});
});
})

Cache.add(request)

抓取这个URL, 检索并把返回的response对象添加到给定的Cache对象.这在功能上等同于调用 fetch(), 然后使用 Cache.put() 将response添加到cache中.

Cache接口的 add()方法接受一个URL作为参数,请求参数指定的URL,并将返回的response对象添加到给定的cache中。 add() 方法在功能上等同于以下代码:

1
2
3
4
5
6
fetch(url).then(function (response) {
if (!response.ok) {
throw new TypeError('bad response status');
}
return cache.put(url, response);
})

之前的Cache (Blink 和 Gecko内核版本) 在实现Cache.add, Cache.addAll, 和 Cache.put 的策略是在response结果完全写入缓存后才会resolve当前的promise。更新后的规范版本中一旦条目被记录到数据库就会resolve当前的promise,即使当前response结果还在传输中。

语法

1
2
3
cache.add(request).then(function() {
// request has been added to the cache
});

参数

  • request:要添加到cache的request。它可以是一个 Request 对象,也可以是 URL。

返回值

  • void返回值的 Promise

如:

1
2
3
4
5
6
7
this.addEventListener('install', function(event) {
event.waitUntil(
caches.open('v1').then(function(cache) {
return cache.add('/sw-test/index.html');
})
);
});

Cache.addAll(requests)

抓取一个URL数组,检索并把返回的response对象添加到给定的Cache对象。

语法

1
2
3
cache.addAll(requests[]).then(function() {
// requests have been added to the cache
});

参数

  • requests:要获取并添加到缓存的字符串URL数组。

返回值

  • A Promise that resolves with void.

如下。此代码块等待一个 InstallEvent 事件触发,然后运行 waitUntil 来处理该应用程序的安装进程。 包括调用 CacheStorage.open 创建一个新的cache,然后使用 addAll() 添加一系列资源。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
this.addEventListener('install', function(event) {
event.waitUntil(
caches.open('v1').then(function(cache) {
return cache.addAll([
'/sw-test/',
'/sw-test/index.html',
'/sw-test/style.css',
'/sw-test/app.js',
'/sw-test/image-list.js',
'/sw-test/star-wars-logo.jpg',
'/sw-test/gallery/',
'/sw-test/gallery/bountyHunters.jpg',
'/sw-test/gallery/myLittleVader.jpg',
'/sw-test/gallery/snowTroopers.jpg'
]);
})
);
});

Cache.put(request, response)

同时抓取一个请求及其响应,并将其添加到给定的cache。

语法:

1
2
3
cache.put(request, response).then(function() {
// request/response pair has been added to the cache
});

参数

  • request:你想缓存的请求
  • response:你想匹配的请求响应

返回值

  • A Promise that resolves with void.

如:

1
2
3
4
5
6
7
8
9
10
11
12
var response;
var cachedResponse = caches.match(event.request).catch(function() {
return fetch(event.request);
}).then(function(r) {
response = r;
caches.open('v1').then(function(cache) {
cache.put(event.request, response);
});
return response.clone();
}).catch(function() {
return caches.match('/sw-test/gallery/myLittleVader.jpg');
});

Cache.delete(request, options)

搜索key值为request的Cache 条目。如果找到,则删除该Cache 条目,并且返回一个resolve为true的Promise对象;如果未找到,则返回一个resolve为false的Promise对象。

语法:

1
2
3
cache.delete(request,{options}).then(function(true) {
// your cache entry has been deleted
});

参数

  • request:请求删除的 Request。
  • options:(可选)一个对象,其属性控制删除操作中如何处理匹配缓存。可用的选项是:
    • ignoreSearch: 一个 Boolean 值,指定匹配进程中是否忽略url中的查询字符串。如果设置为true,http://foo.com/?value=bar 中的 ?value=bar 部分在执行匹配时会被忽略。默认为false。
    • ignoreMethod: 一个 Boolean 值,当设置为true时,将阻止匹配操作验证{domxref(“Request”)}} HTTP方法(通常只允许GET和HEAD)。默认为false。
    • ignoreVary: 一个 Boolean 值,当设置为true时,告诉匹配操作不执行VARY头匹配,默认为false。
    • cacheName: DOMString代表一个特定的缓存中搜索。注意,这个选项是忽视了Cache.delete()。

返回值

  • 如果cache条目被删除,则返回resolve为true的Promise,否则,返回resolve为false的 Promise。

Cache.keys(request, options)

返回一个Promise对象,resolve的结果是Cache对象key值组成的数组。

语法:

1
2
3
cache.keys(request,{options}).then(function(keys) {
// do something with your array of requests
});

参数

  • request:(可选)如果一个相关键被指定,则返对应的 Request 。
  • options:(可选)一个对象,它的属性决定了 keys 操作中的匹配操作是如何执行的。可选的属性有:
    • ignoreSearch: 一个 Boolean 值,指定了匹配操作是否忽略url中的查询部分。如果为 true ,在执行匹配操作时, http://foo.com/?value=bar 的 ?value=bar 部分将会被忽。默认为 false 。
    • ignoreMethod: 一个 Boolean 值,当为 true 时, 将会阻止匹配操作验证 Request 的 HTTP 方法(通常只有 GET 和 HEAD 方法被允许)。默认为 false 。
    • ignoreVary: 一个 Boolean 值,当为 true 时,告诉匹配操作不要验证 VARY 头部。换句话说,如果 URL 匹配,你会得到一个匹配而不管 Response 对象是否有 VARY 头部。默认为 false 。
    • cacheName: 一个 DOMString 值,描述了在一个特定的 cache 中进行匹配。注意这个选项会被 Cache.keys()方法忽略。

返回值

  • 返回一个解析为 Cache 键数组的 Promise。

如:

1
2
3
4
5
6
7
caches.open('v1').then(function(cache) {
cache.keys().then(function(keys) {
keys.forEach(function(request, index, array) {
cache.delete(request);
});
});
})

综合示例

此代码段来自 service worker selective caching sample。该代码使用CacheStorage.open(cacheName) 打开任何具有以font/开始的Content-Type头的Cache对象。

代码然后使用Cache.match(request, options)查看缓存中是否已经有一个匹配的font,如果是,则返回它。 如果没有匹配的字体,代码将通过网络获取字体,并使用 Cache.put(request, response)来缓存获取的资源。

代码处理从fetch() 操作抛出的异常。 请注意,HTTP错误响应(例如404)不会触发异常。 它将返回一个具有相应错误代码集的正常响应对象。

该代码片段还展示了服务工作线程使用的缓存版本控制的最佳实践。 虽然在这个例子中只有一个缓存,但同样的方法可用于多个缓存。 它将缓存的速记标识符映射到特定的版本化缓存名称。 代码还会删除命名不在CURRENT_CACHES中的所有缓存。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
var CACHE_VERSION = 1;

// 简写标识符映射到特定版本的缓存。
var CURRENT_CACHES = {
font: 'font-cache-v' + CACHE_VERSION
};

self.addEventListener('activate', function(event) {
var expectedCacheNames = Object.keys(CURRENT_CACHES).map(function(key) {
return CURRENT_CACHES[key];
});

// 在promise成功完成之前,活跃的worker不会被视作已激活。
event.waitUntil(
caches.keys().then(function(cacheNames) {
return Promise.all(
cacheNames.map(function(cacheName) {
if (expectedCacheNames.indexOf(cacheName) == -1) {
console.log('Deleting out of date cache:', cacheName);

return caches.delete(cacheName);
}
})
);
})
);
});

self.addEventListener('fetch', function(event) {
console.log('Handling fetch event for', event.request.url);

event.respondWith(

// 打开以'font'开头的Cache对象。
caches.open(CURRENT_CACHES['font']).then(function(cache) {
return cache.match(event.request).then(function(response) {
if (response) {
console.log(' Found response in cache:', response);

return response;
}
}).catch(function(error) {

// 处理match()或fetch()引起的异常。
console.error(' Error in fetch handler:', error);

throw error;
});
})
);
});

CacheStorage

CacheStorage 接口表示 Cache 对象的存储。它提供了一个 ServiceWorker 、其它类型worker或者 window 范围内可以访问到的所有命名cache的主目录(它并不是一定要和service workers一起使用,即使它是在service workers规范中定义的),并维护一份字符串名称到相应 Cache 对象的映射。

CacheStorage 同样暴露了 CacheStorage.open()CacheStorage.match() 方法。使用 CacheStorage.open() 获取 Cache 实例。使用 CacheStorage.match() 检查给定的 Request 是否是 CacheStorage 对象跟踪的任何 Cache 对象中的键。

你可以通过 caches 属性访问 CacheStorage .

CacheStorage.match()

检查给定的 Request 是否是 CacheStorage 对象跟踪的任何 Cache 对象的键,并返回一个resolve为该匹配的 Promise .

CacheStorage.has()

如果存在与 cacheName 匹配的 Cache 对象,则返回一个resolve为true的 Promise .
CacheStorage.open()
返回一个 Promise ,resolve为匹配 cacheName (如果不存在则创建一个新的cache)的 Cache 对象

CacheStorage.delete()

查找匹配 cacheName 的 Cache 对象,如果找到,则删除 Cache 对象并返回一个resolve为true的 Promise 。如果没有找到 Cache 对象,则返回 false.

CacheStorage.keys()

返回一个 Promise ,它将使用一个包含与 CacheStorage 追踪的所有命名 Cache 对象对应字符串的数组来resolve. 使用该方法迭代所有 Cache 对象的列表。

综合示例

此 service worker 脚本等待 InstallEvent 触发,然后运行 waitUntil 来处理应用程序的安装过程。这包括调用 CacheStorage.open 创建一个新的cache,然后使用 Cache.addAll 向其添加一系列资源。

在第二个代码块,我们等待 FetchEvent 触发。我们构建自定义相应,像这样:

检查CacheStorage中是否找到了匹配请求的内容。如果是,使用匹配内容。
如果没有,从网络获取请求,然后同样打开第一个代码块中创建的cache,并使用 Cache.put (cache.put(event.request, response.clone()).) 将请求的clone副本添加到它。
如果此操作失败(例如,因为网络关闭),返回备用相应。
最后,使用 FetchEvent.respondWith 返回自定义响应最终等于的内容。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
this.addEventListener('install', function(event) {
event.waitUntil(
caches.open('v1').then(function(cache) {
return cache.addAll([
'/sw-test/',
'/sw-test/index.html',
'/sw-test/style.css',
'/sw-test/app.js',
'/sw-test/image-list.js',
'/sw-test/star-wars-logo.jpg',
'/sw-test/gallery/bountyHunters.jpg',
'/sw-test/gallery/myLittleVader.jpg',
'/sw-test/gallery/snowTroopers.jpg'
]);
})
);
});

this.addEventListener('fetch', function(event) {
var response;
event.respondWith(caches.match(event.request).catch(function() {
return fetch(event.request);
}).then(function(r) {
response = r;
caches.open('v1').then(function(cache) {
cache.put(event.request, response);
});
return response.clone();
}).catch(function() {
return caches.match('/sw-test/gallery/myLittleVader.jpg');
}));
});

Client

Client 接口表示一个可执行的上下文,如Worker或SharedWorker。Window 客户端由更具体的WindowClient表示。 你可以从Clients.matchAll()Clients.get()等方法获取Client/WindowClient对象。

Client.postMessage()

向client发送一条消息。

属性

  • Client.id:只读。客户端的唯一通用标识符,字符串形式。
  • Client.type:只读。客户端的类型,字符串形式。可能是”window”, “worker”, 或 “sharedworker”。
  • Client.url:只读。客户端的URL,字符串形式。

Clients

Clients 接口提供对 Client 对象的访问. 通过在 service worker 中使用 self.clients 访问它.

Clients.get()

返回一个匹配给定 id 的 Client 的 Promise .

Clients.matchAll()

返回一个 Client 对象数组的 Promise . options参数允许您控制返回的clients类型.
Clients.openWindow()
打开给定URL的新浏览器窗口,并返回新 WindowClient a 的 Promise .

Clients.claim()

允许一个激活的 service worker 将自己设置为其scope 内所有 clients 的 controller .

综合示例

下面示例显示一个已有的聊天窗口,或者当用户点击通知时创建新的窗口.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
addEventListener('notificationclick', event => {
event.waitUntil(async function() {
const allClients = await clients.matchAll({
includeUncontrolled: true
});

let chatClient;

// Let's see if we already have a chat window open:
for (const client of allClients) {
const url = new URL(client.url);

if (url.pathname == '/chat/') {
// Excellent, let's use it!
client.focus();
chatClient = client;
break;
}
}

// If we didn't find an existing chat window,
// open a new one:
if (!chatClient) {
chatClient = await clients.openWindow('/chat/');
}

// Message the client:
chatClient.postMessage("New chat messages!");
}());
});

ExtendableEvent

作为 service worker 生命周期的一部分,ExtendableEvent接口延长了在全局范围上install和activate事件的生命周期。这样可以确保在升级数据库架构并删除过时的caches之前,不会调度任何函数事件(如FetchEvent)。
如果在ExtendableEvent处理程序之外调用waitUntil(),浏览器应该抛出一个InvalidStateError;还要注意,多个调用将堆积起来,结果promises 将添加到extend lifetime promises.

综合示例

代码在ServiceWorkerGlobalScope.oninstall中调用ExtendableEvent.waitUntil(),延迟将ServiceWorkerRegistration.installing Worker视为已安装,直到传递的promise resolve(在所有资源都已被提取和缓存的情况,或者发生任何异常时的问题.)

代码段还显示了对service worker使用的缓存进行版本控制的最佳实践。虽然在这个例子中只有一个缓存,但是相同的方法可以用于多个缓存。它将缓存的速记标识符映射到特定的、版本化的缓存名称。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
var CACHE_VERSION = 1;
var CURRENT_CACHES = {
prefetch: 'prefetch-cache-v' + CACHE_VERSION
};

self.addEventListener('install', function(event) {
var urlsToPrefetch = [
'./static/pre_fetched.txt',
'./static/pre_fetched.html',
'https://www.chromium.org/_/rsrc/1302286216006/config/customLogo.gif'
];

console.log('Handling install event. Resources to pre-fetch:', urlsToPrefetch);

event.waitUntil(
caches.open(CURRENT_CACHES['prefetch']).then(function(cache) {
cache.addAll(urlsToPrefetch.map(function(urlToPrefetch) {
return new Request(urlToPrefetch, {mode: 'no-cors'});
})).then(function() {
console.log('All resources have been fetched and cached.');
});
}).catch(function(error) {
console.error('Pre-fetching failed:', error);
})
);
});

ExtendableMessageEvent

FetchEvent

使用ServiceWorker技术时,页面的提取动作会在ServiceWorker作用域(ServiceWorkerGlobalScope)中触发fetch事件.

使用 ServiceWorkerGlobalScope.onfetch或addEventListener监听.
该事件回调会注入FetchEvent参数.它携带了有关请求和结果响应的信息以及方法FetchEvent.respondWith() ,允许我们向受控页面提供任意响应.

InstallEvent

该参数传递到 oninstall 事件处理程序,InstallEvent接口表示一个 ServiceWorker 的 ServiceWorkerGlobalScope 上分派的安装操作。作为 ExtendableEvent 的一个子类,它确保在安装期间不调度诸如 FetchEvent 之类的功能事件。

serviceWorker

Navigator.serviceWorker 只读属性,返回关联文件的 ServiceWorkerContainer 对象,它提供对ServiceWorker 的注册、删除、升级和通信的访问。

ServiceWorker

ServiceWorker API 的 ServiceWorker接口 提供一个对一个服务工作者的引用。 多个浏览上下文(例如页面,工作者等)可以与相同的服务工作者相关联,每个都通过唯一的ServiceWorker对象。

一个ServiceWorker对象在 ServiceWorkerRegistration.active 属性和 ServiceWorkerContainer.controller 属性中可用 — 这是一个激活并在控制页面的service worker(service worker成功注册,被控页面已经重新加载完毕.)

ServiceWorker接口被分配了一系列生命周期事件 — 安装和激活 — 以及功能型的事件,包括 fetch.一个ServiceWorker对象有一个与之关联的 ServiceWorker.state,指示着它的生命周期.

属性
ServiceWorker 接口继承它父类Worker的属性.

  • ServiceWorker.scriptURL:只读。返回作为 ServiceWorkerRegistration 一部分所定义的ServiceWorker序列化脚本URL.这个URL必须和注册该ServiceWorker的文档处于同一域.
  • ServiceWorker.state:只读。返回service worker的状态.其值可能是如下之一:installing,installed,activating,activated或者是redundant.

事件

  • ServiceWorker.onstatechange:只读
    一个一旦 ServiceWorker.state 发生改变时,即一个类型为statechange事件触发时就会被调用的 EventListener 属性.

综合示例

这段代码监听了ServiceWorker.state 的变化并且返回它的值.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('service-worker.js', {
scope: './'
}).then(function (registration) {
var serviceWorker;
if (registration.installing) {
serviceWorker = registration.installing;
document.querySelector('#kind').textContent = 'installing';
} else if (registration.waiting) {
serviceWorker = registration.waiting;
document.querySelector('#kind').textContent = 'waiting';
} else if (registration.active) {
serviceWorker = registration.active;
document.querySelector('#kind').textContent = 'active';
}
if (serviceWorker) {
// logState(serviceWorker.state);
serviceWorker.addEventListener('statechange', function (e) {
// logState(e.target.state);
});
}
}).catch (function (error) {
// Something went wrong during registration. The service-worker.js file
// might be unavailable or contain a syntax error.
});
} else {
// The current browser doesn't support service workers.
}

ServiceWorkerContainer

ServiceWorkerContainer接口为 service worker提供一个容器般的功能,包括对service worker的注册,卸载 ,更新和访问service worker的状态,以及他们的注册者

主要是ServiceWorkerContainer.register(scriptURL, scope[, base])提供一个注册service worker的方法,ServiceWorkerContainer.controller将获取当前控制页面网络的service worker。

ServiceWorkerGlobalScope

ServiceWorkerRegistration

WindowClient

安全

有一篇较为著名的文章《Using Appcache and ServiceWorker for Evil》,文章讲述黑下服务器后,通过 Middlekit 技术,污染每个访问者的浏览器cache,通过这个方法,我们能够改变请求的响应,将请求代理到我们的server,造成持久的session hijacking 和 XSS。

i-sw-xss.png

其中 Appcache 主要利用的是 cache-manifest 文件也被缓存时,从站点方面无法进行更新,那么网站方面即使知道了这种问题,也是无能为力。

对于 ServiceWorker 的利用,则是通过构造 ServiceWorker 的脚本并向用户注册。
想象一下,如果站点方面还没有 ServiceWorker 的意识,却被中间人提前利用了,用户被攻击了一次,却导致后续的访问都会被该 ServiceWorker 劫持。

demos

demo 1

下面的例子说明了service worker的运行时加载图像缓存的响应。(github访问源码>>

第一次请求给定的图像,服务人员将从网络请求,但每个随后的时间,它将从缓存中检索。

1
2
3
4
5
6
7
8
9
10
11
// 执行js
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('service-worker.js');
}

document.querySelector('#show').addEventListener('click', () => {
const iconUrl = document.querySelector('select').selectedOptions[0].value;
let imgElement = document.createElement('img');
imgElement.src = iconUrl;
document.querySelector('#container').appendChild(imgElement);
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
// service-worker.js

// Names of the two caches used in this version of the service worker.
// Change to v2, etc. when you update any of the local resources, which will
// in turn trigger the install event again.
const PRECACHE = 'precache-v1';
const RUNTIME = 'runtime';

// 我们想缓存的本地资源列表
const PRECACHE_URLS = [
'index.html',
'./', // Alias for index.html
'styles.css',
'../../styles/main.css',
'demo.js'
];

// 安装控制器处理我们需要预缓存的资源
self.addEventListener('install', event => {
event.waitUntil(
caches.open(PRECACHE)
.then(cache => cache.addAll(PRECACHE_URLS))
.then(self.skipWaiting())
);
});

// 激活控制器处理要清除的旧缓存
self.addEventListener('activate', event => {
const currentCaches = [PRECACHE, RUNTIME];
event.waitUntil(
caches.keys().then(cacheNames => {
return cacheNames.filter(cacheName => !currentCaches.includes(cacheName));
}).then(cachesToDelete => {
return Promise.all(cachesToDelete.map(cacheToDelete => {
return caches.delete(cacheToDelete);
}));
}).then(() => self.clients.claim())
);
});

// 请求控制器从缓存中提供同源资源响应
// 如果没有发现任何响应,它填充运行时缓存响应
// 从网络返回之前页面。
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;
});
});
});
})
);
}
});

项目使用

webpack接入

插件npm serviceworker-webpack-plugin

用 Webpack 构建接入 Service Workers 的离线应用要解决的关键问题在于如何生成上面提到的 sw.js 文件, 并且sw.js文件中的 cacheFileList 变量,代表需要被缓存文件的 URL 列表,需要根据输出文件列表所对应的 URL 来决定,而不是像上面那样写成静态值。

假如构建输出的文件目录结构为:

1
2
3
├── app_4c3e186f.js
├── app_7cc98ad0.css
└── index.html

那么 sw.js 文件中 cacheFileList 的值应该是:

1
2
3
4
5
var cacheFileList = [
'/index.html',
'app_4c3e186f.js',
'app_7cc98ad0.css'
];

使用该插件后的 Webpack 配置如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
const path = require('path');
const ExtractTextPlugin = require('extract-text-webpack-plugin');
const { WebPlugin } = require('web-webpack-plugin');
const ServiceWorkerWebpackPlugin = require('serviceworker-webpack-plugin');

module.exports = {
entry: {
app: './main.js'// Chunk app 的 JS 执行入口文件
},
output: {
filename: '[name].js',
publicPath: '',
},
module: {
rules: [
{
test: /\.css/,// 增加对 CSS 文件的支持
// 提取出 Chunk 中的 CSS 代码到单独的文件中
use: ExtractTextPlugin.extract({
use: ['css-loader'] // 压缩 CSS 代码
}),
},
]
},
plugins: [
// 一个 WebPlugin 对应一个 HTML 文件
new WebPlugin({
template: './template.html', // HTML 模版文件所在的文件路径
filename: 'index.html' // 输出的 HTML 的文件名称
}),
new ExtractTextPlugin({
filename: `[name].css`,// 给输出的 CSS 文件名称加上 Hash 值
}),
new ServiceWorkerWebpackPlugin({
// 自定义的 sw.js 文件所在路径
// ServiceWorkerWebpackPlugin 会把文件列表注入到生成的 sw.js 中
entry: path.join(__dirname, 'sw.js'),
}),
],
devServer: {
// Service Workers 依赖 HTTPS,使用 DevServer 提供的 HTTPS 功能。
https: true,
}
};

注意:serviceworker-webpack-plugin 插件为了保证灵活性,允许使用者自定义 sw.js,构建输出的 sw.js 文件中会在头部注入一个变量 serviceWorkerOption.assets 到全局,里面存放着所有需要被缓存的文件的 URL 列表。

react

create-react-app的默认模板支持。

vue

vue-cli 3起在模板选择时选择PWA Support即可。

常见问题

如果把HTML给cache了,如何进行更新?

如果把页面html也缓存了,例如把首页缓存了,就会有一个尴尬的问题——Service Worker是在页面注册的,但是现在获取页面的时候是从缓存取的,每次都是一样的,所以就导致无法更新Service Worker,如变成sw-5.js,但是PWA又要求我们能缓存页面html。那怎么办呢?谷歌的开发者文档它只是提到会存在这个问题,但并没有说明怎么解决这个问题。这个的问题的解决就要求我们要有一个机制能知道html更新了,从而把缓存里的html给替换掉。

Manifest更新缓存的机制是去看Manifest的文本内容有没有发生变化,如果发生变化了,则会去更新缓存,Service Worker也是根据sw.js的文本内容有没有发生变化,我们可以借鉴这个思想,如果请求的是html并从缓存里取出来后,再发个请求获取一个文件看html更新时间是否发生变化,如果发生变化了则说明发生更改了,进而把缓存给删了。所以可以在服务端通过控制这个文件从而去更新客户端的缓存。如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
this.addEventListener("fetch", function(event) {

event.respondWith(
caches.match(event.request).then(response => {
// cache hit
if (response) {
//如果取的是html,则看发个请求看html是否更新了
if (response.headers.get("Content-Type").indexOf("text/html") >= 0) {
console.log("update html");
let url = new URL(event.request.url);
util.updateHtmlPage(url, event.request.clone(), event.clientId);
}
return response;
}

return util.fetchPut(event.request.clone());
})
);
});

通过响应头header的content-type是否为text/html,如果是的话就去发个请求获取一个文件,根据这个文件的内容决定是否需要删除缓存,这个更新的函数util.updateHtmlPage是这么实现的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
let pageUpdateTime = {

};
let util = {
updateHtmlPage: function (url, htmlRequest) {
let pageName = util.getPageName(url);
let jsonRequest = new Request("/html/service-worker/cache-json/" + pageName + ".sw.json");
fetch(jsonRequest).then(response => {
response.json().then(content => {
if (pageUpdateTime[pageName] !== content.updateTime) {
console.log("update page html");
// 如果有更新则重新获取html
util.fetchPut(htmlRequest);
pageUpdateTime[pageName] = content.updateTime;
}
});
});
},
delCache: function (url) {
caches.open(CACHE_NAME).then(cache => {
console.log("delete cache " + url);
cache.delete(url, {ignoreVary: true});
});
}
};

代码先去获取一个json文件,一个页面会对应一个json文件,这个json的内容是这样的:

1
{"updateTime":"10/2/2017, 3:23:57 PM","resources": {img: [], css: []}}

里面主要有一个updateTime的字段,如果本地内存没有这个页面的updateTime的数据或者是和最新updateTime不一样,则重新去获取 html,然后放到缓存里。接着需要通知页面线程数据发生变化了,你刷新下页面吧。这样就不用等用户刷新页面才能生效了。所以当刷新完页面后用postMessage通知页面:

1
2
3
4
5
6
7
8
9
let util = {
postMessage: async function (msg) {
const allClients = await clients.matchAll();
allClients.forEach(client => client.postMessage(msg));
}
};
util.fetchPut(htmlRequest, false, function() {
util.postMessage({type: 1, desc: "html found updated", url: url.href});
});

并规定type: 1就表示这是一个更新html的消息,然后在页面监听message事件:

1
2
3
4
5
6
7
8
9
if("serviceWorker" in navigator) {
navigator.serviceWorker.addEventListener("message", function(event) {
let msg = event.data;
if (msg.type === 1 && window.location.href === msg.url) {
console.log("recv from service worker", event.data);
window.location.reload();
}
});
}

然后当我们需要更新html的时候就更新json文件,这样用户就能看到最新的页面了。或者是当用户重新启动浏览器的时候会导致Service Worker的运行内存都被清空了,即存储页面更新时间的变量被清空了,这个时候也会重新请求页面。

需要注意的是,要把这个json文件的http cache时间设置成0,这样浏览器就不会缓存了,如下nginx的配置:

1
2
3
location ~* .sw.json$ {
expires 0;
}

因为这个文件是需要实时获取的,不能被缓存,firefox默认会缓存,Chrome不会,加上http缓存时间为0,firefox也不会缓存了。

还有一种更新是用户更新的,例如用户发表了评论,需要在页面通知service worker把html缓存删了重新获取,这是一个反过来的消息通知:

1
2
3
4
5
6
7
8
9
10
if ("serviceWorker" in navigator) {
document.querySelector(".comment-form").addEventListener("submit", function() {
navigator.serviceWorker.controller.postMessage({
type: 1,
desc: "remove html cache",
url: window.location.href}
);
}
});
}

Service Worker也监听message事件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const messageProcess = {
// 删除html index
1: function (url) {
util.delCache(url);
}
};

let util = {
delCache: function (url) {
caches.open(CACHE_NAME).then(cache => {
console.log("delete cache " + url);
cache.delete(url, {ignoreVary: true});
});
}
};

this.addEventListener("message", function(event) {
let msg = event.data;
console.log(msg);
if (typeof messageProcess[msg.type] === "function") {
messageProcess[msg.type](msg.url);
}
});

根据不同的消息类型调不同的回调函数,如果是1的话就是删除cache。用户发表完评论后会触发刷新页面,刷新的时候缓存已经被删了就会重新去请求了。

这样就解决了实时更新的问题。

我们知道http也有缓存,如果都用了会如何?

要缓存可以使用三种手段,使用Http Cache设置缓存时间,也可以用Manifest的Application Cache,还可以用Service Worker缓存,如果三者都用上了会怎么样呢?

会以Service Worker为优先,因为Service Worker把请求拦截了,它最先做处理,如果它缓存库里有的话直接返回,没有的话正常请求,就相当于没有Service Worker了,这个时候就到了Manifest层,Manifest缓存里如果有的话就取这个缓存,如果没有的话就相当于没有Manifest了,于是就会从Http缓存里取了,如果Http缓存里也没有就会发请求去获取,服务端根据Http的etag或者Modified Time可能会返回304 Not Modified,否则正常返回200和数据内容。这就是整一个获取的过程。

所以如果既用了Manifest又用Service Worker的话应该会导致同一个资源存了两次。但是可以让支持Service Worker的浏览器使用Service Worker,而不支持的使用Manifest.

相关链接