Service Worker 以其 异步安装 和 持续运行 两个特色,决定了针对它的更新操做必须很是谨慎当心。由于它具备拦截并处理网络请求的能力,所以必须作到网页(主要是发出去的请求)和 Service Worker 版本一致才行,不然就会致使新版本的 Service Worker 处理旧版本的网页,或者一个网页前后由两个版本的 Service Worker 控制引起种种问题。
通过近 2 年的发展,PWA 在 WEB 圈的知名度已经大大提高,即使你没用过可能也至少据说过。Service Worker (如下简称 SW)是 PWA 中最复杂最核心的部分,其中涉及的主要有 Caches API (caches.put
, caches.addAll
等), Service Worker API (self.addEventListener
, self.skipWaiting
等) 和 Registration API (reg.installing
, reg.onupdatefound
等)。html
本文再也不科普 SW 的基础,我主要想在这里谈一谈 SW 的更新问题。须要作到 SW 和页面的彻底同步,其实并不容易。在此以前,我假设你已经了解了:前端
navigator.serviceWorker.register
)在开始正式谈论 SW 的更新机制以前,咱们有必要先肯定组织 SW 时的两个禁忌。在将 SW 应用到本身的站点时,咱们要避开这两种方法,他们是:git
通常针对静态文件,时下流行的作法是在每次构建时根据内容(或者当时的时间等随机因素)给它们一个惟一的命名,例如 index.[hash].js
。由于这些文件不常修改,再配以长时间的强制缓存,可以大大下降访问它们的耗时。程序员
惋惜针对 SW,这种作法并不合适。咱们假设一个项目github
index.html
,底下包含了一段 <script>
用于注册 service-worker.v1.js
。service-worker.v1.js
会把 index.html
缓存起来。index.html
须要配上 service-worker.v2.js
使用了,因此源码中底下的 <script>
中修改了注册的地址。service-worker.v1.js
的做用,从缓存中取出的 index.html
引用的依然是 v1
,并非咱们升级后引用 v2
。之因此出现这种状况,是由于把 v1
升级为 v2
依赖于 index.html
引用地址的变化,但它自己却被缓存了起来。一旦到达这种窘境,除非用户手动清除缓存,卸载 v1
,不然咱们无能为力。web
因此 service-worker.js
必须使用相同的名字,不能在文件名上加上任何会改变的因素。浏览器
理由和第一点相似,也是为了防止在浏览器须要请求新版本的 SW 时,由于缓存的干扰而没法实现。毕竟咱们不能要求用户去清除缓存。所以给 SW 及相关的 JS (例如 sw-register.js
,若是独立出来的话)设置 Cache-control: no-store
是比较安全的。缓存
注册 SW 是经过 navigator.serviceWorker.register(swUrl, options)
方法进行的。但和普通的 JS 代码不一样,这句执行在浏览器看来其实有两种不一样的状况:安全
swUrl
发起请求,获取内容和和已有的 SW 比较。如没有差异,则结束安装。若有差异,则安装新版本的 SW(执行 install
阶段),以后令其等待(进入 waiting
阶段)此时当前页面会有两个 SW,但状态不一样,以下图:bash
activated
阶段),使之接管页面。这是一种比较温和和安全的作法,至关于新旧版本的天然淘汰。但毕竟关闭全部页面是用户的选择而不是程序员能控制的。另外咱们还需注意一点:因为浏览器的内部实现原理,当页面切换或者自身刷新时,浏览器是等到新的页面完成渲染以后再销毁旧的页面。这表示新旧两个页面中间有共同存在的交叉时间,所以简单的切换页面或者刷新是不能使得 SW 进行更新的,老的 SW 依然接管页面,新的 SW 依然在等待。(这点也要求咱们在检测 SW 更新时,除了 onupdatefound
以外,还须要判断是否存在处在等待状态的 SW,即 reg.waiting
是否存在。不过这在本文讨论范围以外,就不展开了)
假设咱们提供了一次重大升级,但愿新的 SW 尽快接管页面,应该怎么作呢?
在遭遇突发状况时,很容易想到经过“插队”的方式来解决问题,现实生活中的救护车消防车等特种车辆就采用了这种方案。SW 也给程序员提供了实现这种方案的可能性,那就是在 SW 内部的 self.skipWaiting()
方法。
self.addEventListener('install', event => {
self.skipWaiting()
// 预缓存其余静态内容
})
复制代码
这样可让新的 SW “插队”,强制令它马上取代老的 SW 控制全部页面,而老的 SW 被“斩立决”,简单粗暴。Lavas 最初就使用了这个方案,由于实在是太容易想到也太容易实现了,诱惑极大。
惋惜这个方案是有隐患的。咱们想象以下场景:
index.html
已安装了 sw.v1.js
(实际地址都是 sw.js
,只是为了明显区分如此表达而已)sw.v1.js
,页面加载完成。navigator.serviceWorker.register
。这时候浏览器发现了有个 sw.v2.js
存在,因而安装并让他等待。sw.v2.js
在 install
阶段有 self.skipWaiting()
,因此浏览器强制退休了 sw.v1
,而是让 sw.v2
立刻激活并控制页面。index.html
的后续操做若有网络请求,就由 sw.v2.js
处理了。很明显,同一个页面,前半部分的请求是由 sw.v1.js
控制,然后半部分是由 sw.v2.js
控制。这二者的不一致性很容易致使问题,甚至网页报错崩溃。好比说 sw.v1.js
预缓存了一个 v1/image.png
,而当 sw.v2.js
激活时,一般会删除老版本的预缓存,转而添加例如 v2/image.png
的缓存。因此这时若是用户网络环境不顺畅或者断网,或者采用的是 CacheFirst 之类的缓存策略时,浏览器发现 v1/image.png
已经在缓存中找不到了。即使网络环境正常,浏览器也得再发一次请求去获取这些本已经缓存过的资源,浪费了时间和带宽。再者,这类 SW 引起的错误很难复现,也很难 DEBUG,给程序添加了不稳定因素。
除非你能保证同一个页面在两个版本的 SW 相继处理的状况下依然可以正常工做,才能使用这个方案。
方法一的问题在于,skipWaiting 以后致使一个页面前后被两个 SW 控制。那既然已经安装了新的 SW,则表示老的 SW 已通过时,所以能够推断使用老的 SW 处理过的页面也已通过时。咱们要作的是让页面从头至尾都让新的 SW 处理,就可以保持一致,也能达成咱们的需求了。因此咱们想到了刷新,废弃掉已经被处理过的页面。
在注册 SW 的地方(而不是 SW 里面)能够经过监听 controllerchange
事件来得知控制当前页面的 SW 是否发生了变化,以下:
navigator.serviceWorker.addEventListener('controllerchange', () => {
window.location.reload();
})
复制代码
当发现控制本身的 SW 已经发生了变化,那就刷新本身,让本身从头至尾都被新的 SW 控制,就必定能保证数据的一致性。道理是对,但忽然的更新会打断用户的操做,可能会引起不适。刷新的源头在于 SW 的变动;SW 的变动又来源于浏览器安装新的 SW 碰上了 skipWaiting
,因此此次刷新绝大部分状况会发生在加载页面后的几秒内。用户刚开始浏览内容或者填写信息就赶上了莫名的刷新,可能会砸键盘。
另外这里还有两个注意点:
在讲到 SW 的 waiting 状态时,我曾经说过 简单的切换页面或者刷新是不能使得 SW 进行更新的,而这里又一次牵涉到了 SW 的更新和页面的刷新,难免产生混淆。
咱们简单理一下逻辑,其实也不复杂:
skipWaiting
迫使 SW 新老交替。在交替完成后,经过 controllerchange
监听到变化再执行刷新。因此二者的因果是相反的,并不矛盾。
在使用 Chrome Dev Tools 的 Update on Reload 功能时,使用如上代码会引起无限的自我刷新。为了弥补这一点,须要添加一个 flag 判断一下,以下:
let refreshing = false
navigator.serviceWorker.addEventListener('controllerchange', () => {
if (refreshing) {
return
}
refreshing = true;
window.location.reload();
});
复制代码
方法二有一个思路值得借鉴,即“经过 SW 的变化触发事件,而在事件监听中执行刷新”。但毫无征兆的刷新页面的确不可接受,因此咱们再改进一下,给用户一个提示,让他来点击后更新 SW,并引起刷新,岂不美哉?
大体的流程是:
updatefound
事件skipWaiting
并取得控制权controllerchange
事件,咱们在这个事件的回调中刷新页面便可这里值得注意的是第 3 步。由于用户点击的响应代码是位于普通的 JS 代码中,而 skipWaiting
的调用位于 SW 的代码中,所以这二者还须要一次 postMessage
进行通信。
代码方面,咱们以 Lavas 的实现来分步骤看一下:
第 1 步是浏览器执行的,与咱们无关。第 2 步须要咱们监听这个 updatefound
事件,这是须要经过注册 SW 时返回的 Registration 对象来监听的,所以一般咱们能够在注册时直接监听,避免后续还要再去获取这个对象,徒增复杂。
function emitUpdate() {
var event = document.createEvent('Event');
event.initEvent('sw.update', true, true);
window.dispatchEvent(event);
}
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/service-worker.js').then(function (reg) {
if (reg.waiting) {
emitUpdate();
return;
}
reg.onupdatefound = function () {
var installingWorker = reg.installing;
installingWorker.onstatechange = function () {
switch (installingWorker.state) {
case 'installed':
if (navigator.serviceWorker.controller) {
emitUpdate();
}
break;
}
};
};
}).catch(function(e) {
console.error('Error during service worker registration:', e);
});
}
复制代码
这里咱们经过发送一个事件 (名为 sw.update
,位于 emitUpdate()
方法内) 来通知外部,这是由于提示条是一个单独的组件,不方便在这里直接展示。固然若是你的应用有不一样的结构,也能够自行修改。总之想办法展现提示条,或者单纯使用 confirm
让用户确认便可。
第 3 步须要处理用户点击,并和 SW 进行通信。处理点击的代码比较简单,就不重复了,这里主要列出和 SW 的通信代码:
try {
navigator.serviceWorker.getRegistration().then(reg => {
reg.waiting.postMessage('skipWaiting');
});
} catch (e) {
window.location.reload();
}
复制代码
注意经过 reg.waiting
向 等待中的 SW 发消息,而不是向当前的老的 SW 发消息。而 SW 部分则负责接收消息,并执行“插队”逻辑。
// service-worker.js
// SW 再也不在 install 阶段执行 skipWaiting 了
self.addEventListener('message', event => {
if (event.data === 'skipWaiting') {
self.skipWaiting();
}
})
复制代码
第 4 步和方法二一致,也是经过 navigator.serviceWorker
监听 controllerchange
事件来执行刷新操做,这里就不重复列出代码了。
从运行结果上看,这个方法兼顾了快速更新和用户体验,是当前最好的解决方案。但它也有弊端。
updatefound
和处理 DOM 的展示和点击在普通的 JS 中,监听信息并执行 skipWaiting
是在 SW 的代码中),这还不算咱们可能为了代码的模块分离,把 DOM 的展示点击和 SW 的注册分红两个文件updatefound
和发送消息时使用),SW 生命周期和 API(skipWaiting
)以及普通的 DOM API尤为是为了达成用户点击后的 SW “插队”,须要从 DOM 点击响应,到发送消息给 SW,再到 SW 里面操做。这一串操做横跨好几个 JS,很是不直观且复杂。为此已有 Google 大佬 Jake Archibald 向 W3C 提出建议,简化这个过程,容许在普通的 JS 中经过 reg.waiting.skipWaiting()
直接插队,而不是只能在 SW 内部操做。
这里指的是 SW 的更新只能经过用户点击通知条上的按钮,使用 JS 来完成,而 不能经过浏览器的刷新按钮完成。这实际上是浏览器的设计问题,而非方案自己的问题。
不过反过来讲,若是浏览器帮助咱们完成了上述操做,那就变成容许经过一个 Tab 的刷新去强制其余 Tab 刷新,在当前浏览器以 Tab 为单位的前提下,存在这种交叉控制也是不安全和难以理解的。
惟一可行的优化是当 SW 控制的页面仅存在一个 Tab 时,刷新这个 Tab 若是可以更新 SW,也能给咱们省去很多操做,也不会带来交叉控制的问题。只是这样可能加剧了浏览器的判断成本,也丧失了操做一致性的美感,只能说这可能也是一个久远的梦想了。
SW 的功能至关强大,但同时涉及的 API 也相对较多,是一个须要投入至关学习成本的强力技术(国外文章称之为 rocket science)。SW 的更新对使用 SW 的站点来讲很是重要,但如上所述,其方案也相对复杂,远远超过了其余经常使用前端基础技术的复杂度(例如 DOM API,JS 运算,闭包等等)。不过 SW 从其起步至今也不过两三年的时间,尚处在发展期。相信经过 W3C 的不断修正以及前端圈的持续使用,会有更加简洁,更加自动,更加完备的方案出现,届时咱们可能就能像使用 DOM API 那样简单地使用 SW 了。