深入了解 Service Worker ,看這篇就夠了

這是一個特殊的 worker

瀏覽器一般有三類 web Worker:

  1. Dedicated Worker:專用的 worker,只能被創建它的 JS 訪問,創建它的頁面關閉,它的生命周期就結束了。
  2. Shared Worker:共享的 worker,可以被同一域名下的 JS 訪問,關聯的頁面都關閉時,它的生命周期就結束了。
  3. ServiceWorker:是事件驅動的 worker,生命周期與頁面無關,關聯頁面未關閉時,它也可以退出,沒有關聯頁面時,它也可以啟動,

這三者有什麼區別呢?眾所周知,JShted 的執行線程,同一時刻內只會有一段代碼在執行。Web worker 目的是為 JS 是單線程的,即一個瀏覽器進程中只有一個 JS 創造多線程環境,允許主線程將一些任務分配給子線程。Web Worker 一般是用於在後台執行一些耗時較長的 JS,避免影響 UI 線程的響應速度。

Dedicated worker 或 shared worker 最主要的能力,一是後台運行 JS,不影響 UI 線程,二是使用消息機制實現並行,可以監聽 onmessage 事件。所以 dedicated worker 和 shared worker 專註於解決「耗時的 JS 執行影響 UI 響應」的問題,而 service worker 則是為解決「Web App 的用戶體驗不如 Native App」的普遍問題而提供的一系列技術集合,必然部分處理邏輯會牽扯到 UI 線程,從而在啟動 service worker 的時候,UI 線程的繁忙也會影響其啟動性能。

顯然 service worker 的使命更加遠大,雖然規範把它定義為 web worker,但它已不是一個普通的 worker了。

每一部分的作用

Google 官方入門文檔提到,它能提供豐富的離線體驗,周期的後台同步,消息推送通知,攔截和處理網路請求,以及管理資源緩存。這每個能力各自都有什麼作用呢?

1. 豐富的離線體驗

首先,一提到 service worker,很多人都會想到離線訪問,而且不少文章都會提到,service worker 能提供豐富的離線體驗,但實際情況來說,需要離線訪問的場景很少,畢竟 web 最大的特點在於可傳播性,所以 service worker 的離線體驗主要還是在於解決頁面載入的可靠性,讓用戶能夠完整地打開整個頁面,比如頁面的白屏時間過長,網路不穩定造成的載入中斷導致頁面不可用。

有實際意義的離線,一般不是指斷開網路能訪問,而是指在用戶想訪問之前,能提前把資源載入回來。離線並不是一直都斷開網路,而是在網路連接良好的情況下,能把需要的資源都載入回來。一些比較糟糕的做法是在 WIFI 網路下把整個 App 客戶端的資源都拉下來,這樣其實很多資源是用戶不需要的,浪費了用戶的網路和存儲。Service worker 提供了更好更豐富的離線技術,Push / Fetch / Cache 這些技術的結合,能夠提供非常完美的離線體驗。比如,在小程序頁面發版時,推送消息給客戶端,客戶端喚起頁面的 service worker,去將需要用到的資源提前載入回來。

2. 消息推送通知

Service worker 的消息推送,其實是提供了一種伺服器與頁面交互的技術。消息推送在 Native App 或 Hybird App 已經比較常見。很多 Hybird App 裡面其實還會有一些 H5 頁面,在沒有實現 service worker 消息推送之前,消息是推送不到頁面的。消息能推送到頁面,意味著頁面提前知道要發生的一些事情,把這些事情做好,比如,提前準備好頁面需要的資源。Push 的推送伺服器,Chromium 默認使用 GCM / FCM,在國內都不能訪問,無法使用。瀏覽器廠商自己搭建 Push 伺服器,成本也不低,目前國內還未有瀏覽器廠商支持標準的Push 服務。從 API 的使用規範來看,消息推送與通知彈窗的關聯比較密切,基本上使用的業務場景僅限制在消息通知範圍。

3. 管理資源緩存

瀏覽器提供了很多存儲相關的 H5 API,比如 application cache、localStorage,但都不是非常好用,主要是給予頁端的控制權太少,限制太多,頁端不能完全控制每一個資源請求的存儲邏輯,或多或少會有一些趟不過的坑。Service worker Cache API 的出現徹底改變了這一局面,賦予了頁端強大的靈活性,更大的存儲空間。如何靈活地控制緩存,可以參考 Google 官方文章 《The Offline Cookbook》。

