Service Worker(下簡稱 SW)是 Web Worker 的一種,而後者是泛指各種平行於網頁的前端執行緒、躲在背景中執行的 JavaScript 程式。在各種 Worker 之中,SW 之所以特別,就是因為它特別還負責中介一切從網頁發送的資源請求處理:每當網頁要取得一項資源或是發送一項 AJAX 請求,SW 都會攔截到這個操作,然後 SW 就可以決定是真的要去抓遠端的資料、還是直接傳回快取的內容,或者甚至對於網頁的請求作出一些修改等等。

簡單來說就是:SW 可以讓我們完全地控制網頁資源的快取使用。

在過去我們要做快取管理,我們需要很麻煩地透過 HTTP header 發送 cache-control, etag 等設定,然後「期望」瀏覽器乖乖地依循這些設定來處理快取,但是搞了老半天我們到最後還是會遇到瀏覽器沒有正確刷到最新版本檔案的狀況。如果只是文字圖片沒有更新那倒也還只是小事,比較麻煩的情況就是具有相依關係的資源 1 沒有正確地被同步刷新,這樣可能甚至會使得整個 web app 跑不起來。一怒之下,開發者常常乾脆就關掉一切快取,但是這樣一來不但網頁跑得慢、伺服器的負荷也不必要地變大。有了 SW,這些情況都可以被完全地終結掉。

不過,SW 本身提供的 API 都是非常底層的 API,要用那些 API 組合出正確的快取管理機制並不是一件容易的事。幸好我們並不需要自己刻;存在一套由 Google 推出的 SW 框架 Workbox 把很多複雜的 SW 底層機制都處理掉了,內建的幾種快取管理策略可以讓我們輕易地寫出自己想要的快取管理。

但是!講真的,如果要自己拿 Workbox 的快取策略來組合,要組到完全理想還是有困難的。所以,底下我要分享一些我自己的 Workbox 使用攻略。底下我都假定讀者已經具備 SW 和 Workbox 的基本知識了,我一樣不重複這些網路上已經有很多教學文章的內容。

理解 precaching 的重要性

初學 Workbox 的人如果不知道它裡頭有 workbox-precaching 這個模組,直接在那邊研究 Workbox 的內建策略的什麼的,那一定會不知道 precaching 有多重要。

要解釋 precaching 的必要性,我們先回到 SW 的啟動流程。在最最一開始瀏覽器第一次打開網頁的時候,當然這個時候 SW 還沒有被註冊上去,所以一切的請求都是走原生的瀏覽器連線,而抓下來的資源也不會被馬上被放進 Cache Storage 裡面。此時,瀏覽器執行到了

navigator.serviceWorker.register("sw.js")

這一行指令,於是就開始非同步地下載 SW,而假設 SW 裡面的 install 事件也加上了 skipWaiting(),那麼 SW 一旦被抓下來就會馬上啟動,並且開始攔截資源請求、並且儲存快取到 Cache Storage 裡面。可是,從剛才的流程我們可以發現,在網頁第一次啟動的時候,可能會有一些資源是比 SW 啟動更早就被下載下來的(這無法預期,因為 SW 是非同步下載下來的),這些資源是不會被 SW 快取起來的——尤其是起始的這個網頁(.html 檔案)自己肯定沒有被快取,因為它的下載一定比 SW 更早!!

這樣一來會發生什麼事?想像一下如果使用者第一次逛完了你的網站之後就把網路關掉(他心想「你不是號稱你的網站可以離線瀏覽嗎?」),然後再次重新打開你的網站,此時會發生什麼事?完全打不開,因為甚至連起始的 .html 檔案都沒有被快取到,SW 要拿什麼來開?如果沒有做 precaching,那這樣的一個極為基本但代志大條的缺陷,是不管結構多麼簡單的網站都免不了的。你以為你用了 SW 就可以讓網站被離線使用,卻沒想到連這麼一種簡單的狀況都會破功。

到這邊就可以解釋 precaching 是在幹嘛了:它的意思就是,當 SW 正在安裝的時候,讓 SW 去下載並且快取一些事先寫好的資源清單,於是只要 SW 啟動了,即使下次造訪這個網站立刻就是離線瀏覽,也能保證我們指定的清單的資源是存在的。理所當然地,這個資源清單裡面也包含了起始網頁本身。

