Service Worker 生命週期

Jake Archibald
Jake Archibald

Service Worker 的生命週期是最複雜的部分。如果不知道目標達成的目標和好處,可能會讓大家感覺猶豫不決。不過,一旦瞭解這項服務的運作方式,即可混用網頁和原生模式,為使用者提供順暢無礙的更新。

這部分的內容詳細介紹,但每個章節開頭的條目已涵蓋大部分需瞭解的資訊。

意圖

生命週期的用意是:

  • 啟用離線優先模式
  • 允許新的 Service Worker 自行做好準備,而不會中斷目前運作。
  • 確保範圍內頁面是由相同的 Service Worker (或沒有 Service Worker) 控管。
  • 請確保您的網站同時只有一個版本運行。

最後一點很重要。沒有服務工作人員的話,使用者可以載入一個分頁到您的網站上,稍後再開啟另一個分頁。這可能會導致您的網站同時執行兩個版本。有時候沒關係,但是處理儲存空間時,很容易有兩個分頁,對於共用儲存空間的管理方式截然不同。這可能會造成錯誤,或更嚴重地遺失資料。

第一位 Service Worker

簡單來說:

  • install 事件是 Service Worker 收到的第一個事件,且只會發生一次。
  • 傳遞至 installEvent.waitUntil() 的承諾代表安裝作業持續時間,以及安裝成功或失敗。
  • Service Worker 要等到成功安裝完畢並成為「使用中」後,才會收到 fetchpush 等事件。
  • 根據預設,除非網頁要求是透過 Service Worker 進行,否則網頁的擷取作業不會透過 Service Worker 進行。因此您必須重新整理頁面,才能查看 Service Worker 的效果。
  • clients.claim() 可以覆寫這項預設值,並控管非受控制的網頁。

取得這個 HTML:

<!DOCTYPE html>
An image will appear here in 3 seconds:
<script>
  navigator.serviceWorker.register('/sw.js')
    .then(reg => console.log('SW registered!', reg))
    .catch(err => console.log('Boo!', err));

  setTimeout(() => {
    const img = new Image();
    img.src = '/dog.svg';
    document.body.appendChild(img);
  }, 3000);
</script>

此應用程式會註冊 Service Worker,並在 3 秒後新增狗的圖片。

以下是其 Service Worker sw.js

self.addEventListener('install', event => {
  console.log('V1 installing…');

  // cache a cat SVG
  event.waitUntil(
    caches.open('static-v1').then(cache => cache.add('/cat.svg'))
  );
});

self.addEventListener('activate', event => {
  console.log('V1 now ready to handle fetches!');
});

self.addEventListener('fetch', event => {
  const url = new URL(event.request.url);

  // serve the cat SVG from the cache if the request is
  // same-origin and the path is '/dog.svg'
  if (url.origin == location.origin && url.pathname == '/dog.svg') {
    event.respondWith(caches.match('/cat.svg'));
  }
});

它會快取貓的圖片,並在每次發出 /dog.svg 要求時提供。不過,如果您執行上述範例,系統會在您初次載入網頁時顯示狗。按下重新整理後,就會看到貓咪。

範圍和控管機制

Service Worker 登錄的預設範圍是 ./,相對於指令碼網址。也就是說,您在 //example.com/foo/bar.js 註冊 Service Worker 的預設範圍是 //example.com/foo/

我們呼叫了工作頁面、工作人員和共用員工clients。您的服務工作處理程序只能控管範圍內的用戶端。一旦用戶端「受到控管」,其擷取作業就會執行範圍內的 Service Worker。您可以偵測用戶端是否透過 navigator.serviceWorker.controller 控管,會顯示空值或 Service Worker 執行個體。

下載、剖析及執行

呼叫 .register() 時,系統會下載第一個 Service Worker。如果指令碼無法在初次執行時下載、剖析或擲回錯誤,則註冊保證會拒絕,而 Service Worker 會遭到捨棄。

Chrome 開發人員工具會在控制台和應用程式分頁的 Service Worker 部分顯示錯誤:

