建構離線應用程式的常見技術

Jake Archibald
Jake Archibald

我們透過 Service Worker,為開發人員提供解決網路連線問題的方法。您可以控管快取和要求處理方式。 也就是說,你可以自行建立模式。請先個別查看幾種可能的模式,但實際上,您可能會根據網址和內容一併使用這些模式。

如要查看部分模式的運作示範,請參閱「訓練到令人興奮」。

何時儲存資源

Browser Support

  • Chrome: 40.
  • Edge: 17.
  • Firefox: 44.
  • Safari: 11.1.

Source

Service Worker 可讓您獨立處理要求和快取,因此我會分開示範這兩項功能。首先,請判斷何時應使用快取。

安裝時做為依附元件

安裝時,做為依附元件。

Service Worker API 會提供 install 事件。您可以使用這個方法準備好必要項目,這些項目必須準備就緒,您才能處理其他事件。在 install 期間,服務工作人員的舊版會繼續執行並提供網頁。此時進行的任何操作都不應中斷現有的服務工作人員。

適用於: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 會採用 Promise 來定義安裝長度和成功與否。如果 Promise 遭到拒絕,系統會將安裝視為失敗,並捨棄這個 Service Worker (如果較舊的版本正在執行,則會保持不變)。caches.open()cache.addAll() 會傳回 Promise。 如果無法擷取任何資源,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 層時可能會遭到終止,這表示這些層級不會快取。網頁週期性背景同步處理 API 可處理這類情況,以及電影等較大的下載內容。

Browser Support

  • Chrome: 40.
  • Edge: 17.
  • Firefox: 44.
  • Safari: 11.1.

Source

啟用時

啟用時。

適用於:清理和遷移。

新的 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);
      });
  });
});

網頁和 Service Worker 都能使用 Cache API,因此您可以直接從網頁新增至快取。

Browser Support

  • Chrome: 40.
  • Edge: 16.
  • Firefox: 41.
  • Safari: 11.1.

Source

網路回應

網路回應。

適用於:經常更新資源,例如使用者的收件匣或文章內容。也適用於非必要內容 (例如虛擬人偶),但需要謹慎使用。

如果要求與快取中的任何內容都不相符,請從網路取得要求,然後傳送至網頁,同時新增至快取。

如果為一系列網址 (例如虛擬人偶) 執行這項操作,請務必小心,以免來源儲存空間膨脹。如果使用者需要回收磁碟空間,您不希望成為主要候選人。請務必清除快取中不再需要的項目。

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。

適用於:經常更新資源,但不需要最新版本。虛擬人偶也屬於這類內容。

如有快取版本,請使用該版本,但要擷取更新內容,以供下次使用。

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 為基礎建構的另一項功能。這樣一來,服務工作人員就能在收到 OS 訊息服務的訊息時喚醒。即使使用者未開啟網站的分頁,也會發生這種情況。只有 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/');
  }
});

背景同步處理

在背景同步處理。

背景同步是另一項以 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));
});

…雖然您通常不需要特別處理這個情況,但「快取,回溯至網路」涵蓋了這個情況。

僅限網路

僅限網路。

適用於:沒有離線對應項目的項目,例如 Analytics 連線偵測、非 GET 要求。

self.addEventListener('fetch', function (event) {
  event.respondWith(fetch(event.request));
  // or 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 rejects when a promise rejects before fulfilling.
// To make a 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);

服務工作站中的程式碼

您應一律前往網路並更新快取。

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 寄件匣,並回應網頁,告知傳送失敗但資料已成功保留。

Service Worker 端範本

服務工作人員端範本。

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

在伺服器上算繪網頁的速度較快,但這可能表示要納入快取中可能沒有意義的狀態資料,例如登入狀態。如果網頁由 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 在我按下「發布」前,發現許多錯誤。