在 Google 建構 PWA - 第 1 部分

Bulletin 團隊在開發 PWA 時學到服務工作人員的經驗。

道格拉斯帕克
Douglas Parker
喬爾裡利
Joel Riley
迪克拉科恩
Dikla Cohen

這是一系列網誌文章的第一篇,說明 Google 安全性公告團隊在建構對外發布的 PWA 時學到的經驗。在這些文章中,我們會分享 Google 遇到的一些挑戰、我們如何解決這些挑戰,以及避免陷阱的一般建議。這不是 PWA 的完整總覽。我們的目標是分享從 Google 團隊體驗到的經驗。

在第一篇文章中,我們會先說明一些背景資訊,再深入探討我們學到的服務工作處理程序。

背景

公告在 2017 年中到 2019 年中還在積極開發中。

我們選擇建構 PWA 的原因

在深入探討開發程序之前,讓我們先瞭解為何建構 PWA 對這項專案很有吸引力:

  • 能夠快速疊代。公告功能特別重要,因為 Bulletin 將在多個市場進行測試。
  • 單一程式碼集:Android 和 iOS 的使用者大致平均分配 20% 的時間。透過 PWA,我們可以建構一個能夠在這兩種平台上運作的單一網頁應用程式。這增加了團隊的速度和影響
  • 快速更新,不受使用者行為影響。PWA 可自動更新,減少公開的過時用戶端數量。我們能在極短的時間內,為用戶端推送破壞性後端變更。
  • 輕鬆與第一方和第三方應用程式整合。這類整合是應用程式不可或缺的一環。使用 PWA 通常只需開啟網址,
  • 不再需要安裝應用程式。

我們的架構

在公告中,我們使用 Polymer,但任何支援良好的新型架構都能正常運作。

我們聽過 Service Worker

如果沒有服務工作站,就無法使用 PWA。服務工作站可為您提供許多強大功能,例如進階快取策略、離線功能、背景同步處理等。雖然服務工作處理程序會增加複雜度,但我們發現這類工作的優點比起更複雜。

盡可能產生

請避免手動編寫 Service Worker 指令碼。如要手動編寫 Service 工作站,您必須手動管理快取資源,以及重新編寫大多數服務工作站程式庫 (例如 Workbox) 常見的邏輯。

話雖如此,由於內部技術堆疊,我們無法使用程式庫產生及管理服務工作站。我們將在下方收穫,以反映這一點。詳情請參閱「非產生的 Service Worker 套件」。

並非所有程式庫皆與服務工作站相容

某些 JS 程式庫的假設在服務工作站執行時無法正常運作。例如,假設 windowdocument 可供使用,或者使用服務工作站沒有可用的 API (XMLHttpRequest、本機儲存空間等)。確認應用程式需要的任何重要程式庫與 Service Worker 相容。針對這個特定 PWA,我們希望使用 gapi.js 進行驗證,但因為不支援 Service Worker,所以無法進行驗證。程式庫作者也應盡可能減少或移除 JavaScript 結構定義不必要的假設,以支援服務工作站用途,例如避免與 Service Worker 不相容的 API,以及避免全域狀態

避免在初始化期間存取 IndexedDB

初始化 Service Worker 指令碼時,請勿讀取 IndexedDB,否則可能會遇到這種意外情況:

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

在這個案例中,在 Service Worker 安裝時快取已失效,因此如果使用者從未安裝 Service Worker,使用者就從未收到更新的應用程式。

靈活調整

雖然 Service Worker 指令碼在背景執行,但可能隨時終止,即使在 I/O 作業期間 (網路、IDB 等) 也不會中斷。任何長時間執行的程序都應該在任何時間點恢復運作。

如果是將大型檔案上傳到伺服器並儲存至 IDB 的同步處理程序,我們的中斷部分上傳解決方案就是利用內部上傳程式庫的續傳系統,在上傳前將支援續傳的上傳網址儲存至 IDB,並在上傳前使用該網址繼續上傳。此外,在任何長時間執行 I/O 作業之前,狀態都會儲存至 IDB,以指出我們每筆記錄在程序中的哪個位置。