Service Worker 開發人員工具分頁顯示的錯誤

安裝

Service Worker 收到的第一個事件是 install。這會在 worker 執行後立即觸發,而且每個 Service Worker 只會呼叫一次。如果您修改了 Service Worker 指令碼,瀏覽器會將這個指令碼視為不同的 Service Worker,且會收到自己的 install 事件。我們會在稍後詳細說明

install 事件可讓您先快取所有必要內容,再控制用戶端。瀏覽器會把您傳遞至 event.waitUntil() 的承諾,告知瀏覽器何時完成安裝以及安裝是否成功。

如果承諾拒絕,表示安裝失敗,瀏覽器也會捨棄 Service Worker。用戶端完全無法控制用戶端。也就是說,我們可以依賴 fetch 事件中快取中的 cat.svg。也就是依附元件

啟用

當 Service Worker 準備好控制用戶端及處理功能事件 (例如 pushsync) 後,您會收到 activate 事件。但這不代表系統會控管名為 .register() 的網頁。

第一次載入示範時,即使 Service Worker 啟用後很長一段時間就要求了 dog.svg,但該要求將不會處理要求,而且仍會看到犬隻的圖片。預設值為在沒有 Service Worker 的情況下載入網頁,這不包含子資源。如果您第二次載入示範影片 (也就是重新整理頁面),即可受到控制。網頁和圖片都會透過 fetch 事件,而會改為顯示貓。

clients.claim

啟用後,您可以在 Service Worker 中呼叫 clients.claim(),控制未控管的用戶端。

以下上述示範的變化版本會在 activate 事件中呼叫 clients.claim()。您在第一次看到貓咪。這屬於時效性,所以我說「應該」。只有在 Service Worker 已啟用,且 clients.claim() 在圖片嘗試載入前生效時,您才會看到貓咪。

如果使用 Service Worker 進行網頁載入方式與透過網路載入的方式不同,clients.claim() 可能會發生問題,因為服務工作處理程序會控制某些沒有該網路的用戶端載入。

更新 Service Worker

簡單來說:

  • 只要發生下列任一情況,系統就會觸發更新作業:
    • 前往範圍內頁面。
    • 功能事件,例如 pushsync (除非在過去 24 小時內有更新檢查)。
    • 「只有」在 Service Worker 網址有所變更時,才呼叫 .register()。不過,請避免變更作業程式網址
  • 大多數瀏覽器 (包括 Chrome 68 以上版本) 預設會在檢查已註冊 Service Worker 指令碼的更新時忽略快取標頭。透過 importScripts() 擷取 Service Worker 內載入的資源時,這類標頭仍會遵循快取標頭。註冊 Service Worker 時,您可以設定 updateViaCache 選項來覆寫這個預設行為。
  • 如果 Service Worker 與瀏覽器已經使用的位元組不同,就會視為已更新。(我們也將此功能延伸到包含匯入的指令碼/模組。)
  • 更新後的 Service Worker 會隨著現有項目啟動,並取得本身的 install 事件。
  • 如果新工作站的狀態碼 (例如 404) 無法剖析、在執行期間擲回錯誤,或在安裝期間遭拒,則新的工作站會遭到捨棄,但目前的工作站仍會保持運作。
  • 安裝成功後,更新過的工作站將會wait,直到現有工作站可以控制零用戶端為止。(請注意,用戶端在重新整理時會重疊)。
  • self.skipWaiting() 可避免等候,這意味著 Service Worker 會在安裝完成後立即啟動。

假設我們變更了 Service Worker 指令碼,以便回應馬匹而非貓的圖片:

const expectedCaches = ['static-v2'];

self.addEventListener('install', event => {
  console.log('V2 installing…');

  // cache a horse SVG into a new cache, static-v2
  event.waitUntil(
    caches.open('static-v2').then(cache => cache.add('/horse.svg'))
  );
});

self.addEventListener('activate', event => {
  // delete any caches that aren't in expectedCaches
  // which will get rid of static-v1
  event.waitUntil(
    caches.keys().then(keys => Promise.all(
      keys.map(key => {
        if (!expectedCaches.includes(key)) {
          return caches.delete(key);
        }
      })
    )).then(() => {
      console.log('V2 now ready to handle fetches!');
    })
  );
});

self.addEventListener('fetch', event => {
  const url = new URL(event.request.url);

  // serve the horse SVG from the cache if the request is
  // same-origin and the path is '/dog.svg'
  if (url.origin == location.origin && url.pathname == '/dog.svg') {
    event.respondWith(caches.match('/horse.svg'));
  }
});

查看上述示範。您應該仍會看到貓咪的圖片。原因如下...

安裝

請注意,我已經將快取名稱從 static-v1 變更為 static-v2。換句話說,我可以設定新的快取,而不必覆寫仍在使用中的舊版 Service Worker 的資料。

這個模式會建立特定版本的快取,類似於原生應用程式會與其可執行檔封裝的資產。此外,也可能有不是版本專屬的快取,例如 avatars

等待中

安裝成功後,更新的 Service Worker 會延遲啟用,直到現有 Service Worker 不再控制用戶端為止。此狀態稱為「等待中」,而是瀏覽器確保一次只有一個版本的 Service Worker。

如果您已執行新版示範,應該仍會看到貓咪圖片,因為 V2 工作站尚未啟用。您會在「Application」(應用程式) 中看到新的 Service Worker開發人員工具分頁:

開發人員工具顯示新的 Service Worker 正在等待

即使您只有一個分頁開啟示範,重新整理頁面還不足以讓新版本接手。原因在於瀏覽器導覽功能的運作方式。瀏覽網路時,系統會等到收到回應標頭後,才關閉目前的網頁;即使回應含有 Content-Disposition 標頭,目前的網頁也可能會停留。由於這種重疊情況,目前的 Service Worker 一律會在重新整理期間控制用戶端。

如要取得更新,請使用目前的 Service Worker 關閉或離開所有分頁。這樣一來,當您再次前往示範頁面時,應該會看到馬。

這個模式與 Chrome 更新的方式類似。在背景中下載 Chrome 的更新,但 Chrome 重新啟動後才會生效。與此同時,您可以繼續使用現行版本,服務不會中斷。不過,這在開發過程中是個痛點,但開發人員工具備有簡化作業的方式,詳情請參閱本文後續說明

啟用

當舊的 Service Worker 消失時,就會觸發,新 Service Worker 可以控制用戶端。舊版工作站仍在使用中,因此您無法執行這些作業,例如遷移資料庫及清除快取資料。

在上述示範中,我保留了預期存在的快取清單,並在 activate 事件中刪除任何其他快取,從而移除舊的 static-v1 快取。

如果將承諾傳遞至 event.waitUntil(),它會緩衝功能事件 (fetchpushsync 等),直到承諾解決為止。因此,當 fetch 事件觸發時,整個啟用程序就完成了。

略過等候階段

等候階段表示您一次只執行一個版本的網站,但是如果您目前不需要該功能,可以呼叫 self.skipWaiting() 讓新 Service Worker 更快啟用。

這會導致服務工作處理程序啟動目前的作用中工作站,並在進入等待階段時立即啟動 (或者如果已在等待階段中,則會立即啟動)。它「不會」造成工作站略過安裝,只是等著您。

呼叫 skipWaiting() 並不重要,只要在等待期間或之前結束即可。在 install 事件中呼叫此函式很常見:

self.addEventListener('install', event => {
  self.skipWaiting();

  event.waitUntil(
    // caching etc
  );
});

但建議您將其呼叫為 Service Worker 的 postMessage() 結果。同理,您希望在追蹤使用者互動時skipWaiting()

以下是使用 skipWaiting() 的示範內容。正常情況下,螢幕上會顯示牛隻的相片,而不會離開頁面。就像 clients.claim() 本身一樣賽跑,所以只有在新 Service Worker 要擷取、安裝及啟動,才在頁面嘗試載入圖片時,才會看到牛仔。

