前言
什么是 Service Worker ? Service Worker
是运行于浏览器后台的独立脚本,它让单页应用拥有了拦截网页请求、离线缓存、离线通知、并发处理复杂的业务逻辑等能力,使用它可以搭建渐进式Web应用(PWA)。本文将通过几个demo来熟悉Service Worker工作原理,了解Service Worker如何发送message和缓存网页中的路径。
Service Worker 工作原理
本文参考了这篇文章中的Service Worker章节 ,该文对service worker、注册安装流程和工作原理讲的很详细,本文中的demo也是看了这篇文章的介绍后搭建和扩展的,下面简单整理下service worker开发所需知识点。
作用域
Service Worker作用域是一个URL路径,即注册时传给navigator.serviceWorker.register
方法的url,例如:navigator.serviceWorker.register('./a/b/sw.js')
的作用域为 https://somehost/a/b/
,那这个 Service Worker 能控制 https://somehost/a/b/
目录下的所有页面,可以包含下面列出的页面:
https://somehost/a/b/index.html
https://somehost/a/b/c/index.html
https://somehost/a/b/anothor.html
- ...
由于SPA工程上都只有一个 index.html 入口,所以一般使用根目录下的sw文件来控制整个应用的缓存,即使用 navigator.serviceWorker.register('./sw.js')
来注册service worker,其作用域为”/’。
生命周期
每次打开应用时主进程需要注册service worker(后面简称sw),注册成功后会去安装sw并触发安装事件(Install Event),一般会在该事件中处理应用缓存,安装成功后会激活sw并触发激活事件(Activate Event),激活成功后sw进入idle状态,在该状态页面的所有请求都会触发Fetch事件(Fetch Event),直到应用关闭。如下图:
需要注意的是,一个sw的作用域是一个url(spa应用作用域一般都会设置为根目录,即当前域名,后面讨论sw时,我们都默认作用域为根目录),所以同一个域名下的网页都公用一个sw的,并且只用所用该域名下的网页都关闭,sw才会关闭结束。而一个浏览器可以同时打开多个域名一样的网页,所以sw和网页的关系是一对多的,如下图:
如上所述会引发一个问题,因为所有同域名网页都公用一个sw,只有当所有sw作用域下网页都关闭了才会结束sw,那当sw有更新时该怎么办呢,我们通过例子来分析sw安装注册激活流程。
示例代码
通过以下几个场景示例代码来进一步了解server worker工作原理:
首先,初始化一个工程,并安装local-web-server
依赖
npm init
npm install local-web-server
然后添加如下html
文件
<!DOCTYPE html>
<head>
<title>Service Worker Demo</title>
</head>
<body>
<button id="loadImage">load</button>
<img id="img" alt="demo image" style="width: 100px;height: 100px;" />
<img src="./imgs/test.jpg" alt="demo image" />
<script>
if ('serviceWorker' in navigator) {
// 由于 127.0.0.1:8000 是所有测试 Demo 的 host
// 为了防止作用域污染,将安装前注销所有已生效的 Service Worker
navigator.serviceWorker.getRegistrations()
.then(regs => {
for (let reg of regs) {
reg.unregister()
}
navigator.serviceWorker.register('./sw.js')
})
}
/**
* 用于测试img fetch
*/
document.getElementById('loadImage').onclick = function () {
if (!!document.getElementById('img').src.includes("test2.jpg")) {
document.getElementById('img').src = ""
} else {
console.log('load ./imgs/test2.jpg')
document.getElementById('img').src = "./imgs/test2.jpg"
}
}
</script>
</body>
</html>
添加如下sw
文件
// sw.js
console.log('service worker 注册成功')
self.addEventListener('install', () => {
// 安装回调的逻辑处理
console.log('service worker 安装成功')
})
self.addEventListener('activate', () => {
// 激活回调的逻辑处理
console.log('service worker 激活成功')
})
self.addEventListener('fetch', event => {
console.log(`service worker 抓取请求成功:${event.request.url} (clientId:${event.clientId})`)
})
最后运行ws --spa index.html
启动sw
场景一:第一次注册sw
第一次执行上面的例子会发现console控制台打印结果为:
service worker 注册成功
service worker 安装成功
service worker 激活成功
可以看到第一次注册时会执行注册→安装→激活流程,但fetch事件并没有触发,说明首次注册成功的 Service Worker 没能拦截注册前的页面请求
需要注意的是这里说的第一次注册的意思是该域名(sw作用域下的域名)网页中没有已注册的sw,并且是第一次打开含有注册sw代码的网页。如果之前已经注册过了想要复现第一次注册的情况,可以打开控制台中的应用→Service Workers把该域名下的sw手动取消注册,然后在关闭掉所用该域名下的网页,然后第一次打开该域名网页就可以复现第一次注册sw的场景了
场景二:刷新网页
刷新已经注册过sw的网页会发现console控制台打印结果为:
service worker 抓取请求成功:<http://127.0.0.1:8000/imgs/test.jpg> (clientId:2d87d3b7-8606-4897-8a1d-8772929c0b36)
可以看到重新刷新页面不会执行注册→安装→激活流程,但可以拦截所有的当前页面请求
场景三:打开新的标签页
在浏览器新开一个http://127.0.0.1:8000
标签页,console控制台打印结果为:
service worker 注册成功
service worker 安装成功
service worker 激活成功
service worker 抓取请求成功:<http://127.0.0.1:8000/> (clientId:)
service worker 抓取请求成功:<http://127.0.0.1:8000/imgs/test.jpg> (clientId:2d87d3b7-8606-4897-8a1d-8772929c0b36)
service worker 抓取请求成功:<http://127.0.0.1:8000/> (clientId:)
service worker 抓取请求成功:<http://127.0.0.1:8000/imgs/test.jpg> (clientId:2eff2973-793e-406b-b749-0ec789412ce6)
如果再开一个标签页,console控制台打印结果为:
service worker 注册成功
service worker 安装成功
service worker 激活成功
service worker 抓取请求成功:<http://127.0.0.1:8000/> (clientId:)
service worker 抓取请求成功:<http://127.0.0.1:8000/imgs/test.jpg> (clientId:2d87d3b7-8606-4897-8a1d-8772929c0b36)
service worker 抓取请求成功:<http://127.0.0.1:8000/> (clientId:)
service worker 抓取请求成功:<http://127.0.0.1:8000/imgs/test.jpg> (clientId:2eff2973-793e-406b-b749-0ec789412ce6)
service worker 抓取请求成功:<http://127.0.0.1:8000/> (clientId:)
service worker 抓取请求成功:<http://127.0.0.1:8000/imgs/test.jpg> (clientId:394bdb9d-ec3a-4c9e-8d3e-aa61bacfb7af)
这时已经开启了三个同域名的标签页,你会发现除了刷新过页面的那个标签页,其他两个标签页的打印结果都一样
说明新开的网页也会执行注册→安装→激活流程,并且可以拦截sw所有的页面请求(包括当前页面和其他同域名页面之前的请求),这也解释了sw和网页终端时一对多的关系,每次打开页面sw都同步之前拦截过的所有请求,当然这些请求是有clientId的,可以区分是否是当前页面触发的
场景四:结束sw
此时关闭所有该域名下的网页,然后重新打开(需要等20s左右(chrome测试得到的数据)再打开,因为sw的结束前会有一小段的等待时间)[<http://127.0.0.1:8000/>](<http://127.0.0.1:8000/>)
会发现还是会打印为:
service worker 注册成功
service worker 安装成功
service worker 激活成功
说明sw作用域下的网页全部关闭后,sw会结束,等下次重新打开时会重新执行 注册→安装→激活流程
提示:关闭所有该域名下的网页后可以新开一个tab,然后在控制台中查看sw是否真的被关闭
点击查看所有注册,如果http://127.0.0.1:8000/ 的按钮变为 Unregister
和 Start
则代表sw已经结束,如果按钮为 Stop
、 Inspect
和Unregister
则代表sw正在运行
sw已经结束🔼
sw还在运行🔼
场景五:更新sw
尝试更新sw文件,修改一下安装成功回调打印和fetch事件回调打印,如下:
service worker 抓取请求成功:<http://127.0.0.1:8000/imgs/test.jpg> (clientId:c9d4c78a-ca02-4fd9-b5b5-5dedbbbc04a7)
service worker 注册成功
安装成功1
可以发现更新sw后刷新,sw重新注册安装了,但是没有执行激活,然后点击页面中的load按钮
会得到如下打印,打印“抓取请求成功”而不是“抓取请求成功1”说明当前执行的还是旧的sw代码
service worker 抓取请求成功:<http://127.0.0.1:8000/imgs/test2.jpg> (clientId:c9d4c78a-ca02-4fd9-b5b5-5dedbbbc04a7)
结束sw进程(方法看上面的“结束sw”小节),重新打开新的网页,并点击页面中的load按钮
或刷新页面会发现打印更新了,说明sw也更新了
service worker 抓取请求成功1:<http://127.0.0.1:8000/imgs/test2.jpg> (clientId:2dffb96a-ff2e-42ce-9da3-27dd7940c114)
由此可以得出结论,sw更新后在当前sw结束前,新的sw只会执行重新注册和安装,不会立即生效,只有当sw进程结束后,在下一次重新打开时才会执行最新的sw代码
场景六:立即执行最新的sw(skipWaiting)
如果每次更新都要等待sw结束会导致我们最新的sw代码不能立即运行,那有没有什么方法可以让sw立即生效呢,当然有,在 install
回调中执行 self.skipWaiting()
可以跳过这个等待过程,让新的sw立即生效
在 install
回调中执行 self.skipWaiting()
跳过等待,并更新下回调打印,如下:
刷新网页,发现打印结果如下
service worker 抓取请求成功1:<http://127.0.0.1:8000/imgs/test.jpg> (clientId:00c3be85-4f75-442e-a5e7-73594ecca22a)
service worker 注册成功
sw-lifecycle-test.js:10 service worker 安装成功2
sw-lifecycle-test.js:16 service worker 激活成功
在点击下页面中的 load按钮
发现打印更新了,说明sw立即生效了
service worker 抓取请求成功2:<http://127.0.0.1:8000/imgs/test2.jpg> (clientId:00c3be85-4f75-442e-a5e7-73594ecca22a)
最终流程图
打开一个带有sw代码的网页流程图如下:
Service Worker应用
发送message
由于sw和client是一对多的关系,所以sw可以接收到所有client发来的消息并通过各自的id来区分消息哪个client发过来的,然后做出回应,client则只能接收到sw发给自己的消息。
配置主进程(web client)
client给sw发送消息: 使用 navigator.serviceWorker.controller.postMessage
api
let count1 = 100
document.getElementById('send').onclick = function () {
navigator.serviceWorker.controller.postMessage(++count1)
}
client接收sw的消息
navigator.serviceWorker.addEventListener('message', function(e) {
console.log('接收到Service Worker消息: ', e.data);
})
配置service worker
sw给client发送消息
方法一:在message回调中发送,使用 event.source.postMessage
方法会将消息发送回对应的client(id为event.source.id)
self.addEventListener('message', event => {
console.log('Service Worker(接收消息):', event.data, event)
if (event.source && event.source.postMessage) {
// 发送消息给id为event.source.id的client
console.log('Service Worker(发送消息):', event.data, event.source.id)
event.source.postMessage('我收到消息啦')
}
})
方法二:使用 self.clients.get
获取到指定的client,然后使用 postMessage
向client发送消息
/**
* 发送消息给client
* @param {Number} clientId
* @param {String} message
* @returns
*/
function sendMessageToClient(clientId, message) {
// Get the client.
const client = await self.clients.get(clientId);
if (!client) return;
// Send a message to the client.
client.postMessage(message);
}
方法三:使用 self.clients.matchAll
获取到所有的client,然后给所有的client发送消息(当然也可以通过这种方式找到对应的client然后给单个client发消息)
/**
* 发送消息给所有的client
* @param {String} message
* @returns
*/
function sendMessageToAllClients(message) {
self.clients.matchAll().then(function(clients) {
clients.forEach(function(client) {
client.postMessage(message);
});
})
}
发送message最终测试代码:
<!DOCTYPE html>
<head>
<title>Service Worker Message Demo</title>
</head>
<body>
<button id="send">send</button>
<button id="loadImage">load</button>
<img id="img" alt="demo image" style="width: 100px;height: 100px;" />
<img src="./imgs/test.jpg" alt="demo image" style="width: 100px;height: 100px;" />
<script>
if ('serviceWorker' in navigator) {
// 由于 127.0.0.1:8000 是所有测试 Demo 的 host
// 为了防止作用域污染,将安装前注销所有已生效的 Service Worker
navigator.serviceWorker.getRegistrations()
.then(regs => {
for (let reg of regs) {
reg.unregister()
}
navigator.serviceWorker.register('./sw.js')
})
}
/**
* 用于测试img fetch
*/
document.getElementById('loadImage').onclick = function () {
if (!!document.getElementById('img').src.includes("test2.jpg")) {
document.getElementById('img').src = ""
} else {
console.log('load ./imgs/test2.jpg')
document.getElementById('img').src = "./imgs/test2.jpg"
}
}
/**
* 监听message事件
*/
navigator.serviceWorker.addEventListener('message', function(e) {
console.log('主进程(接收消息): ', e.data);
})
/**
* 发送message
*/
let count1 = 100
document.getElementById('send').onclick = function () {
console.log('主进程(发送消息)', ++count1)
navigator.serviceWorker.controller.postMessage(++count1)
}
/**
* serviceWorker Ready事件
*/
navigator.serviceWorker.ready.then( registration => {
registration.active.postMessage("Hi service worker");
});
</script>
</body>
</html>
// sw.js
console.log('service worker 注册成功')
self.addEventListener('install', () => {
// 安装回调的逻辑处理
console.log('service worker 安装成功')
// 跳过等待
self.skipWaiting()
})
self.addEventListener('activate', () => {
// 激活回调的逻辑处理
console.log('service worker 激活成功')
})
let count = 0;
self.addEventListener('fetch', event => {
console.log(`service worker 抓取请求成功:${event.request.url} (clientId:${event.clientId})`)
++count;
event.waitUntil(async function() {
await sendMessageToClient(event.clientId, `抓取请求成功: ${event.request.url}`, `${count}`)
}())
})
self.addEventListener('message', event => {
console.log('Service Worker(接收消息):', event.data)
if (event.source && event.source.postMessage) {
// 发送消息给id为event.source.id的client
console.log('Service Worker(发送消息):', event.data, event.source.id)
event.source.postMessage(`我收到消息啦`)
sendMessageToClient(event.source.id, '我是通过self.clients.get获取到你的id的')
sendMessageToAllClients('这条消息是发给所有client的')
}
})
/**
* 发送消息给client
* @param {Number} clientId
* @param {String} message
* @returns
*/
async function sendMessageToClient(clientId, message) {
// Get the client.
const client = await self.clients.get(clientId);
if (!client) return;
// Send a message to the client.
client.postMessage(message);
}
/**
* 发送消息给所有的client
* @param {String} message
* @returns
*/
function sendMessageToAllClients(message) {
self.clients.matchAll().then(function(clients) {
clients.forEach(function(client) {
client.postMessage(message);
});
})
}
缓存cache
我们可以cache api来缓存路径(如图片、文件、请求url等),参考service worker说明 来进行相关的配置,具体步骤如下:
-
初始化cache:在sw的
install
回调中初始化缓存路径,即将应用默认要缓存的路径通过 cache api 添加到缓存中,需要注意的是不要缓存根目录,否则获取页面时拿到的一直都是缓存中的数据const CACHE_NAME = 'my-site-cache-v1'; const ROOT_URL = '<http://127.0.0.1:8000/>'; const urlsToCache = [ // '/', // 不要缓存根目录,否则页面一直都会加载缓存无法更新 '/imgs/test.jpg', // 默认缓存/imgs/test.jpg ]; /** * sw安装回调,一般在install中初始化缓存 * 使用skipWaiting可以跳过等待,当sw文件有更新时可以立即生效 */ self.addEventListener('install', (event) => { // 安装回调的逻辑处理 console.log('<======service worker 安装成功======>') // 跳过等待 self.skipWaiting() /** * 在install中初始化cache,将urlsToCache中的路径缓存 */ event.waitUntil(new Promise((resolve, reject) => { // 返回处理缓存更新的相关事情的 Promise caches.open(CACHE_NAME) .then(async function(cache) { let currentCaches = await cache.keys() const rootCache = currentCaches.find(c => c.url === ROOT_URL) if (rootCache) { // 如果缓存的根目录,则删除该缓存 await cache.delete(rootCache) } // 缓存urlsToCache中的路径 await cache.addAll(urlsToCache) currentCaches = await cache.keys() console.log('Cache 初始化成功', currentCaches) resolve('Cache 初始化成功') }) })) })
-
当页面请求的是缓存中的数据时,返回缓存
/** * 拦截请求,可以再这个回调中处理请求或添加新的缓存路径 */ self.addEventListener('fetch', event => { wsLog(`service worker 抓取请求成功: ${event.request.url}`) event.respondWith( caches.match(event.request) .then(function(response) { if (response) { // 命中缓存,把被缓存的值返回 console.log('命中缓存,把被缓存的值返回', response) return response; } else { console.log('未找到缓存,正常请求', event.request) return fetch(event.request); } } )); })
如果为请求未缓存,希望立即添加到缓存时,也可以在”fetch“回调中动态的添加缓存,但是要注意不要缓存根目录,否则请求页面时拿到的一直都是缓存中的页面,应用就没办法更新了
/** * 拦截请求,可以再这个回调中处理请求或添加新的缓存路径 */ self.addEventListener('fetch', event => { wsLog(`service worker 抓取请求成功: ${event.request.url}`) /** * 对于install阶段已缓存的请求,返回缓存;对于未缓存的请求,加入缓存(根路径除外) */ event.respondWith( caches.match(event.request) .then(function(response) { if (response) { // 命中缓存,把被缓存的值返回 console.log('命中缓存,把被缓存的值返回', response) return response; } else { if (event.request.url === ROOT_URL) { // 不要缓存根目录,否则页面一直都会加载缓存无法更新 console.log('请求为根目录,直接返回', event.request) return fetch(event.request); } // 没有命中缓存,将这个请求添加到缓存 console.log('没有命中缓存,将这个请求缓存', event.request) /** * 克隆请求 * 请求是一个流,只能使用一次。 * 因为我们通过缓存请求和浏览器请求分别使用了一次获取,所以我们需要克隆响应 */ const fetchRequest = event.request.clone(); return fetch(fetchRequest).then( function(response) { /** * 检查response是否有效 * 确保 response 有效 * 检查 response 的状态是200 * 确保 response 的类型是 basic 类型的,这说明请求是同源的,这意味着第三方的请求不能被缓存。 */ if(!response || response.status !== 200 || response.type !== 'basic') { return response; } /** * 克隆response * response 是一个 Stream,那么它的 body 只能被使用一次 * 所以为了让浏览器跟缓存都使用这个body,我们必须克隆这个body,一份到浏览器,一份到缓存中缓存 */ const responseToCache = response.clone(); console.log('添加缓存', event.request) caches.open(CACHE_NAME) .then(function(cache) { cache.put(event.request, responseToCache); }); return response; } ); } } )); })
缓存cache最终测试代码:
<!DOCTYPE html>
<head>
<title>Service Worker Demo</title>
</head>
<body>
<button id="loadImage">load</button>
<img id="img" alt="demo image" style="width: 100px;height: 100px;" />
<img src="./imgs/test.jpg" alt="demo image" />
<script>
if ('serviceWorker' in navigator) {
// 由于 127.0.0.1:8000 是所有测试 Demo 的 host
// 为了防止作用域污染,将安装前注销所有已生效的 Service Worker
navigator.serviceWorker.getRegistrations()
.then(regs => {
for (let reg of regs) {
reg.unregister()
}
navigator.serviceWorker.register('./sw.js')
})
}
/**
* 用于测试img缓存
*/
document.getElementById('loadImage').onclick = function () {
if (!!document.getElementById('img').src.includes("test2.jpg")) {
document.getElementById('img').src = ""
} else {
console.log('load ./imgs/test2.jpg')
document.getElementById('img').src = "./imgs/test2.jpg"
}
}
</script>
</body>
</html>
// sw.js
/**
* cacheApi: <https://developer.mozilla.org/zh-CN/docs/Web/API/Cache>
*/
const CACHE_NAME = 'my-site-cache-v2';
const ROOT_URL = '<http://127.0.0.1:8000/>';
const urlsToCache = [
// '/', // 不要缓存根目录,否则页面一直都会加载缓存无法更新
'/imgs/test.jpg', // 默认缓存/imgs/test.jpg
];
/**
* sw安装回调,一般在install中初始化缓存
* 使用skipWaiting可以跳过等待,当sw文件有更新时可以立即生效
*/
self.addEventListener('install', (event) => {
// 安装回调的逻辑处理
console.log('<======service worker 安装成功======>')
// 跳过等待
self.skipWaiting()
/**
* 在install中初始化cache,将urlsToCache中的路径缓存
*/
event.waitUntil(new Promise((resolve, reject) => {
// 返回处理缓存更新的相关事情的 Promise
caches.open(CACHE_NAME)
.then(async function(cache) {
let currentCaches = await cache.keys()
const rootCache = currentCaches.find(c => c.url === ROOT_URL)
if (rootCache) {
// 如果缓存的根目录,则删除该缓存
await cache.delete(rootCache)
}
// 缓存urlsToCache中的路径
await cache.addAll(urlsToCache)
currentCaches = await cache.keys()
console.log('Cache 初始化成功', currentCaches)
resolve('Cache 初始化成功')
})
}))
})
/**
* sw激活回调
*/
self.addEventListener('activate', (event) => {
// 激活回调的逻辑处理
console.log('<======service worker 激活成功======>')
})
/**
* 拦截请求,可以再这个回调中处理请求或添加新的缓存路径
*/
self.addEventListener('fetch', event => {
console.log(`service worker 抓取请求成功: ${event.request.url}`)
if (!event.request) return
/**
* 缓存策略
* 如果为0,对于install阶段已缓存的请求,返回缓存;对于未缓存的请求,正常去线上请求
* 如果为1,对于install阶段已缓存的请求,返回缓存;对于未缓存的请求,加入缓存(根路径除外)
*/
const mode = 1
if (mode === 0) {
/**
* 对于install阶段已缓存的请求,返回缓存;对于未缓存的请求,正常去线上请求
*/
event.respondWith(
caches.match(event.request)
.then(function(response) {
if (response) {
// 命中缓存,把被缓存的值返回
wsLog('命中缓存,把被缓存的值返回', response)
return response;
} else {
wsLog('未找到缓存,正常请求', event.request)
return fetch(event.request);
}
}
));
} else {
/**
* 对于install阶段已缓存的请求,返回缓存;对于未缓存的请求,加入缓存(根路径除外)
*/
event.respondWith(
caches.match(event.request)
.then(function(response) {
if (response) {
// 命中缓存,把被缓存的值返回
console.log('命中缓存,把被缓存的值返回', response)
return response;
} else {
if (event.request.url === ROOT_URL) {
// 不要缓存根目录,否则页面一直都会加载缓存无法更新
console.log('请求为根目录,直接返回', event.request)
return fetch(event.request);
}
// 没有命中缓存,将这个请求添加到缓存
console.log('没有命中缓存,将这个请求缓存', event.request)
/**
* 克隆请求
* 请求是一个流,只能使用一次。
* 因为我们通过缓存请求和浏览器请求分别使用了一次获取,所以我们需要克隆响应
*/
const fetchRequest = event.request.clone();
return fetch(fetchRequest).then(
function(response) {
/**
* 检查response是否有效
* 确保 response 有效
* 检查 response 的状态是200
* 确保 response 的类型是 basic 类型的,这说明请求是同源的,这意味着第三方的请求不能被缓存。
*/
if(!response || response.status !== 200 || response.type !== 'basic') {
return response;
}
/**
* 克隆response
* response 是一个 Stream,那么它的 body 只能被使用一次
* 所以为了让浏览器跟缓存都使用这个body,我们必须克隆这个body,一份到浏览器,一份到缓存中缓存
*/
const responseToCache = response.clone();
console.log('添加缓存', event.request)
caches.open(CACHE_NAME)
.then(function(cache) {
cache.put(event.request, responseToCache);
});
return response;
}
);
}
}
));
}
})
执行以上代码,通过控制台 应用→缓存 可以看到 images/test.jpg
已经被缓存的
点击页面中的load按钮会去加载images/test2.jpg
,如果将代码中mode改为1,即缓存策略为”对于install阶段已缓存的请求,返回缓存;对于未缓存的请求,加入缓存(根路径除外)“,则images/test2.jpg
也会被加入到缓存中
调试
调试方法看这里。
所有demo代码
懒得敲代码的可以直接到github上下载 service worker demo。