謹慎處理 Service Worker 的更新

發表於2018-12-06

謹慎處理 Service Worker 的更新

Service Worker 以其 非同步安裝 和 持續執行 兩個特點,決定了針對它的更新操作必須非常謹慎小心。因為它具有攔截並處理網路請求的能力,因此必須做到網頁(主要是發出去的請求)和 Service Worker 版本一致才行,否則就會導致新版本的 Service Worker 處理舊版本的網頁,或者一個網頁先後由兩個版本的 Service Worker 控制引發種種問題。

經過近 2 年的發展,PWA 在 WEB 圈的知名度已經大大提升,即便你沒用過可能也至少聽說過。Service Worker (以下簡稱 SW)是 PWA 中最複雜最核心的部分,其中涉及的主要有 Caches API (caches.putcaches.addAll 等), Service Worker API (self.addEventListenerself.skipWaiting 等) 和 Registration API (reg.installingreg.onupdatefound 等)。

本文不再科普 SW 的基礎,我主要想在這裡談一談 SW 的更新問題。需要做到 SW 和頁面的完全同步,其實並不容易。在此之前,我假設你已經瞭解了:

  1. SW 的作用
  2. SW 的註冊方式 (navigator.serviceWorker.register)
  3. SW 的生命週期 (install -> waiting -> activate -> fetch)

組織 SW 的兩大禁忌

在開始正式談論 SW 的更新機制之前,我們有必要先確定組織 SW 時的兩個禁忌。在將 SW 應用到自己的站點時,我們要避開這兩種方法,他們是:

不要給 service-worker.js 設定不同的名字

一般針對靜態檔案,時下流行的做法是在每次構建時根據內容(或者當時的時間等隨機因素)給它們一個唯一的命名,例如 index.[hash].js。因為這些檔案不常修改,再配以長時間的強制快取,能夠大大降低訪問它們的耗時。

可惜針對 SW,這種做法並不合適。我們假設一個專案

  1. 首頁 index.html,底下包含了一段 <script> 用於註冊 service-worker.v1.js
  2. 為了提升速度或者離線可用,這個 service-worker.v1.js 會把 index.html 快取起來。
  3. 某次升級更新之後,現在 index.html 需要配上 service-worker.v2.js 使用了,所以原始碼中底下的 <script> 中修改了註冊的地址。
  4. 但我們發現,使用者訪問站點時由於舊版 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,但狀態不同,如下圖:謹慎處理 Service Worker 的更新

  • 如果老的 SW 控制的所有頁面 全部關閉,則老的 SW 結束執行,轉而啟用新的 SW(執行 activated 階段),使之接管頁面。

這是一種比較溫和和安全的做法,相當於新舊版本的自然淘汰。但畢竟關閉所有頁面是使用者的選擇而不是程式設計師能控制的。另外我們還需注意一點:由於瀏覽器的內部實現原理,當頁面切換或者自身重新整理時,瀏覽器是等到新的頁面完成渲染之後再銷燬舊的頁面。這表示新舊兩個頁面中間有共同存在的交叉時間,因此簡單的切換頁面或者重新整理是不能使得 SW 進行更新的,老的 SW 依然接管頁面,新的 SW 依然在等待。(這點也要求我們在檢測 SW 更新時,除了 onupdatefound 之外,還需要判斷是否存在處在等待狀態的 SW,即 reg.waiting 是否存在。不過這在本文討論範圍之外,就不展開了)

假設我們提供了一次重大升級,希望新的 SW 儘快接管頁面,應該怎麼做呢?

方法一:skipWaiting

在遭遇突發情況時,很容易想到通過“插隊”的方式來解決問題,現實生活中的救護車消防車等特種車輛就採用了這種方案。SW 也給程式設計師提供了實現這種方案的可能性,那就是在 SW 內部的 self.skipWaiting() 方法。

這樣可以讓新的 SW “插隊”,強制令它立刻取代老的 SW 控制所有頁面,而老的 SW 被“斬立決”,簡單粗暴。Lavas 最初就使用了這個方案,因為實在是太容易想到也太容易實現了,誘惑極大。

