Stories

Detail Return Return

如何構建可控,可靠,可擴展的 PWA 應用 - Stories Detail

概述

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 開發了更加優秀的工具集 Workbox,sw-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 應用的 「可擴展性」

參考

  • 《PWA實戰:面向下一代的Progressive Web APP》
  • Service Worker 註冊
  • Two HTTP headers related to Service Workers you never may have heard of
  • 如何優雅的為 PWA 註冊 Service Worker
  • Workbox
  • GlacierJS - 多維洋葱插件系統
  • GlacierJS - 資源緩存
  • GlacierJS - 遠程控制
  • GlacierJS - 數據收集
user avatar evilboy Avatar shuirong1997 Avatar wupeng_5a4de5c290b9d Avatar huaiyug Avatar
Favorites 4 users favorite the story!
Favorites

Add a new Comments

Some HTML is okay.