不要依附全域狀態

由於 Service Worker 的存在,因此不存在您預期會存在的許多符號。我們許多程式碼會同時在 window 環境和 Service Worker 結構定義 (例如記錄、旗標、同步處理等) 執行。程式碼需要針對其使用的服務 (例如本機儲存空間或 Cookie) 建立防禦機制。您可以使用 globalThis 參照全域物件,該方式適用於所有情況。此外,請謹慎使用儲存在全域變數中的資料,因為系統無法保證指令碼何時終止及移除狀態。

本機開發

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

燈塔

Lighthouse 提供許多適用於 PWA 的偵錯工具。這項工具會掃描網站並產生報表,內容涵蓋 PWA、效能、無障礙設計、SEO 和其他最佳做法。建議您在持續整合時執行 Lighthouse,以傳送快訊通知,以便您突破其中一種 PWA 標準。其實我們曾經發生過一次這種情況,服務工作處理程序並未安裝,我們在正式推送前無法察覺。將 Lighthouse 納入我們的持續整合系統, 就會發生這種問題

擁抱持續推送軟體更新

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

我們能夠在極短的用戶端遷移時間中,推出破壞性的後端變更。一般而言,我們會提供一個月,讓使用者在進行破壞性變更前,先更新用戶端的更新資料。由於應用程式會在過時當下提供服務,因此如果舊版用戶端使用者長時間未開啟應用程式,實際上可能就會出現在外部用戶端。在 iOS 上,服務工作站會在數週後撤銷,因此這種情況不會發生。以 Android 來說,即使內容過時且無法提供,或是在幾週後手動到期,或許可以解決這個問題。實際上,我們從來沒有遇到過時用戶端的問題特定團隊希望達到的嚴格程度取決於特定用途,但 PWA 比 iOS/Android 應用程式高出許多。

在 Service Worker 中取得 Cookie 值

某些情況下,我們必須存取 Service Worker 內容中的 Cookie 值。在本例中,我們需要存取 Cookie 值來產生權杖來驗證第一方 API 要求。在 Service Worker 中無法使用同步 API,例如 document.cookies。您隨時可以向服務工作站傳送訊息給使用中 (視窗狀態) 的用戶端,以要求 Cookie 值,不過服務工作站可能會在背景中執行,而沒有使用視窗的用戶端 (例如在背景同步處理期間)。為解決這個問題,我們在前端伺服器上建立一個端點,只回應回用戶端 Cookie 的值。Service Worker 已向這個端點發出網路要求,並讀取回應以取得 Cookie 值。

隨著 Cookie Store API 推出,支援此 API 的瀏覽器不再需要採用這個解決方法,因為此 API 可為瀏覽器 Cookie 提供非同步存取權,並可供服務工作站直接使用。

非產生的 Service Worker 陷阱

確保任何靜態快取檔案有所變更時,Service Worker 指令碼都會有所變更

常見的 PWA 模式是讓服務工作站在 install 階段安裝所有靜態應用程式檔案,從而可讓用戶端直接在所有後續造訪中直接命中 Cache Storage API 快取。只有在瀏覽器偵測到服務工作站指令碼發生某種變更時,才會安裝 Service Worker,因此必須確保在快取檔案發生變更時,Service Worker 指令碼檔案本身以某種方式變更。我們透過在 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)));

由於服務工作工作站指令碼進行單位測試時遇到困難,因此我們盡可能將核心服務工作站指令碼保持精簡,將大部分的實作項目分割為其他模組。由於這些檔案只是標準 JS 模組,因此較容易使用標準測試程式庫對其進行單元測試。

敬請密切留意第 2 部分和第 3 部分的內容

在本系列的第 2 部分和第 3 部分中,我們將探討媒體管理和 iOS 相關問題。如果您想詢問有關在 Google 建構 PWA 的更多資訊,請參閱我們的作者個人資料,瞭解如何與我們聯絡: