在 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,通常只需開啟網址即可。
  • 消除安裝應用程式的阻礙因素

我們的架構

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

我們對 Service Worker 的瞭解

您無法在沒有服務工作人員的情況下執行 PWA。Service Worker 可提供許多強大的功能,例如進階快取策略、離線功能、背景同步等。雖然 Service Worker 會增加一些複雜性,但我們發現其優點遠大於複雜性。

請盡可能產生

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

儘管如此,基於內部技術堆疊,我們無法使用程式庫產生及管理 Service Worker。我們有時會根據以下經驗教訓。如需瞭解詳情,請參閱「非產生 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. Service Worker 停止運作且從未安裝

在我們的案例中,系統在安裝 Service Worker 時快取已失效,因此如果 Service Worker 從未安裝,使用者就永遠不會收到更新的應用程式。

提高安全性

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

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

不要依賴全域狀態

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

本機開發

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

燈塔

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

採用持續推送軟體更新

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

我們可以在短時間內為用戶端推送破壞後端變更的時間點,一般來說,我們會在進行重大變更前,給使用者一個月的時間更新至較新的用戶端。由於應用程式會在過時時提供服務,因此如果使用者長時間未開啟應用程式,舊版用戶端就有可能在外部環境中存在。在 iOS 上,服務工作者會在幾週後遭到淘汰,因此不會發生這種情況。對 Android 來說,如果無法在過時放送,或在幾週後手動將內容到期,此問題可以減緩此問題。實際上,我們從未遇到過因舊版用戶端而發生的問題。究竟是特定團隊的願景為何,才能達到該團隊的特定用途,但 PWA 的靈活度遠高於 iOS/Android 應用程式。

在服務工作者中取得 Cookie 值

有時需要在服務工作者背景中存取 Cookie 值。在我們的案例中,我們需要存取 Cookie 值,才能產生用於驗證第一方 API 要求的權杖。在 Service Worker 中,無法使用同步 API,例如 document.cookies。您隨時可以從服務工作站向處於活動狀態 (視窗化) 的用戶端傳送訊息,要求取得 Cookie 值,但服務工作站可以在背景執行,而無需任何可用的視窗化用戶端,例如在背景同步期間。為解決這個問題,我們在前端伺服器上建立了一個端點,這個端點會將 Cookie 值回傳給用戶端。Service Worker 會向這個端點發出網路要求並讀取回應,以取得 Cookie 值。

隨著 Cookie Store API 推出,支援這項功能的瀏覽器就不再需要這個解決方法,因為這個解決方案能讓使用者以非同步方式存取瀏覽器 Cookie,而且可直接由 Service Worker 使用。

非產生的服務工作站陷阱

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

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

單元測試

將事件監聽器新增至全域物件,藉此執行 Service Worker API 函式。例如:

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

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

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

由於針對 Service Worker 指令碼進行單元測試會遇到困難,我們盡可能保留核心服務工作站指令碼,將大部分的實作項目分割成其他模組。由於這些檔案只是標準的 JS 模組,因此可以更輕鬆地透過標準測試程式庫進行單元測試。

敬請期待第 2 和 3 部分

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