離線食譜

Jake Archibald
Jake Archibald

我們透過 Service Worker 放棄嘗試解決離線問題,並提供開發人員可自行解決問題的動態元件。可讓您控管快取和要求的處理方式。也就是說,您可以自行建立模式。我們將個別介紹幾種可能的模式,但實際上,您可能會根據網址和情境同時使用其中許多模式。

如要查看這些模式的實際示範,請參閱「Trained-to-thrill」和這部影片,瞭解成效影響。

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() 呼叫就會遭到拒絕。

trained-to-thrill 中,我用它來快取靜態資產

安裝時,而非做為依附元件

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

這與上述方法類似,但不會延遲安裝程序,也不會在快取失敗時導致安裝失敗。

適用於:較大的資源,例如遊戲後續關卡的素材資源,這些資源並非立即需要。

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,因此即使失敗,遊戲仍可離線使用。當然,您必須考量這些層級可能不存在的情況,並在缺少時重新嘗試快取。

在第 11 到 20 級下載時,Service Worker 可能會因已完成事件處理作業而遭到終止,這表示這些事件不會快取。日後,Web Periodic Background Sync API 將處理這類情況,以及電影等較大的下載作業。該 API 目前僅支援 Chromium 分支。

啟用

啟用時。
啟用時。

適用於:清理和遷移。

新版 Service Worker 安裝完成且舊版未使用後,新版就會啟用,並觸發 activate 事件。由於舊版已淘汰,因此您可以處理 IndexedDB 中的結構定義遷移作業,並刪除未使用的快取。

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) 會放入佇列,因此啟用程序可能會長時間執行,進而阻斷網頁載入作業。請盡量精簡啟用程序,並只用於舊版無法執行的操作。

trained-to-thrill 中,我用它來移除舊的快取

使用者互動時

使用者互動時。
使用者互動時。

適用情況:當整個網站無法離線時,您可以選擇允許使用者選取要離線使用的內容。例如 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);
     
});
 
});
});

快取 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() 建立其他可個別讀取的副本。

trained-to-thrill 中,我用它來快取 Flickr 圖片

Stale-while-revalidate

Stale-while-revalidate。
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 的「stale-while-revalidate」非常相似。

在推送訊息中

推播訊息。
推送訊息。

Push API 是另一項建構在 Service Worker 之上的功能。這樣一來,Service Worker 就能在回應作業系統訊息服務傳送的訊息時喚醒。即使使用者沒有開啟網站的分頁,也會發生這種情況。只有 Service Worker 會喚醒。您可以透過網頁要求權限,系統就會向使用者顯示提示訊息。

適用於:與通知相關的內容,例如即時通訊訊息、即時新聞或電子郵件。以及不常變更的內容,因為這類內容可從即時同步處理作業中受益,例如待辦事項清單更新或日曆變更。

常見的最終結果是通知,只要輕觸即可開啟/聚焦相關頁面,但在發生這種情況之前更新快取非常重要。使用者在收到推播訊息時顯然已連上線,但最終與通知互動時可能已離線,因此提供離線內容十分重要。

以下程式碼會在顯示通知前更新快取:

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/');
 
}
});

關於 background-sync

關於 background-sync。
在背景同步處理。

背景同步處理是另一項以 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檔案系統存取權,當然還有快取資料

您獲得的金額未指定。這項時間會因裝置和儲存空間狀況而異。您可以透過以下方式查看獲得的點數:

if (navigator.storage && navigator.storage.estimate) {
 
const quota = await navigator.storage.estimate();
 
// quota.usage -> Number of bytes used.
 
// quota.quota -> Maximum number of bytes available.
 
const percentageUsed = (quota.usage / quota.quota) * 100;
  console
.log(`You've used ${percentageUsed}% of the available storage.`);
 
const remaining = quota.quota - quota.usage;
  console
.log(`You can write up to ${remaining} more 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));
});

雖然您不常需要特別處理這種情況,但「Cache, falling back to network」會涵蓋這類情況。

僅限網路

僅限網路。
僅限網路。

適用於:沒有離線等價項目的項目,例如數據分析回報、非 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
});

雖然您不常需要特別處理這種情況,但「Cache, falling back to network」會涵蓋這類情況。

快取,改用網路

快取,改用網路。
快取,並改用網路。

適用情況:建構離線優先應用程式。在這種情況下,您應以這種方式處理大多數要求。其他模式則會根據傳入的要求做為例外狀況。

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;
     
});
   
}),
 
);
});

trained-to-thrill 中,我使用 XHR 而非 fetch 來解決這個問題,並濫用 Accept 標頭,告訴 Service Worker 從何處取得結果 (網頁程式碼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.
     
}),
 
);
});

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

如果網頁要發布電子郵件,服務工作者可能會改為將電子郵件儲存在 IndexedDB 的「outbox」中,並回應讓網頁知道傳送失敗,但資料已成功保留。

服務工作者端範本

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',
       
},
     
});
   
}),
 
);
});

全部整合在一起

您可以使用其中一種方法,事實上,您可能會根據要求網址使用其中許多。舉例來說,trained-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 在我按下「發布」前,找出許多錯誤。

延伸閱讀