上回研究Web Worker时偶然看到了Service Worker(后文简称sw),这次来学习一下。
Service workers 本质上充当 Web 应用程序、浏览器与网络(可用时)之间的代理服务器。这个 API 旨在创建有效的离线体验,它会拦截网络请求并根据网络是否可用来采取适当的动作、更新来自服务器的的资源。它还提供入口以推送通知和访问后台同步 API。
MDN开卷语解释了sw的使用场景和角色,核心为两点
- HTTP请求的本地代理服务器
- 响应和资源的缓存管理器
第二点本质是Cache,后文会提到。
另外补充几点可能的应用场景:
- 全静态站点,可离线
- 预加载,首屏渲染性能方案
- 临时伪造响应,捕获5xx错误码,返回固定数据
生命周期
首先看下Chrome浏览器对sw的支持
最下面update cycle就是生命周期钩子,还有一个download钩子没显示。
注册
sw需要被注册到客户端才能发挥作用,代码如下
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('sw.js').then(
function (registration) {
console.log(':) Success. ', registration.scope);
},
function (err) {
console.log(':( Failed. ', err);
}
);
}
因为要注册同源文件,所以这里客户端我是用express服务端渲染了一个页面。
注册成功后会经历下载、安装、激活三个生命周期,sw可以监听这三个事件,可以当成是钩子函数。
事件监听
监听是sw内部的处理,分为监听生命周期事件和请求、通信事件
监听生命周期事件
install
const CACHE_NAME = 'cacheName';
const urlsToCache = [
'/',
'/styles/main.css',
'/script/main.js',
'/imgs/1.jpg'
];
self.addEventListener('install', event => {
event.waitUntil(
caches.open(CACHE_NAME)
.then(function(cache) {
console.log('Opened cache');
return cache.addAll(urlsToCache);
})
);
});
上述做的事就是待sw安装完成后,将一些文件缓存起来。
插入介绍一下一些概念:
cache,这个在浏览器里可以看到
caches,全局变量,是个CacheStorage对象。cache,Cache对象。类似数据库和数据表的关系。API不做介绍,看名字也能知道啥意思。
ExtendableEvent.waitUntil() 方法告诉事件分发器该事件仍在进行。这个方法也可以用于检测进行的任务是否成功。在服务工作线程中,这个方法告诉浏览器事件一直进行,直至 promise 解决,浏览器不应该在事件中的异步操作完成之前终止服务工作线程。
activate
激活事件标志着从这之后的请求将被Service Worker接管,所以通常用于区分Service Worker接管前后的分隔。 但这不意味着当前这次有效完成".register()"的页面会受到Service Worker 管理,因为我们无法预测页面资源先获取完成还是激活事件先响应。
监听请求、通信事件
fetch
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
if (url.origin == location.origin && url.pathname.endsWith('/imgs/2.jpg')) {
event.respondWith(caches.match('/imgs/1.jpg'));
}
});
监听fetch会处理所有请求,类似express监听所有路由的中间件。
上面是判断请求2.jpg时,返回1.jpg的缓存。另外,img标签也算fetch。
这里也有几个比较陌生的API:
- FetchEvent.respondWith(Response | network error | fetch)
- new Response(body?, option?)
- body: Blob | BufferSource | FormData | ReadableStream | URLSearchParams | USVString
我按照FormData和Blob的格式分别试验了一下返回体
if (url.pathname == '/test1') {
let blob = new Blob(['test...']);
event.respondWith(new Response(blob));
}
if (url.pathname == '/test2') {
let formdata = new FormData();
formdata.append('name', 'test');
formdata.append('attr2', 'value2');
event.respondWith(new Response(formdata));
}
formdata的fetch返回用text()格式化如下
Blob的fetch返回也是用text()处理,值为构造体数组内的元素,这里就是test...字符串。
PS:本地调试更新sw.js时需要刷新浏览器,并更新service worker
要手动点skipWaiting,不然一直waiting。
通信
通过MessageChannel进行通信。
Channel Messaging API的MessageChannel接口允许我们创建一个新的消息通道,并通过它的两个MessagePort属性发送数据。
其构造实例有两个属性port1和port2,port组成为onmessage和onmessageerror。用法是用其中一个port发送,一个port接收。
客户端
sendMessage('test...').then((res) => {
console.log(res);
});
function sendMessage(message) {
return new Promise(function (resolve, reject) {
var messageChannel = new MessageChannel();
messageChannel.port1.onmessage = function (event) {
if (event.data.error) {
reject(event.data.error);
} else {
resolve(event.data);
}
};
navigator.serviceWorker.controller.postMessage(message, [messageChannel.port2]);
});
}
sw.js
addEventListener('message', (event) => {
console.log(`The client sent me a message: ${event.data}`);
// clients.matchAll().postMessage({
// msg: 'Hey I just got a fetch from you!'
// });
event.ports[0].postMessage({
msg: 'res test...'
});
});
代码比较简单,就是发送和接收,比较需要注意的是发送的主体为navigator.serviceWorker.controller。
注释掉的部分是参考知乎一篇文章的步骤,但是api不对,但也可以简单了解一下client
clients 提供对Client对象的访问,在service worker中使用
clients.get(id)
clients.matchAll({includeUncontrolled?: boolean, type?: 'window' | 'worker' | 'sharedworker' | 'all'})
这两个都是返回promise,不能直接调用postMessage。
资源加载策略与开源sw框架
这部分我没有实践,纯搬运
- 仅使用Cache(Cache only)
几乎没用
self.addEventListener('fetch', function(event) {
event.respondWith(caches.match(event.request));
});
- 仅使用网络(Network only)
需要强制更新的资源,时效性要求很高。如不需要离线访问的 HTML 资源。
self.addEventListener('fetch', function(event) {
event.respondWith(fetch(event.request));
});
- 先使用 SW 缓存,没有则使用网络资源
self.addEventListener('fetch', function(event) {
event.respondWith(
caches.match(event.request).then(function(response) {
return response || fetch(event.request);
})
);
});
- 缓存资源与网络资源,谁快用谁
function promiseAny(promises) {
return new Promise((resolve, reject) => {
promises = promises.map(p => Promise.resolve(p));
promises.forEach(p => p.then(resolve));
promises.reduce((a, b) => a.catch(() => b))
.catch(() => reject(Error("All failed")));
});
};
self.addEventListener('fetch', function(event) {
event.respondWith(
promiseAny([
caches.match(event.request),
fetch(event.request)
])
);
});
- 优先使用网络,失败则使用缓存(Network )
对于时效性要求比较高的资源,或者关键性需要降级显示的资源。
self.addEventListener('fetch', function(event) {
event.respondWith(
fetch(event.request).catch(function() {
return caches.match(event.request);
})
);
});
- 先使用 SW 缓存,再访问网络更新缓存 (Fastest)
可以缓存但可以二次请求后生效的资源。保持相对最高的缓存速度和高失效性。
self.addEventListener('fetch', function(event) {
fetch(event.request).then(res => caches.update(res));
event.respondWith(
caches.match(event.request)
);
});
- 检查缓存离线时间
不同缓存的时效时间可以由服务获取,然后开启定期检查,消灭失效资源。
setInterval(async () => {
const res = await fetch('/pageA/sw-cache-config');
const data = await res.json();
caches.checkCacheLifeTime(data);
}, SW_CACHE_INTERVAL);
开源框架
Workbox、sw-toolbox,等用到的时候再研究,都是简化写法的。
Angular Service Worker
Angular体系内也有sw,简单用了一下,基本只有缓存功能,监听没找到api
添加sw
ng add @angular/pwa --project *project-name*
我是直接在主应用加的打包
ng build
启动打包的项目
http-server dist/
-
模拟网络离线
-
刷新页面
可以看到size列都是sw
- 更新dist
修改内容,重新打包启动
刷新缓存即可看到变化,这个离线部署的方案感觉还行。
配置文件
主要配置文件是根目录下的ngsw-config.json文件
默认创建的如下:
{
"$schema": "./node_modules/@angular/service-worker/config/schema.json",
"index": "/index.html",
"assetGroups": [
{
"name": "app",
"installMode": "prefetch",
"resources": {
"files": [
"/favicon.ico",
"/index.html",
"/manifest.webmanifest",
"/*.css",
"/*.js"
]
}
},
{
"name": "assets",
"installMode": "lazy",
"updateMode": "prefetch",
"resources": {
"files": [
"/assets/**",
"/*.(svg|cur|jpg|jpeg|png|apng|webp|avif|gif|otf|ttf|woff|woff2)"
]
}
}
]
}
assetsGroups数据结构
interface AssetGroup {
name: string;
installMode?: 'prefetch' | 'lazy';
updateMode?: 'prefetch' | 'lazy';
resources: {
files?: string[];
urls?: string[];
};
cacheQueryOptions?: {
ignoreSearch?: boolean;
};
}
总结:
sw主要用来做离线缓存,目前接触不到类似的业务场景。
MessageChannel是目前用过最不好的通信api,2个port命名就过于随意,而且可以互换,只要不用同一个port就行。
阮大的博客错误略多,错别字和代码缺失,demo阻塞只能看看其他的,结果收获颇丰;学习新东西还是不能在一个地方死钻,多处借鉴能查漏补缺。
reference:
MDN
https://www.bookstack.cn/read/webapi-tutorial/docs-service-worker.md
https://zhuanlan.zhihu.com/p/161204142
https://angular.cn/guide/service-worker-getting-started
https://juejin.cn/post/6996901512462991374#heading-1