今天要聊的話題是前端最近的一個更新方向 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 中仍有兼容性問題