PWA之Workbox緩存策略分析

作者:陳達孚

香港中文大學研究生,《移動Web前端高效開發實戰》作者之一,《前端開發者指南2017》譯者之一,在中國前端開發者大會,中生代技術大會等技術會議發表過主題演講, 專註於新技術的調研和使用.

本文為原創文章,轉載請註明作者及出處

本文主要分析通過workbox(基於1.x和2.x版本,未來3.x版本會有新的結構)生成Service-Worker的緩存策略,workbox是GoogleChrome團隊對原來sw-precache和sw-toolbox的封裝,並且提供了Webpack和Gulp插件方便開發者快速生成sw.js文件。

precache(預緩存)

首先看一下 workbox 提供的 Webpack 插件 workboxPlugin 的三個最主要參數:

  • globDirectory
  • staticFileGlobs
  • swDest

其中 globDirectorystaticFileGlobs 會決定需要緩存的靜態文件,這兩個參數也存在默認值,插件會從compilation參數中獲取開發者在 Webpack 配置的 output.path 作為 globDirectory 的默認值,staticFileGlobs 的默認配置是 html,js,css 文件,如果需要緩存一些界面必須的圖片,這個地方需要自己配置。

之後 Webpack 插件會將配置作為參數傳遞給 workbox-build 模塊,workbox-build 模塊中會根據 globDirectory 和 staticFileGlobs 讀取文件生成一份配置信息,交給 precache 處理。需要注意的是,precache里不要存太多的文件,workbox-build 對文件會有一個過濾, 該模塊會讀取利用 node 的 fs 模塊讀取文件,如果文件大於2M則不會加入配置中(可以通過配置 maximumFileSize 修改),同時會根據文件的 buffer 生成一個 hash 值,也就是說就算開發者不改變文件名,只要文件內容修改了,也會生成一個新的配置內容,讓瀏覽器更新緩存。

那麼說了那麼多,precache 到底幹了什麼,看一下生成的sw文件:

const fileManifest = [n {n url: main.js,n revision: 0e438282dc400829497725a6931f66e3n },n {n url: main.css,n revision: 02ba19bb320adb687e08dded3e71408dn }n];nnconst workboxSW = new self.WorkboxSW();nworkboxSW.precache(fileManifest);n

那還是需要看一下 precache 的代碼:

precache(revisionedFiles) {n this._revisionedCacheManager.addToCacheList({n revisionedFiles,n })n}n n

是的,workbox會提供一個對象 revisionedCacheManager 來管理所有的緩存,先不管裡面具體怎麼處理的,往下看有個 registerInstallActivateEvents