手動更新

如前所述,瀏覽器會在導覽和功能事件後自動檢查是否有更新,但您也可以手動觸發更新:

navigator.serviceWorker.register('/sw.js').then(reg => {
  // sometime later…
  reg.update();
});

如果您預期使用者會長時間使用您的網站,而不需重新載入,則可以每隔一段時間 (例如每小時) 呼叫 update()

避免變更 Service Worker 指令碼的網址

閱讀關於快取最佳做法的貼文後,建議您為各個服務工作處理程序提供一個專屬網址。別這樣!對服務工作處理程序而言,這通常是不當做法,只需更新目前位置的指令碼即可。

進而得出以下問題:

  1. index.html 會將 sw-v1.js 註冊為 Service Worker。
  2. sw-v1.js 會快取並提供 index.html,使其在離線優先運作。
  3. 更新 index.html 即可註冊閃亮的全新 sw-v2.js

如果您執行上述操作,使用者就無法取得 sw-v2.js,因為 sw-v1.js 正從快取提供舊版 index.html。您已經設好某個位置,必須更新服務工作處理程序,才能更新服務工作人員。呃,

不過,針對上述示範,我「曾」變更了 Service Worker 的網址。因此,為了方便示範,您可以在不同版本之間切換。這不是我在實際工作環境中做的事。

簡化開發作業

Service Worker 生命週期是基於使用者考量而建立,但在開發期間可能會有點痛。幸好有幾項工具可以派上用場:

重新載入時更新

這是我收藏的相片

開發人員工具顯示「重新載入時更新」

這會將生命週期變更為適合開發人員使用。每次導覽都會:

  1. 重新擷取 Service Worker。
  2. 即使資料與位元組完全相同,也請將其安裝為新版本,也就是說,系統會執行 install 事件並更新快取。
  3. 請略過等候階段,新的 Service Worker 就會啟用。
  4. 瀏覽頁面。

也就是說,你每次進行導覽時都會收到更新內容 (包括重新整理),而不用重新載入兩次或關閉分頁。

略過等候

開發人員工具顯示「略過等待」畫面

如果有工作人員正在等候,您可以點選「略過等待時間」,立即將其升級為「啟用」。

Shift-重新載入

如果您強制重新載入頁面 (shift 重新載入),將完全略過 Service Worker。以免裝置無法控制這項功能與規格相容,因此可以在其他支援工作人員的瀏覽器中運作。

處理更新

Service Worker 是擴充式網路的一部分。概念就是我們身為瀏覽器開發人員,明白 Google 不擅長網頁程式開發,因此,我們不應提供範圍狹窄的 API 來使用「我們」等模式來解決特定問題,而是讓您取用瀏覽器的指標,以最適合「您的」使用者的方式執行操作。

因此,為了盡可能啟用各種模式,系統將可觀察整個更新週期:

navigator.serviceWorker.register('/sw.js').then(reg => {
  reg.installing; // the installing worker, or undefined
  reg.waiting; // the waiting worker, or undefined
  reg.active; // the active worker, or undefined

  reg.addEventListener('updatefound', () => {
    // A wild service worker has appeared in reg.installing!
    const newWorker = reg.installing;

    newWorker.state;
    // "installing" - the install event has fired, but not yet complete
    // "installed"  - install complete
    // "activating" - the activate event has fired, but not yet complete
    // "activated"  - fully active
    // "redundant"  - discarded. Either failed install, or it's been
    //                replaced by a newer version

    newWorker.addEventListener('statechange', () => {
      // newWorker.state has changed
    });
  });
});

navigator.serviceWorker.addEventListener('controllerchange', () => {
  // This fires when the service worker controlling this page
  // changes, eg a new worker has skipped waiting and become
  // the new active worker.
});

生命週期永遠是

如您所見,瞭解 Service Worker 的生命週期會很值得。因此,瞭解 Service Worker 的行為看起來應該較為邏輯,較少令人費解。這些知識可讓您在部署及更新服務工作時更有信心。