在 Google 建構 PWA - 第 1 部分

公布欄團隊在開發 PWA 時,瞭解到的服務工作處理程序。

Douglas Parker
Douglas Parker
Joel Riley
Joel Riley
Dikla Cohen
Dikla Cohen

這是一系列的網誌文章,這是 Google 公布欄團隊建構對外 PWA 時,學到的經驗。在這些文章中,我們將分享面臨的部分挑戰、克服挑戰的做法,以及避免陷阱的一般建議。這絕非 PWA 的完整概略。目的是分享我們團隊親身體驗的經驗。

在第一篇貼文中,我們會先提供一些背景資訊,然後再深入研究我們所學到的服務工作處理程序。

背景

從 2017 年中到 2019 年中,我們積極開發了 Bulletin。

為何選擇建構 PWA

在深入探討開發程序之前,讓我們先來探討為何在這個專案中建構 PWA 是個不錯的選擇:

  • 快速疊代的能力:由於 Bulletin 將在多個市場試行,因此這項資訊特別有價值。
  • 單一程式碼集。我們的使用者大致上分為 Android 和 iOS 兩類。PWA 可讓我們建構同時在兩個平台上運作的單一網頁應用程式。這項做法提升了團隊的速度和影響力。
  • 快速更新,不受使用者行為影響。PWA 可自動更新,以減少流向過時用戶端的數量。我們能夠為客戶推送重大的後端變更,並在極短的時間內完成遷移。
  • 可輕鬆整合第一方和第三方應用程式。這類整合是應用程式的要求。對於 PWA 來說,這通常意味著只要開啟網址即可。
  • 讓使用者放心安裝應用程式。

我們的架構

我們在公告中使用了 Polymer,但所有新式架構都能正常運作。

我們對 Service Worker 的瞭解

您必須有服務 worker,才能建立 PWA。Service Worker 可提供許多強大的功能,例如進階快取策略、離線功能、背景同步等。雖然 Service Worker 會增加一些複雜性,但我們發現其優點遠大於複雜性。

請盡可能產生

請勿手動編寫服務工作者指令碼。手動編寫服務工作者時,必須手動管理快取資源,並重寫大多數服務工作者程式庫 (例如 Workbox) 的常見邏輯。

不過,由於我們的內部技術堆疊,我們無法使用程式庫來產生及管理服務工作者。以下的學習成果有時會反映這一點。如需瞭解詳情,請參閱「非產生 Service Worker 的陷阱」。

並非所有程式庫都支援服務工作者

某些 JS 程式庫假設在 Service Worker 執行時,有不如預期的運作情況。舉例來說,假設 windowdocument 可用,或使用了服務工作站無法使用的 API (XMLHttpRequest、本機儲存空間等)。請確認應用程式所需的所有重要程式庫皆與服務工作單元相容。針對這個特定的 PWA,我們想使用 gapi.js 進行驗證,但因為不支援 Service Worker,因此無法使用。程式庫作者也應盡可能減少或移除對 JavaScript 上下文的非必要假設,以便支援服務工作程使用案例,例如避免使用與服務工作程不相容的 API,以及避免全域狀態

避免在初始化期間存取 IndexedDB

請勿在初始化服務工作者指令碼時讀取 IndexedDB,否則可能會發生這種不理想的情況:

  1. 使用者擁有採用 IndexedDB (IDB) 版本 N 的網頁應用程式
  2. 使用 IDB 版本 N+1 推送新的網頁應用程式
  3. 使用者造訪 PWA,觸發下載新的服務工作者
  4. 新的服務工作者會在註冊 install 事件處理常式之前從 IDB 讀取資料,觸發 IDB 升級週期,從 N 升級至 N+1
  5. 由於使用者擁有版本 N 的舊用戶端,因此服務工作站升級程序會停滯,因為持續連線仍開啟舊版資料庫
  6. Service Worker 停止運作且從未安裝

在我們的案例中,快取在服務工作站安裝時失效,因此如果服務工作站從未安裝,使用者就不會收到更新的應用程式。

提高安全性

雖然服務工作者指令碼會在背景執行,但也可以隨時終止,即使是在 I/O 作業 (網路、IDB 等) 期間也一樣。任何長時間執行的程序都應可隨時恢復。

在同步處理程序將大型檔案上傳至伺服器並儲存到 IDB 的情況下,我們解決部分上傳作業中斷的解決方案是利用內部上傳程式庫的可續傳系統,在上傳前將可續傳的上傳網址儲存至 IDB,並在上傳之前並未完成第一次完成時,使用該網址繼續上傳作業。此外,在進行任何長時間執行的 I/O 作業之前,狀態會儲存至 IDB,以便指出每個記錄在程序中的所在位置。

不要依賴全域狀態

由於服務工作站位於不同的情境中,因此許多您預期會出現的符號並未出現。我們的許多程式碼都是在 window 內容和 Service Worker 環境中執行,例如記錄、旗標和同步處理等。程式碼必須針對所使用的服務 (例如本機儲存空間或 Cookie) 採取防禦措施。您可以使用 globalThis,以在所有情境中運作的方式參照全域物件。此外,請謹慎使用儲存在全域變數中的資料,因為無法保證指令碼何時會終止並淘汰狀態。

本機開發

服務工作者的重點在於在本機快取資源。不過,在開發期間,這正是您所需的相反情況,尤其是在更新作業以惰性方式完成時。您仍希望安裝伺服器工作站,以便對其問題進行偵錯,或與背景同步處理或通知等其他 API 搭配運作。在 Chrome 中,您可以透過 Chrome 開發人員工具完成這項設定,方法是同時勾選「Bypass for network」核取方塊 (「Application」面板 >「Service worker」窗格),同時啟用「Network」面板中的「Disable cache」核取方塊,以便一併停用記憶體快取。為了涵蓋更多瀏覽器,我們選擇了不同的解決方案,也就是在服務工作者中加入標記,以便在開發人員版本中預設啟用快取功能。這可確保開發人員一律取得最新的變更,且不會發生任何快取問題。請務必加入 Cache-Control: no-cache 標頭,以防止瀏覽器快取任何素材資源

燈塔

Lighthouse 提供許多適用於 PWA 的偵錯工具。它會掃描網站並產生報告,內容涵蓋 PWA、效能、無障礙功能、搜尋引擎最佳化 (SEO) 和其他最佳做法。 建議您在持續整合中執行 Lighthouse,以便在您違反其中一個 PWA 標準時發出警示。我們實際上曾經遇到這種情況,服務工作未安裝,但我們在正式版推送前並未察覺。將 Lighthouse 納入 CI 就能避免這種情況。

採用持續推送軟體更新

由於服務工作者可自動更新,使用者無法限制升級。這樣可大幅減少舊有用戶端的數量。使用者開啟應用程式時,服務工作者會提供舊版用戶端,同時以延遲方式下載新版用戶端。新用戶端下載完成後,系統會提示使用者重新整理網頁,以便存取新功能。即使使用者忽略這項要求,下次重新整理網頁時,他們仍會收到新版的用戶端。因此,使用者很難以與 iOS/Android 應用程式相同的方式拒絕更新。

我們能夠為用戶端推送重大的後端變更,並在極短的時間內完成遷移。一般來說,我們會在進行重大變更前,給使用者一個月的時間更新至較新的用戶端。由於應用程式會在過時時提供服務,因此如果使用者長時間未開啟應用程式,舊版用戶端就有可能在外部環境中存在。在 iOS 上,服務工作者會在幾週後遭到淘汰,因此不會發生這種情況。針對 Android,您可以透過在內容過時時停止放送,或是在幾週後手動讓內容過期,來緩解這個問題。實際上,我們從未遇到過因舊版用戶端而發生的問題。特定團隊想採用的嚴格程度取決於其特定用途,但 PWAs 比 iOS/Android 應用程式提供更多彈性。

在服務工作者中取得 Cookie 值

有時需要在服務工作者背景中存取 Cookie 值。在我們的案例中,我們需要存取 Cookie 值,才能產生用於驗證第一方 API 要求的權杖。在服務工作者中,您無法使用 document.cookies 等同步 API。您隨時可以從 Service Worker 向使用中 (視窗) 的用戶端傳送訊息,要求 Cookie 值,不過服務工作站也可能在沒有視窗型用戶端 (例如背景同步處理期間) 的情況下,在背景執行。為解決這個問題,我們在前端伺服器上建立端點,會直接將 Cookie 值傳回至用戶端。服務工作架構會向這個端點提出網路要求,並讀取回應來取得 Cookie 值。

隨著 Cookie Store API 的推出,對於支援這項 API 的瀏覽器來說,這個解決方法應該已不再需要,因為這項 API 可提供對瀏覽器 Cookie 的非同步存取權,且可由服務工作者直接使用。

非產生的服務工作站陷阱

確保如有靜態快取檔案變更時,Service Worker 指令碼會隨之變更

常見的 PWA 模式是讓服務工作者在 install 階段安裝所有靜態應用程式檔案,讓用戶端在所有後續造訪時直接存取 Cache Storage API 快取。只有在瀏覽器偵測到服務工作者指令碼已以某種方式變更時,才會安裝服務工作者。因此,我們必須確保在快取檔案變更時,服務工作者指令碼檔案本身也已以某種方式變更。我們是透過手動方式,在服務工作者指令碼中嵌入靜態資源檔案集的雜湊,因此每個版本都會產生不同的服務工作者 JavaScript 檔案。Workbox 等服務工作程程式庫會自動執行這項程序。

單元測試

服務工作者 API 會將事件監聽器新增至全域物件,以便運作。例如:

self.addEventListener('fetch', (evt) => evt.respondWith(fetch('/foo')));

這可能是進行測試的困難,因為您需要模擬事件觸發條件、事件物件,等待 respondWith() 回呼,然後等待結果,最後才對結果斷言。更簡單的做法是將所有實作委派給另一個更容易測試的檔案。

import fetchHandler from './fetch_handler.js';
self
.addEventListener('fetch', (evt) => evt.respondWith(fetchHandler(evt)));

由於服務工作者指令碼的單元測試難度較高,我們盡可能將核心服務工作者指令碼保持在最基本的狀態,將大部分的實作項目分割到其他模組。由於這些檔案只是標準 JS 模組,因此使用標準測試程式庫進行單元測試會比較容易。

敬請期待第 2 和 3 部分

在本系列的 2 和 3 部分,我們將討論媒體管理和 iOS 專屬問題。如要進一步瞭解在 Google 建構 PWA 的相關資訊,請造訪作者個人資料,瞭解如何與我們聯絡: