離線食譜

Jake Archibald
Jake Archibald

透過服務工作處理程序,我們不但努力嘗試離線解決,也讓開發人員能自行解決。可讓您控制快取和要求的處理方式。這表示您必須建立自己的模式。以下逐一說明幾種可能的模式,但實際上,您可能會根據網址和內容同時使用多種模式。

如需其中一些模式的實際運作示範,請參閱「訓練體驗」和這部影片,瞭解效能影響。

快取機器 (何時儲存資源)

Service Worker 可讓您從快取單獨處理要求,因此我會另外示範。首先是快取,應何時完成快取?

在安裝時做為依附元件

安裝時 - 做為依附元件。
安裝時做為依附元件。

Service Worker 提供 install 事件。您可以利用此方法準備工作,也就是在處理其他事件前必須準備就緒的內容。雖然這麼做會執行任何舊版 Service Worker 仍在執行中的頁面,因此您在此處執行的動作不會造成乾擾。

適用於:CSS、圖片、字型、JS、範本;基本上,凡是您認定為網站「版本」的靜態項目。

否則,要是無法擷取,您的網站就完全無法運作;初始下載作業也會包含對等的平台專屬應用程式。

self.addEventListener('install', function (event) {
  event.waitUntil(
    caches.open('mysite-static-v3').then(function (cache) {
      return cache.addAll([
        '/css/whatever-v3.css',
        '/css/imgs/sprites-v6.png',
        '/css/fonts/whatever-v8.woff',
        '/js/all-min-v4.js',
        // etc.
      ]);
    }),
  );
});

event.waitUntil 會做出承諾,藉此定義安裝的時間長度和成功情況。如果承諾遭拒,安裝就會視為失敗,並捨棄這個 Service Worker (如果執行的是較舊版本,系統會完整保留該版本)。caches.open()cache.addAll() 的退貨承諾。如果無法擷取任何資源,cache.addAll() 呼叫會拒絕。

訓練有素時,我會使用這項金鑰來快取靜態資產

安裝時,而非依附元件

安裝時 - 不屬於依附元件。
安裝時 - 不屬於依附元件。

這與上述類似,但並不會延遲安裝完成,而且如果快取失敗,也不會導致安裝失敗。

適合對象:不需要立即取得的較大資源,例如遊戲後期的資產。

self.addEventListener('install', function (event) {
  event.waitUntil(
    caches.open('mygame-core-v1').then(function (cache) {
      cache
        .addAll
        // levels 11–20
        ();
      return cache
        .addAll
        // core assets and levels 1–10
        ();
    }),
  );
});

上述範例不會將 11 至 20 關的 cache.addAll 承諾傳回 event.waitUntil,因此即使失敗,遊戲仍可離線使用。當然,如果缺少這些層級,您必須做好心理準備,萬一缺少這些層級,請重新嘗試快取。

系統處理完事件後,可能會終止 Service Worker,這也代表系統不會快取第 11 至 20 級的下載作業。日後,Web Periodic Background Synchronization API 將處理這類情況,並處理較大的下載項目 (例如電影)。目前只有 Chromium 分支支援該 API。

啟用時

啟用。
啟用。

適用於:清除和遷移。

安裝新的 Service Worker 且不再使用舊版本後,新的 Service Worker 會啟用,而您會收到 activate 事件。由於舊版已經淘汰,因此建議您盡快處理索引資料庫中的結構定義遷移作業,並將未使用的快取刪除。

self.addEventListener('activate', function (event) {
  event.waitUntil(
    caches.keys().then(function (cacheNames) {
      return Promise.all(
        cacheNames
          .filter(function (cacheName) {
            // Return true if you want to remove this cache,
            // but remember that caches are shared across
            // the whole origin
          })
          .map(function (cacheName) {
            return caches.delete(cacheName);
          }),
      );
    }),
  );
});

在啟用期間,fetch 等其他事件會排入佇列,因此長時間啟用可能會封鎖網頁載入。盡可能保持啟用狀態,並只在舊版本啟用時「無法」用於其他用途。