注意到這將導致一個現象是,當我們要使用 precaching 機制的時候,有一些資源在網站第一次被開啟的時候連續被請求了兩次:瀏覽器自己請求了一次,SW 在安裝的時候又再次透過瀏覽器請求了一次。一個值得問的問題是:第二次請求的時候瀏覽器會直接使用剛才快取的結果嗎?關於這個問題我等一下再回答。

用 workbox-precaching 來做版本控制

除了上述的重要性之外,workbox-precaching 還有另一個很強大的好處在於可以用來做資源的版本控制。同樣的資源會有版本更新是非常普遍的一種需求,而我們希望的最理想狀況就是:

  1. 只要沒有更新,前端就一直使用快取而盡量不要動用到網路流量;
  2. 但是只要一有更新,前端應該要立即反應最新的資源。

我們來比較一下幾種不同的作法相較於上述的理想目標的表現如何:

  • 使用 cache-control 來控制快取。
    這種作法的精神是避免瀏覽器無止盡地檢查更新而浪費流量。對於比較不常更新的資源,就設置比較久的快取時間,而更新頻繁的資源就設短一點。但是這樣做不管是上面的 1. 還是 2. 的表現都不好:這種作法既無法根本免去多餘的更新檢查,也不可能即時反應更新。
  • 使用 etag 來檢驗更新。
    這種作法可以讓伺服器快速根據瀏覽器傳來的 etag 來進行比對,如果結果相同就傳回 304 代碼告訴瀏覽器「沒有更新」以快速完成檢查。如此一來,即使頻繁到每次請求資源都進行更新檢查,代價也不大。這樣的作法確實可以即時反應更新沒有錯,但問題是頻繁的檢查就算代價不大也一樣是無謂的流量和伺服器資源的浪費。除此之外,瀏覽器還是必須等候伺服端傳回 304 代碼才能安心地使用快取,這當網路真的很卡的時候仍舊還是導致載入緩慢。
  • Workbox 的 StaleWhileRevalidate 策略。
    這種策略的辦法是「不管怎樣先傳回快取內容,然後再到背景中偷偷檢查並更新快取」。這樣做的好處是避免了上一種作法最後提到的缺點,不管網路多卡、都只有第一次載入會是慢的,之後因為總是直接使用快取所以一定都很快。而如果搭配伺服器的 etag,也能夠讓檢查更新的流量消耗減到最少。然而這種策略有一大罩門在於,如果網頁對於資源的請求是動態的(亦即有些資源是看情況才會被請求的),那麼很有可能網頁會需要被刷新非常多次、才能夠使得所有會被用到的資源都被更新到了最新版本,於是就有可能出現網頁在同一個時間點裡面部份地使用了新舊程度不一的資源,如果資源之間存在版本相依性,那這將會導致問題。
  • 在資源名稱上面加上 hash。
    不知道是誰發明這個方法的,但仔細分析起來這真的是很天才的一招。它的辦法是直接在請求的資源路徑上面加上檔案內容的 hash,例如寫在檔名裡面變成 style.c932a0d8.css(很多打包工具都採用了此法),或者寫在 query 裡頭變成 style.css?c932a0d8WordPress 採用了類似的作法)。由於快取機制(無論是瀏覽器內建的還是 Workbox)會自動把帶有不同的 hash 的資源視為相異,於是就可以藉由更新資源請求的 hash 來自動告知快取機制「有更新發生了」,從而就可以當場立刻去抓更新的資源而不用等到下一次。反之,如果 hash 沒變,那就一定沒有更新,所以連檢查都可以不用檢查。不僅如此,它也克服了版本不一致的問題:因為寫在網頁上的資源路徑一定是一致的,絕對不會同時間裡面取用了版本不同調的資源。
    然而,這個幾乎完美的解法仍舊是有缺點的。首先,它變成所有的網頁上任何有出現資源的地方全都要加上一樣的 hash(於是網頁內容無謂地變長了),而且要當資源有更新的時候去更新這些全部的 hash(雖然我們當然會用程式去自動更新,但感覺上還是很煩)。第二個且也更大條的問題在於:你沒有辦法把網頁的網址本身加上版本 hash 啊 2!!也就是說,雖然只要 HTML 有更新就可以自動知道所有裡面引用到的資源有沒有發生更新,但是誰來告訴瀏覽器 HTML 本身有沒有更新?沒辦法的。如此一來,HTML 檔案就成了唯一的例外,我們唯獨這類型的資源必須回歸到前面的某一種辦法來處理,變成 HTML 資源成了唯一的破綻(例如,有可能發生同一個站台裡面暫時出現不同頁面版本不一的狀況)。

討論到這邊,我們終於要來講大招了:workbox-precaching 的版控。它厲害之處就在於:

  1. 它融合了 hash 法的精神,給資源加上了 revision 來識別資源的更新與否,所以保證所有資源的版本一致與即時的更新、並且幾乎完全省去一切檢查的成本(唯一的檢查就是 SW 一個檔案是否更新而已,而這部份瀏覽器做得很好的,不用擔心)。
  2. 它把 hash 全部統一集中到一個檔案(SW 本身)裡面來管理,所以更新 hash 的時候只要更新一個地方,其它檔案在呼叫資源的時候全部都不用特地寫上 hash。
  3. 它連 HTML 資源也照樣管理進去了!簡直無懈可擊!

不過,在我的觀點中,目前的 workbox-precaching 的機制中還是有一個非常小的美中不足;截至 6.0.2 版為止,從 Workbox 的原始碼可以看得出來,如果在做 precaching 的時候有使用 revision,那麼稍早我提到的「第二次請求時是否會使用剛才的快取」的問題答案就是否定的:不管怎樣它都會使用 reload 模式來進行資源請求 3,所以使得第一次載入的時候一定會消耗兩倍的流量。當然 Workbox 這樣設計有一個好處是我們比較不會因為 HTTP cache-control 設定錯誤而導致資源沒有正確刷新,但是我覺得 Workbox 應該要提供進階選項來讓比較進階的使用者更進一步優化這個環節才對。

設置 workbox-precaching 模組

說了這麼多,那麼這個強大的模組到底要怎麼設置?我們當然不可能自己手動去寫那個檔案清單(即 manifest)和 revision 的 hash,所以當然要依賴一下它提供的另外一個工具:workbox-build

假如你的網站非常單純,全部都是靜態資源、而且使用上把全部的檔案都快取下來就對了,那你可以直接使用它提供的 generateSW() 方法讓它去爬你的資料夾把整個 sw.js 檔案生出來,什麼都不用改。

不過在我的 BPS 中,我需要的比這個稍微要多一些。首先,BPS 並非所有的檔案都要 precaching,有些檔案(例如更新 log 檔案)是我故意要採用 NetworkFirst 策略的 4。其次,我的 SW 並不是只是用來管理快取而已,它還同時兼任一些例如幫助瀏覽器不同頁籤的頁面之間溝通的工作 5。最後,我的 SW 原始碼並不是 JavaScript 而是 TypeScript,我希望可以在自動在編譯流程裡面注入 manifest 而直接產生最後的檔案。因此,我用的甚至不是 workbox-build 裡面的 injectManifest() 方法,而是最低階的 getManifest() 方法。我自己寫了一個 Gulp 的外掛 gulp-workbox 去呼叫 getManifest() 的功能,只要把這個外掛插入在 gulp 建置流程中編譯完 TypeScript 之後就行了。

至於原始碼的部份,我既不是使用內建的 precacheAndRoute() 方法(因為我要自己管控我的 install 事件)也不是完全照著文件裡面的 PrecacheController 的範例去寫的(因為照它那樣的寫法,我會沒辦法註冊自己的 route;而且我覺得 precacheAndRoute() 方法的選項參數很好用,那部份我不想自己刻)。我最後是去爬 precacheAndRoute() 的原始碼並找出了更加靈活的作法。關鍵部份的程式碼大致是這樣的:

importScripts('https://storage.googleapis.com/workbox-cdn/releases/6.0.2/workbox-sw.js');
// 底下這一行在 TypeScript 裡面需要仰賴我等一下會提到的定義檔
const { strategies, routing, googleAnalytics, broadcastUpdate, precaching } = workbox;

// 啟動 Workbox GA;GA 當然也很重要,下次再提
// 基本上只要加上這一行,就可以讓 GA 即使在離線狀態下也持續收集使用者行為,
// 並且在下次連線的時候送出這些行為數據
googleAnalytics.initialize();

