離線和後臺操作
通常,網站非常依賴可靠的網路連線,並且依賴使用者在瀏覽器中開啟頁面。沒有網路連線,大多數網站都無法使用;如果使用者沒有在瀏覽器標籤頁中開啟網站,大多數網站也無法做任何事情。
然而,考慮以下場景:
- 一個音樂應用允許使用者線上時播放音樂,但可以在後臺下載歌曲,然後在使用者離線時繼續播放。
- 使用者撰寫了一封很長的電子郵件,點選“傳送”,然後失去了網路連線。裝置會在網路再次可用時,在後臺傳送這封電子郵件。
- 使用者的聊天應用收到來自聯絡人的訊息,即使應用沒有開啟,它也會在應用圖示上顯示一個徽章,告知使用者有新訊息。
這些都是使用者期望已安裝應用具備的功能。本指南將介紹一組技術,使 PWA 能夠實現以下功能:
- 即使裝置網路連線不穩定,也能提供良好的使用者體驗
- 在應用未執行時更新其狀態
- 在應用未執行時,通知使用者發生了重要事件
本指南中介紹的技術包括:
網站和工作執行緒
本指南中討論的所有技術的基礎是 Service Worker。本節將簡要介紹 Worker 以及它們如何改變 Web 應用的架構。
通常,整個網站都在一個單獨的執行緒中執行。這包括網站自身的 JavaScript,以及渲染網站 UI 的所有工作。其結果之一是,如果你的 JavaScript 運行了某些耗時操作,網站的主 UI 就會被阻塞,網站對使用者而言會顯得無響應。
Service Worker 是一種特定型別的 Web Worker,用於實現 PWA。與所有 Web Worker 一樣,Service Worker 在與主 JavaScript 程式碼分離的單獨執行緒中執行。主程式碼建立 Worker,傳入 Worker 指令碼的 URL。Worker 和主程式碼不能直接訪問彼此的狀態,但可以透過互相傳送訊息進行通訊。Worker 可用於在後臺執行計算密集型任務:由於它們在單獨的執行緒中執行,因此實現應用 UI 的主 JavaScript 程式碼可以保持對使用者的響應。
因此,PWA 總是具有高度架構分離,分為:
- 主應用,包含 HTML、CSS 和實現應用 UI 的 JavaScript 部分(例如,透過處理使用者事件)
- Service Worker,處理離線和後臺任務
在本指南中,當我們展示程式碼示例時,我們會用類似 // main.js 或 // service-worker.js 的註釋來指明程式碼屬於應用的哪個部分。
離線操作
離線操作允許 PWA 即使裝置沒有網路連線也能提供良好的使用者體驗。這透過嚮應用新增 Service Worker 來實現。
Service Worker 控制 應用的部分或全部頁面。Service Worker 安裝後,可以從伺服器為其控制的頁面(例如,包括頁面、樣式、指令碼和影像)獲取資源,並將它們新增到本地快取中。Cache 介面用於將資源新增到快取。Cache 例項透過 Service Worker 全域性作用域中的 WorkerGlobalScope.caches 屬性可訪問。
然後,每當應用請求資源時(例如,因為使用者打開了應用或點選了內部連結),瀏覽器會在 Service Worker 的全域性作用域中觸發一個名為 fetch 的事件。透過監聽此事件,Service Worker 可以攔截請求。
fetch 事件的事件處理程式會傳遞一個 FetchEvent 物件,該物件:
- 提供對請求的訪問,作為
Request例項 - 提供
respondWith()方法,用於向請求傳送響應。
Service Worker 處理請求的一種方式是“快取優先”策略。在此策略中:
- 如果請求的資源存在於快取中,則從快取中獲取資源並將其返回給應用。
- 如果請求的資源不存在於快取中,則嘗試從網路中獲取資源。
- 如果資源可以獲取,則將資源新增到快取中以便下次使用,並將資源返回給應用。
- 如果資源無法獲取,則返回一些預設的備用資源。
以下程式碼示例展示了這種實現:
// service-worker.js
const putInCache = async (request, response) => {
const cache = await caches.open("v1");
await cache.put(request, response);
};
const cacheFirst = async ({ request, fallbackUrl }) => {
// First try to get the resource from the cache.
const responseFromCache = await caches.match(request);
if (responseFromCache) {
return responseFromCache;
}
// If the response was not found in the cache,
// try to get the resource from the network.
try {
const responseFromNetwork = await fetch(request);
// If the network request succeeded, clone the response:
// - put one copy in the cache, for the next time
// - return the original to the app
// Cloning is needed because a response can only be consumed once.
putInCache(request, responseFromNetwork.clone());
return responseFromNetwork;
} catch (error) {
// If the network request failed,
// get the fallback response from the cache.
const fallbackResponse = await caches.match(fallbackUrl);
if (fallbackResponse) {
return fallbackResponse;
}
// When even the fallback response is not available,
// there is nothing we can do, but we must always
// return a Response object.
return new Response("Network error happened", {
status: 408,
headers: { "Content-Type": "text/plain" },
});
}
};
self.addEventListener("fetch", (event) => {
event.respondWith(
cacheFirst({
request: event.request,
fallbackUrl: "/fallback.html",
}),
);
});
這意味著在許多情況下,即使網路連線不穩定,Web 應用也能執行良好。從主應用程式碼的角度來看,它是完全透明的:應用只發出網路請求並獲得響應。此外,由於 Service Worker 在單獨的執行緒中,因此在獲取和快取資源時,主應用程式碼可以保持對使用者輸入的響應。
注意: 這裡描述的策略只是 Service Worker 實現快取的一種方式。具體來說,在快取優先策略中,我們首先檢查快取而不是網路,這意味著我們更有可能返回快速響應而無需承擔網路成本,但也更有可能返回過時的響應。
另一種選擇是網路優先策略,在該策略中,我們首先嚐試從伺服器獲取資源,如果裝置離線則回退到快取。
最佳快取策略取決於特定的 Web 應用及其使用方式。
有關設定 Service Worker 並使用它們新增離線功能的更多詳細資訊,請參閱我們的使用 Service Worker 指南。
後臺操作
雖然離線操作是 Service Worker 最常見的用途,但它們還允許 PWA 即使在主應用關閉時也能執行。這是可能的,因為 Service Worker 可以在主應用未執行時執行。
這並不意味著 Service Worker 一直都在執行:瀏覽器可能會在它們認為合適時停止 Service Worker。例如,如果 Service Worker 閒置了一段時間,它就會被停止。但是,當發生了需要 Service Worker 處理的事件時,瀏覽器會重新啟動 Service Worker。這使得 PWA 能夠以以下方式實現後臺操作:
- 在主應用中,註冊一個請求,讓 Service Worker 執行一些操作
- 在適當的時候,如果需要,Service Worker 將被重新啟動,並且在 Service Worker 的作用域中會觸發一個事件
- Service Worker 將執行操作
在接下來的部分中,我們將討論一些不同的功能,它們使用此模式來使 PWA 在主應用未開啟時也能工作。
後臺同步
假設使用者撰寫了一封電子郵件並點選了“傳送”。在傳統網站中,他們必須保持標籤頁開啟,直到應用傳送了電子郵件:如果他們關閉標籤頁,或者裝置失去連線,那麼訊息將不會被髮送。後臺同步,在 後臺同步 API 中定義,是 PWA 解決此問題的方法。
後臺同步使應用能夠請求其 Service Worker 代替其執行任務。一旦裝置具有網路連線,如果需要,瀏覽器將重新啟動 Service Worker,並在 Service Worker 的作用域中觸發一個名為 sync 的事件。然後 Service Worker 可以嘗試執行任務。如果任務無法完成,則瀏覽器可能會透過再次觸發事件來重試有限的次數。
註冊同步事件
為了請求 Service Worker 執行任務,主應用可以訪問 navigator.serviceWorker.ready,它會解析為一個 ServiceWorkerRegistration 物件。然後應用在該 ServiceWorkerRegistration 物件上呼叫 sync.register(),如下所示:
// main.js
async function registerSync() {
const swRegistration = await navigator.serviceWorker.ready;
swRegistration.sync.register("send-message");
}
請注意,應用會為任務傳遞一個名稱:在此示例中為 "send-message"。
處理同步事件
一旦裝置有網路連線,sync 事件就會在 Service Worker 作用域中觸發。Service Worker 檢查任務名稱並執行相應的函式,在此示例中為 sendMessage():
// service-worker.js
self.addEventListener("sync", (event) => {
if (event.tag === "send-message") {
event.waitUntil(sendMessage());
}
});
請注意,我們將 sendMessage() 函式的結果傳遞給事件的 waitUntil() 方法。waitUntil() 方法接受一個 Promise 作為引數,並要求瀏覽器在 Promise 解決之前不要停止 Service Worker。這也是瀏覽器如何知道操作是否成功的方法:如果 Promise 被拒絕,則瀏覽器可能會透過再次觸發 sync 事件來重試。
waitUntil() 方法並不能保證瀏覽器不會停止 Service Worker:如果操作花費時間過長,Service Worker 仍然會被停止。如果發生這種情況,操作將被中止,下次觸發 sync 事件時,處理程式將從頭開始再次執行,而不是從中斷處恢復。
“太長”的時間是瀏覽器特定的。對於 Chrome,Service Worker 很可能會在以下情況被關閉:
- 它已閒置 30 秒
- 它已運行同步 JavaScript 30 秒
- 傳遞給
waitUntil()的 Promise 解決時間超過 5 分鐘
後臺抓取
後臺同步對於相對較短的後臺操作很有用,但正如我們剛剛看到的:如果 Service Worker 在相對較短的時間內沒有完成處理同步事件,瀏覽器將停止 Service Worker。這是一項有意為之的措施,旨在透過最大程度地減少在應用處於後臺時使用者的 IP 地址暴露給伺服器的時間,以節省電池壽命並保護使用者隱私。
這使得後臺同步不適合較長的操作,例如下載電影。對於這種情況,你需要 後臺抓取 API。通過後臺抓取,即使主應用 UI 和 Service Worker 都已關閉,也可以執行網路請求。
使用後臺抓取:
- 請求從主應用 UI 發起
- 無論主應用是否開啟,瀏覽器都會顯示一個持久的 UI 元素,通知使用者正在進行的請求,並允許他們取消或檢查其進度
- 當請求成功或失敗完成時,或者使用者要求檢查請求進度時,瀏覽器會啟動 Service Worker(如果需要),並在 Service Worker 的作用域中觸發相應的事件。
發出後臺抓取請求
透過在 ServiceWorkerRegistration 物件上呼叫 backgroundFetch.fetch(),在主應用程式碼中發起後臺抓取請求,如下所示:
// main.js
async function requestBackgroundFetch(movieData) {
const swRegistration = await navigator.serviceWorker.ready;
const fetchRegistration = await swRegistration.backgroundFetch.fetch(
"download-movie",
["/my-movie-part-1.webm", "/my-movie-part-2.webm"],
{
icons: movieIcons,
title: "Downloading my movie",
downloadTotal: 60 * 1024 * 1024,
},
);
// …
}
我們向 backgroundFetch.fetch() 傳遞三個引數:
- 此抓取請求的識別符號
- 一個
Request物件或 URL 陣列。單個後臺抓取請求可以包含多個網路請求。 - 一個包含 UI 資料的物件,瀏覽器使用這些資料來顯示請求的存在和進度。
backgroundFetch.fetch() 呼叫返回一個 Promise,該 Promise 解析為 BackgroundFetchRegistration 物件。這使得主應用能夠隨著請求的進展更新其自身的 UI。但是,如果主應用關閉,抓取將在後臺繼續。
瀏覽器將顯示一個持久的 UI 元素,提醒使用者請求正在進行,讓他們有機會了解更多關於請求的資訊,並根據需要取消它。UI 將包括從 icons 和 title 引數中獲取的圖示和標題,並使用 downloadTotal 作為總下載大小的估計值,以顯示請求的進度。
處理請求結果
當抓取成功或失敗完成後,或者使用者點選了進度 UI,瀏覽器就會啟動應用的 Service Worker(如果需要),並在 Service Worker 的作用域中觸發一個事件。可以觸發以下事件:
backgroundfetchsuccess:所有請求均成功backgroundfetchfail:至少一個請求失敗backgroundfetchabort:抓取被使用者或主應用取消backgroundfetchclick:使用者點選了瀏覽器正在顯示的進度 UI 元素
檢索響應資料
在 backgroundfetchsuccess、backgroundfetchfail 和 backgroundfetchabort 事件的處理程式中,Service Worker 可以檢索請求和響應資料。
要獲取響應,事件處理程式會訪問事件的 registration 屬性。這是一個 BackgroundFetchRegistration 物件,它具有 matchAll() 和 match() 方法,這些方法返回與給定 URL 匹配的 BackgroundFetchRecord 物件(或者,在 matchAll() 的情況下,如果沒有給定 URL,則返回所有記錄)。
每個 BackgroundFetchRecord 都有一個 responseReady 屬性,它是一個 Promise,一旦響應可用,就會解析為 Response。
因此,要訪問響應資料,處理程式可以執行以下操作:
// service-worker.js
self.addEventListener("backgroundfetchsuccess", (event) => {
const registration = event.registration;
event.waitUntil(async () => {
const registration = event.registration;
const records = await registration.matchAll();
const responsePromises = records.map(
async (record) => await record.responseReady,
);
const responses = Promise.all(responsePromises);
// do something with the responses
});
});
由於處理程式退出後響應資料將不再可用,因此如果應用仍然需要資料,處理程式應將其儲存起來(例如,在 Cache 中)。
更新瀏覽器的 UI
傳遞給 backgroundfetchsuccess 和 backgroundfetchfail 的事件物件還包含一個 updateUI() 方法,可用於更新瀏覽器顯示的 UI,以便讓使用者瞭解抓取操作。透過 updateUI(),處理程式可以更新 UI 元素的標題和圖示:
// service-worker.js
self.addEventListener("backgroundfetchsuccess", (event) => {
// retrieve and store response data
// …
event.updateUI({ title: "Finished your download!" });
});
self.addEventListener("backgroundfetchfail", (event) => {
event.updateUI({ title: "Could not complete download" });
});
響應使用者互動
當用戶點選瀏覽器在抓取進行時顯示的 UI 元素時,會觸發 backgroundfetchclick 事件。
此處預期的響應是開啟一個視窗,向用戶提供有關抓取操作的更多資訊,這可以透過 Service Worker 使用 clients.openWindow() 來完成。例如:
// service-worker.js
self.addEventListener("backgroundfetchclick", (event) => {
const registration = event.registration;
if (registration.result === "success") {
clients.openWindow("/play-movie");
} else {
clients.openWindow("/movie-download-progress");
}
});
週期性後臺同步
週期性後臺同步 API 允許 PWA 在主應用關閉時在後臺定期更新其資料。
這可以極大地改善 PWA 提供的離線體驗。考慮一個依賴於相對新鮮內容的應用程式,比如新聞應用程式。如果使用者開啟應用程式時裝置處於離線狀態,那麼即使有基於 Service Worker 的快取,新聞內容也只會和上次應用程式開啟時一樣新鮮。透過週期性後臺同步,應用程式可以在裝置有連線時在後臺重新整理其新聞,因此能夠向用戶顯示相對新鮮的內容。
這利用了這樣一個事實:尤其是在移動裝置上,連線性與其說是差,不如說是間歇性的:透過利用裝置有連線的時間,應用程式可以彌補連線中斷。
註冊週期性同步事件
註冊週期性同步事件的程式碼模式與註冊同步事件的模式相同。ServiceWorkerRegistration 有一個 periodicSync 屬性,該屬性有一個 register() 方法,該方法接受週期性同步的名稱作為引數。
但是,periodicSync.register() 接受一個額外的引數,它是一個帶有 minInterval 屬性的物件。這表示同步嘗試之間的最小間隔(以毫秒為單位):
// main.js
async function registerPeriodicSync() {
const swRegistration = await navigator.serviceWorker.ready;
swRegistration.periodicSync.register("update-news", {
// try to update every 24 hours
minInterval: 24 * 60 * 60 * 1000,
});
}
處理週期性同步事件
儘管 PWA 在 register() 呼叫中請求特定的間隔,但瀏覽器生成周期性同步事件的頻率由瀏覽器決定。使用者經常開啟和互動的應用程式更有可能接收到週期性同步事件,並且接收頻率更高,而使用者很少或從不互動的應用程式則接收到的事件較少(甚至沒有)。
當瀏覽器決定生成周期性同步事件時,模式如下:如果需要,它會啟動 Service Worker,並在 Service Worker 的全域性作用域中觸發一個 periodicSync 事件。
Service Worker 的事件處理程式檢查事件的名稱,並在事件的 waitUntil() 方法內部呼叫相應的函式:
// service-worker.js
self.addEventListener("periodicsync", (event) => {
if (event.tag === "update-news") {
event.waitUntil(updateNews());
}
});
在 updateNews() 內部,Service Worker 可以獲取並快取最新的新聞。updateNews() 函式應該相對快速地完成:如果 Service Worker 更新內容花費太長時間,瀏覽器將停止它。
取消註冊週期性同步
當 PWA 不再需要週期性後臺更新時(例如,因為使用者在應用的設定中將其關閉),PWA 應該透過呼叫 periodicSync 的 unregister() 方法來要求瀏覽器停止生成周期性同步事件:
// main.js
async function unregisterPeriodicSync() {
const swRegistration = await navigator.serviceWorker.ready;
swRegistration.periodicSync.unregister("update-news");
}
推送
推送 API 使 PWA 能夠接收來自伺服器的推送訊息,無論應用是否正在執行。當裝置收到訊息時,應用的 Service Worker 會啟動並處理訊息,並向用戶顯示一個 通知。規範允許“靜默推送”(不顯示通知),但由於隱私問題(例如,推送可能被用於跟蹤使用者位置),目前沒有瀏覽器支援此功能。
向用戶顯示通知會分散他們的注意力,並且可能非常煩人,因此請謹慎使用推送訊息。通常,它們適用於需要提醒使用者某事,並且無法等到他們下次開啟應用的情況。
推送通知的一個常見用例是聊天應用:當用戶收到來自其聯絡人的訊息時,它會作為推送訊息傳遞,並且應用會顯示一個通知。
推送訊息不是直接從應用伺服器傳送到裝置。相反,您的應用伺服器將訊息傳送到推送服務,裝置可以從該服務檢索訊息並將其傳遞給應用。
這也意味著您的伺服器傳送到推送服務的訊息需要進行加密(以便推送服務無法讀取它們)和簽名(以便推送服務知道訊息確實來自您的伺服器,而不是冒充您的伺服器的人)。
推送服務由瀏覽器供應商或第三方運營,應用伺服器使用 HTTP Push 協議與它通訊。應用伺服器可以使用第三方庫(如 web-push)來處理協議細節。
訂閱推送訊息
訂閱推送訊息的模式如下:
-
在裝置上,應用使用
PushManager.subscribe()方法訂閱來自伺服器的訊息。subscribe()方法:-
接受應用伺服器的公鑰作為引數:這是推送服務用於驗證來自應用伺服器的訊息簽名的方式。
-
返回一個解析為
PushSubscription物件的Promise。此物件包括:
-
-
應用將端點和公共加密金鑰傳送到您的伺服器(例如,使用
fetch())。
此後,應用伺服器即可開始傳送推送訊息。
傳送、傳遞和處理推送訊息
當伺服器上發生伺服器希望應用程式處理的事件時,伺服器可以傳送訊息,步驟序列如下:
- 應用伺服器使用其私有簽名金鑰對訊息進行簽名,並使用推送服務的公共加密金鑰對訊息進行加密。應用伺服器可以使用 web-push 等庫來簡化此操作。
- 應用伺服器使用 HTTP Push 協議將訊息傳送到推送服務的端點,並且可以選擇再次使用 web-push 等庫。
- 推送服務檢查訊息上的簽名,如果簽名有效,則推送服務將訊息排隊等待傳送。
- 當裝置具有網路連線時,推送服務將加密訊息傳送給瀏覽器。
- 當瀏覽器收到加密訊息時,它會解密該訊息。
- 瀏覽器在必要時啟動 Service Worker,並在 Service Worker 的全域性作用域中觸發一個名為
push的事件。事件處理程式會接收一個PushEvent物件,其中包含訊息資料。 - 在其事件處理程式中,Service Worker 對訊息進行任何處理。像往常一樣,事件處理程式呼叫
event.waitUntil()以請求瀏覽器保持 Service Worker 執行。 - 在其事件處理程式中,Service Worker 使用
registration.showNotification()建立通知。 - 如果使用者點選或關閉通知,則分別在 Service Worker 的全域性作用域中觸發
notificationclick和notificationclose事件。這些事件使應用程式能夠處理使用者對通知的響應。
許可權和限制
瀏覽器必須在為 Web 開發人員提供強大 API 的同時保護使用者免受惡意、剝削性或編寫不當網站的侵害之間取得平衡。它們提供的主要保護之一是使用者可以關閉網站頁面,之後它在他們的裝置上就不再活躍了。本文描述的 API 往往會違反這一保證,因此瀏覽器必須採取額外措施來幫助確保使用者瞭解這一點,並且 API 的使用方式符合使用者的利益。
本節將概述這些步驟。其中一些 API 需要明確的使用者許可權,以及各種其他限制和設計選擇,以幫助保護使用者。
-
後臺同步 API 不需要顯式的使用者許可權,但只能在主應用開啟時發出後臺同步請求,並且瀏覽器會限制重試次數和後臺同步操作所需的時間。
-
後臺抓取 API 需要
"background-fetch"使用者許可權,並且瀏覽器會顯示抓取操作的進行進度,允許使用者取消它。 -
週期性後臺同步 API 需要
"periodic-background-sync"使用者許可權,並且瀏覽器應該允許使用者完全停用週期性後臺同步。此外,瀏覽器可能會將同步事件的頻率與使用者選擇與應用互動的程度掛鉤:因此,使用者很少使用(甚至從不使用)的應用可能很少收到事件(甚至根本沒有事件)。 -
推送 API 需要
"push"使用者許可權,並且所有瀏覽器都要求推送事件對使用者可見,這意味著它們會生成使用者可見的通知。
另見
參考
指南
- Chrome 開發者部落格上的後臺同步簡介 (2017)
- Chrome 開發者部落格上的後臺抓取簡介 (2022)
- Chrome 開發者部落格上的週期性後臺同步 API (2020)
- web.dev 上的通知
- web.dev 上的 PWA 離線流媒體 (2021)