在 Google 建構 PWA - 第 1 部分

開發 PWA 時,Bulletin 團隊對 Service Worker 的瞭解。

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

這是一系列網誌文章的第一篇,內容是 Google Bulletin 團隊在建構外部 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 程式庫會在服務工作者執行時做出無法正常運作的假設。舉例來說,假設 windowdocument 可用,或是使用服務 worker 無法使用的 API (XMLHttpRequest、本機儲存空間等)。請確認應用程式所需的所有重要程式庫皆與服務工作單元相容。針對這個特定 PWA,我們想使用 gapi.js 進行驗證,但因其不支援服務工作者而無法使用。程式庫作者也應盡可能減少或移除對 JavaScript 上下文的非必要假設,以便支援服務工作程使用案例,例如避免使用與服務工作程不相容的 API,以及避免使用全域狀態

避免在初始化期間存取 IndexedDB

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

  1. 使用者擁有採用 IndexedDB (IDB) 版本 N 的網頁應用程式
  2. 使用 IDB 版本 N+1 推送新的網頁應用程式
  3. 使用者造訪 PWA,觸發下載新的服務工作者
  4. 新的服務工作者會在註冊 install 事件處理常式之前從 IDB 讀取資料,觸發 IDB 升級週期,從 N 升級至 N+1
  5. 由於使用者擁有舊版用戶端 (版本 N),服務工作者升級程序會在執行中,因為仍與舊版資料庫建立了有效連線
  6. 服務工作程掛起,且永遠不會安裝

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

讓系統更具韌性

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

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

不要依賴全域狀態

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

本機開發

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

燈塔

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

採用持續推送軟體更新

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

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

在服務工作者中取得 Cookie 值

有時需要在服務代 work 情境中存取 Cookie 值。在我們的案例中,我們需要存取 Cookie 值,才能產生用於驗證第一方 API 要求的權杖。在服務工作者中,您無法使用 document.cookies 等同步 API。您隨時可以從服務工作站向處於活動狀態 (視窗化) 的用戶端傳送訊息,要求取得 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,請參閱作者個人資料,瞭解如何與我們聯絡: