Service Worker 生命週期

Jake Archibald
Jake Archibald

服務工作者的生命週期是其最複雜的部分。如果您不清楚系統的運作方式和優點,可能會覺得系統在與您對抗。但只要瞭解這項功能的運作方式,您就能為使用者提供流暢且不顯眼的更新,並結合網頁和原生模式的優點。

這篇文章會深入探討這個主題,但每個部分開頭的項目會涵蓋您需要瞭解的大部分內容。

意圖

生命週期的用意如下:

  • 盡可能以離線為優先。
  • 允許新服務工作站在不中斷目前服務工作站的情況下,自行準備就緒。
  • 確保範圍內的網頁在整個過程中都由同一個 Service Worker (或沒有 Service Worker) 控管。
  • 請確認一次只執行一個網站版本。

最後一個步驟相當重要。如果沒有服務工作者,使用者可以將一個分頁載入至您的網站,然後稍後再開啟另一個分頁。這可能會導致網站的兩個版本同時執行。有時這沒問題,但如果您處理的是儲存空間,兩個分頁很可能會對共用儲存空間的管理方式有截然不同的看法。這可能會導致錯誤,甚至資料遺失。

第一個服務工作者

簡單來說:

  • install 事件是服務工作站收到的第一個事件,且只會發生一次。
  • 傳遞至 installEvent.waitUntil() 的承諾會指出安裝作業的時間長度和成功/失敗狀態。
  • 服務工作程式必須成功完成安裝並變成「有效」狀態,才能接收 fetchpush 等事件。
  • 根據預設,除非網頁要求本身經過 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>

它會註冊服務工作者,並在 3 秒後新增狗的圖片。

以下是其服務工作者 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 要求時提供該圖片。不過,如果您執行上述範例,首次載入網頁時就會看到狗。按下重新整理,就會看到貓咪。

範圍和控制

服務工作者註冊的預設範圍是相對於指令碼網址的 ./。也就是說,如果您在 //example.com/foo/bar.js 註冊服務工作者,其預設範圍為 //example.com/foo/

我們將網頁、worker 和共用 worker 稱為 clients。您的服務工作者只能控制範圍內的用戶端。一旦用戶端「受控」,其擷取作業就會透過範圍內的服務工作站進行。您可以偵測用戶端是否透過 navigator.serviceWorker.controller 進行控制,該值會為空值或服務工作者例項。

下載、剖析及執行

您第一次呼叫 .register() 時,系統會下載第一個服務工作者。如果您的指令碼無法下載、剖析或在初始執行時擲回錯誤,註冊應許條件就會遭到拒絕,服務工作者也會遭到捨棄。

Chrome 開發人員工具會在主控台和應用程式分頁的服務工作站部分顯示錯誤:

服務工作者開發人員工具分頁中顯示的錯誤

安裝

服務工作者收到的第一個事件是 install。這個事件會在 worker 執行時觸發,且每個 service worker 只會呼叫一次。如果您變更服務工作者指令碼,瀏覽器會將其視為不同的服務工作者,並取得自己的 install 事件。我稍後會詳細說明更新內容

在控制用戶端之前,您可以利用 install 事件快取所需的所有內容。您傳遞至 event.waitUntil() 的承諾會讓瀏覽器知道安裝作業何時完成,以及是否成功。

如果承諾遭到拒絕,表示安裝失敗,且瀏覽器會丟棄服務工作者。絕不會控制用戶端。也就是說,我們可以依賴 cat.svgfetch 事件的快取中出現。這是依附元件。

啟用

當服務工作者準備好控制用戶端,並處理 pushsync 等功能事件時,您就會收到 activate 事件。但這並不表示會控制呼叫 .register() 的網頁。

第一次載入示範時,即使在服務工作者啟用後很久才要求 dog.svg,系統也不會處理要求,您仍會看到狗的圖片。預設值為「一致性」,如果網頁在沒有 Service Worker 的情況下載入,其子資源也不會載入。如果您第二次載入示範 (也就是重新整理頁面),系統就會控管該示範。網頁和圖片都會經過 fetch 事件,您會看到貓咪圖片。

clients.claim

您可以在服務工作者啟用後,在服務工作者中呼叫 clients.claim(),藉此控制未受控的用戶端。

以下是上述示範的變化版本,會在 activate 事件中呼叫 clients.claim()。您應該會在第一次執行時看到貓咪。我說「應該」是因為這與時間有關。只有在服務工作者啟用且 clients.claim() 在圖片嘗試載入前生效時,您才會看到貓咪。

如果您使用服務工作者載入網頁的方式與透過網路載入的方式不同,clients.claim() 可能會造成問題,因為服務工作者最終會控制某些未經由服務工作者載入的用戶端。

更新服務工作站

簡單來說:

  • 發生下列任一情況時,系統就會觸發更新:
    • 前往範圍內頁面的導覽。
    • 功能事件 (例如 pushsync),除非在過去 24 小時內已進行更新檢查。
    • 只有在服務工作者網址變更時,才呼叫 .register()不過,請避免變更 worker 網址
  • 大部分的瀏覽器 (包括 Chrome 68 以上版本) 預設會在檢查已註冊的服務工作者指令碼更新時,忽略快取標頭。透過 importScripts() 擷取 Service Worker 中載入的資源時,仍會遵循快取標頭。您可以在註冊服務工作者時設定 updateViaCache 選項,藉此覆寫這個預設行為。
  • 如果服務工作者與瀏覽器已有的服務工作者有位元組差異,系統就會視為已更新。(我們也將這項功能擴大至匯入的指令碼/模組)。
  • 更新後的服務工作站會與現有工作站一併啟動,並取得自己的 install 事件。
  • 如果新 worker 的狀態碼非正常狀態 (例如 404)、無法剖析、執行期間擲回錯誤,或在安裝期間遭到拒絕,系統會捨棄新 worker,但目前的 worker 仍會保持運作。
  • 成功安裝後,更新後的工作站會 wait,直到現有工作站控制零個用戶端為止。(請注意,客戶端會在重新整理期間重疊)。
  • self.skipWaiting() 會避免等待,也就是說服務工作者會在安裝完成後立即啟用。

假設我們變更服務工作者指令碼,以馬的圖片而非貓咪圖片回應:

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。這表示我可以設定新的快取,而不必覆寫舊服務工作者仍在使用的快取內容。

這個模式會建立特定版本的快取,類似於原生應用程式會與其可執行檔一起包裝的資產。您也可能有非特定版本的快取,例如 avatars

等待中

成功安裝後,更新的服務工作者會延遲啟用,直到現有的服務工作者不再控制用戶端為止。這個狀態稱為「等待」,可確保瀏覽器一次只執行一個服務工作程式版本。

如果您執行更新後的示範內容,應該還是會看到貓咪圖片,因為 V2 工作者尚未啟用。您可以在「DevTools」的「Application」分頁中,看到新的服務工作者正在等待:

開發人員工具顯示新服務 worker 正在等待

即使您只開啟一個分頁來進行示範,重新整理頁面也無法讓新版本生效。這是因為瀏覽器導覽的運作方式。在您導覽時,系統會在收到回應標頭後才關閉目前的網頁,而且如果回應包含 Content-Disposition 標頭,目前的網頁可能會保留。由於這兩者重疊,因此在重新整理期間,目前的 Service Worker 一律會控制用戶端。

如要取得更新,請關閉或離開所有使用目前服務工作站的分頁。接著,當您再次前往示範畫面時,應該會看到馬。

這與 Chrome 的更新方式類似。Chrome 更新會在背景下載,但必須重新啟動 Chrome 才能套用。在此期間,您可以繼續使用目前的版本,不會受到任何干擾。不過,這在開發過程中會造成不便,但 DevTools 有方法可以簡化這項作業,我會在本文後半段說明。

啟用