4. 網路請求

在 Fetch 出現之前,頁面 JS 一般通過 XHR 發起網路資源請求,但 XHR 有一定的局限性,比如,它不像普通請求那樣支持 Request 和 Response 對象,也不支持 streaming response,一些跨域的場景也限制較多。而現在,Fetch API 支持 Request 和 Response 對象,也支持 streaming response,Foreign Fetch 還具備跨域的能力。

一般來說,基於 webview 的客戶端攔截網路請求,都會基於 WebViewClient 的標準的 shouldInterceptRequest 介面。那麼 service worker 的請求在 webview 還能不能攔截呢?WebViewClient 的標準的 shouldInterceptRequest 介面是攔截不了 service worker 的請求了,但 Chrome 49.0 提供了新的 ServiceWorkerController 可以攔截所有 service worker 的請求。另外,頁端 JS 可以監聽 Fetch 事件,通過 FetchEvent.respondWith 返回符合期望的 Response,即頁端也能攔截 Response。

尷尬的處境

Service worker 的理想看起來很美好,現實卻很骨感,為什麼這麼說呢?GCM / FCM 服務被牆不說,強大的 Background Sync 功能也需要依賴 Google Play,而國內 Android 手機廠商自帶的 ROM 基本上都把 Google Play 幹掉了,並且還被牆了,略尷尬。比這更尷尬的是,Apple iOS 團隊對 Service Worker 的態度很不明朗,現在是,將來可能也是,所以現在很多特性在 iOS 上都不支持。

啟動分析

Service worker 線程的整個啟動流程可劃分為五個步驟:

1. 觸發啟動流程

一般來說,我們在訪問一個含有 service worker 的 HTML 文檔時,在發起主文檔請求之前,它會先派發一個 Fetch 事件,這個事件會觸發該頁面 service worker 的啟動流程。

2. 分派進程(多進程模式)/ 線程(單進程模式)

Service worker 啟動之前,它必須先向瀏覽器 UI 線程申請分派一個線程,再回到 IO 線程繼續執行 service worker 線程的啟動流程:

content::EmbeddedWorkerInstance::Start

--> content::EmbeddedWorkerInstance::RunProcessAllocated

--> ServiceWorkerProcessManager::AllocateWorkerProcess // from IO thread

--> ServiceWorkerProcessManager::AllocateWorkerProcess // PostTask to UI thread

--> ServiceWorkerProcessManager::AllocateWorkerProcess // from UI thread

--> content::EmbeddedWorkerInstance::ProcessAllocated // from IO thread

--> content::EmbeddedWorkerInstance::RegisterToWorkerDevToolsManager // from IO thread

--> content::EmbeddedWorkerInstance::RegisterToWorkerDevToolsManager // PostTask to UI thread

--> content::EmbeddedWorkerInstance::RegisterToWorkerDevToolsManager // from UI thread

--> content::EmbeddedWorkerInstance::SendStartWorker // from IO thread

--> content::EmbeddedWorkerRegistry::SendStartWorker

--> content::EmbeddedWorkerDispatcher::OnStartWorker

這個過程中,由於頻繁的 IO 與 UI 的線程切換,導致 service worker 啟動過程中,存在一定的性能開銷。

3. 載入 service worker js 文件

分派了 service worker 線程之後,就會繼續執行 service worker js 文件的載入流程:

content::EmbeddedWorkerDispatcher::OnStartWorker

--> blink::WebEmbeddedWorkerImpl::startWorkerContext

--> blink::WebEmbeddedWorkerImpl::loadShadowPage // 載入一個與 Service Worker js 文件相同 URL 的空白影子文檔

--> blink::FrameLoader::load // 觸發空白文檔的載入

--> ... ...

--> blink::WebEmbeddedWorkerImpl::didFinishDocumentLoad

--> blink::WebEmbeddedWorkerImpl::Loader::load // 觸發 Service Worker js 文件的載入

--> content::ResourceDispatcherHostImpl::BeginRequest

--> content::ServiceWorkerReadFromCacheJob::Start

--> content::ServiceWorkerReadFromCacheJob::OnReadComplete

--> ResourceLoader::didFinishLoading // 完成 Service Worker js 文件的載入

這個過程中,它會先載入一個空白影子文檔,再去載入 service worker js 文件,也就是說,會走兩次完整的載入流程。

4. 啟動 Service Worker 線程

Service worker js 文件載入完成之後,就會觸發 service worker 線程的啟動流程。這個過程中,主要包括創建 ServiceWorkerGlobalScope,初始化上下文( WorkerScriptController::initializeContextIfNeeded )和執行 JS 代碼( WorkerScriptController::evaluate )。

5. 回調通知啟動完成

Service worker 線程啟動完成之後,回調通知 ServiceWorkerVersion。至此,service worker 線程啟動完成。

WebEmbeddedWorkerImpl::startWorkerThread // 啟動 Service Worker 線程

--> new ServiceWorkerThread::ServiceWorkerThread

--> content::ServiceWorkerDispatcherHost::OnWorkerStarted

--> content::EmbeddedWorkerRegistry::OnWorkerStarted

--> content::EmbeddedWorkerInstance::OnStarted

--> content::ServiceWorkerVersion::OnStarted // 啟動 Service Worker 線程完成

從上面 5 個步驟可以看到,service worker 的啟動流程極其複雜,這麼複雜的啟動流程,會帶來怎樣的性能消耗呢?我們通過本地測試 Chromium 57 內核版本,初步得出幾個結論:

  • 分派 service worker 進程/線程的過程中,有頻繁的不同類型線程轉換,IO --> UI --> IO --> UI --> IO,這個過程中 UI 線程如果非常繁忙,耗時將會非常大,甚至可以超過 200ms。
  • 載入 service worker js 文件,首次載入需要創建 https 連接並等待伺服器響應,耗時可以超過 700ms,但在非首次的場景下,可以從緩存讀取,一般能在 50ms 以內完成。
  • 手機鎖屏開屏的場景下,瀏覽器大部分內存都會被清除,會極大的影響緩存讀取以及對象創建的時間,比如創建 v8 isolate,一般能在 10ms 完成,但鎖屏之後要 80ms 才能完成。

Google 官方文檔《Speed up Service Worker with Navigation Preloads》提到:

The bootup time depends on the device and conditions. It"s usually around 50ms. On mobile it"s more like 250ms. In extreme cases (slow devices, CPU in distress) it can be over 500ms. However, since the service worker stays awake for a browser-determined time between events, you only get this delay occasionally, such as when the user navigates to your site from a fresh tab, or another site.

Service worker 的啟動時間與用戶設備條件有關,在 PC 上一般為 50ms,手機上大概為 250ms。在極端的場景下,如低端手機且 CPU 壓力較大時,可能會超出 500ms。Chromium 團隊已嘗試使用多種方式來減少 service worker 的啟動時間, 比如:

  • 使用 V8 Code Cache(using code-caching in V8 v8project.blogspot.hk/2)。
  • 在沒有註冊監聽 Fetch 事件的頁面允許先發網路請求(skipping service workers that don"t have a fetch event 605844 - Optimization when no fetch handler is registered - chromium - Monorail)。
  • 在特定情境下(比如 mouse / touch 事件), 預先啟動 service worker(launching service workers speculatively Issue 2045153003: Speculatively launch Service Workers on mouse/touch events. [4/5])。
  • 使用 Navigation Preloads chromestatus.com/featur 技術, 允許 Fetch 請求在 service worker 還未啟動完成時就可發出, 從而減少啟動時間對總體性能的影響。
  • 從我們的測試數據來看,service worker 線程的啟動耗時一般在 100-300ms,與 Chromium 官方的數據相近。所以我們能夠得出一個大概的推論,service worker 線程的啟動是有較大成本的,一般在 100-300ms。

生命周期與狀態

1. 生命周期

Google 官方文檔 《The Service Worker Lifecycle》 提到:

Service worker 生命周期的目的:

  • 實現離線優先。
  • 允許新服務工作線程自行做好運行準備,無需中斷當前的服務工作線程。
  • 確保整個過程中作用域頁面由同一個服務工作線程(或者沒有服務工作線程)控制。
  • 確保每次只運行網站的一個版本。

整個生命周期的運作方式,官方文檔已經說得很清楚,這裡不再多說,我們來看看狀態管理的機制是怎樣的。

2. 狀態管理

Service worker 在瀏覽器內核有兩類狀態,一類是 service worker 線程的運行狀態,另一類是 service worker 腳本版本的狀態。

1) Service worker 線程的運行狀態, 一般對應 service worker 線程的狀態,這類狀態只保存在內存中。

  • STOPPED:已停止,EmbeddedWorkerInstance::OnStopped 時設置。
  • STARTING:正在啟動,EmbeddedWorkerInstance::Start 時設置。
  • RUNNING:正在運行,EmbeddedWorkerInstance::OnStarted 時設置。
  • STOPPING:正在停止,EmbeddedWorkerInstance::Stop --> EmbeddedWorkerRegistry::StopWorker 返回 status 為 SERVICE_WORKER_OK 時設置。

2) Service worker 腳本版本(即註冊函數中指定的 service worker js 文件)的狀態,這類狀態中的 INSTALLED 和 ACTIVATED 可以被持久化存儲。

  • NEW:瀏覽器內核的 ServiceWorkerVersion 已創建,屬於一個初始值。
  • INSTALLING:Install 事件被派發和處理,一般在 service worker 線程啟動後,即 ServiceWorkerVersion::StartWorker 返回 status 為 SERVICE_WORKER_OK 時設置。
  • INSTALLED:Install 事件已處理完成,準備進入 ACTIVATING 狀態。一般在註冊信息已存儲到資料庫,即 ServiceWorkerStorage::StoreRegistration 返回 status 為 SERVICE_WORKER_OK 時設置。
  • ACTIVATING:Activate 事件被派發和處理。一般在當前 scope 下沒有 active ServiceWorker 或 INSTALLED 狀態的 service worker 調用了 skipWaiting,service worker 就會從 INSTALLED 狀態轉為 ACTIVATING 狀態。
  • ACTIVATED:Activate 事件已處理完成,已正式開始控制頁面,可處理各類功能事件。一般在 activate 事件處理完成後就會轉為 ACTIVATED 狀態,此時 service worker 就可以控制頁面行為,可以處理功能事件,比如 fetch、push。
  • REDUNDANT:ServiceWorkerVersion 已失效,一般是因為執行了 unregister 操作或已被新 service worker 更新替換。

需要注意的是:

  • Service worker 規範中提到的 "service workers may be started and killed many times a second",指的是 service worker 線程隨時可以被 Started 和 Killed。在關聯文檔未關閉時,Service worker 線程可以處於 Stopped 狀態。在全部關聯文檔都已關閉時,service worker 線程也可以處於 Running 狀態。
  • Service worker 腳本版本的狀態,也是獨立於文檔生命周期的,與 service worker 線程的運行狀態無關,service worker 線程關閉時,service worker 腳本版本也可處於 ACTIVATED 狀態。
  • Service worker 腳本版本的狀態,INSTALLED 和 ACTIVATED 是穩定的狀態,service worker 線程啟動之後一般是進入這兩種狀態之一。INSTALLING 和 ACTIVATING 是中間狀態,一般只會在 service worker 新註冊或更新時觸發一次,刷新頁面一般不會觸發。INSTALLING 成功就轉入 INSTALLED,失敗就轉入 REDUNDANT。ACTIVATING 成功就轉入 ACTIVATED,失敗就轉入 REDUNDANT。
  • 如果 service worker 腳本版本處於 ACTIVATED 狀態,功能事件處理完之後,service worker 線程會被 Stop,當再次有功能事件時,service worker 線程又會被 Start,Start 完成後 service worker 就可以立即進入 ACTIVATED 狀態。

瀏覽器內核會管理三種 service worker 腳本版本:

  • installing_version:處於 INSTALLING 狀態的版本
  • waiting_version:處於 INSTALLED 狀態的版本
  • active_version:處於 ACTIVATED 狀態的版本

installing_version 一般是在 service worker 線程啟動後,即 ServiceWorkerVersion::StartWorker 返回 status 為 SERVICE_WORKER_OK 時,處於此版本狀態,這是一個中間版本,在正確安裝完成後會轉入 waiting_version。

waiting_version 一般在註冊信息已存儲到資料庫,即 ServiceWorkerStorage::StoreRegistration 返回 status 為 SERVICE_WORKER_OK 時,處於此版本狀態。或者在再次打開 service worker 頁面時,檢查到 service worker 腳本版本的狀態為 INSTALLED,也會進入此版本狀態。waiting_version 的存在確保了當前 scope 下只有一個 active service worker。

