PWA之Workbox快取策略分析

iKcamp發表於2017-12-07

作者:陳達孚

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

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

PWA之Workbox快取策略分析

本文主要分析通過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 = [
  {
    'url': 'main.js',
    'revision': '0e438282dc400829497725a6931f66e3'
  },
  {
    'url': 'main.css',
    'revision': '02ba19bb320adb687e08dded3e71408d'
  }
];

const workboxSW = new self.WorkboxSW();
workboxSW.precache(fileManifest);
複製程式碼

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

precache(revisionedFiles) {
  this._revisionedCacheManager.addToCacheList({
    revisionedFiles,
  })
}
複製程式碼

是的,workbox會提供一個物件 revisionedCacheManager 來管理所有的快取,先不管裡面具體怎麼處理的,往下看有個 registerInstallActivateEvents

_registerInstallActivateEvents(skipWating, clientsClaim) {
  self.addEventListener('install', (event) => {
    const cachedUrls = this._revisionedCacheManager.getCachedUrls();
    event.waitUntil(
      this._revisionedCacheManager.install().then(() => {
        if (skipWaiting) {
          return self.skipWaiting();
        }
      })
    )
}
複製程式碼

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

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

{
  entryID,
  revision,
  request: new Request(url),
  cacheBust
}
複製程式碼

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) {
  const isCached = await this._isAlreadyCached(precacheEntry);
  const precacheDetails = {
    url: precacheEntry.request.url,
    revision: precacheEntry.revision,
    wasUpdated: !isCached,
  };
  if (isCached) {
    return precacheDetails;
  }

  try {
    await this._requestWrapper.fetchAndCache({
      request: precacheEntry.getNetworkRequest(),
      waitOnCache: true,
      cacheKey: precacheEntry.request,
      cleanRedirects: true,
    });

    await this._onEntryCached(precacheEntry);
    return precacheDetails;
  } catch (err) {
    throw new WorkboxError('request-not-cached', {
      url: precacheEntry.request.url,
      error: err,
    });
  }
}
複製程式碼

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

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

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

const effectiveCacheableResponsePlugin =
  this._userSpecifiedCachableResponsePlugin ||
  cacheResponsePlugin ||
  this.getDefaultCacheableResponsePlugin();
複製程式碼

如果沒有外掛配置,會使用 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});
this._revisionedCacheManager = new RevisionedCacheManager({
  cacheId,
  plugins,
});
this._strategies = new Strategies({
  cacheId,
});

this._router = new Router(
  this._revisionedCacheManager.getCacheName(),
  handleFetch
);
this._registerInstallActivateEvents(skipWaiting, clientsClaim);
this._registerDefaultRoutes(ignoreUrlParametersMatching, directoryIndex);
複製程式碼

所以看出 workbox-sw 例項化的過程主要有生成快取對應空間名,快取空間,掛載快取策略,掛載路由方法(用於處理對應路徑的快取策略),註冊安裝啟用方法,註冊預設路由。

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

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

const cacheFirstHandler = this.strategies.cacheFirst({
  cacheName: this._revisionedCacheManager.getCacheName(),
  plugins,
  excludeCacheId: true,
});

const capture = ({url}) => {
  url.hash = '';

  const cachedUrls = this._revisionedCacheManager.getCachedUrls();
  if (cachedUrls.indexOf(url.href) !== -1) {
    return true;
  }

  let strippedUrl =
    this._removeIgnoreUrlParams(url.href, ignoreUrlParametersMatching);
  if (cachedUrls.indexOf(strippedUrl.href) !== -1) {
    return true;
  }

  if (directoryIndex && strippedUrl.pathname.endsWith('/')) {
    strippedUrl.pathname += directoryIndex;
    return cachedUrls.indexOf(strippedUrl.href) !== -1;
  }

    return false;
  };

  this._precacheRouter.registerRoute(capture, cacheFirstHandler);
複製程式碼

簡單的說,如果你一個路徑能直接在 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({
  request: event.request,
});

return cachedResponse || await this.requestWrapper.fetchAndCache({
  request: event.request,
  waitOnCache: this.waitOnCache,
});
複製程式碼

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

然後是 Cache-only,它只會去快取裡拿資料,沒有就失敗了。

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

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

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

const fetchAndCacheResponse = this.requestWrapper.fetchAndCache({
  request: event.request,
  waitOnCache: this.waitOnCache,
  cacheResponsePlugin: this._cacheablePlugin,
}).catch(() => Response.error());

const cachedResponse = await this.requestWrapper.match({
  request: event.request,
});

return cachedResponse || await fetchAndCacheResponse;
  
複製程式碼

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

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

自定義快取配置

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

const workboxSW = new WorkboxSW();
const cacheFirstStrategy = workboxSW.strategies.cacheFirst({
  cacheName: 'example-cache',
  cacheExpiration: {
    maxEntries: 10,
    maxAgeSeconds: 7 * 24 * 60 * 60
  },
  broadcastCacheUpdate: {
    channelName: 'example-channel-name'
  },
  cacheableResponse: {
    stses: [0, 200, 404],
    headers: {
      'Example-Header-1': 'Header-Value-1',
      'Example-Header-2': 'Header-Value-2'
    }
  }
  plugins: [
    // Additional Plugins
  ]
});
複製程式碼

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

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

options.requestWrapper = new RequestWrapper(wrapperOptions);
複製程式碼

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

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

constructor({cacheName, cacheId, plugins, fetchOptions, matchOptions} = {}) {

  this.plugins = new Map();

  if (plugins) {
    isArrayOfType({plugins}, 'object');

    plugins.forEach((plugin) => {
      for (let callbackName of pluginCallbacks) {
        if (typeof plugin[callbackName] === 'function') {
          if (!this.plugins.has(callbackName)) {
            this.plugins.set(callbackName, []);
          } else if (callbackName === 'cacheWillUpdate') {
            throw ErrorFactory.createError(
              'multiple-cache-will-update-plugins');
          } else if (callbackName === 'cachedResponseWillBeUsed') {
            throw ErrorFactory.createError(
              'multiple-cached-response-will-be-used-plugins');
          }
          this.plugins.get(callbackName).push(plugin);
          }
        }
    });
  }
}
複製程式碼

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} = {}) {
  isType({cacheName}, 'string');
  isInstance({newResponse}, Response);

  if (typeof now === 'undefined') {
    now = Date.now();
  }

  await this.updateTimestamp({cacheName, url, now});
  await this.expireEntries({cacheName, now});
}
複製程式碼

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

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

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

  return null;
}
複製程式碼

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: {
  path: [
    '.../xxx.html',
    '.../xxx.json'
  ]
}
複製程式碼

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

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

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

總結

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

PWA之Workbox快取策略分析


PWA之Workbox快取策略分析

2019年,iKcamp原創新書《Koa與Node.js開發實戰》已在京東、天貓、亞馬遜、噹噹開售啦!

相關文章