Service Worker 生命週期

Jake Archibald
Jake Archibald

Service Worker 的生命週期是最複雜的部分。如果你不瞭解這項功能的作用和好處,可能會覺得它在跟你作對。不過,只要瞭解運作方式,就能為使用者提供流暢、不干擾的更新,結合網頁和原生模式的最佳做法。

這份指南會進行深度對談,但每個章節開頭的項目符號清單已涵蓋大部分重要資訊。

意圖

生命週期的目的如下:

  • 實現離線優先。
  • 允許新的 Service Worker 自行準備,不中斷目前的 Service Worker。
  • 確保範圍內的網頁一律由同一個 Service Worker 控制 (或沒有 Service Worker)。
  • 確保一次只執行一個版本的網站。

最後一點相當重要。如果沒有 Service Worker,使用者可以先載入一個網站分頁,稍後再開啟另一個分頁。這可能導致兩個版本的網站同時運作。有時這樣做沒問題,但如果涉及儲存空間,兩個分頁可能對共用儲存空間的管理方式有不同看法。這可能會導致錯誤,甚至資料遺失。

第一個 Service Worker

簡單來說:

  • install 事件是 Service Worker 收到的第一個事件,而且只會發生一次。
  • 傳遞至 installEvent.waitUntil() 的 Promise 會指出安裝作業的時間長度和成功或失敗。
  • Service Worker 必須成功完成安裝並進入「啟用」狀態,才會收到 fetch 和 push 等事件。
  • 根據預設,除非網頁要求本身是透過 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/。

我們將網頁、worker 和共用 worker 稱為clients。Service Worker 只能控制範圍內的用戶端。用戶端受到「控制」後,其擷取作業會透過範圍內的 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() 的 Promise 會讓瀏覽器知道安裝作業何時完成,以及是否成功。

如果 Promise 遭到拒絕,表示安裝失敗,瀏覽器會捨棄 Service Worker。不會控制用戶端。這表示我們可以依賴 fetch 事件中快取中的 cat.svg。這是一個依附元件。

啟用

當 Service Worker 準備好控制用戶端並處理 push 和 sync 等功能事件時,您會收到 activate 事件。但這不代表呼叫 .register() 的網頁會受到控制。

第一次載入示範時,即使 dog.svg 是在 Service Worker 啟用後很久才要求,也不會處理要求,您仍會看到狗的圖片。預設為「一致性」,如果網頁載入時沒有 Service Worker,子資源也不會有。如果再次載入試用版 (也就是重新整理頁面),就會受到控制。網頁和圖片都會經過 fetch 事件,您會看到貓咪。

clients.claim

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

上方示範的變化版本會在 activate 事件中呼叫 clients.claim()。第一次應該會看到貓。我說「應該」,是因為這與時機有關。只有在 Service Worker 啟用,且 clients.claim() 在圖片嘗試載入前生效時,才會看到貓咪。

如果您使用 Service Worker 載入網頁的方式與透過網路載入網頁的方式不同,clients.claim() 可能會造成問題,因為 Service Worker 最終會控制部分未載入的用戶端。

更新 Service Worker

簡單來說:

  • 如果發生下列任一情況,就會觸發更新:
    • 前往範圍內頁面的導覽。
    • push 和 sync 等功能事件,除非過去 24 小時內曾檢查更新。
    • 只有在 Service Worker 網址變更時,才呼叫 .register()。不過,請避免變更 worker 網址。
  • 大多數瀏覽器 (包括 Chrome 68 以上版本) 在檢查已註冊 Service Worker 指令碼的更新時,預設會忽略快取標頭。透過 importScripts() 擷取 Service Worker 內載入的資源時,仍會遵循快取標頭。註冊 Service Worker 時,您可以設定 updateViaCache 選項,覆寫這項預設行為。
  • 如果 Service Worker 的位元組與瀏覽器現有的不同,就會視為已更新。(我們也將擴大這項功能,納入匯入的指令碼/模組)。
  • 更新後的 Service Worker 會與現有的 Service Worker 一起啟動,並取得自己的 install 事件。
  • 如果新 worker 的狀態碼不是 ok (例如 404)、無法剖析、在執行期間擲回錯誤,或在安裝期間遭到拒絕,系統會捨棄新 worker,但目前的 worker 仍會保持啟用狀態。
  • 安裝成功後,更新後的 worker 會wait,直到現有 worker 控制的用戶端數量為零。(請注意,用戶端在重新整理期間會重疊)。
  • 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 worker 尚未啟用。您可以在開發人員工具的「Application」分頁中,看到新的 Service Worker 正在等待:

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

