謹慎處理 Service Worker 的更新

今天要聊的話題是前端最近的一個更新方向 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

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

組織 SW 的兩大禁忌

在開始正式談論 SW 的更新機制以前,咱們有必要先肯定組織 SW 時的兩個禁忌。在將 SW 應用到本身的站點時,咱們要避開這兩種方法,他們是:git

不要給 service-worker.js 設置不一樣的名字

通常針對靜態文件,時下流行的作法是在每次構建時根據內容(或者當時的時間等隨機因素)給它們一個惟一的命名,例如 index.[hash].js。由於這些文件不常修改,再配以長時間的強制緩存,可以大大下降訪問它們的耗時。程序員

惋惜針對 SW,這種作法並不合適。咱們假設一個項目github

  1. 首頁 index.html,底下包含了一段 <script> 用於註冊 service-worker.v1.jsweb

  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,但狀態不一樣,以下圖:

兩個 SW 同時存在

  • 若是老的 SW 控制的全部頁面 所有關閉,則老的 SW 結束運行,轉而激活新的 SW(執行 activated 階段),使之接管頁面。

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

假設咱們提供了一次重大升級,但願新的 SW 儘快接管頁面,應該怎麼作呢?

方法一:skipWaiting

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

self.addEventListener('install', event => {
  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.jsinstall 階段有 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 是否發生了變化,以下:

navigator.serviceWorker.addEventListener('controllerchange', () => {
  window.location.reload();
})
複製代碼

當發現控制本身的 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 判斷一下,以下:

let refreshing = false
navigator.serviceWorker.addEventListener('controllerchange', () => {
  if (refreshing) {
    return
  }
  refreshing = true;
  window.location.reload();
});
複製代碼

方法三:給用戶一個提示

方法二有一個思路值得借鑑,即「經過 SW 的變化觸發事件,而在事件監聽中執行刷新」。但毫無徵兆的刷新頁面的確不可接受,因此咱們再改進一下,給用戶一個提示,讓他來點擊後更新 SW,並引起刷新,豈不美哉?

大體的流程是:

  1. 瀏覽器檢測到存在新的(不一樣的)SW 時,安裝並讓它等待,同時觸發 updatefound 事件

  2. 咱們監聽事件,彈出一個提示條,詢問用戶是否是要更新 SW

提示條

  1. 若是用戶確認,則向處在等待的 SW 發送消息,要求其執行 skipWaiting 並取得控制權

  2. 由於 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 內部操做。

弊端二:必須經過 JS 完成更新

這裏指的是 SW 的更新只能經過用戶點擊通知條上的按鈕,使用 JS 來完成,而 不能經過瀏覽器的刷新按鈕完成。這實際上是瀏覽器的設計問題,而非方案自己的問題。

不過反過來講,若是瀏覽器幫助咱們完成了上述操做,那就變成容許經過一個 Tab 的刷新去強制其餘 Tab 刷新,在當前瀏覽器以 Tab 爲單位的前提下,存在這種交叉控制也是不安全和難以理解的。

惟一可行的優化是當 SW 控制的頁面僅存在一個 Tab 時,刷新這個 Tab 若是可以更新 SW,也能給咱們省去很多操做,也不會帶來交叉控制的問題。只是這樣可能加劇了瀏覽器的判斷成本,也喪失了操做一致性的美感,只能說這可能也是一個久遠的夢想了。

後記

SW 的功能至關強大,但同時涉及的 API 也相對較多,是一個須要投入至關學習成本的強力技術(國外文章稱之爲 rocket science)。SW 的更新對使用 SW 的站點來講很是重要,但如上所述,其方案也相對複雜,遠遠超過了其餘經常使用前端基礎技術的複雜度(例如 DOM API,JS 運算,閉包等等)。不過 SW 從其起步至今也不過兩三年的時間,尚處在發展期。相信經過 W3C 的不斷修正以及前端圈的持續使用,會有更加簡潔,更加自動,更加完備的方案出現,屆時咱們可能就能像使用 DOM API 那樣簡單地使用 SW 了。

參考文章

相關文章
相關標籤/搜索