WEB前端没有上述问题,但是很多时候页面会卡顿,用户体验不佳。虽然社区之前也做过很多努力,例如
virtual dom
、
spa
、用
canvas
将整个页面画出来,用户体验也有了很大的改善,但是仍然无法解决几个重要的问题:
离线时用户无法使用
无法接收消息推送,作为App厂商,需要有将消息送达到应用的能力
移动端没有一级入口,无法将Web应用安装到桌面,每次都需要通过浏览器来打开
W3C和谷歌看到了这些问题,于是推出了
PWA
。
PWA 是什么?
PWA全称
Progressive Web Apps
,直译过来是“渐进式Web应用”
PWA 中的
W
就是Web,表明PWA所支持的首先是一个Web页面。
它的重点在于
P
上:
对Web应用开发者来说,PWA提供了一个渐进式的过渡方案,让Web应用能逐步具有本地应用的能力。采取渐进式可以降低站点改造的代价,使得站点逐步支持各项新技术,而不是一步到位。
站在技术角度来说,PWA技术也是一个渐进式的演化过程,在技术层面会一点点演进,比如逐渐提供更好的设备特性支持,不断优化更加流畅的动画效果,不断让页面的加载速度变得更快,不断实现本地应用的特性。
PWA采取的是非常缓和的渐进式策略,不再像以前那样动不动就取代本地App、取代小程序。与之相反,而是要充分发挥Web的优势,渐进式地缩短和本地应用/小程序的差距。
PWA
不是特指某一项技术,而是应用了多项技术的
Web App
。其核心技术包括
App Manifest
、
Service Worker
、
Web Push
,等等
简单总结:
PWA是Web应用的自然进化,Service Worker是PWA的关键。通过引入Service Worker来试着解决离线存储和消息推送的问题,通过引入manifest.json来解决一级入口的问题
PWA 特点
渐进式:能确保每个用户都能打开网页
响应式:PC,手机,平板,不管哪种格式,网页格式都能完美适配
离线应用:支持用户在没网的条件下也能打开网页,这里就需要 Service Worker 的帮助
APP 化:能够像 APP 一样和用户进行交互
常更新:一旦 Web 网页有什么改动,都能立即在用户端体现出来
安全:PWA基于HTTPS协议
可搜索:能够被引擎搜索到
推送:做到在不打开网页的前提下,推送新的消息
可安装:能够将 Web 想 APP 一样添加到桌面
可跳转:只要通过一个连接就可以跳转到你的 Web 页面
想详细了解PWA,可
点击查看
认识 Service Worker
什么是 Service Worker
Service Worker
是浏览器在后台独立于网页运行的、用JavaScript编写的脚本。Service Worker 中运行的代码
不会被普通JS阻塞
,也不会阻塞其他页面的 JS 文件中的代码;
这个脚本与普通js脚本的区别主要是因为他们的运行容器不同,在普通页面脚本中,有许多宿主对象可以使用,如
window
,
document
等,这些是service worker无法使用的。worker中的全局对象变成了
self
。
其次,service worker被设计成完全异步的,所以需要尽量避免在其中使用需要长时间计算的同步逻辑
Service Worker 是一个浏览器中的
进程
而不是浏览器内核下的线程,因此它在被注册安装之后,能够被在多个页面中使用,也不会因为页面的关闭而被销毁。
浏览器支持情况
Service Worker要求HTTPS,但为了开发调试方便,localhost除外。
Service Worker脚本缓存规则与一般脚本不同。如果设置了强缓存,并且max-age设置小于24小时,那么与普通http缓存无异,但是如果max-age大于24小时,那么service worker文件会在24小时之后强制更新
Service Worker 能做什么?
由于它能够拦截全局的fetch事件,以及能在后台运行的能力等,可以做到
缓存静态资源
、
离线缓存
和
后台同步
等
Service Worker 如何通信
service worker挂载在
navigator.serviceWorker.controller
上,所以可以通过controller进行通讯。与web worker的postMessage类似,在service worker中则是使用
self.clients
来send消息。
注意,
self.clients
能得到哪些 clients ,和scope设置有关。
navigator.serviceWorker.controller.postMessage('hello');
navigator.serviceWorker.addEventListener('message', function(e) {
console.log(e.data);
self.addEventListener('message', e => {
console.log(e.data);
e.source.postMessage('response from service worker')
(async function() {
let cls = await self.clients.matchAll();
cls.forEach(cl => cl.postMessage('message from service worker'));
})();
对于不同 scope 的多个 Service Worker ,我么也可以给指定的 Service Worker 发送信息。
if ('serviceWorker' in window.navigator) {
navigator.serviceWorker.register('./sw.js', { scope: './sw' })
.then(function (reg) {
reg.active.postMessage("this message is from page, to sw");
navigator.serviceWorker.register('./sw2.js', { scope: './sw2' })
.then(function (reg) {
reg.active.postMessage("this message is from page, to sw 2");
this.addEventListener('message', function (event) {
console.log(event.data);
this.addEventListener('message', function (event) {
console.log(event.data);
与其它地方的postMessage类似,也可以通过MessageChannel进行通讯:
const channel = new MessageChannel()
navigator.serviceWorker.controller.postMessage('hello', [channel.port1])
channel.port2.onmessge = function(e) { console.log(e.data)
// sw.js
self.addEventListener('message', e => {
e.ports[0].postMessage('message from service worker')
Service Worker 主要事件
install: 安装时触发,参考生命周期 Installing。
通常在这个事件里缓存静态资源文件,存到CacheStorage里
activate:激活时触发,参考生命周期 Activating。
通常在这个事件里进行重置操作,例如处理旧实例缓存等
fetch:浏览器发起http请求时触发,通常在这个事件里匹配缓存
push:推送通知时触发
sync:后台同步时触发
Service Worker 生命周期
生命周期分为:Installing -> Installed(Waiting) -> Activating -> Activated -> Redundant
Installing:当注册成功后,就进入了 Installing 阶段。每个 Service Worker 只会调用一次安装事件。
在这个阶段,可以通过返回event.waitUntil()的Promise来告诉浏览器什么时候安装完成了。
还可以通过 self.skipWaiting() 来跳过下面的 Waiting 阶段(谨慎使用)
Installed:已存在 Service Worker 的情况下,当 新Service Worker
安装完成后,就进入 Waiting 阶段。因为需要确保浏览器只运行一个Service Worker版本。所以在所有相关客户端都关闭之前,都不会激活当前的Service Worker。
假设使用Chrome打开了两个tab来运行有 旧Service Worker 网页, 那么 旧Service Worker 就控制了两个客户端。当完全关闭Chrome之后,再次打开网页,新Service Worker 才会进入到激活阶段。
Activating:Service Worker 安装完成后,激活时触发。此时已经没有客户端被 旧Service Worker 控制了。
然而,现在客户端(调用register的页面)仍然不受它控制。要第二次加载或者调用 clients.claim()
提前控制所有客户端时,它才具备处理push,sync和fetch事件的能力。
在这个阶段,可以通过返回event.waitUntil()的Promise来告诉浏览器什么时候激活完成了。
还可以通过 self.skipWaiting() 来跳过 Waiting 阶段(一般不在这里使用)
Activated:已经激活完成。此时可以处理事件了(比如拦截fetch返回缓存等)
Redundant:新Service Worker 正常激活后,旧Service Worker 会变成这个状态
可以看出来,并不是每个生命周期都有对应的事件可以操作的。仔细理解生命周期,理解后下面的工作流程会很轻松
Service Worker 工作流程
首次 Service Worker 工作流程
结合上面的生命周期,我们可以看到流程如下:
首次打开网页,调用 serviceWorker.register()
,传入对应的 sw 脚本,注册对应的 Service Worker 实例
注册成功后,用户首次访问 Service Worker 控制的网站或页面时,Service Worker 会被下载到客户端,这将作用于整个域内用户可访问的URL或特定子集(Scope)。
首次启用 Service Worker,页面会尝试安装。如果 Install事件回调函数中的操作都执行成功,标志 Service Worker 安装成功,进入下一阶段。如果任何文件下载失败或缓存失败,那么安装步骤失败,该 Service Worker 会被丢弃。
安装成功后会进入激活,激活成功后,此时页面依旧不会缓存。因为是首次注册该 Service Worker ,需要刷新后才接管相应页面的控制权,从而处理fetch、post、sync等事件。
Scope 是什么?
Service Worker 注册的默认作用域是与脚本网址相对的 ./。也就是说,如果你在 exam.com/src/index.j… 上注册了一个 Service Worker,则它的默认作用域为 exam.com/src/*
可以在注册时通过传入 Scope 参数配置作用域。
想知道客户端是否受 SW 控制,可以通过通navigator.serviceWorker.controller(其将为 null 或一个 Service Worker 实例)检测
注册失败会怎样?
在调用 navigator.serviceWorker.register(sw.js) 注册 Service Worker 实例时,返回一个Promise。如果 sw.js 脚本在初始执行中未能进行下载、解析,或引发错误,则注册器 promise 将拒绝,并舍弃此 Service Worker。可以通过 catch 来捕获错误信息;如果注册成功,可以使用 then 来获取一个 ServiceWorkerRegistration 的实例
Chrome 的 DevTools 在控制台和应用标签的 Service Worker 部分中显示此错误
理解首次注册激活的 Service Worker 需要再次加载来生效
// index.js
// 3s后创建了一个 src 为 "/dog.svg" 的image
navigator.serviceWorker.register('/sw.js')
.then(reg => console.log('SW registered!', reg))
.catch(err => console.log('registered err', err))
setTimeout(() => {
const img = new Image()
img.src = '/dog.svg'
document.body.appendChild(img)
}, 3000)
// sw.js
self.addEventListener('install', event => {
console.log('V1 installing…')
event.waitUntil(
caches.open('static-v1').then(cache => cache.add('/cat.svg'))
self.addEventListener('activate', event => {
console.log('V1 now ready to handle fetches!')
self.addEventListener('fetch', event => {
const url = new URL(event.request.url)
if (url.origin == location.origin && url.pathname == '/dog.svg') {
event.respondWith(caches.match('/cat.svg'))
Service Worker 在 install 里加入了缓存图片 cat.svg。并在请求 /dog.svg 时提供该图像。然而我们第一次打开页面时(也就是首次注册/激活SW实例时),会发现,尽管3s后才去获取 /dog.svg(此时SW应该已经激活),但实际返回的不是我们预想的 /cat.svg,还是 /dog.svg。只有刷新后,才出现 /cat.svg
如果我不想重刷呢?
我们从上述例子知道了,Service Worker 激活后,需要重刷一次才能真正接管控制权。那我不想重刷呢?
那就使用clients.claim()
self.addEventListener('activate', event => {
+ event.waitUntil(clients.claim());
console.log('Now ready to handle fetches!');
有了这行代码后, SW 就会立马接管网页控制权。如果sw.js执行够快,在3s以内,那此时即使不刷新我们也会看到 /cat.svg
更新 Service Worker 流程
是否更新service worker是首先是由浏览器来决定的,只有当前后两个service worker文件在内容上不同的时候浏览器才会启动新的Service Worker的注册安装流程
默认情况下,Service Worker 必定会每24小时被下载一次,如果下载的文件是最新文件,那么它就会被重新注册和安装,但不会被激活,当不再有页面使用旧的 Service Worker 的时候,它就会被激活。
打开网页A,调用 serviceWorker.register()
,发现脚本已更新,注册新的 Service Worker 实例。同时旧实例依旧启用。
同首次下载流程
安装 新Service Worker
Service Worker 安装完成后,因为已有 Service Worker 被启用,那新版本会进入 Waiting 状态。直到所有已打开界面(这里就是网页A)不再使用 旧Service Worker 后,一般是关闭页面,新Service Worker 才进入激活过程
理解Waiting状态
我们试图把原本返回的 /cat.svg 改为 /mouse.svg
self.addEventListener('install', event => {
console.log('V2 installing…')
event.waitUntil(
+ caches.open('static-v2').then(cache => cache.add('/horse.svg'))
self.addEventListener('fetch', event => {
const url = new URL(event.request.url)
if (url.origin == location.origin && url.pathname == '/dog.svg') {
+ event.respondWith(caches.match('/mouse.svg'))
我们期望的是,现在不返回 /cat.svg 而是 /mouse.svg,但实际却还是看到了 /cat.svg 。为什么?
打开网页A,浏览器检测到脚本内容发生了改变,尝试重新注册新的 Service Worker,旧Service Worker 依旧启用
安装时,在新的脚本里,缓存名从 'static-v1' -> 'static-v2',也就是说,新旧缓存不会覆盖,独立存在
安装成功,由于网页A中 旧Service Worker 依旧在运行,所以 新Service Worker 处于Waiting阶段,因为浏览器需要确保同一时间只运行一个 Service Worker。可在 Chrome 的 DevTools 里查看状态
刷新界面,会发现 新Service Worker 依旧Waiting
原因可能是:由于浏览器的内部实现原理,当页面切换或者自身刷新时,浏览器是等到新的页面完成渲染之后再销毁旧的页面。这表示新旧两个页面中间有共同存在的交叉时间,由于存在这种重叠情况,在刷新时当前 Service Worker 始终会控制一个页面。
关闭浏览器或关闭网页A,重新打开后, 新Service Worker 会激活,进而获取控制权。此时显示 /mouse.svg
Service Worker 什么时候接管?
旧 Service Worker 退出时将触发 Activate,新 Service Worker 将能够控制客户端。此时可以处理一些迁移数据库或清除缓存的工作。将一个 promise 传递到 event.waitUntil(),它将清除缓存后,才真正激活接管
// sw.js
const expectedCaches = ['static-v2']
self.addEventListener('activate', event => {
event.waitUntil(
caches.keys().then(keys => Promise.all(
keys.map(key => {
if (!expectedCaches.includes(key)) {
return caches.delete(key)
)).then(() => {
console.log('V2 now ready to handle fetches!')
不想 Waiting 怎么办?
我们从上一章可以直到,当Service Worker 更新时,新Service Worker 一直在Wating 阶段,直到页面关闭再打开。那如果想让新Service Worker 跳过Waiting,尽快激活呢?
skipWaiting 方案
可以在 install 或 activate 事件(通常在install里)里调用 self.skipWaiting() 来跳过Waiting阶段。这会导致新Service Worker 将旧Service Worker停用,并在Waiting阶段立即进入激活阶段。
self.addEventListener('install', event => {
+ self.skipWaiting()
然而,这样做是有风险的
用户打开 index.html, 安装了 sw-v1.js,产生 SW1
实例,后续网络请求通过了 SW1
,页面加载完成
代码更新,用户重新打开 index.html,此时 SW1
处理http请求。同时,执行到 navigator.serviceWorker.register
,发现有个 sw-v2.js,由于 Service Worker 异步安装,此时后台异步安装 SW2
实例。
因为 sw.v2.js
在 install
阶段有 self.skipWaiting()
,所以浏览器强制停止了 SW1
,而是让 SW2
马上激活并控制页面
后续http请求,由 SW2
处理
同一个页面,前半部分的请求是由 SW1
控制,而后半部分是由 SW2
控制。这两者的不一致性很容易导致问题,甚至网页报错崩溃。比如说 SW1
预缓存了一个 v1/image.png
,而当 SW2
激活时,通常会删除老版本的预缓存,转而添加例如 v2/image.png
的缓存。这时如果断网,或者采用的是 CacheFirst 之类的缓存策略时,浏览器发现 v1/image.png
已经在缓存中找不到了。即便网络正常,浏览器也得再发一次请求去获取这些本已经缓存过的资源,浪费了时间和带宽。再者,这类 SW 引发的错误很难复现,也很难 DEBUG,给程序添加了不稳定因素。
skipWaiting + 刷新 方案
直接 skipWaiting,我们会发现同一页面请求有一部分被旧SW控制,一部分被新的控制。而且旧SW控制的数据已经被清空,此时缺少部分缓存。此时如果刷新网页,那所有请求就会重新发起,那些缺失的缓存数据,会重新去服务端获取。
我们在 新SW 接管后,重刷网页。这样做的缺点也很明显,会打断用户体验,因为刷新了嘛
navigator.serviceWorker.addEventListener('controllerchange', (e) => {
console.log(e);
window.location.reload();
Notice:
我们知道,SW 在Waiting状态时,靠刷新并不会改变其状态,因为不会使 旧SW 退出
这里的做法是,先使用 skipWaiting
来使 旧SW 退出,然后通过 controllerchange
监听到变化再执行刷新。
我们还要注意,当在 Dev Tools 里开启Update on Reload 功能时,使用如上代码会引发无限的自我刷新
。所以通常我们加一个flag判断
let refreshing = false
navigator.serviceWorker.addEventListener('controllerchange', () => {
if (refreshing) {
return
refreshing = true
window.location.reload()
让用户主动刷新
我们知道,通过 controllerchange
监听就可以执行刷新操作了。既然我们刷新会打断用户体验,那换个思路,让用户自己去刷新不是也可以?
浏览器检测到 新SW,安装并进入Waiting阶段,同时会触发 updatefound
事件
监听 updatefound
事件,弹出提示,让用户选择是否更新
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').then(function (registration) {
if (registration.waiting) {
emit('updated', registration);
return;
registration.onupdatefound = function () {
var installingWorker = registration.installing;
installingWorker.onstatechange = function () {
switch (installingWorker.state) {
case 'installed':
if (navigator.serviceWorker.controller) {
emit('updated', registration);
break;
}).catch(function(e) {
console.error('Error during service worker registration:', e);
如果用户选择更新,通过 postMessage
通知 新SW 执行 skipWaiting
try {
navigator.serviceWorker.getRegistration().then(registration => {
registration.waiting.postMessage('skipWaiting');
} catch (e) {
window.location.reload();
self.addEventListener('message', event => {
if (event.data === 'skipWaiting') {
self.skipWaiting();
通过 controllerchange
监听事件,刷新网页
// sw-v2.js
let refreshing = false
navigator.serviceWorker.addEventListener('controllerchange', () => {
if (refreshing) {
return
refreshing = true
window.location.reload()
当然,该方法也有弊端
过于复杂,设计API过多,跨文件传输消息,且多了UI设计
SW的更新依赖用户点击
Question
如果在新版本里,更改了 service-worker.js 地址(名称)会怎么样?
针对静态文件,现在流行的做法是通过hash值(或其他值)在每次构建时生成不同的名称,再配以缓存策略,降低访问耗时。
但如果 service-worker.js 也这样做,就可能会:
index.html 将 sw-v1.js 注册为 Service Worker。
sw-v1.js 把 index.html 缓存起来,以实现离线功能。
更新 index.html,注册全新的 sw-v2.js。
执行上述操作,我们会发现用户将永远无法获取 sw-v2.js,因为 sw-v1.js 将从其缓存中提供旧版本的 index.html,里面引用的是 sw-v1.js。
一旦遇到这种情况,除非用户手动清除缓存,卸载 v1
,否则我们无能为力
所以 service-worker.js
必须使用相同的名字,不能在文件名上加上任何会改变的因素。
如果给 service-worker.js 设置缓存会怎么样?
会遇到和上面一毛一样的情况!
最好是将 service-worker.js 独立出来并设置 Cache-control: no-store
如何判断页面是否被 service worker 控制
如果需要判断一个页面是否受service worker控制,可以检测navigator.serviceWorker.controller
这个属性是否为null或者一个service worker实例。
可以注册多个 Service Worker吗?
在同一个 Origin 下,可以注册多个 Service Worker。但是请注意,这些 Service Worker 的 scope 必须是不相同的。
if ('serviceWorker' in window.navigator) {
navigator.serviceWorker.register('./sw/sw.js', { scope: './sw' })
.then(function (reg) {
console.log('success', reg);
navigator.serviceWorker.register('./sw2/sw2.js', { scope: './sw2' })
.then(function (reg) {
console.log('success', reg);
开发时不想手动刷新?
在开发时为了每次都使用新SW,可以在chrome开发者工具里勾上 Update on reload 的单选框,选中它之后,我们每次刷新页面都能够使用最新的 service worker 文件。
静态资源缓存
正常情况下,用户打开网页,浏览器会自动下载网页所需要的 JS 文件、图片等静态资源。但是如果用户在没有联网的情况下打开网页,浏览器就无法下载这些展示页面效果所必须的资源,页面也就无法正常的展示出来。
我们可以使用 Service Worker 配合 CacheStroage 来实现对静态资源的缓存。
CacheStorage
service worker的缓存能力主要与self.caches
对象有关,这是一个CacheStorage对象,在普通页面中也可以使用,但是一般用在service worker中
我们需要注意以下几点:
Cache Storage只能用在https环境中
Cache Stroage 只能缓存静态资源,所以它只能缓存用户的 GET 请求
Cache Stroage 中的缓存不会过期,但是浏览器对它的大小是有限制的,所以需要我们定期进行清理
缓存指定静态资源
this.addEventListener('install', function (event) {
event.waitUntil(
caches.open('sw_1').then(function (cache) {
return cache.addAll([
'/style.css',
'/main.jpg',
'./main.js'
我们使用 caches.open
方法新建或打开一个已存在的缓存;cache.addAll
方法的作用是请求指定链接的资源并把它们存储到之前打开的缓存中。由于资源的下载、缓存是异步行为,所以我们要使用事件对象提供的 event.waitUntil
方法,它能够保证资源被缓存完成前 Service Worker 不会被安装完成,避免发生错误。
从 Chrome 开发工具中的 Application 的 Cache Strogae 中可以看到我们缓存的资源。
缓存fetch静态资源
// 针对 GET 有效
this.addEventListener('fetch', function (event) {
console.log(event.request.url)
event.respondWith(
caches.match(event.request).then(res => {
return res ||
fetch(event.request)
.then(responese => {
const responeseClone = responese.clone()
caches.open('sw_1').then(cache => {
cache.put(event.request, responeseClone)
return responese
.catch(err => {
console.log(err)
我们需要监听 fetch 事件,每当用户向服务器发起请求的时候这个事件就会被触发。有一点需要注意,页面的路径不能大于 Service Worker 的 scope,不然 fetch 事件是无法被触发的。
使用事件对象提供的 respondWith
方法劫持用户发出的 http 请求,并把一个 Promise 作为响应结果返回给用户。然后在 Cache Stroage 匹配请求,如果匹配成功,则返回缓存中的资源;如果匹配失败,则向服务器请求资源,将原返回数据返回给用户。因为请求和响应流只能被读取一次,在响应里使用 clone
方法复制一份数据,并使用 cache.put
方法把复制数据存储在缓存中。
Service Worker 缓存策略
所有请求都从缓存里读取
何时使用:通常用于获取不变的静态资源。
self.addEventListener('fetch', function(event) {
event.respondWith(caches.match(event.request));
客户端发出请求,Service Worker 拦截该请求并将请求发送到网络。
何时使用:当不是请求静态资源时,比如 ping 检测、非 GET 的请求。
self.addEventListener('fetch', function(event) {
event.respondWith(fetch(event.request));
其实,如果我们不使用 responseWith 方法,请求也会正常发出。
缓存优先,如果请求缓存不成功,Service Worker 则会将请求网络。
何时使用:当您在构建离线优先的应用时
self.addEventListener('fetch', function(event) {
event.respondWith(
caches.match(event.request).then(function(response) {
return response || fetch(event.request);
Service Worker 将向网络发出一个请求,如果请求成功,那么就将资源存入缓存。
何时使用:当您在构建一些需要频繁改变的内容时,此策略便是首选。
self.addEventListener('fetch', function(event) {
event.respondWith(
fetch(event.request).catch(function() {
return caches.match(event.request);
此方法存在缺陷。如果用户的网络时断时续或很慢,这需要花很长的时间。
当两个请求都失败时(一个请求失败于缓存,另一个失败于网络),您将显示一个通用的回退,以便您的用户不会感受到白屏或某些奇怪的错误。
self.addEventListener('fetch', function(event) {
event.respondWith(
caches.match(event.request).then(function(response) {
return response || fetch(event.request);
}).catch(function() {
return caches.match('/offline.html');
可查看这里的缓存模式,有更多选项
英文好的看这里
谨慎处理 Service Worker 的更新
MemoryCache、DiskCache、ServiceWorker比较