即使只開啟一個示範分頁,重新整理頁面也不足以讓新版本接管。這是因為瀏覽器導覽的運作方式。瀏覽時,系統收到回應標頭後,目前頁面才會消失。即使如此,如果回應含有 Content-Disposition 標頭,目前頁面仍可能保留。由於這項重疊,目前的 Service Worker 一律會在重新整理期間控制用戶端。

如要取得更新,請關閉或離開所有使用目前 Service Worker 的分頁。接著,當您再次前往試用版時,應該會看到馬。

這個模式與 Chrome 更新方式類似。Chrome 會在背景下載更新,但要等到重新啟動後才會套用。在此期間,您可以繼續使用目前的版本,不會受到干擾。不過,這在開發過程中會造成不便,但開發人員工具可簡化這項作業,我會在本文稍後說明。

啟用

舊的 Service Worker 消失後,新的 Service Worker 就能控制用戶端,並觸發這個事件。這段時間最適合執行舊 worker 仍在使用時無法執行的作業,例如遷移資料庫和清除快取。

在上述示範中,我維護了預期會存在的快取清單,並在 activate 事件中移除其他快取,進而移除舊的 static-v1 快取。

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

略過等待階段

等待階段代表您一次只能執行一個版本的網站,但如果您不需要這項功能,可以呼叫 self.skipWaiting(),讓新的 Service Worker 提早啟用。

這會導致 Service Worker 踢出目前有效的 Worker,並在進入等待階段時立即啟用 (如果已處於等待階段,則立即啟用)。不會導致 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 指令碼的網址

如果您已閱讀快取最佳做法,可能會考慮為每個版本的 Service Worker 提供專屬網址。錯誤做法這通常不適合 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。好噁。

不過,在上述示範中,我已變更 Service Worker 的網址。這樣一來,您就能在示範時切換版本。我不會在正式環境中這麼做。

輕鬆開發

Service Worker 生命週期是以使用者為考量,但在開發期間會有點麻煩。幸好,有幾項工具可以派上用場:

重新載入時更新

這是我最喜歡的。

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

這項異動可讓生命週期更符合開發人員需求。每次導航都會:

  1. 重新擷取 Service Worker。
  2. 即使位元組相同,也請安裝為新版本,這表示 install 事件會執行,快取也會更新。
  3. 略過等待階段,啟用新的 Service Worker。
  4. 瀏覽頁面。

這表示每次瀏覽 (包括重新整理) 時,您都會看到最新內容,不必重新載入兩次或關閉分頁。

略過等待

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

如果有 worker 正在等待,您可以在開發人員工具中點選「略過等待」,立即將其升級為「啟用中」。

Shift + 重新載入

如果強制重新載入頁面 (Shift + 重新載入),系統會完全略過 Service Worker。這會失控。這項功能已納入規格,因此可在其他支援 Service Worker 的瀏覽器中運作。

處理更新

Service Worker 是可擴充網頁的一部分。我們的想法是,身為瀏覽器開發人員,我們承認自己不比網頁程式開發人員更擅長網頁開發。因此,我們不應提供狹隘的高階 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 的行為應該會更合乎邏輯,也更不神秘。掌握這些知識後,您就能更有信心地部署及更新 Service Worker。