如何構建可控,可靠,可擴充套件的 PWA 應用

JerryC發表於2022-04-21

概述

PWA (Progressive Web App)指的是使用指定技術和標準模式來開發的 Web 應用,讓 Web 應用具有原生應用的特性和體驗。比如我們覺得本地應用使用便捷,響應速度更加快等。

PWA 由 Google 於 2016 年提出,於 2017 年正式技術落地,並在 2018 年迎來重大突破,全球頂級的瀏覽器廠商,Google、Microsoft、Apple 已經全數宣佈支援 PWA 技術。

PWA 的關鍵技術有兩個:

  1. Manifest:瀏覽器允許你提供一個清單檔案,從而實現 A2HS
  2. ServiceWorker:通過對網路請求的代理,從而實現資源快取、站點加速、離線應用等場景。

這兩個是目前絕大部分開發者構建 PWA 應用所使用的最多的技術。

其次還有諸如:訊息推送、WebStream、Web藍芽、Web分享、硬體訪問等API。出於瀏覽器廠商的支援不一,普及度還不高。

不管怎麼樣,使用 ServiceWorker 來優化使用者體驗,已經成為Web前端優化的主流技術。

工具與框架

2018 年之前,主流的工具是:

  1. google/sw-toolbox: 提供了一套工具,用於方便的構建 ServiceWorker。
  2. google/sw-precache: 提供在構建階段,注入資源清單到 ServiceWorker 中,從而實現預快取功能。
  3. baidu/Lavas: 百度開發的基於 Vue 的 PWA 整合解決方案。

後來由於 Google 開發了更加優秀的工具集 Workboxsw-toolboxsw-precache 得以退出舞臺。

而 Lavas 由於團隊解散,主要作者離職,已處於停止維護狀態。

痛點

Workbox 提供了一套工具集合,用以幫助我們管理 ServiceWorker ,它對 CacheStorage 的封裝,也得以讓我們更輕鬆的去管理資源。

但是在構建實際的 PWA 應用的時候,我們還需要關心很多問題:

  1. 如何組織工程和程式碼?
  2. 如何進行單元測試?
  3. 如何解決 MPA (Multiple Page Application) 應用間的 ServiceWorker 作用域衝突問題?
  4. 如何遠端控制我們的 ServiceWorker?
  5. 最優的資源快取方案?
  6. 如何監控我們的 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(...);
});

模組化能減少主檔案的程式碼量,同時也一定程度上對功能進行了解耦,但是這種方式還存在一些問題:

  1. 複用困難:當要使用一個模組的功能時,要在多個事件中去正確的呼叫模組的介面。同樣,要去掉一個模組事,也要多個事件中去修改。
  2. 使用成本高:模組暴露各種介面,使用者必須瞭解透徹模組的運轉方式,以及介面的使用,才能很好的使用。
  3. 解耦有限:如果模組更多,甚至要解決同域名下多個前端應用的名稱空間衝突問題,就會顯得捉襟見肘。

要達到我們目的:「漸進式」,我們需要對程式碼的組織再優化一下。

外掛化實現

我們可以把 ServiceWorker 的一系列事件的控制權交出去,各模組通過外掛的方式來使用這些事件。

我們知道 Koa.js 著名的洋蔥模型:

koa洋蔥模型

洋蔥模型是「外掛化」的很好的思想,但是它是 「一維」 的,Koa 完成一次網路請求的應答,各個中介軟體只需要監聽一個事件。

而在 ServiceWorker 中,除了上面提及到的常用四個事件,他還有更多事件,如:SyncEvent, NotificationEvent

所以,我們還要多弄幾個「洋蔥」去滿足更多的事件。

同時由於 PWA 應用的程式碼一般會執行在兩個執行緒:主執行緒、ServiceWorker 執行緒。

最後,我們去封裝原生的事件,去提供外掛化支援,從而有了:「多維洋蔥外掛系統」

GlacierJS 多維洋蔥外掛系統

對原生事件和生命週期進行封裝之後,我們為每一個外掛提供更優雅的生命週期鉤子函式:

GlacierJS 生命週期圖示

我們基於 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 的作用域有兩個關鍵特性:

  1. 預設的作用域是註冊時候的 Path。
  2. 同個路徑下同時間只能有一個 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

img

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

app1app2 分別由不同的團隊維護。

如果我們在根目錄 '/' 註冊了 root-service-worker.js,去完成一些通用的功能,例如:「日誌收集」、「靜態資源快取」等。

然後 app1 團隊利用 ServiceWorker 的能力開發了一些特定的功能需要,例如 app1 的「離線化功能」。

他們在 app1/index.html 目錄註冊了 app1-service-worker.js

