Service Worker 生命週期

Jake Archibald
Jake Archibald

服務工作者的生命週期是其最複雜的部分。如果您不知道系統嘗試執行什麼操作,以及帶來哪些好處,就會覺得系統在與您對抗。但只要瞭解這項功能的運作方式,您就能為使用者提供流暢且不顯眼的更新,並結合網頁和原生模式的優點。

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

意圖

生命週期的用意如下:

  • 啟用離線優先模式
  • 允許新服務工作站在無須中斷現有服務工作站的情況下,自行準備就緒。
  • 確保範圍內頁面是由相同的 Service Worker (或沒有 Service Worker) 控管。
  • 請確認一次只執行一個網站版本。

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

第一個服務工作者

簡單來說:

  • install 事件是 Service Worker 收到的第一個事件,且只會發生一次。
  • 傳遞至 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 要求時提供該圖片。不過,如果您執行上述範例,首次載入網頁時就會看到狗。按下重新整理,就會看到貓咪。

範圍和控制

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

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

下載、剖析及執行

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

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

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

安裝

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

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

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

啟用

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

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

clients.claim

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

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

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

更新服務工作站

簡單來說:

  • 發生下列任一情況時,系統就會觸發更新:
    • 前往範圍內頁面的導覽。
    • 功能事件 (例如 pushsync),除非在過去 24 小時內已進行更新檢查。
    • 只有在服務工作者網址變更時,才呼叫 .register()不過,請避免變更作業程式網址
  • 大部分的瀏覽器 (包括 Chrome 68 以上版本) 在檢查已註冊的服務工作者指令碼更新時,預設會忽略快取標頭。透過 importScripts() 擷取 Service Worker 中載入的資源時,仍會遵循快取標頭。您可以在註冊服務工作者時設定 updateViaCache 選項,藉此覆寫這個預設行為。
  • 如果 Service Worker 與瀏覽器已經使用的位元組不同,就會視為已更新。(我們也將此功能延伸到包含匯入的指令碼/模組。)
  • 更新後的服務工作站會與現有工作站一併啟動,並取得自己的 install 事件。
  • 如果新工作站的狀態碼 (例如 404) 無法剖析、在執行期間擲回錯誤,或在安裝期間遭拒,則新的工作站會遭到捨棄,但目前的工作站仍會保持運作。
  • 安裝成功後,更新過的工作站將會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

等待中

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

如果您執行更新後的示範內容,您仍會看到貓咪圖片,因為 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
  );
});

但建議您將其呼叫為 Service Worker 的 postMessage() 結果。也就是說,您想在使用者互動後 skipWaiting()

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

手動更新

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

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 生命週期在建構時會考量使用者需求,但在開發期間會造成一些不便。幸好有幾項工具可以派上用場:

重新載入時更新

這是我最喜歡的。

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

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

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

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

略過等待時間

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

如果有工作站正在等待,您可以在開發人員工具中按下「略過等待訊息」,立即將其升級為「啟用」。

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.
});

生命週期永遠是

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