active_version 一般在 activate 事件處理完成後,就會處於此版本狀態,同一 scope 下只有一個 active Service Worker。需要特別注意的是,當前頁面已有 active worker 控制,刷新頁面時,新版本 Waiting(Installed) 狀態的 service worker 並不能轉入 active 狀態。

Service worker 可以從 waiting_version 轉入 active_version 的條件:

  • 當前 scope 下沒有 active service worker 在運行。
  • 頁面 JS 調用 self.skipWaiting 跳過 waiting 狀態。
  • 用戶關閉頁面,釋放了當前處於 active 狀態的 service worker。
  • 瀏覽器周期性檢測,發現 active service worker 處於 idle 狀態,就會釋放當前處於 active 狀態的 service worker。

3. 更新機制

Service worker 註冊函數中指定的 scriptURL(比如 serviceworker.js),一般有兩種更新方式:

1) 強制更新

距離上一次更新檢查已超過 24 小時,會忽略瀏覽器緩存,強制到伺服器更新一次。

2) 檢查更新(Soft Update)

一般在下面情況會檢查更新,

  • 第一次訪問 scope 里的頁面。
  • 距離上一次更新檢查已超過 24 小時。
  • 有功能性事件發生,比如 push、sync。
  • 在 service worker URL 發生變化時調用了 register 方法。
  • Service worker JS 資源文件的緩存時間已超出其頭部的 max-age 設置的時間(註:max-age 大於 24 小時,會使用 24 小時作為其值)。
  • Service worker JS 資源文件的代碼只要有一個位元組發生了變化,就會觸發更新,包括其引入的腳本發生了變化。

我們看看瀏覽器內核是怎樣實現周期性的檢查更新的,service worker schedule update 代碼如下:

ServiceWorkerControlleeRequestHandler::~ServiceWorkerControlleeRequestHandler

// Navigation triggers an update to occur shortly after the page and its initial subresources load。

--> ServiceWorkerVersion::ScheduleUpdate // if (is_main_resource_load_)

--> ServiceWorkerVersion::StartUpdate

從上述代碼流程可以看到,service worker 頁面主文檔載入完成時,就會觸發 active_version 的一次檢查更新,如果距離上一次腳本更新的時間超過了 24 小時,就會設置 LOAD_BYPASS_CACHE 的標記,忽略瀏覽器緩存,直接從網路載入。

上一次腳本更新的時間,一般在 service worker 安裝完成時會更新為當前時間,或者檢查到腳本超過 24 小時都沒有發生變化也會更新為當前時間,這樣就能保證 service worker 在安裝完成之後,每隔 24 小時,至少會更新一次。

4. 線程退出

Service worker 線程一般在什麼情況下會被停止呢?

  • Service worker JS 資源文件有任何異常,都會導致 service worker 線程退出。包括但不限於如 JS 文件存在語法錯誤、service worker 安裝失敗或激活失敗、service worker JS 執行時出現未捕獲的異常。
  • Service worker 功能事件處理完成,處於空閑狀態,Service Worker 線程會自動退出。
  • Service worker JS 執行時間過長,service worker 線程會自動退出。比如 service worker JS 執行時間超過 30 秒,或 Fetch 請求在 5 分鐘內還未完成。
  • 瀏覽器會周期性檢查各個 service worker 線程是否可以退出,一般在啟動 service worker 線程時會檢查一次。
  • 為了方便開發者調試,Chromium 進行了特殊處理,在連上 devtools 之後,service worker 線程不會退出。

5. 消息通信機制

我們知道,在 worker 中無法直接操作 DOM,service worker 也不例外,那麼它如何與其控制的頁面(至少一個)進行通信呢?接下來我們來看 service worker 與其控制的頁面之間的通信機制到底是怎樣的。

單向通信

1) 頁面使用 ServiceWorker.postMessage 發送消息給 service worker。

function oneWayCommunication() { if (navigator.serviceWorker.controller) { navigator.serviceWorker.controller.postMessage({ command: "oneWayCommunication", message: "Hi, SW" }); }}

2) Service worker 監聽 onmessage 事件,即可獲取到頁面發過來的消息。

self.addEventListener("message", function(event) { const data = event.data; if (data.command === "oneWayCommunication") { console.log(`Message from the Page : ${data.message}`); } });