_registerInstallActivateEvents(skipWating, clientsClaim) {n self.addEventListener(install, (event) => {n const cachedUrls = this._revisionedCacheManager.getCachedUrls();n event.waitUntil(n this._revisionedCacheManager.install().then(() => {n if (skipWaiting) {n return self.skipWaiting();n }n })n )n}n

這裡可以看出,所有的 precache 都會在 service worker 的 install 事件中完成。event.waitUntil 會根據內部promise的結果來確定安裝是否完成。如果安裝失敗,則會捨棄這個ServiceWorker。

現在看一下 _revisionedCacheManager.install 里幹了什麼,首先 revisionedFiles 會被放在一個 Map 中,當然這個 revisionedFiles 是已經被處理過了, 在經過 addToCacheList ->_addEntries -> _parseEntry 的過程後,會返回:

{n entryID,n revision,n request: new Request(url),n cacheBustn}n n

entryID 不主動傳入可以視為用戶傳入的url,將用來作為IndexDB中的key存儲revision,而request則用來提供給之後的fetch請求,cacheBust默認為true,功能等會再分析。

Map 的set 過程在 _addEntries_addEntryToInstallList 函數中,這裡只需注意因為 fileManifest 中不能存放具有相同 url (或者說entryID)的值,不然會被警告。

現在回來看install,install是一個async函數,返回一個包含一系列Promise請求的Promise.all,符合waitUntil的要求。每一個需要緩存的文件會到 cacheEntry 函數中處理:

async _cacheEntry(precacheEntry) {n const isCached = await this._isAlreadyCached(precacheEntry);n const precacheDetails = {n url: precacheEntry.request.url,n revision: precacheEntry.revision,n wasUpdated: !isCached,n };n if (isCached) {n return precacheDetails;n }nn try {n await this._requestWrapper.fetchAndCache({n request: precacheEntry.getNetworkRequest(),n waitOnCache: true,n cacheKey: precacheEntry.request,n cleanRedirects: true,n });nn await this._onEntryCached(precacheEntry);n return precacheDetails;n } catch (err) {n throw new WorkboxError(request-not-cached, {n url: precacheEntry.request.url,n error: err,n });n }n}n

對於每一個請求會去通過 _isAlreadyCached 方法訪問indexDB 得知是否被緩存過。這裡可能有讀者會疑惑,我們不是不能在 fileManifest 中不允許存儲同樣的url,為什麼還要查是否緩存過,這是因為當你sw文件更新後,原來的緩存還是存在的,它們或許持有相同的url,如果它們的revision也相同,就不用獲取了。

在 _cacheEntry 內部,還有兩個非同步操作,一個是通過包裝後的 requestWrapperfetchAndCache 請求並緩存數據,一個是通過 _onEntryCached 方法更新indexDB,可以看到雖然catch了錯誤,但依舊會throw出來,意味著任何一個precache的文件請求失敗,都會終止此次install。

這裡另一個需要注意的地方是 _requestWrapper.fetchAndCache,所有請求最後都會在 requestWrapper中處理,這裡調用的實例方法是 fetchAndCache ,說明這次請求會涉及到網路請求和緩存處理兩部分。在發出請求後,首先會判斷請求結果是否需要加入緩存中:

const effectiveCacheableResponsePlugin =n this._userSpecifiedCachableResponsePlugin ||n cacheResponsePlugin ||n this.getDefaultCacheableResponsePlugin();n n

如果沒有插件配置,會使用 getDefaultCacheableResponsePlugin()來取得默認配置,即緩存返回狀態為200的請求。

在上面的代碼中可以看到在 precache 環境下,會有兩個參數為 true, 一個是 waitOnCache,另一個是cleanRedirects。waitOnCache保證在需要緩存的情況下返回網路結果時必須完成緩存的處理,cleanRedirects則會重新包裝一下請求重定向的結果。

最後用_onEntryCached把緩存的路徑憑證信息存在indexDB中。

在activate階段,會對precache在cache里的內容進行clean,因為前面只做了更新,如果是新的precache沒有的資源地址,在這裡會刪除。

所以 precache 就是在 service-worker 的 install 事件下完成一次對配置資源的網路請求,並在請求結果返回時完成對結果的緩存。

runtimecache(運行時緩存)###

在了解 runtimecache 前,先看下 workbox-sw 的實例化過程中比較重要的部分:

this._runtimeCacheName = getDefaultCacheName({cacheId});nthis._revisionedCacheManager = new RevisionedCacheManager({n cacheId,n plugins,n});nthis._strategies = new Strategies({n cacheId,n});nnthis._router = new Router(n this._revisionedCacheManager.getCacheName(),n handleFetchn);nthis._registerInstallActivateEvents(skipWaiting, clientsClaim);nthis._registerDefaultRoutes(ignoreUrlParametersMatching, directoryIndex);n

所以看出 workbox-sw 實例化的過程主要有生成緩存對應空間名,緩存空間,掛載緩存策略,掛載路由方法(用於處理對應路徑的緩存策略),註冊安裝激活方法,註冊默認路由。

precache 對應的就是 runtimecache,runtimecache 顧名思義就是處理所有運行時的緩存,runtimecache 往往應對著各種類型的資源,對於不同類型的資源往往也有不同的緩存策略,所以在 workbox 中使用 runtimecache 需要調用方法,workbox.router.registerRoute 也是說明 runtimecache 需要路由層面的細緻劃分。

看到最後一步的 _registerDefaultRoutes,看一下其中的代碼,可以發現 workbox 有一個最基本的cache,這個 cache 其實處理的就是前面的 precache,這個 cache 遵從著 cacheFirst 原則:

const cacheFirstHandler = this.strategies.cacheFirst({n cacheName: this._revisionedCacheManager.getCacheName(),n plugins,n excludeCacheId: true,n});nnconst capture = ({url}) => {n url.hash = ;nn const cachedUrls = this._revisionedCacheManager.getCachedUrls();n if (cachedUrls.indexOf(url.href) !== -1) {n return true;n }nn let strippedUrl =n this._removeIgnoreUrlParams(url.href, ignoreUrlParametersMatching);n if (cachedUrls.indexOf(strippedUrl.href) !== -1) {n return true;n }nn if (directoryIndex && strippedUrl.pathname.endsWith(/)) {n strippedUrl.pathname += directoryIndex;n return cachedUrls.indexOf(strippedUrl.href) !== -1;n }nn return false;n };nn this._precacheRouter.registerRoute(capture, cacheFirstHandler);n

簡單的說,如果你一個路徑能直接在 precache 中可以找到,或者在去除了部分查詢參數後符合,或者去處部分查詢參數添加後綴後符合,就會直接返回緩存,至於請求過來怎麼處理的,稍後再看。

我們可以這麼認為 precache 就是添加了 cache,至於真實請求時如何處理還是和 runtimecache 在一個地方處理,現在看來,在 workbox 初始化的時候就有了第一個 router.registerRoute(),之後的就需要手動註冊了。

在寫自己註冊的策略之前,考慮下,註冊了 route 後,又怎麼處理呢?在實例化 Router 的時候,我們就會添加一個 self.addEventListener(fetch, (event) => {...}),除非你手動傳入一個handleFetch參數為false。

在註冊路由的時候,registerRoute(capture, handler, method)在類中接受一個捕獲條件和一個句柄函數,這個捕獲條件可以是字元串,正則表達式或者是直接的Route對象,當然最終都會變成 Route 對象(分別通過 ExpressRoute 和 RegExpRoute),Route對象包含匹配,處理方法,和方法(默認為 GET)。然後在註冊時會使用一個 Map,以每個使用到的方法為 Key,值為包含所有Route對象的數組,在遍歷時也只會遍歷相應方法的值。所以你也可以給不同的方法定義同樣的捕獲路徑。

這裡使用了 unshift 操作,所以每個新的配置會被壓入堆棧的頂部,在遍歷時則會被優先遍歷到。因為 workbox 實例化是在 registerRoute 之前,所以默認配置優先順序最低,配置後面的註冊會優先於前面的。

所以最終在頁面上,你的每次請求都會被監聽,到相應的請求方法數組裡找有沒有匹配的,如果沒有匹配的話,也可以使用 setDefaultHandlersetDefaultHandler不是前面的 _registerDefaultRoutes,它需要開發者自己定義,並決定策略,如果定義了,所有沒被匹配的請求就會被這個策略處理。請求還支持設置在,在請求被匹配卻沒有正確被方法處理情況下的錯誤處理,最終 event 會用處理方法(策略)處理這個請求,否則就正常請求。這些請求就是 workbox下的 runtimecache。

緩存策略

現在來看看 Workbox 提供的緩存策略,主要有這幾種:cache-first,cache-only,network-first,network-only,stale-while-revalidate

在前面看到,實例化的時候會給 workbox 掛載一個 Strategies 的實例。提供上面一系列的緩存策略,但在實際調用中,使用的是 _getCachingMechanism,然後把整個策略類放到一參中,二參則提供了配置項,在每個策略類中都有 handle 方法的實現,最終也會調用 handle方法。那既然如此還搞個 _getCachingMechanism幹嘛,直接返回策略類就得了,這個等下看。

先看下各個策略,這裡就簡單說下,可以參考離線指南,雖然會有一點不一樣。

第一個 Cache-First, 它的 handle 方法:

const cachedResponse = await this.requestWrapper.match({n request: event.request,n});nnreturn cachedResponse || await this.requestWrapper.fetchAndCache({n request: event.request,n waitOnCache: this.waitOnCache,n});n

Cache-First策略會在有緩存的時候返回緩存,沒有緩存才會去請求並且把請求結果緩存,這也是我們對於precache的策略。

然後是 Cache-only,它只會去緩存里拿數據,沒有就失敗了。

network-first 是一個比較複雜的策略,它接受 networkTimeoutSeconds 參數,如果沒有傳這個參數,請求將會發出,成功的話就返回結果添加到緩存中,如果失敗則返回立即緩存。這種網路回退到緩存的方式雖然利於那些頻繁更新的資源,但是在網路情況比較差的情況(無網會直接返回緩存)下,等待會比較久,這時候 networkTimeoutSeconds 就提供了作用,如果設置了,會生成一個setTimeout後被resolve的緩存調用,再把它和請求放倒一個 Promise.race 中,那麼請求超時後就會返回緩存。

network-only,也比較簡單,只請求,不讀寫緩存。

最後提供的策略是 StaleWhileRevalidate,這種策略比較接近 cache-first,代碼如下:

const fetchAndCacheResponse = this.requestWrapper.fetchAndCache({n request: event.request,n waitOnCache: this.waitOnCache,n cacheResponsePlugin: this._cacheablePlugin,n}).catch(() => Response.error());nnconst cachedResponse = await this.requestWrapper.match({n request: event.request,n});nnreturn cachedResponse || await fetchAndCacheResponse;n n

他們的區別在於就算有緩存,它仍然會發出請求,請求的結果會用來更新緩存,也就是說你的下一次訪問的如果時間足夠請求返回的話,你就能拿到最新的數據了。

可以看到離線指南中還提供了緩存然後訪問網路再更新頁面的方法,但這種需要配合主進程代碼的修改,WorkBox 沒有提供這種模式。

自定義緩存配置

回到在緩存策略里提到的,講講 _getCachingMechanism和緩存策略的參數。默認支持5個參數:cacheExpiration, broadcastCacheUpdate, cacheableResponse, cacheName, plugins,(當然你會發現還有幾個參數不在這裡處理,比如你可以傳一個自定義的 requestWrapper, 前面提到的 waitOnCache 和 NetworkFirst 支持的 networkTimeoutSeconds),先看一個完整的示例:

const workboxSW = new WorkboxSW();nconst cacheFirstStrategy = workboxSW.strategies.cacheFirst({n cacheName: example-cache,n cacheExpiration: {n maxEntries: 10,n maxAgeSeconds: 7 * 24 * 60 * 60n },n broadcastCacheUpdate: {n channelName: example-channel-namen },n cacheableResponse: {n stses: [0, 200, 404],n headers: {n Example-Header-1: Header-Value-1,n Example-Header-2: Header-Value-2n }n }n plugins: [n // Additional Pluginsn ]n});n

大致可以認定的是 cacheExpiration 會用來處理緩存失效,cacheName 決定了 cache 的索引名,cacheableResponse 則決定了什麼請求返回可以被緩存。

那麼插件到底是怎麼被處理,現在可以看_getCachingMechanism函數了,_getCachingMechanism函數處理了什麼,它其實就是把 cacheExpirationbroadcastCacheUpdate,cacheabelResponse里的參數找到對應方法,傳入參數實例化,然後掛在在封裝後的wrapperOptions的plugins參數里,但是只是實例化了有什麼用呢?這裡有關鍵的一步:

options.requestWrapper = new RequestWrapper(wrapperOptions);n

所以最終這些插件還是會在 RequestWrapper 里處理,這裡的一些操作是我們之前沒有提到的,來看下 RequestWrapper 里怎麼處理的。

看下 RequestWrapper 的構造函數,取其中涉及到 plugins 的部分:

constructor({cacheName, cacheId, plugins, fetchOptions, matchOptions} = {}) {nn this.plugins = new Map();nn if (plugins) {n isArrayOfType({plugins}, object);nn plugins.forEach((plugin) => {n for (let callbackName of pluginCallbacks) {n if (typeof plugin[callbackName] === function) {n if (!this.plugins.has(callbackName)) {n this.plugins.set(callbackName, []);n } else if (callbackName === cacheWillUpdate) {n throw ErrorFactory.createError(n multiple-cache-will-update-plugins);n } else if (callbackName === cachedResponseWillBeUsed) {n throw ErrorFactory.createError(n multiple-cached-response-will-be-used-plugins);n }n this.plugins.get(callbackName).push(plugin);n }n }n });n }n}n

plugins是一個Map,默認支持以下幾種Key:cacheDidUpdate, cacheWillUpdate, fetchDidFail, requestWillFetch, cachedResponseWillBeUsed。可以理解為 requestWrapper 提供了一些hooks或者生命周期,而插件就是在 hook 上進行一些處理。

這裡舉個緩存失效的例子看看怎麼處理:

首先我們需要實例化CacheExpirationPlugin,CacheExpirationPlugin沒有構造函數,實例化的是CacheExpiration,然後在this上添加maxEntries,maxAgeSeconds。所有的 hook 方法實現都放在了 CacheExpirationPlugin,提供了兩個 hook: cachedResponseWillBeUsed 和 cacheDidUpdate,cachedResponseWillBeUsed 會在 RequestWrapper的match中執行,cacheDidUpdate 在 fetchAndCache中 執行。

這裡可以看出,每個plugin其實就是對hook或者生命周期調用的具體實現,在把response扔到cache里之後,調用了插件的cacheDidUpdate方法,看下CacheExpirationPlugin中的cacheDidUpdate:

async cacheDidUpdate({cacheName, newResponse, url, now} = {}) {n isType({cacheName}, string);n isInstance({newResponse}, Response);nn if (typeof now === undefined) {n now = Date.now();n }nn await this.updateTimestamp({cacheName, url, now});n await this.expireEntries({cacheName, now});n}n

那麼關鍵就是更新時間戳和失效條數,如果設置了更新時間戳會怎麼樣呢,在請求的時候,runtimecache也會添加到IndexedDB,值存入的是一個對象,包含了一個url和時間戳。

這個時間戳怎麼生效,CacheExpirationPlugin提供了另外一個方法,cachedResponseWillBeUsed:

cachedResponseWillBeUsed({cachedResponse, cachedResponse, now} = {}) {n if (this.isResponseFresh({cachedResponse, now})) {n return cachedResponse;n }nn return null;n}n

RequestWrapper中的match方法會默認從cache里取,取到的是當時的完整 response, 在cache的 response 里的 headers 里取到 date,然後把當時的date加上 maxAgeSecond 和 現在的時間比, 如果小於了就返回 false,那麼自然會去發起請求了。

CacheableResponsePlugin用來控制 fetchAndCache 里的 cacheable,它設置了一個 cacheWillUpdate,可以設置哪些 http status 或者 headers 的 response 要緩存,做到更精細的緩存操作。

如何配置我的緩存

離線指南已經提供了一些緩存方式,在 workbox 中,可以大致認為,有一些資源會直接影響整個應用的框架能否顯示的(開發應用的 JS,CSS 和部分圖片)可以做 precache,這些資源一般不存在「非同步」的載入,它們如果不顯示整個頁面無法正常載入。

那他們的更新策略也很簡單,一般這些資源的更新需要發版,而在這裡用更新sw文件更新。

對於大部分無狀態(注意無狀態)數據請求,推薦StaleWhileRevalidate方式或者緩存回退,在某些後端數據變化比較快的情況下,添加失效時間也是可以的,對於其它(業務圖片)需求,cache-first比較適用。

最後需要討論的是頁面和有狀態的請求,頁面是一個比較複雜的情況,頁面如果是純靜態的,那麼可以放入precache。但要注意,如果我們的頁面不是打包工具生成的,頁面文件很可能不在dist目錄下,那麼怎麼追蹤變化呢,這裡推薦一種方式,我們的頁面往往有一個模版,和一個json串配置hash變數,那麼你可以添加這種模式:

templatedUrls: {n path: [n .../xxx.html,n .../xxx.jsonn ]n}n

如果沒有json,就需要關聯所有可能影響生成頁面的數據了,那麼這些文件的變化都會改變最後生成的sw文件。

如果你在頁面上有一些動態信息(比如用戶信息等等),那就比較麻煩了,推薦使用 network-first 配合一個合適的失敗時間,畢竟大家都不希望用戶登錄了另一個賬號,顯示的還是上一個賬號,這同樣適用於那些使用cookie(有狀態)的請求,這些請求也推薦你添加失效策略,和失敗狀態。

永遠記住你的目標,讓用戶能夠更快的看到頁面,但不要給用戶一個錯誤的頁面。

總結

在目前的網路環境下,service worker 的推送服務並不能得到很好的利用,所以使用 service worker 很大程度就是利用其強大的緩存能力給用戶在弱網和無網環境的優化,甚至可以通過判斷網路環境進行一些預下載,豐富頁面的交互。但是一個錯誤的緩存策略可能會使用戶得不到最新的內容,每一個致力於使用 service worker 或者 PWA 的開發者都需要了解其緩存的處理。Google 提供了一系列的工具能夠快速生成優質的sw文件,但是配套文檔過分簡單和無本地化讓這些配置如同一個黑盒,使開發者很難確定正確的配置方案。希望能夠閱讀本文,解決讀者這方面的困惑。

免費參加前端公開課,400本好書等你拿~

推薦閱讀:

蘋果官方對PWA支持步伐奇快, iOS 11.3 和 macOS 10.13.4 將默認支持Service Worker
PWA 入門: 理解和創建 Service Worker 腳本
Progressive Web Apps - Part.3 U4 PWA 特性支持概覽
Progressive Web Apps - Part.1 為什麼是 PWA?
SSR 架構項目實現離線可用(思路&案例)

TAG:pwa | 前端开发 | 前端入门 |