今天要聊的话题是前端最近的一个更新方向 PWA 中的核心 Service Worker 的更新问题。这是一个很容易被开发者忽略的问题,由于绝大部分开发者可能对它还不太熟悉。javascript
Service Worker 以其 异步安装 和 持续运行 两个特色,决定了针对它的更新操做必须很是谨慎当心。由于它具备拦截并处理网络请求的能力,所以必须作到网页(主要是发出去的请求)和 Service Worker 版本一致才行,不然就会致使新版本的 Service Worker 处理旧版本的网页,或者一个网页前后由两个版本的 Service Worker 控制引起种种问题。html
通过近 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
等)。前端
本文再也不科普 SW 的基础,我主要想在这里谈一谈 SW 的更新问题。须要作到 SW 和页面的彻底同步,其实并不容易。在此以前,我假设你已经了解了:java
navigator.serviceWorker.register
)在开始正式谈论 SW 的更新机制以前,咱们有必要先肯定组织 SW 时的两个禁忌。在将 SW 应用到本身的站点时,咱们要避开这两种方法,他们是:git
通常针对静态文件,时下流行的作法是在每次构建时根据内容(或者当时的时间等随机因素)给它们一个惟一的命名,例如 index.[hash].js
。由于这些文件不常修改,再配以长时间的强制缓存,可以大大下降访问它们的耗时。程序员
惋惜针对 SW,这种作法并不合适。咱们假设一个项目github
首页 index.html
,底下包含了一段 <script>
用于注册 service-worker.v1.js
。web
为了提高速度或者离线可用,这个 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
,不然咱们无能为力。
因此 service-worker.js
必须使用相同的名字,不能在文件名上加上任何会改变的因素。
理由和第一点相似,也是为了防止在浏览器须要请求新版本的 SW 时,由于缓存的干扰而没法实现。毕竟咱们不能要求用户去清除缓存。所以给 SW 及相关的 JS (例如 sw-register.js
,若是独立出来的话)设置 Cache-control: no-store
是比较安全的。
注册 SW 是经过 navigator.serviceWorker.register(swUrl, options)
方法进行的。但和普通的 JS 代码不一样,这句执行在浏览器看来其实有两种不一样的状况:
若是目前还没有有活跃的 SW ,那就直接安装并激活。
若是已有 SW 安装着,向新的 swUrl
发起请求,获取内容和和已有的 SW 比较。如没有差异,则结束安装。若有差异,则安装新版本的 SW(执行 install
阶段),以后令其等待(进入 waiting
阶段)
此时当前页面会有两个 SW,但状态不一样,以下图:
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
,页面加载完成。
由于 SW 异步安装的特性,通常在浏览器空闲时,他会去执行那句 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 的更新和页面的刷新,难免产生混淆。
咱们简单理一下逻辑,其实也不复杂:
刷新不能使得 SW 发生更新,即老的 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,并引起刷新,岂不美哉?
大体的流程是:
浏览器检测到存在新的(不一样的)SW 时,安装并让它等待,同时触发 updatefound
事件
咱们监听事件,弹出一个提示条,询问用户是否是要更新 SW
若是用户确认,则向处在等待的 SW 发送消息,要求其执行 skipWaiting
并取得控制权
由于 SW 的变化触发 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
事件来执行刷新操做,这里就不重复列出代码了。
从运行结果上看,这个方法兼顾了快速更新和用户体验,是当前最好的解决方案。但它也有弊端。
在文件数量方面,涉及到至少 2 个文件(注册 SW,监听 updatefound
和处理 DOM 的展示和点击在普通的 JS 中,监听信息并执行 skipWaiting
是在 SW 的代码中),这还不算咱们可能为了代码的模块分离,把 DOM 的展示点击和 SW 的注册分红两个文件
在 API 种类方面,涉及到 Registration API(注册,监听 updatefound
和发送消息时使用),SW 生命周期和 API(skipWaiting
)以及普通的 DOM API
测试和 DEBUG 方法复杂,至少须要制造新老 2 个版本 SW 的环境,而且熟练掌握 SW 的 DEBUG 方式。
尤为是为了达成用户点击后的 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 了。
有关 Service Worker 更新的两点改进 - 编写本文的源头
The Service Worker Lifecycle - 来自 Google Developers 的 Service Worker 科普文章之一
How to Fix the Refresh Button When Using Service Workers - 说起了第四种方法,不过在 Firefox 中仍有兼容性问题