單向通信模式下,頁面可以向 service worker 發送消息,但是 service worker 不能回復消息響應給頁面。

雙向通信

1) 頁面建立 MessageChannel,使用 MessageChannel.port1 監聽來自 service worker 的消息。使用 ServiceWorker.postMessage 發送消息給 service worker,並且將MessageChannel.port2 也一起傳遞給 service worker。

function twoWayCommunication() { if (navigator.serviceWorker.controller) { const messageChannel = new MessageChannel(); messageChannel.port1.onmessage = function(event) { console.log(`Response from the SW : ${event.data.message}`); } navigator.serviceWorker.controller.postMessage({ command: "twoWayCommunication", message: "Hi, SW" }, [messageChannel.port2]); }}

2. Service worker 監聽 onmessage 事件,即可獲取到頁面發過來的消息。同時,它可使用頁面傳遞過來的 MessageChannel.port2(即 event.ports[0])的 postMessage 方法回復消息給頁面。

self.addEventListener("message", function(event) { const data = event.data; if (data.command === "twoWayCommunication") { event.ports[0].postMessage({ message: "Hi, Page" }); }});

廣播通信

1) 頁面使用 ServiceWorker.postMessage 發送消息給 service worker,要求它向所有 Client 廣播消息。同時,註冊 onmessage 事件以監聽來自 service worker 的廣播消息。

function registerBroadcastReceiver() { navigator.serviceWorker.onmessage = function(event) { const data = event.data; if (data.command === "broadcastOnRequest") { console.log(`Broadcasted message from the ServiceWorker : ${data.message}`); } };}function requestBroadcast() { registerBroadcastReceiver(); if (navigator.serviceWorker.controller) { navigator.serviceWorker.controller.postMessage({ command: "broadcast" }); }}

2) Service worker 監聽 onmessage 事件,獲取到頁面發過來的廣播請求。Service worker 遍歷所有的 Client,並使用 Client.postMessage 發送消息給每一個 Client,從而實現消息廣播。

self.addEventListener("message", function(event) { const data = event.data; if (data.command === "broadcast") { self.clients.matchAll().then(function(clients) { clients.forEach(function(client) { client.postMessage({ command: "broadcastOnRequest", message: "This is a broadcast on request from the SW" }); }) }) }});

存在的問題

這裡我們重點探討下 MessageChannel,理解它的原理和可能存在的問題。原理我們描述一下:

  • 頁面實例化 MessageChannel 對象,瀏覽器內核在創建 MessageChannel 的過程中,同時會創建兩個 MessagePort,一個用於監聽來自 service worker 的消息,另外一個傳遞給 service worker,service worker 可使用它來回復消息。頁面使用 ServiceWorker.postMessage 向 service worker 發送消息,而 service worker 使用 port2 回復消息。
  • Service worker 的 StopWorker 會觸發 MessagePort::close, MessageChannel 會關閉,MessagePort 在 close 之後就不能收發消息了,而且 service worker 再次重啟之後也無法重建原來的 Messagechannel,最新的 Chromium 版本存在同樣的問題。這就意味著,在 service worker stop之後,整個雙向通信的通道就完全不能使用了。按照 service worker 規範的說明,瀏覽器可以在任意需要的時候關閉和重啟 service worker,這也等同於 service worker 與其控制頁面建立的 MessageChannel 隨時會斷掉,而且無法重建。