在「經過訓練的訓練」中,我會使用這個項目來移除舊的快取

使用者互動時

使用者進行互動時。
使用者互動時。

適用情況:當整個網站無法下線,而且您選擇允許使用者選取想離線使用的內容。例如 YouTube 上的影片、維基百科上的文章、Flickr 上的特定影片庫。

為使用者提供「稍後閱讀」或「儲存至離線觀看清單」按鈕。按一下該按鈕後,即可從網路擷取所需內容,並彈出至快取中。

document.querySelector('.cache-article').addEventListener('click', function (event) {
  event.preventDefault();

  var id = this.dataset.articleId;
  caches.open('mysite-article-' + id).then(function (cache) {
    fetch('/get-article-urls?id=' + id)
      .then(function (response) {
        // /get-article-urls returns a JSON-encoded array of
        // resource URLs that a given article depends on
        return response.json();
      })
      .then(function (urls) {
        cache.addAll(urls);
      });
  });
});

caches API 可供網頁和 Service Worker 使用,也就是您可以直接從網頁新增至快取。

有網路回應

有網路回應。
需要網路回應。

適用情況:經常更新使用者的收件匣或文章內容等資源。此外,對顯示圖片等非必要的內容也很實用,但您必須謹慎處理。

如果要求與快取中的任何資料不符,請從網路取得要求,並傳送至頁面,再同時將其新增至快取。

如果要為多個網址 (例如顯示圖片) 執行這項操作,請特別留意,不要增加來源的儲存空間。如果使用者需要收回磁碟空間,您就不需要是最佳選擇。確定刪除快取中不再需要的項目。

self.addEventListener('fetch', function (event) {
  event.respondWith(
    caches.open('mysite-dynamic').then(function (cache) {
      return cache.match(event.request).then(function (response) {
        return (
          response ||
          fetch(event.request).then(function (response) {
            cache.put(event.request, response.clone());
            return response;
          })
        );
      });
    }),
  );
});

為了維持記憶體使用效率,您只能讀取回應/要求的主體一次。上述程式碼使用 .clone() 建立可單獨讀取的其他副本。

訓練有素的訓練中,我會使用這個選項快取 Flickr 圖片

重新驗證時過時

重新驗證時過時。
Stale-while-revalidate。

適用情況:經常更新資源,因為這是最基本的做法。 顯示圖片可屬於這個類別。

如果有可用的快取版本,請直接使用該版本,但擷取更新供下次使用。

self.addEventListener('fetch', function (event) {
  event.respondWith(
    caches.open('mysite-dynamic').then(function (cache) {
      return cache.match(event.request).then(function (response) {
        var fetchPromise = fetch(event.request).then(function (networkResponse) {
          cache.put(event.request, networkResponse.clone());
          return networkResponse;
        });
        return response || fetchPromise;
      });
    }),
  );
});

這與 HTTP 的「過時的時重新驗證」非常類似。

推送訊息時

推送訊息時。
推送訊息。

Push API 是以 Service Worker 為基礎建構的另一項功能。如此一來,即可喚醒 Service Worker,以回應來自 OS 的訊息傳遞服務的訊息。就算使用者沒有開啟您的網站分頁,也會發生這種情形。系統只會喚醒 Service Worker。透過頁面要求這項權限後,系統會提示使用者。

適用於:通知相關內容,例如即時通訊訊息、即時新聞報導或電子郵件。此外,頻繁變更無法立即同步處理的內容,例如待辦事項清單更新或日曆變更。

常見的最終結果是通知,使用者輕觸後會開啟/聚焦於相關頁面,但要在這種情況發生前更新快取,這一點「極為重要」extremely。使用者顯然在接收推送訊息時顯然處於線上狀態,但他們最後可能與通知互動時可能無法上線,因此提供離線使用的內容也很重要。

這段程式碼會在顯示通知前更新快取:

self.addEventListener('push', function (event) {
  if (event.data.text() == 'new-email') {
    event.waitUntil(
      caches
        .open('mysite-dynamic')
        .then(function (cache) {
          return fetch('/inbox.json').then(function (response) {
            cache.put('/inbox.json', response.clone());
            return response.json();
          });
        })
        .then(function (emails) {
          registration.showNotification('New email', {
            body: 'From ' + emails[0].from.name,
            tag: 'new-email',
          });
        }),
    );
  }
});

self.addEventListener('notificationclick', function (event) {
  if (event.notification.tag == 'new-email') {
    // Assume that all of the resources needed to render
    // /inbox/ have previously been cached, e.g. as part
    // of the install handler.
    new WindowClient('/inbox/');
  }
});

背景同步

開啟背景同步。
在背景同步處理時。

「背景同步處理」是以 Service Worker 為基礎建構的另一項功能。這可讓您以一次性或 (極度經驗法則) 間隔要求背景資料同步處理。即使使用者沒有開啟您的網站分頁,也會發生這種情況。系統只喚醒 Service Worker。透過頁面要求這項權限後,系統會提示使用者進行這項操作。

適合對象:非緊急的更新,尤其是定期更新,對使用者而言,每次更新推送訊息的頻率過高,例如社交時間軸或新聞報導。

self.addEventListener('sync', function (event) {
  if (event.id == 'update-leaderboard') {
    event.waitUntil(
      caches.open('mygame-dynamic').then(function (cache) {
        return cache.add('/leaderboard.json');
      }),
    );
  }
});

快取持續性

您的來源可獲得一定數量的空間來完成工作。該可用空間由所有來源儲存空間共用:(本機) 儲存空間IndexedDB檔案系統存取權,以及快取

您不必限制實際獲得的金額,實際情況取決於裝置和儲存空間條件。你可以透過下列方式查看收益:

navigator.storageQuota.queryInfo('temporary').then(function (info) {
  console.log(info.quota);
  // Result: <quota in bytes>
  console.log(info.usage);
  // Result: <used data in bytes>
});

不過,如同所有瀏覽器儲存空間,如果裝置有儲存壓力,瀏覽器便可自由捨棄資料。遺憾的是,瀏覽器無法分辨您要以付費方式播放的電影,還有您真正想看的遊戲。

如要解決這個問題,請使用 StorageManager 介面:

// From a page:
navigator.storage.persist()
.then(function(persisted) {
  if (persisted) {
    // Hurrah, your data is here to stay!
  } else {
   // So sad, your data may get chucked. Sorry.
});

當然,使用者必須授予權限。方法是使用 Permissions API。

現在,讓使用者能參與此流程至關重要,因為我們現在預期使用者能夠掌控刪除程序。如果裝置有儲存空間壓力,而且清除非必要資料無法解決問題,使用者就可以判斷要保留並移除哪些項目。

為達到這個目的,作業系統在儲存空間用量詳細分析資料時,必須將「耐用」來源視為與平台專用應用程式相等的來源,而不是將瀏覽器回報為單一項目。

提供建議 - 回應要求

不論您有多少快取內容,服務工作站都不會使用快取,除非您指定快取的時間和方式。以下是一些處理要求的模式:

僅快取

僅限快取。
僅限快取。

適合對象:你認為網站特定「版本」的靜態項目。您應已在安裝事件中快取這些程式碼,以便隨時使用。

self.addEventListener('fetch', function (event) {
  // If a match isn't found in the cache, the response
  // will look like a connection error
  event.respondWith(caches.match(event.request));
});

...雖然您通常不需要特別處理這個案例,但「快取改回網路」可以解決這個問題。

僅限網路

僅限網路。
僅限網路。

適用於:沒有離線對應項目的項目,例如分析連線偵測 (ping) 和非 GET 要求。

self.addEventListener('fetch', function (event) {
  event.respondWith(fetch(event.request));
  // or simply don't call event.respondWith, which
  // will result in default browser behavior
});

...雖然您通常不需要特別處理這個案例,但「快取改回網路」可以解決這個問題。

快取,改回使用網路

快取,改用網路。
快取,改回使用網路。

理想情況:離線優先建立。在這種情況下,這是您處理大多數要求的方式。其他模式則是視傳入的要求而定。

self.addEventListener('fetch', function (event) {
  event.respondWith(
    caches.match(event.request).then(function (response) {
      return response || fetch(event.request);
    }),
  );
});

如此一來,對於快取中的項目,可以「僅快取」行為;對於未快取的任何項目 (包括所有無法快取的非 GET 要求),都屬於「僅限網路」行為。

快取和網路競賽

快取和網路競賽。
快取和網路競賽。

適用於:需要在磁碟存取速度緩慢的裝置上觀察效能的小型資產。

使用某些較舊的硬碟、病毒掃描器以及更快速的網路連線組合時,從網路取得資源可能會比前往磁碟更快。但是,當使用者的裝置中有內容可能會造成浪費,因此前往網路時請特別留意。

// Promise.race is no good to us because it rejects if
// a promise rejects before fulfilling. Let's make a proper
// race function:
function promiseAny(promises) {
  return new Promise((resolve, reject) => {
    // make sure promises are all promises
    promises = promises.map((p) => Promise.resolve(p));
    // resolve this promise as soon as one resolves
    promises.forEach((p) => p.then(resolve));
    // reject if all promises reject
    promises.reduce((a, b) => a.catch(() => b)).catch(() => reject(Error('All failed')));
  });
}

self.addEventListener('fetch', function (event) {
  event.respondWith(promiseAny([caches.match(event.request), fetch(event.request)]));
});

網路改回快取

網路改回使用快取。
網路改回使用快取。

適用於:針對經常更新的資源 (位於網站「版本」之外) 快速修正。例如文章、顯示圖片、社群媒體時間軸和遊戲領導板。

這表示您可以提供線上使用者最新的內容,但離線使用者則取得較舊的快取版本。如果網路要求成功,建議您更新快取項目

但這種方法已有瑕疵。如果使用者的連線不穩或連線速度緩慢,就要等待網路失敗,才能取得裝置上已經可以接受的內容。這項作業可能需要相當長的時間,且會對使用者體驗造成困擾。請參閱下一種模式,快取再網路,以取得更好的解決方案。

self.addEventListener('fetch', function (event) {
  event.respondWith(
    fetch(event.request).catch(function () {
      return caches.match(event.request);
    }),
  );
});

先快取快取,再指定網路

依序快取和網路
先快取,再儲存網路。

適合:經常更新的內容。例如文章、社群媒體時間軸和遊戲排行榜。

這會讓網頁發出兩個要求:一個傳送至快取,另一個傳送至網路。其概念是先顯示快取資料,然後在網路資料送達時更新頁面。

有時您可以在新資料送達時直接取代現有資料 (例如遊戲排行榜),但這可能會受到大量內容的影響。基本上,請勿「消失」使用者可能正在閱讀或互動的內容。

Twitter 會在舊內容上方加入新內容,並調整捲動位置,讓使用者不會受到干擾。可能的原因是 Twitter 會保留大部分內容的訂單。我複製了這個模式,用於「訓練有益」,以便在內容送達時盡快在螢幕上呈現最新內容。

網頁中的程式碼:

var networkDataReceived = false;

startSpinner();

// fetch fresh data
var networkUpdate = fetch('/data.json')
  .then(function (response) {
    return response.json();
  })
  .then(function (data) {
    networkDataReceived = true;
    updatePage(data);
  });

// fetch cached data
caches
  .match('/data.json')
  .then(function (response) {
    if (!response) throw Error('No data');
    return response.json();
  })
  .then(function (data) {
    // don't overwrite newer network data
    if (!networkDataReceived) {
      updatePage(data);
    }
  })
  .catch(function () {
    // we didn't get cached data, the network is our last hope:
    return networkUpdate;
  })
  .catch(showErrorMessage)
  .then(stopSpinner);

Service Worker 中的程式碼:

請務必隨時前往網路並更新快取。

self.addEventListener('fetch', function (event) {
  event.respondWith(
    caches.open('mysite-dynamic').then(function (cache) {
      return fetch(event.request).then(function (response) {
        cache.put(event.request, response.clone());
        return response;
      });
    }),
  );
});

已訓練完成的情況下,我使用 XHR (而非擷取) 來解決這個問題,並濫用 Accept 標頭告知服務工作站從何處取得結果 (頁面程式碼Service Worker 程式碼)。

一般備用廣告

一般備用廣告。
一般備用。

如果您無法透過快取和/或網路提供內容,建議您提供一般備用項。

適用於:次要圖像,例如顯示圖片、失敗的 POST 要求,以及「離線時無法使用」頁面。

self.addEventListener('fetch', function (event) {
  event.respondWith(
    // Try the cache
    caches
      .match(event.request)
      .then(function (response) {
        // Fall back to network
        return response || fetch(event.request);
      })
      .catch(function () {
        // If both fail, show a generic fallback:
        return caches.match('/offline.html');
        // However, in reality you'd have many different
        // fallbacks, depending on URL and headers.
        // Eg, a fallback silhouette image for avatars.
      }),
  );
});

您備用的項目可能是安裝依附元件

如果您的網頁正在張貼電子郵件,服務工作人員可能會改回將電子郵件儲存在索引資料庫「寄件匣」中,並通知頁面傳送失敗但資料保留成功。

Service Worker 端範本

ServiceWorker 端範本。
ServiceWorker 端範本。

適合:無法快取伺服器回應的網頁。

在伺服器上轉譯網頁可讓作業快速,但這可能包括在快取中不合乎的狀態資料,例如「已登入...」。如果您的頁面是由 Service Worker 控管,那麼您可以選擇要求 JSON 資料與範本,然後改為算繪該頁面。

importScripts('templating-engine.js');

self.addEventListener('fetch', function (event) {
  var requestURL = new URL(event.request.url);

  event.respondWith(
    Promise.all([
      caches.match('/article-template.html').then(function (response) {
        return response.text();
      }),
      caches.match(requestURL.path + '.json').then(function (response) {
        return response.json();
      }),
    ]).then(function (responses) {
      var template = responses[0];
      var data = responses[1];

      return new Response(renderTemplate(template, data), {
        headers: {
          'Content-Type': 'text/html',
        },
      });
    }),
  );
});

全部整合在一起

並不限於上述任一方法。實際上,您可能會根據要求網址使用許多參數。例如,「train-to-thrill」會使用:

請查看要求並決定要採取的行動:

self.addEventListener('fetch', function (event) {
  // Parse the URL:
  var requestURL = new URL(event.request.url);

  // Handle requests to a particular host specifically
  if (requestURL.hostname == 'api.example.com') {
    event.respondWith(/* some combination of patterns */);
    return;
  }
  // Routing for local URLs
  if (requestURL.origin == location.origin) {
    // Handle article URLs
    if (/^\/article\//.test(requestURL.pathname)) {
      event.respondWith(/* some other combination of patterns */);
      return;
    }
    if (/\.webp$/.test(requestURL.pathname)) {
      event.respondWith(/* some other combination of patterns */);
      return;
    }
    if (request.method == 'POST') {
      event.respondWith(/* some other combination of patterns */);
      return;
    }
    if (/cheese/.test(requestURL.pathname)) {
      event.respondWith(
        new Response('Flagrant cheese error', {
          status: 512,
        }),
      );
      return;
    }
  }

  // A sensible default pattern
  event.respondWith(
    caches.match(event.request).then(function (response) {
      return response || fetch(event.request);
    }),
  );
});

...這樣就大功告成了。

抵免額

...使用可愛的圖示:

感謝 Jeff Posnick,在按下「發布」前 成功發現幾個錯誤

其他資訊