概述
PWA (Progressive Web App)指的是使用指定技術和標準模式來開發的 Web 應用,讓 Web 應用具有原生應用的特性和體驗。比如我們覺得本地應用使用便捷,響應速度更加快等。
PWA 由 Google 於 2016 年提出,於 2017 年正式技術落地,並在 2018 年迎來重大突破,全球頂級的瀏覽器廠商,Google、Microsoft、Apple 已經全數宣佈支援 PWA 技術。
PWA 的關鍵技術有兩個:
- Manifest:瀏覽器允許你提供一個清單檔案,從而實現 A2HS
- ServiceWorker:通過對網路請求的代理,從而實現資源快取、站點加速、離線應用等場景。
這兩個是目前絕大部分開發者構建 PWA 應用所使用的最多的技術。
其次還有諸如:訊息推送、WebStream、Web藍芽、Web分享、硬體訪問等API。出於瀏覽器廠商的支援不一,普及度還不高。
不管怎麼樣,使用 ServiceWorker 來優化使用者體驗,已經成為Web前端優化的主流技術。
工具與框架
2018 年之前,主流的工具是:
- google/sw-toolbox: 提供了一套工具,用於方便的構建 ServiceWorker。
- google/sw-precache: 提供在構建階段,注入資源清單到 ServiceWorker 中,從而實現預快取功能。
- baidu/Lavas: 百度開發的基於 Vue 的 PWA 整合解決方案。
後來由於 Google 開發了更加優秀的工具集 Workbox,sw-toolbox
和 sw-precache
得以退出舞臺。
而 Lavas 由於團隊解散,主要作者離職,已處於停止維護狀態。
痛點
Workbox 提供了一套工具集合,用以幫助我們管理 ServiceWorker ,它對 CacheStorage 的封裝,也得以讓我們更輕鬆的去管理資源。
但是在構建實際的 PWA 應用的時候,我們還需要關心很多問題:
- 如何組織工程和程式碼?
- 如何進行單元測試?
- 如何解決 MPA (Multiple Page Application) 應用間的 ServiceWorker 作用域衝突問題?
- 如何遠端控制我們的 ServiceWorker?
- 最優的資源快取方案?
- 如何監控我們的 ServiceWorker,收集資料?
由於 Workbox 的定位是 「Library」,而我們需要一個 「Framework」 去為這些通用問題提供統一的解決方案。
並且, 我們希望它是漸進式(Progressive)的,就猶如 PWA 所提倡的那樣。
程式碼解耦
是什麼問題?
當我們的 ServiceWorker 程式程式碼越來越多的時候,會造成程式碼臃腫,管理混亂,複用困難。
同時一些常見的實現,如:遠端控制、程式通訊、資料上報等,希望能實現按需插拔式的複用,這樣才能達到「漸進式」的目的。
我們都知道,ServiceWorker 在執行時提供了一系列事件,常用的有:
self.addEventListener('install', event => { });
self.addEventListener('activate', event => { });
self.addEventListener("fetch", event => { });
self.addEventListener('message', event => { });
當我們有多個功能實現都要監聽相同的事件,就會導致同個檔案的程式碼越來越臃腫:
self.addEventListener('install', event => {
// 遠端控制模組 - 配置初始化
...
// 資源預快取模組 - 快取資源
...
// 資料上報模組 - 收集事件
...
});
self.addEventListener('activate', event => {
// 遠端控制模組 - 重新整理配置
...
// 資料上報模組 - 收集事件
...
});
self.addEventListener("fetch", event => {
// 遠端控制模組 - 心跳檢查
...
// 資源快取模組 - 快取匹配
...
// 資料上報模組 - 收集事件
...
});
self.addEventListener('message', event => {
// 資料上報模組 - 收集事件
...
});
你可能會說可以進行「模組化」:
import remoteController from './remoete-controller.ts'; // 遠端控制模組
import assetsCache from './assets-cache.ts'; // 資源快取模組
import collector from './collector.ts'; // 資料收集模組
import precache from './pre-cache.ts'; // 資源預快取模組
self.addEventListener('install', event => {
// 遠端控制模組 - 配置初始化
remoteController.init(...);
// 資源預快取模組 - 快取資源
assetsCache.store(...);
// 資料上報模組 - 收集事件
collector.log(...);
});
self.addEventListener('activate', event => {
// 遠端控制模組 - 重新整理配置
remoteController.refresh(..);
// 資料上報模組 - 收集事件
collector.log(...);
});
self.addEventListener("fetch", event => {
// 遠端控制模組 - 心跳檢查
remoteController.heartbeat(...);
// 資源快取模組 - 快取匹配
assetsCache.match(...);
// 資料上報模組 - 收集事件
collector.log(...);
});
self.addEventListener('message', event => {
// 資料上報模組 - 收集事件
collector.log(...);
});
模組化能減少主檔案的程式碼量,同時也一定程度上對功能進行了解耦,但是這種方式還存在一些問題:
- 複用困難:當要使用一個模組的功能時,要在多個事件中去正確的呼叫模組的介面。同樣,要去掉一個模組事,也要多個事件中去修改。
- 使用成本高:模組暴露各種介面,使用者必須瞭解透徹模組的運轉方式,以及介面的使用,才能很好的使用。
- 解耦有限:如果模組更多,甚至要解決同域名下多個前端應用的名稱空間衝突問題,就會顯得捉襟見肘。
要達到我們目的:「漸進式」,我們需要對程式碼的組織再優化一下。
外掛化實現
我們可以把 ServiceWorker 的一系列事件的控制權交出去,各模組通過外掛的方式來使用這些事件。
我們知道 Koa.js 著名的洋蔥模型:
洋蔥模型是「外掛化」的很好的思想,但是它是 「一維」 的,Koa 完成一次網路請求的應答,各個中介軟體只需要監聽一個事件。
而在 ServiceWorker 中,除了上面提及到的常用四個事件,他還有更多事件,如:SyncEvent
, NotificationEvent
。
所以,我們還要多弄幾個「洋蔥」去滿足更多的事件。
同時由於 PWA 應用的程式碼一般會執行在兩個執行緒:主執行緒、ServiceWorker 執行緒。
最後,我們去封裝原生的事件,去提供外掛化支援,從而有了:「多維洋蔥外掛系統」:
對原生事件和生命週期進行封裝之後,我們為每一個外掛提供更優雅的生命週期鉤子函式:
我們基於 GlacierJS 的話,可以很容易做到模組的外掛化。
在 ServiceWorker 執行緒的主檔案中註冊外掛:
import { GlacierSW } from '@glacierjs/sw';
import RemoteController from './remoete-controller.ts'; // 遠端控制模組
import AssetsCache from './assets-cache.ts'; // 資源快取模組
import Collector from './collector.ts'; // 資料收集模組
import Precache from './pre-cache.ts'; // 資源預快取模組
import MyPluginSW from './my-plugin.ts'
const glacier = new GlacierSW();
glacier.use(new Log(...));
glacier.use(new RemoteController(...));
glacier.use(new AssetsCache(...));
glacier.use(new Collector(...));
glacier.use(new Precache(...));
glacier.listen();
而在外掛中,我們可以通過監聽事件去收歸一個獨立模組的邏輯:
import { ServiceWorkerPlugin } from '@glacierjs/sw';
import type { FetchContext, UseContext } from '@glacierjs/sw';
export class MyPluginSW implements ServiceWorkerPlugin {
constructor() {...}
public async onUse(context: UseContext) {...}
public async onInstall(event) {...}
public async onActivate() {...}
public async onFetch(context: FetchContext) {...}
public async onMessage(event) {...}
public async onUninstall() {...}
}
作用域衝突
我們都知道關於 ServiceWorker 的作用域有兩個關鍵特性:
- 預設的作用域是註冊時候的 Path。
- 同個路徑下同時間只能有一個 ServiceWorker 得到控制權。
作用域縮小與擴大
關於第一個特性,例如註冊 Service Worker 檔案為 /a/b/sw.js
,則 scope 預設為 /a/b/
:
if (navigator.serviceWorker) {
navigator.serviceWorker.register('/a/b/sw.js').then(function (reg) {
console.log(reg.scope);
// scope => https://yourhost/a/b/
});
}
當然我們可以在註冊的的時候指定 scope
去向下縮小作用域,例如:
if (navigator.serviceWorker) {
navigator.serviceWorker.register('/a/b/sw.js', {scope: '/a/b/c/'})
.then(function (reg) {
console.log(reg.scope);
// scope => https://yourhost/a/b/c/
});
}
也可以通過伺服器對 ServiceWorker 檔案的響應設定 Service-Worker-Allowed
頭部,去擴大作用域。
例如 Google Docs 在作用域 https://docs.google.com/document/u/0/
註冊了一個來自於 https://docs.google.com/document/offline/serviceworker.js
的 ServiceWorker
MPA下的 ServiceWorker 治理
現代 Web App 專案主要有兩種架構形式存在: SPA(Single Page Application) 和 MPA(Multiple Page Application)
MPA 這種架構的模式在現如今的大型 Web App 非常常見,這種 Web App 相比較於 SPA 能夠承受更重的業務體量,並且利於大型 Web App 的後期維護和擴充套件,它往往會有多個團隊去維護。
假設我們有一個 MPA 的站點:
.
|-- app1
| |-- app1-service-worker.js
| `-- index.html
|-- app2
| `-- index.html
|-- index.html
`-- root-service-worker.js
app1 和 app2 分別由不同的團隊維護。
如果我們在根目錄 '/'
註冊了 root-service-worker.js
,去完成一些通用的功能,例如:「日誌收集」、「靜態資源快取」等。
然後 app1 團隊利用 ServiceWorker 的能力開發了一些特定的功能需要,例如 app1 的「離線化功能」。
他們在 app1/index.html
目錄註冊了 app1-service-worker.js
。
這時候,訪問 app1/*
下的所有頁面,ServiceWorker 控制權會交給 app1-service-worker.js
,也就是隻有app1的「離線化功能」在工作,而原來的「日誌收集」、「靜態快取」等功能會失效。
顯然這種情況是我們不希望看到的,並且在實際的開發中發生的概率會很大。
解決這個問題有兩種方案:
- 封裝「日誌收集」、「靜態資源快取」功能,
app1-service-worker.js
引入並使用這些功能。 - 把「離線化功能」整合到
root-service-worker.js
,只允許註冊該 ServiceWorker。
關於方案一,封裝通用功能這是正確的,但是主域下的功能可能完全沒辦法一一拆解,並且後續主域的 ServiceWorker 更新了新功能,子域下的 ServiceWorker 還需要主動去更新和升級。
關於方案二,顯然可以解決方案一的問題,但是其他應用,例如 app2 可能不需要「離線化功能」。
基於此,我們引入方案三:功能整合到主域,支援功能的組合按照作用域隔離。
基於 GlacierJS 的話程式碼上可能會是這樣的:
const mainPlugins = [
new Collector(); // 日誌收集功能
new AssetsCache(); // 靜態資源快取功能
];
glacier.use('/', mainPlugins);
glacier.use('/app1', [
...mainPlugins,
new Offiline(), // 離線化功能
]);
資源快取
ServiceWorker 一個很核心的能力就是能結合 CacheAPI 進行靈活的快取資源,從而達到優化站點的載入速度、弱網訪問、離線應用等。
對於靜態資源有五種常用的快取策略:
- stale-while-revalidate
該模式允許您使用快取(如果可用)儘快響應請求,如果沒有快取則回退到網路請求,然後使用網路請求來更新快取,它是一種比較安全的快取策略。 - cache-first
離線 Web 應用程式將嚴重依賴快取,但對於非關鍵且可以逐漸快取的資源,「快取優先」是最佳選擇。
如果快取中有響應,則將使用快取的響應來滿足請求,並且根本不會使用網路。
如果沒有快取響應,則請求將由網路請求完成,然後響應會被快取,以便下次直接從快取中提供下一個請求。 - network-first
對於頻繁更新的請求,「網路優先」策略是理想的解決方案。
預設情況下,它會嘗試從網路獲取最新響應。如果請求成功,它會將響應放入快取中。如果網路未能返回響應,則將使用快取的響應。 - network-only
如果您需要從網路滿足特定請求,network-only 模式會將資源請求進行透傳到網路。 - cache-only
該策略確保從快取中獲取響應。這種場景不太常見,它一般匹配著「預快取」策略會比較有用。
那這些策略中,我們應該使用哪種呢?答案是根據資源的種類具體選擇。
例如一些資源如果只是在 Web 應用釋出的時候才會更新,我們就可以使用 cache-first 策略,例如一些 JS、樣式、圖片等。
而 index.html 作為頁面的載入的主入口,更加適宜使用 stale-while-revalidate 策略。
我們以 GlacierJS 的快取外掛(@glacierjs/plugin-assets-cache)為例:
// in service-worker.js
importScripts("//cdn.jsdelivr.net/npm/@glacierjs/core/dist/index.min.js");
importScripts('//cdn.jsdelivr.net/npm/@glacierjs/sw/dist/index.min.js');
importScripts('//cdn.jsdelivr.net/npm/@glacierjs/plugin-assets-cache/dist/index.min.js');
const { GlacierSW } = self['@glacierjs/sw'];
const { AssetsCacheSW, Strategy } = self['@glacierjs/plugin-assets-cache'];
const glacierSW = new GlacierSW();
glacierSW.use(new AssetsCacheSW({
routes: [{
// capture as string: store index.html with stale-while-revalidate strategy.
capture: 'https://mysite.com/index.html',
strategy: Strategy.STALE_WHILE_REVALIDATE,
}, {
// capture as RegExp: store all images with cache-first strategy
capture: /\.(png|jpg)$/,
strategy: Strategy.CACHE_FIRST
}, {
// capture as function: store all stylesheet with cache-first strategy
capture: ({ request }) => request.destination === 'style',
strategy: Strategy.CACHE_FIRST
}],
}));
遠端控制
基於 ServiceWorker 的原理,一旦在瀏覽器安裝上了,如果遇到緊急線上問題,唯有釋出新的 ServiceWorker 才能解決問題。但是 ServiceWorker 的安裝是有時延的,再加上有些團隊從修改程式碼到釋出的流程,這個反射弧就很長了。我們有什麼辦法能縮短對於線上問題的反射弧呢?
我們可以在遠端儲存一個配置,針對可預見的場景,進行「遠端控制」:
那麼我們怎麼去獲取配置呢?
方案一,如果我們在主執行緒中獲取配置:
- 需要使用者主動重新整理頁面才會生效。
- 做不到輕量的功能關閉,什麼意思呢,我們會有開關的場景,主執行緒只能通過解除安裝或者清理快取去實現「關閉」,這個太重了。
方案二,如果我們在 ServiceWorker 執行緒去獲取配置:
- 可以實現輕量功能關閉,透傳請求就行了。
- 但是如果遇到要乾淨的清理使用者環境的需要,去解除安裝 ServiceWorker 的時候,就會導致主程式每次註冊,到了 ServiceWorker 就解除安裝,造成頻繁安裝解除安裝。
所以我們的 最後方案 是 「基於雙執行緒的實時配置獲取」。
主執行緒也要獲取配置,然後配置前面要加上防抖保護,防止 onFetch 事件短時間併發的問題。
程式碼上,我們使用 Glacier 的外掛 @glacierjs/plugin-remote-controller 可以輕鬆實現遠端控制:
// in ./remote-controller-sw.ts
import { RemoteControllerSW } from '@glacierjs/plugin-remote-controller';
import { GlacierSW } from '@glacierjs/sw';
import { options } from './options';
const glacierSW = new GlacierSW();
glacierSW.use(new RemoteControllerSW({
fetchConfig: () => getMyRemoteConfig();
}));
// 其中 getMyRemoteConfig 用於獲取你存在遠端的配置,返回的格式規定如下:
const getMyRemoteConfig = async () => {
const config: RemoteConfig = {
// 全域性關閉,解除安裝 ServiceWorker
switch: true,
// 快取功能開關
assetsEnable: true,
// 精細控制特定快取
assetsCacheRoutes: [{
capture: 'https://mysite.com/index.html',
strategy: Strategy.STALE_WHILE_REVALIDATE,
}],
},
}
資料收集
ServiceWorker 釋出之後,我們需要保持對線上情況的把控。 對於一些必要的統計指標,我們可能需要進行上統計和上報。
@glacierjs/plugin-collector 內建了五個常見的資料事件:
- ServiceWorker 註冊:SW_REGISTER
- ServiceWorker 安裝成功:SW_INSTALLED
- ServiceWorker 控制中:SW_CONTROLLED
- 命中 onFetch 事件:SW_FETCH
- 命中瀏覽器快取:CACHE_HIT of CacheFrom.Window
- 命中 CacheAPI 快取:CACHE_HIT of CacheFrom.SW
基於以上資料的收集,我們就可以得到一些常見的通用指標:
- ServiceWorker 安裝率 = SW_REGISTER / SW_INSTALLED
- ServiceWorker 控制率 = SW_REGISTER / SW_CONTROLLED
- ServiceWorker 快取命中率 = SW_FETCH / CACHE_HIT (of CacheFrom.SW)
首先我們在 ServiceWorker 執行緒中註冊 plugin-collector:
import { AssetsCacheSW } from '@glacierjs/plugin-assets-cache';
import { CollectorSW } from '@glacierjs/plugin-collector';
import { GlacierSW } from '@glacierjs/sw';
const glacierSW = new GlacierSW();
// should use plugin-assets-cache first in order to make CollectedDataType.CACHE_HIT work.
glacierSW.use(new AssetsCacheSW({...}));
glacierSW.use(new CollectorSW());
然後在主執行緒中註冊 plugin-collector,並且監聽資料事件,進行資料上報:
import {
CollectorWindow,
CollectedData,
CollectedDataType,
} from '@glacierjs/plugin-collector';
import { CacheFrom } from '@glacierjs/plugin-assets-cache';
import { GlacierWindow } from '@glacierjs/window';
const glacierWindow = new GlacierWindow('./service-worker.js');
glacierWindow.use(new CollectorWindow({
send(data: CollectedData) {
const { type, data } = data;
switch (type) {
case CollectedDataType.SW_REGISTER:
myReporter.event('sw-register-count');
break;
case CollectedDataType.SW_INSTALLED:
myReporter.event('sw-installed-count');
break;
case CollectedDataType.SW_CONTROLLED:
myReporter.event('sw-controlled-count');
break;
case CollectedDataType.SW_FETCH:
myReporter.event('sw-fetch-count');
break;
case CollectedDataType.CACHE_HIT:
// hit service worker cache
if (data?.from === CacheFrom.SW) {
myReporter.event(`sw-assets-count:hit-sw-${data?.url}`);
}
// hit browser cache or network
if (data?.from === CacheFrom.Window) {
myReporter.event(`sw-assets-count:hit-window-${data?.url}`);
}
break;
}
},
}));
其中 myReporter.event
是你可能會實現的資料上報庫。
單元測試
ServiceWorker 測試可以分解為常見的測試組。
在頂層的是 「整合測試」,在這一層,我們檢查整體的行為,例如:測試頁面可載入,ServiceWorker註冊,離線功能等。整合測試是最慢的,但是也是最接近現實情況的。
再往下一層的是 「瀏覽器單元測試」,由於 ServiceWorker 的生命週期,以及一些 API 只有在瀏覽器環境下才能有,所以我們使用瀏覽器去進行單元測試,會減少很多環境的問題。
接著是 「ServiceWorker 單元測試」,這種測試也是在瀏覽器環境中註冊了測試用的 ServiceWorker 為前提進行的單元測試。
最後一種是 「模擬 ServiceWorker」,這種測試粒度會更加精細,精細到某個類某個方法,只檢測入參和返回。這意味著沒有了瀏覽器啟動成本,並且最終是一種可預測的方式測試程式碼的方式。
但是模擬 ServiceWorker 是一件困難的事情,如果 mock 的 API 表面不正確,則在整合測試或者瀏覽器單元測試之前問題不會被發現。我們可以使用 service-worker-mock 或者 MSW 在 NodeJS 環境中進行 ServiceWorker 的單元測試。
由於篇幅有限,後續我另開專題來講講 ServiceWorker 單元測試的實踐。
總結
本文開篇描述了關於 PWA 的基本概念,然後介紹了一些現在社群優秀的工具,以及要去構建一個「可控、可靠、可擴充套件的 PWA 應用」所面臨的的實際的痛點。
於是在三個「可」給出了一些實踐性的建議:
- 通過「資料收集」、「遠端控制」保證我們對已釋出的 PWA 應用的 「可控性」
- 通過「單元測試」、「整合測試」去保障我們 PWA 應用的 「可靠性」
- 通過「多維洋蔥外掛模型」支援外掛化和 MPA 應用,以及整合多個外掛,從而達到 PWA 應用的 「可擴充套件性」。