// 預設的資源都使用靜態更新策略
let defaultHandler = new strategies.StaleWhileRevalidate({
    cacheName: 'assets',
    // 這邊是我自己要用到的客製化需求
    plugins: [new broadcastUpdate.BroadcastUpdatePlugin({
        generatePayload: options => ({ path: new URL(options.request.url).pathname })
    })]
});
routing.setDefaultHandler(defaultHandler);

// 啟動 workbox-precaching
const precacheController = new precaching.PrecacheController({ cacheName: "assets" });
precacheController.addToCacheList(self.__WB_MANIFEST);
// self.__WB_MANIFEST 會被換成 manifest;其定義在 workbox-precaching 模組中

// 更靈活的作法關鍵就是自己宣告底下這個東西,然後就可以用一樣的選項參數了
const precacheRoute = new precaching.PrecacheRoute(precacheController, {
    ignoreURLParametersMatching: [/.*/],
    directoryIndex: 'index.htm', // 我是老人,副檔名習慣三個字
    cleanURLs: false
});
routing.registerRoute(precacheRoute);

// 在這邊註冊一些我自己的 route,例如,
// 我要除了 precache 之外的 Markdown 檔案都採用網路優先策略
routing.registerRoute(
    ({ url }) => url.pathname.endsWith(".md"),
    new strategies.NetworkFirst({
        // 請瀏覽器不要使用快取;基本上必須加上這個選項才會是真正的 NetworkFirst,
        // 否則視 HTTP cache-control 而定,瀏覽器還是有可能會直接傳回它自己的快取
        fetchOptions: { cache: 'reload' },
        cacheName: 'assets'
    })
);

// 其它的一些我自訂的 route...

self.addEventListener('install', event => {
    skipWaiting();
    precacheController.install(event);
});

self.addEventListener('activate', event => {
    precacheController.activate(event);
});

到這邊就完成了理想的 Workbox!

如果 Workbox 的設置一切都正確,從第二次開啟網頁開始,在 Debug Console 裡面的 Netwrok 部份應該會看到全部的資源都是 SW 直接傳回的才對,如下圖所示:

file

針對 Safari 的伺服端設置

原本應該是到這邊就沒問題了,但偏偏 Safari 在這方面又不聽話。假如只採用上面的設置,然後一樣去打開 Safari 的檢閱器的話,會發現 SW 根本就沒有被呼叫到,幾乎所有的資源都是用記憶體快取直接傳回。當然 Safari 會這樣搞有它自以為聰明的地方:因為記憶體快取真的比呼叫 SW 還要來得更快沒錯(快了上千倍),但是問題是針對我們的版本控制需求,這是不能被允許的事情;我們就是要自己用 SW 來管理快取,拜託你瀏覽器不要自作聰明!

我發現要解決這個問題,唯有的辦法就是伺服端那邊必須傳回不使用快取的 HTTP header,這樣才能夠讓 Safari 乖乖地向 SW 請求資源。關於這部份,不同的後端架構各有不同的設定方式,而如果是 Apache 伺服器的純靜態網站,可以透過在 .htaccess 中加入如下的語法來解決:

<IfModule mod_headers.c>
    Header set Cache-Control "no-cache, no-store, must-revalidate"
    Header set Pragma "no-cache"
    Header set Expires 0
</IfModule>

以 TypeScript 開發 SW

既然我提到我的 SW 原始碼是用 TypeScript 寫的,就值得補充一下用 TypeScript 環境開發 SW 和 Workbox 的配置。

目前官方始終沒有正式推出針對 SW 專用的定義檔;Web Worker 用的 lib.webworker.d.ts 倒是有,但是這個 lib 裡面定義的 self 只是 WorkerGlobalScope 類別,而非 ServiceWorkerGlobalScope,如此一來要寫 SW 相關的程式碼就會有定義檔上的問題。在 GitHub 上頭有一則相關的 issue 在討論這件事,但是一直到現在都還是沒有下文。雖然我很喜歡 TypeScript,但他們團隊在處理這類議題上的態度實在是有夠不積極的。

目前我自己在開發時使用的定義檔是以 Tiernan Cridland 撰寫的定義檔為基礎,後面再加上我自己補充的一段定義:

interface WorkerGlobalScope {
    clients: Clients;
    onactivate: ((event?: ActivateEvent) => any) | null;
    onfetch: ((event?: FetchEvent) => any) | null;
    oninstall: ((event?: InstallEvent) => any) | null;
    onnotificationclick: ((event?: NotificationEvent) => any) | null;
    onnotificationclose: ((event?: NotificationEvent) => any) | null;
    onpush: ((event?: PushEvent) => any) | null;
    onpushsubscriptionchange: (() => any) | null;
    onsync: ((event?: SyncEvent) => any) | null;
    registration: ServiceWorkerRegistration;

    addEventListener<K extends keyof ServiceWorkerGlobalScopeEventMap>(
        type: K,
        listener: (
            this: WorkerGlobalScope,
            ev: ServiceWorkerGlobalScopeEventMap[K]
        ) => any,
        options?: boolean | AddEventListenerOptions
    ): void;
    skipWaiting(): void;
}

然後這是我使用的 tsconfig.json,其中 lib, skipLibCheck 兩項設定是重點:

{
    "compilerOptions": {
        "target": "es2017",
        "removeComments": true,
        "outFile": "sw.js",
        "lib": ["webworker", "esnext"],
        // 因為這邊我們沒有載入 lib.dom,很多定義檔會出錯,這邊指定跳過檢查以避免錯誤
        "skipLibCheck": true,
        "baseUrl": "../../node_modules/"
    },
    "include": [
        "./**/*.ts"
    ]
}

最後,為了撰寫 Workbox 相關的程式,我另外自己寫了一套定義檔,在此分享給各位參考:

import * as backgroundSync from 'workbox-background-sync/index';
import * as broadcastUpdate from 'workbox-broadcast-update/index';
import * as cacheableResponse from 'workbox-cacheable-response/index';
import * as core from 'workbox-core/index';
import * as expiration from 'workbox-expiration/index';
import * as googleAnalytics from 'workbox-google-analytics/index';
import * as navigationPreload from 'workbox-navigation-preload/index';
import * as precaching from 'workbox-precaching/index';
import * as rangeRequests from 'workbox-range-requests/index';
import * as routing from 'workbox-routing/index';
import * as strategies from 'workbox-strategies/index';
import * as streams from 'workbox-streams/index';

export {
    backgroundSync, broadcastUpdate, cacheableResponse, core,
    expiration, googleAnalytics, navigationPreload, precaching,
    rangeRequests, routing, strategies, streams
};
export as namespace workbox;

這個定義裡面引用到的各個模組都是只要有安裝 workbox-build 就會跟著一起安裝的。有了這個定義之後,我稍早給的那一段程式碼的第二行就會自動抓到正確的類別了。


  1. 例如 BPS 裡面有一些 .js 檔案是依賴於另外一些 .js 檔案之上,如果被依賴的檔案有所更新,依賴的檔案也一定要同步更新才能確保執行正確。過去解決這種問題的辦法常常是乾脆把這些 .js 都 bundle 在一起,但這樣一來又有一個壞處是,即便只有一小部份的程式碼修改,使用者仍然得完整地下載整個 bundle。SW 可以做到既確保更新同步、又不需要 bundle 所有檔案。 

  2. 首先,你絕對不可能連起始的網址(例如 https://www.domain.com/ 這種的)都加上 hash,因為這樣一來訪客根本不會知道什麼才是你的正確起始網址;其次,雖然說從第二頁開始你有可能加上 hash,但是這樣一來搜尋網站等等要正確連結到你的個別頁面也會隨著版本的更新而變得幾乎不可行,訪客儲存的書籤也會出問題。 

  3. 反之如果是沒有加上 revision 的資源,那麼它將採用 default 模式來進行 precaching,也就是看 HTTP cache-control 怎麼設定就怎麼處理。 

  4. 因為隨著 app 版本的持續更新,log 會越來越多,我沒有理由叫使用者在安裝的時候一口氣把所有古早的 log 全部抓下來,使用者真的想翻閱早年記錄的時候再下載就好了。至於採用 NetworkFirst 策略,是因為我要確保使用者總是看到最新的 log。 

  5. 時至今日,要做到這種效果本來是有 BroadcastChannel 這個 API 可以用,偏偏這個 API 並不被 Safari 支援,因此我為了相容於 Safari 而用了比較複雜的作法、利用 SW 加上 MessageChannel 來達到一樣的效果。 


分享此頁至:
最後修改日期: 2021/07/08

留言

撰寫回覆或留言

發佈留言必須填寫的電子郵件地址不會公開。您的留言可能會在審核之後才出現在頁面上。