可惜這個方案是有隱患的。我們想象如下場景:

  1. 一個頁面 index.html 已安裝了 sw.v1.js (實際地址都是 sw.js,只是為了明顯區分如此表達而已)
  2. 使用者開啟這個頁面,所有網路請求都通過了 sw.v1.js,頁面載入完成。
  3. 因為 SW 非同步安裝的特性,一般在瀏覽器空閒時,他會去執行那句 navigator.serviceWorker.register。這時候瀏覽器發現了有個 sw.v2.js 存在,於是安裝並讓他等待。
  4. 但因為 sw.v2.js 在 install 階段有 self.skipWaiting(),所以瀏覽器強制退休了 sw.v1,而是讓 sw.v2 馬上啟用並控制頁面。
  5. 使用者在這個 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 是否發生了變化,如下:

當發現控制自己的 SW 已經發生了變化,那就重新整理自己,讓自己從頭到尾都被新的 SW 控制,就一定能保證資料的一致性。道理是對,但突然的更新會打斷使用者的操作,可能會引發不適。重新整理的源頭在於 SW 的變更;SW 的變更又來源於瀏覽器安裝新的 SW 碰上了 skipWaiting,所以這次重新整理絕大部分情況會發生在載入頁面後的幾秒內。使用者剛開始瀏覽內容或者填寫資訊就遇上了莫名的重新整理,可能會砸鍵盤。

另外這裡還有兩個注意點:

SW 的更新和頁面的重新整理

在講到 SW 的 waiting 狀態時,我曾經說過 簡單的切換頁面或者重新整理是不能使得 SW 進行更新的,而這裡又一次牽涉到了 SW 的更新和頁面的重新整理,不免產生混淆。

我們簡單理一下邏輯,其實也不復雜:

  1. 重新整理不能使得 SW 發生更新,即老的 SW 不會退出,新的 SW 也不會啟用。
  2. 這個方法是通過 skipWaiting 迫使 SW 新老交替。在交替完成後,通過 controllerchange監聽到變化再執行重新整理。

所以兩者的因果是相反的,並不矛盾。

避免無限重新整理

在使用 Chrome Dev Tools 的 Update on Reload 功能時,使用如上程式碼會引發無限的自我重新整理。為了彌補這一點,需要新增一個 flag 判斷一下,如下:

方法三:給使用者一個提示

方法二有一個思路值得借鑑,即“通過 SW 的變化觸發事件,而在事件監聽中執行重新整理”。但毫無徵兆的重新整理頁面的確不可接受,所以我們再改進一下,給使用者一個提示,讓他來點選後更新 SW,並引發重新整理,豈不美哉?

大致的流程是:

  1. 瀏覽器檢測到存在新的(不同的)SW 時,安裝並讓它等待,同時觸發 updatefound 事件
  2. 我們監聽事件,彈出一個提示條,詢問使用者是不是要更新 SW
    謹慎處理 Service Worker 的更新
  3. 如果使用者確認,則向處在等待的 SW 傳送訊息,要求其執行 skipWaiting 並取得控制權
  4. 因為 SW 的變化觸發 controllerchange 事件,我們在這個事件的回撥中重新整理頁面即可

這裡值得注意的是第 3 步。因為使用者點選的響應程式碼是位於普通的 JS 程式碼中,而 skipWaiting的呼叫位於 SW 的程式碼中,因此這兩者還需要一次 postMessage 進行通訊。

程式碼方面,我們以 Lavas 的實現來分步驟看一下:

第 1 步是瀏覽器執行的,與我們無關。第 2 步需要我們監聽這個 updatefound 事件,這是需要通過註冊 SW 時返回的 Registration 物件來監聽的,因此通常我們可以在註冊時直接監聽,避免後續還要再去獲取這個物件,徒增複雜。

這裡我們通過傳送一個事件 (名為 sw.update,位於 emitUpdate() 方法內) 來通知外部,這是因為提示條是一個單獨的元件,不方便在這裡直接展現。當然如果你的應用有不同的結構,也可以自行修改。總之想辦法展示提示條,或者單純使用 confirm 讓使用者確認即可。

第 3 步需要處理使用者點選,並和 SW 進行通訊。處理點選的程式碼比較簡單,就不重複了,這裡主要列出和 SW 的通訊程式碼:

注意通過 reg.waiting 向 等待中的 SW 發訊息,而不是向當前的老的 SW 發訊息。而 SW 部分則負責接收訊息,並執行“插隊”邏輯。

第 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 了。

參考文章

相關文章