解決方案有兩種思路:

  • 思路一:從上面分析可以看到,service worker 的 stop 方法會破壞 MessageChannel 的通信通道,那麼如果 service worker 不會 Stop,即在頁面不關閉時保持不退出呢?理論上 MessageChannel 也可以繼續保持正常,這是一個解決思路,但這種思路與規範約定的 service worker 的生命周期存在衝突。
  • 思路二:service worker 的 stop 會破壞 MessageChannel,那麼如果我們每次發送消息都新建 MessageChannel 呢?理論上也是可行的,且 Google 官方的 DEMO (《Service Worker postMessage() Sample》 Service Worker postMessage() Sample 就是使用了這種方式。它實現一個 sendMessage 方法,通過該方法與 service worker 進行通信,其中每次調用該方法都會創建新的 MessageChannel。缺點是每次消息通信都需要新建 MessageChannel 實例,這樣它與單向通信相比,優勢就不明顯了。

function sendMessage(message) { // This wraps the message posting/response in a promise, which will resolve if the response doesn"t // contain an error, and reject with the error if it does. If you"d prefer, it"s possible to call // controller.postMessage() and set up the onmessage handler independently of a promise, but this is // a convenient wrapper. return new Promise(function(resolve, reject) { const messageChannel = new MessageChannel(); messageChannel.port1.onmessage = function(event) { if (event.data.error) { reject(event.data.error); } else { resolve(event.data); } }; // This sends the message data as well as transferring messageChannel.port2 to the service worker. // The service worker can then use the transferred port to reply via postMessage(), which // will in turn trigger the onmessage handler on messageChannel.port1. // See https://html.spec.whatwg.org/multipage/workers.html#dom-worker-postmessage navigator.serviceWorker.controller.postMessage(message, [messageChannel.port2]); });}

異常處理機制

1. 線程退出時機

Service worker 規範中提到:「Service workers may be started by user agents without an attached document and may be killed by the user agent at nearly any time」,即 Service Worker 線程可能在任意時間被瀏覽器停止,即使關聯的文檔還未關閉 service worker 線程也有可能已被停止。這種設計主要是為了降低 Service Worker 對資源(比如瀏覽器內存、手機電量等)的消耗。所以,Service Worker 線程一般在什麼情況下會被停止?(WTF )

  • Service worker JS 有任何異常,都會導致 service worker 線程退出。包括但不限於 JS 文件存在語法錯誤、service worker 安裝失敗或激活失敗、service worker JS 執行時出現未被捕獲的異常。
  • Service worker 功能事件處理完成,處於空閑狀態,service worker 線程會自動退出。
  • Service worker JS 執行時間過長,service worker 線程會自動退出。比如 service worker JS 執行時間超過 30 秒,或 Fetch 請求在 5 分鐘內還未完成。
  • 瀏覽器會周期性檢查各個 service worker 線程是否可以退出, 一般在啟動 service worker 線程時會檢查一次。
  • 為了方便開發者調試, Chromium 進行了特殊處理, 在連上 devtools 之後,service worker 線程不會退出。Keep a serviceworker alive when devtools is attached - chromium - Monorail

所以,service worker 線程退出時會帶來什麼坑呢?

  • Service worker JS 裡面不能使用全局變數,如果需要全局狀態,必須自己進行持久化,比如使用 IndexedDB API。
  • Service worker 註冊過程中出現異常,無法連上 devtools,無法從 devtools 獲取異常信息。

2. 異常案例

Service worker 線程在啟動或執行代碼的過程中,一般會有下面幾類異常:

1) Service worker JS 文件存在語法錯誤,如 Uncaught SyntaxError: Unexpected token function,這種情況,一般在啟動 WorkerThread 的時候,initialize 初始化時,會調用 ScriptController::evaluate 去執行 service worker 的 JS 代碼,檢查到語法錯誤時,會引起 service worker 註冊失敗。

2) Service worker 安裝或激活的事件回調函數執行代碼存在異常,引起 service worker 線程退出。ScriptPromise 本身會捕獲異常,它僅僅返回 Rejected/Fulfilled,並不會再將 JS 異常往上拋,很多時候前端開發同學僅僅能看到 Promise Rejected 了,但並不清楚是什麼原因。同樣,WaitUntilObserver 也一樣,它也只返回 Rejected/Fulfilled,沒有進一步將 JS 異常往上拋,很多時候前端開發同學僅僅能看到 WaitUntil Rejected 了,也並不清楚是什麼原因。

3) 功能事件處理出錯, 如 Fetch ResponseWith 出錯。舉個例子,下面 service worker js 文件的 fetch 事件處理函數中,如果 strategies.networkFallbackToCache 執行出錯了,會出現什麼問題?

self.addEventListener("fetch", function(e) { return e.respondWith(strategies.networkFallbackToCache(e.request));});

這種情況,respondWith 會 Rejected,但並不會拋異常, 表現為資源請求失敗了,很可能造成頁面白屏或者排版顯示異常。這類問題非常難跟進, 只能是一步一步的修改頁面,然後使用devtools 等工具進行調試。


推薦閱讀:

Progressive Web Apps - Part.3 U4 PWA 特性支持概覽
Progressive Web Apps - Part.1 為什麼是 PWA?
SSR 架構項目實現離線可用(思路&案例)

TAG:pwa | 浏览器内核 | 前端开发 |