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
等)。
本文不再科普 SW 的基礎,我主要想在這裡談一談 SW 的更新問題。需要做到 SW 和頁面的完全同步,其實並不容易。在此之前,我假設你已經瞭解了:
- SW 的作用
- SW 的註冊方式 (
navigator.serviceWorker.register
) - SW 的生命週期 (install -> waiting -> activate -> fetch)
組織 SW 的兩大禁忌
在開始正式談論 SW 的更新機制之前,我們有必要先確定組織 SW 時的兩個禁忌。在將 SW 應用到自己的站點時,我們要避開這兩種方法,他們是:
不要給 service-worker.js 設定不同的名字
一般針對靜態檔案,時下流行的做法是在每次構建時根據內容(或者當時的時間等隨機因素)給它們一個唯一的命名,例如 index.[hash].js
。因為這些檔案不常修改,再配以長時間的強制快取,能夠大大降低訪問它們的耗時。
可惜針對 SW,這種做法並不合適。我們假設一個專案
- 首頁
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
,否則我們無能為力。
所以 service-worker.js
必須使用相同的名字,不能在檔名上加上任何會改變的因素。
不要給 service-worker.js 設定快取
理由和第一點類似,也是為了防止在瀏覽器需要請求新版本的 SW 時,因為快取的干擾而無法實現。畢竟我們不能要求使用者去清除快取。因此給 SW 及相關的 JS (例如 sw-register.js
,如果獨立出來的話)設定 Cache-control: no-store
是比較安全的。
SW 的 waiting 狀態
註冊 SW 是通過 navigator.serviceWorker.register(swUrl, options)
方法進行的。但和普通的 JS 程式碼不同,這句執行在瀏覽器看來其實有兩種不同的情況:
- 如果目前尚未有活躍的 SW ,那就直接安裝並啟用。
- 如果已有 SW 安裝著,向新的
swUrl
發起請求,獲取內容和和已有的 SW 比較。如沒有差別,則結束安裝。如有差別,則安裝新版本的 SW(執行install
階段),之後令其等待(進入waiting
階段)
此時當前頁面會有兩個 SW,但狀態不同,如下圖:
- 如果老的 SW 控制的所有頁面 全部關閉,則老的 SW 結束執行,轉而啟用新的 SW(執行
activated
階段),使之接管頁面。
這是一種比較溫和和安全的做法,相當於新舊版本的自然淘汰。但畢竟關閉所有頁面是使用者的選擇而不是程式設計師能控制的。另外我們還需注意一點:由於瀏覽器的內部實現原理,當頁面切換或者自身重新整理時,瀏覽器是等到新的頁面完成渲染之後再銷燬舊的頁面。這表示新舊兩個頁面中間有共同存在的交叉時間,因此簡單的切換頁面或者重新整理是不能使得 SW 進行更新的,老的 SW 依然接管頁面,新的 SW 依然在等待。(這點也要求我們在檢測 SW 更新時,除了 onupdatefound
之外,還需要判斷是否存在處在等待狀態的 SW,即 reg.waiting
是否存在。不過這在本文討論範圍之外,就不展開了)
假設我們提供了一次重大升級,希望新的 SW 儘快接管頁面,應該怎麼做呢?
方法一:skipWaiting
在遭遇突發情況時,很容易想到通過“插隊”的方式來解決問題,現實生活中的救護車消防車等特種車輛就採用了這種方案。SW 也給程式設計師提供了實現這種方案的可能性,那就是在 SW 內部的 self.skipWaiting()
方法。
1 2 3 4 |
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 + 重新整理
方法一的問題在於,skipWaiting 之後導致一個頁面先後被兩個 SW 控制。那既然已經安裝了新的 SW,則表示老的 SW 已經過時,因此可以推斷使用老的 SW 處理過的頁面也已經過時。我們要做的是讓頁面從頭到尾都讓新的 SW 處理,就能夠保持一致,也能達成我們的需求了。所以我們想到了重新整理,廢棄掉已經被處理過的頁面。
在註冊 SW 的地方(而不是 SW 裡面)可以通過監聽 controllerchange
事件來得知控制當前頁面的 SW 是否發生了變化,如下:
1 2 3 |
navigator.serviceWorker.addEventListener('controllerchange', () => { window.location.reload(); }) |
當發現控制自己的 SW 已經發生了變化,那就重新整理自己,讓自己從頭到尾都被新的 SW 控制,就一定能保證資料的一致性。道理是對,但突然的更新會打斷使用者的操作,可能會引發不適。重新整理的源頭在於 SW 的變更;SW 的變更又來源於瀏覽器安裝新的 SW 碰上了 skipWaiting
,所以這次重新整理絕大部分情況會發生在載入頁面後的幾秒內。使用者剛開始瀏覽內容或者填寫資訊就遇上了莫名的重新整理,可能會砸鍵盤。
另外這裡還有兩個注意點:
SW 的更新和頁面的重新整理
在講到 SW 的 waiting 狀態時,我曾經說過 簡單的切換頁面或者重新整理是不能使得 SW 進行更新的,而這裡又一次牽涉到了 SW 的更新和頁面的重新整理,不免產生混淆。
我們簡單理一下邏輯,其實也不復雜:
- 重新整理不能使得 SW 發生更新,即老的 SW 不會退出,新的 SW 也不會啟用。
- 這個方法是通過
skipWaiting
迫使 SW 新老交替。在交替完成後,通過controllerchange
監聽到變化再執行重新整理。
所以兩者的因果是相反的,並不矛盾。
避免無限重新整理
在使用 Chrome Dev Tools 的 Update on Reload 功能時,使用如上程式碼會引發無限的自我重新整理。為了彌補這一點,需要新增一個 flag 判斷一下,如下:
1 2 3 4 5 6 7 8 |
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 物件來監聽的,因此通常我們可以在註冊時直接監聽,避免後續還要再去獲取這個物件,徒增複雜。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
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 的通訊程式碼:
1 2 3 4 5 6 7 |
try { navigator.serviceWorker.getRegistration().then(reg => { reg.waiting.postMessage('skipWaiting'); }); } catch (e) { window.location.reload(); } |
注意通過 reg.waiting
向 等待中的 SW 發訊息,而不是向當前的老的 SW 發訊息。而 SW 部分則負責接收訊息,並執行“插隊”邏輯。
1 2 3 4 5 6 7 |
// 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 內部操作。
弊端二:必須通過 JS 完成更新
這裡指的是 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 中仍有相容性問題