這時候,訪問 app1/* 下的所有頁面,ServiceWorker 控制權會交給 app1-service-worker.js,也就是隻有app1的「離線化功能」在工作,而原來的「日誌收集」、「靜態快取」等功能會失效。

顯然這種情況是我們不希望看到的,並且在實際的開發中發生的概率會很大。

解決這個問題有兩種方案:

  1. 封裝「日誌收集」、「靜態資源快取」功能,app1-service-worker.js引入並使用這些功能。
  2. 把「離線化功能」整合到 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 進行靈活的快取資源,從而達到優化站點的載入速度、弱網訪問、離線應用等。

image-20220414092525515

對於靜態資源有五種常用的快取策略:

  1. stale-while-revalidate
    該模式允許您使用快取(如果可用)儘快響應請求,如果沒有快取則回退到網路請求,然後使用網路請求來更新快取,它是一種比較安全的快取策略。
  2. cache-first
    離線 Web 應用程式將嚴重依賴快取,但對於非關鍵且可以逐漸快取的資源,「快取優先」是最佳選擇。
    如果快取中有響應,則將使用快取的響應來滿足請求,並且根本不會使用網路。
    如果沒有快取響應,則請求將由網路請求完成,然後響應會被快取,以便下次直接從快取中提供下一個請求。
  3. network-first
    對於頻繁更新的請求,「網路優先」策略是理想的解決方案。
    預設情況下,它會嘗試從網路獲取最新響應。如果請求成功,它會將響應放入快取中。如果網路未能返回響應,則將使用快取的響應。
  4. network-only
    如果您需要從網路滿足特定請求,network-only 模式會將資源請求進行透傳到網路。
  5. 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 的安裝是有時延的,再加上有些團隊從修改程式碼到釋出的流程,這個反射弧就很長了。我們有什麼辦法能縮短對於線上問題的反射弧呢?

我們可以在遠端儲存一個配置,針對可預見的場景,進行「遠端控制」

remote-controller.drawio

那麼我們怎麼去獲取配置呢?

方案一,如果我們在主執行緒中獲取配置:

  1. 需要使用者主動重新整理頁面才會生效。
  2. 做不到輕量的功能關閉,什麼意思呢,我們會有開關的場景,主執行緒只能通過解除安裝或者清理快取去實現「關閉」,這個太重了。

方案二,如果我們在 ServiceWorker 執行緒去獲取配置:

  1. 可以實現輕量功能關閉,透傳請求就行了。
  2. 但是如果遇到要乾淨的清理使用者環境的需要,去解除安裝 ServiceWorker 的時候,就會導致主程式每次註冊,到了 ServiceWorker 就解除安裝,造成頻繁安裝解除安裝。

image-20220417012859191

所以我們的 最後方案「基於雙執行緒的實時配置獲取」

主執行緒也要獲取配置,然後配置前面要加上防抖保護,防止 onFetch 事件短時間併發的問題。

image-20220417012934418

程式碼上,我們使用 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 內建了五個常見的資料事件:

  1. ServiceWorker 註冊:SW_REGISTER
  2. ServiceWorker 安裝成功:SW_INSTALLED
  3. ServiceWorker 控制中:SW_CONTROLLED
  4. 命中 onFetch 事件:SW_FETCH
  5. 命中瀏覽器快取:CACHE_HIT of CacheFrom.Window
  6. 命中 CacheAPI 快取:CACHE_HIT of CacheFrom.SW

基於以上資料的收集,我們就可以得到一些常見的通用指標:

  1. ServiceWorker 安裝率 = SW_REGISTER / SW_INSTALLED
  2. ServiceWorker 控制率 = SW_REGISTER / SW_CONTROLLED
  3. 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 測試可以分解為常見的測試組。

img

在頂層的是 「整合測試」,在這一層,我們檢查整體的行為,例如:測試頁面可載入,ServiceWorker註冊,離線功能等。整合測試是最慢的,但是也是最接近現實情況的。

再往下一層的是 「瀏覽器單元測試」,由於 ServiceWorker 的生命週期,以及一些 API 只有在瀏覽器環境下才能有,所以我們使用瀏覽器去進行單元測試,會減少很多環境的問題。

接著是 「ServiceWorker 單元測試」,這種測試也是在瀏覽器環境中註冊了測試用的 ServiceWorker 為前提進行的單元測試。

最後一種是 「模擬 ServiceWorker」,這種測試粒度會更加精細,精細到某個類某個方法,只檢測入參和返回。這意味著沒有了瀏覽器啟動成本,並且最終是一種可預測的方式測試程式碼的方式。

但是模擬 ServiceWorker 是一件困難的事情,如果 mock 的 API 表面不正確,則在整合測試或者瀏覽器單元測試之前問題不會被發現。我們可以使用 service-worker-mock 或者 MSW 在 NodeJS 環境中進行 ServiceWorker 的單元測試。

由於篇幅有限,後續我另開專題來講講 ServiceWorker 單元測試的實踐。

總結

本文開篇描述了關於 PWA 的基本概念,然後介紹了一些現在社群優秀的工具,以及要去構建一個「可控、可靠、可擴充套件的 PWA 應用」所面臨的的實際的痛點。

於是在三個「可」給出了一些實踐性的建議:

  1. 通過「資料收集」、「遠端控制」保證我們對已釋出的 PWA 應用的 「可控性」
  2. 通過「單元測試」、「整合測試」去保障我們 PWA 應用的 「可靠性」
  3. 通過「多維洋蔥外掛模型」支援外掛化和 MPA 應用,以及整合多個外掛,從而達到 PWA 應用的 「可擴充套件性」

參考

相關文章