舊服務工作者消失後,這項事件就會觸發,而新服務工作者就能控制用戶端。這時您可以執行舊 worker 仍在使用時無法執行的作業,例如遷移資料庫和清除快取。

在上方的示範中,我會維護預期會出現的快取清單,並在 activate 事件中移除其他快取,藉此移除舊的 static-v1 快取。

如果您將應許承諾傳遞至 event.waitUntil(),系統會緩衝功能事件 (fetchpushsync 等),直到應許承諾解析為止。因此,當 fetch 事件觸發時,啟用程序就會完全完成。

略過等待階段

等待階段表示您一次只執行一個網站版本,但如果您不需要這項功能,可以呼叫 self.skipWaiting(),讓新的服務工作者更快啟用。

這會導致服務工作站將目前的活動工作站踢出,並在進入等待階段時 (或如果已處於等待階段則立即) 啟用自身。這不會導致 worker 略過安裝作業,而是等待安裝作業。

只要在等待期間或等待前呼叫 skipWaiting(),就不會有太大影響。通常會在 install 事件中呼叫此方法:

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

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

但您可能會將其視為 postMessage() 對服務工作站的結果來呼叫。也就是說,您想在使用者互動後 skipWaiting()

以下是使用 skipWaiting() 的示範。您應該會看到一張牛的圖片,而不需要離開畫面。就像 clients.claim() 一樣,這也是一場競賽,因此只有在新的服務工作者擷取、安裝及啟用圖片前,您才會看到牛隻。

手動更新

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

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

如果您希望使用者長時間使用您的網站,而無須重新載入,建議您以間隔 (例如每小時) 呼叫 update()

避免變更服務工作者指令碼的網址

如果您已閱讀我的文章,瞭解快取最佳做法,建議您為服務工作單元的每個版本指定專屬網址。請勿這麼做!這通常是服務工作程的不當做法,請直接在現有位置更新指令碼。

這可能會導致以下問題:

  1. index.html 會將 sw-v1.js 註冊為服務工作者。
  2. sw-v1.js 會快取並提供 index.html,因此可支援離線優先模式。
  3. 您更新 index.html,讓它註冊全新的 sw-v2.js

如果您執行上述操作,使用者將永遠無法取得 sw-v2.js,因為 sw-v1.js 會從快取中提供舊版 index.html。您必須更新服務工作站,才能更新服務工作站。噁心。

不過,針對上述示範,我變更服務工作者的網址。因此,為了方便您進行示範,您可以切換版本。這不是我在正式環境中會做的事。

簡化開發作業

Service Worker 生命週期在建構時會考量使用者需求,但在開發期間會造成一些不便。所幸,我們有幾項工具可以派上用場:

重新載入時更新

這是我最喜歡的。

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

這麼做可讓生命週期更符合開發人員的需求。每個導覽都會:

  1. 重新擷取服務工作站。
  2. 即使是位元組相同的檔案,也請將其設為新版本安裝,這表示 install 事件會執行,快取也會更新。
  3. 略過等待階段,讓新的服務工作者啟用。
  4. 瀏覽頁面。

也就是說,您可以在每次導覽 (包括重新整理) 時取得更新,無須重新載入兩次或關閉分頁。

略過等待時間

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

如果有等待中的工作站,您可以在 DevTools 中按一下「skip waiting」,立即將其提升為「active」。

Shift 重新載入

如果您強制重新載入網頁 (Shift + 重新載入),系統會完全略過服務工作者。這項功能將無法控制。這項功能已納入規格,因此可在其他支援服務工作元的瀏覽器中運作。

處理更新

服務工作站是可擴充的網頁的一部分。我們的想法是,身為瀏覽器開發人員,我們承認自己在網路開發方面不如網路開發人員。因此,我們不應提供狹隘的高階 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.
});

生命週期會持續進行

如您所見,瞭解服務工作者生命週期是值得的。有了這項知識,服務工作者的行為應該會顯得更有邏輯性,而非神秘莫測。這項知識可讓您在部署及更新服務工作者時更有信心。