Progressive Web App 用戶端儲存與快取 網站離線後備頁面實作

me
林彥成
2021-09-16 | 5 min.
文章目錄
  1. 1. 什麼是用戶端儲存 (Client-side storage)
  2. 2. 常見用戶端儲存機制介紹
  3. 3. 用戶端儲存限制
    1. 3.1. QuotaExceededError 錯誤處理
  4. 4. 儲存淘汰機制 (eviction)
    1. 4.1. LRU policy
    2. 4.2. Persistent Storage
  5. 5. 離線後備頁面介紹 (offline fallback page)
  6. 6. 離線後備頁面實作
    1. 6.1. service worker 離線後備頁面實作
  7. 7. 離線後備頁面

什麼是用戶端儲存 (Client-side storage)

對一個 App 來說,為了達到某些目的,將相關資料存在用戶端是相對方便的,舉例來說

  • 個人偏好設定,像是常用功能、顏色主題、字體大小
  • 將前一次操作快取,像是紀錄購物車資料或是線上編輯器在斷線時先暫時將資料保存
  • 不常改變的資料或靜態資源快取
  • 離線操作所需要的檔案

通常用戶端的儲存和伺服器端的儲存會是一起搭配使用的,舉例來說當我們使用影音串流功能時,會從伺服器端下載相關資料到用戶端使用,在過程中 App 也能夠將資料快取起來以便下次使用。

不過在實務上,瀏覽器用戶端的儲存目前有各式解決方案,也都有著各自的限制存在,如果是有大小限制的儲存,就必須注意定時和伺服器端同步去避免資料遺失。

常見用戶端儲存機制介紹

目前常見的用戶端儲存機制如下:

  • Cookies: 每次發 Request 都會一起送出,所以要大小控制要注意
  • Web Storage API
    • SessionStorage: 同步阻塞 (synchronous),上限 5MB,僅存在於 Tab 當次操作,無法被 web workers 或 service workers 使用
    • LocalStorage: 同步阻塞 (synchronous),上限 5MB,無法被 web workers 或 service workers 使用
  • Cache API: 非同步,較適合跟網路請求相關的靜態資源快取
  • IndexedDB API: 非同步,適合儲存程式邏輯相關資料,使用上相對複雜,較推薦使用像是 idb 這類相關套件操作
  • WebSQL: 不建議使用

用戶端儲存限制

一般來說儲存空間的上限如下:

  • Chrome 總共最多可以用到 80% 的硬碟,每個網域最高是 60%
  • IE 系列 10 以後最多 250MB
  • Firefox 硬碟空間的 50%
  • Safari 1GB

在 Chrome、Firefox、Edge 等瀏覽器中可以使用以下的程式碼去估計剩餘空間:

https://caniuse.com/mdn-api_storagemanager

1
2
3
4
5
6
7
8
9
if (navigator.storage && navigator.storage.estimate) {
const quota = await navigator.storage.estimate();
// quota.usage -> 用了多少 bytes
// quota.quota -> 還剩多少 bytes 可使用
const percentageUsed = (quota.usage / quota.quota) * 100;
console.log(`${percentageUsed}% 已使用`);
const remaining = quota.quota - quota.usage;
console.log(`還可以寫入 ${remaining} bytes`);
}

QuotaExceededError 錯誤處理

  • IndexedDB
1
2
3
4
5
6
7
const transaction = idb.transaction(["entries"], "readwrite");
transaction.onabort = function (event) {
const error = event.target.error; // DOMException
if (error.name == "QuotaExceededError") {
// 錯誤處理
}
};
  • Cache API
1
2
3
4
5
6
7
8
try {
const cache = await caches.open("my-cache");
await cache.add(new Request("/test.jpg"));
} catch (err) {
if (error.name === "QuotaExceededError") {
// 錯誤處理
}
}

儲存淘汰機制 (eviction)

資料在儲存上會分成兩種類別

  • Best Effort: 當瀏覽器空間不足,會開始執行淘汰機制
    • Chromium: 從最少使用的開始,會自動清除資料且不會通知用戶
    • Firefox: 從最少使用的開始,會自動清除資料且不會通知用戶
    • Internet Explorer 10+ 不會清除,但會停止寫入
  • Persistent: 不會被自動清除

LRU policy

當硬碟空間即將用完時,瀏覽器會依據 LRU policy (least recently used) 的規則透過去清除快取檔案。

  1. 目前沒有使用的網頁 (tabs/apps)
  2. 比對最後存取的時間

Persistent Storage

為了避免資料被儲存淘汰機制處理掉,我們能夠通過程式碼去確認、啟用 Persistent Storage。

1
2
3
4
5
6
7
8
9
10
11
// 確認目前儲存空間是否套用 Persistent
if (navigator.storage && navigator.storage.persist) {
const isPersisted = await navigator.storage.persisted();
console.log(`Persisted storage granted: ${isPersisted}`);
}

// 針對這個站台啟用 Persistent Storage
if (navigator.storage && navigator.storage.persist) {
const isPersisted = await navigator.storage.persist();
console.log(`Persisted storage granted: ${isPersisted}`);
}

啟用過後底下的儲存皆會受到保護,但要注意啟用後目前尚沒有透過程式停用的方式。

  • Cache API
  • Cookies
  • DOM Storage (Local Storage)
  • File System API
  • IndexedDB
  • Service workers
  • App Cache (deprecated)
  • WebSQL (deprecated)

離線後備頁面介紹 (offline fallback page)

離線後備頁面提供用戶在網路不穩定的情況下,一個備援的顯示頁面。

在過去的網站大多由伺服器提供,所以斷線的情況下原則上就是什麼都沒有,近幾年 JavaScript 相關的進步以及 SPA 觀念開始盛行後,前端能夠掌握的事情也越來越多,離線後備頁面 (offline fallback page) 一個有名的例子就是 Chrome 在斷線狀態下的小恐龍遊戲。

圖片來源: https://web.dev/

離線後備頁面實作

對 Progressive Web App 來說,目前最佳的實作方式就是透過 service worker 搭配 Cache Storage API 來提供用戶最佳的離線操作體驗。

接下來會示範一個簡單的情境,當用戶網路斷線時,會自動開啟離線後備頁面,頁面中一方面提供重試的按鈕,另一方面也透過程式實作當網路恢復時自動連線並切換回正常的頁面。

Google 的這個範例主要會有三個頁面加上 service worker 實作:

  1. 連線正常的第一頁
  2. 連線正常的第二頁
  3. 離線後備頁面
  4. service worker: 偵測到斷線時會將第一頁或第二頁切換到離線後備頁面

Demo 站台:
https://linyencheng.github.io/pwa-offline-fallback/

原始碼:
https://github.com/LinYenCheng/pwa-offline-fallback/tree/main/docs

service worker 離線後備頁面實作

  1. 宣告常數
1
2
3
4
// 這次 Cache 存放的名稱
const CACHE_NAME = "offline";
// 離線後備頁面檔名
const OFFLINE_URL = "offline.html";
  1. install: 命名快取空間為 CACHE_NAME 並加入 OFFLINE_URL 快取,透過 self.skipWaiting() 跳過等待重啟生效。
1
2
3
4
5
6
7
8
9
10
11
12
13
self.addEventListener("install", (event) => {
event.waitUntil(
(async () => {
// 使用 CACHE_NAME
const cache = await caches.open(CACHE_NAME);
// Setting {cache: 'reload'} in the new request will ensure that the response
// isn't fulfilled from the HTTP cache; i.e., it will be from the network.
await cache.add(new Request(OFFLINE_URL, { cache: "reload" }));
})()
);
// Force the waiting service worker to become the active service worker.
self.skipWaiting();
});
  1. activate: 透過 self.clients.claim() 讓 Service Worker 直接生效
1
2
3
4
5
6
7
8
9
10
11
12
13
14
self.addEventListener("activate", (event) => {
event.waitUntil(
(async () => {
// Enable navigation preload if it's supported.
// See https://developers.google.com/web/updates/2017/02/navigation-preload
if ("navigationPreload" in self.registration) {
await self.registration.navigationPreload.enable();
}
})()
);

// Tell the active service worker to take control of the page immediately.
self.clients.claim();
});
  1. fetch: 遇到連線錯誤則使用 CACHE_NAME 中的 OFFLINE_URL 的快取
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
self.addEventListener("fetch", (event) => {
// We only want to call event.respondWith() if this is a navigation request
// for an HTML page.
if (event.request.mode === "navigate") {
event.respondWith(
(async () => {
try {
// First, try to use the navigation preload response if it's supported.
const preloadResponse = await event.preloadResponse;
if (preloadResponse) {
return preloadResponse;
}

// Always try the network first.
const networkResponse = await fetch(event.request);
return networkResponse;
} catch (error) {
// catch is only triggered if an exception is thrown, which is likely
// due to a network error.
// If fetch() returns a valid HTTP response with a response code in
// the 4xx or 5xx range, the catch() will NOT be called.
console.log("Fetch failed; returning offline page instead.", error);

const cache = await caches.open(CACHE_NAME);
const cachedResponse = await cache.match(OFFLINE_URL);
return cachedResponse;
}
})()
);
}

// If our if() condition is false, then this fetch handler won't intercept the
// request. If there are any other fetch handlers registered, they will get a
// chance to call event.respondWith(). If no fetch handlers call
// event.respondWith(), the request will be handled by the browser as if there
// were no service worker involvement.
});

離線後備頁面

因為是離線後備頁面,所以我們會需要把所有的資源都快取起來,其中最簡單的方式就是將所有需要的東西都用 inline 的方式寫在 html 中,若是想要自己去實作較複雜的快取機制,比較建議取使用 workbox 這套工具。

參考連結:
https://developers.google.com/web/tools/workbox/guides/advanced-recipes#offline_page_only

  • 可依照情境使用不同的快取策略: import {CacheFirst, NetworkFirst, StaleWhileRevalidate} from 'workbox-strategies';
  • 文件中有提供範例可參考修改,底下的例子說明怎麼把 mp4 相關資源快取
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import { registerRoute } from "workbox-routing";
import { CacheFirst } from "workbox-strategies";
import { CacheableResponsePlugin } from "workbox-cacheable-response";
import { RangeRequestsPlugin } from "workbox-range-requests";

// 把 mp4 相關資源都快取起來
registerRoute(
({ url }) => url.pathname.endsWith(".mp4"),
new CacheFirst({
cacheName: "your-cache-name-here",
plugins: [
new CacheableResponsePlugin({ statuses: [200] }),
new RangeRequestsPlugin(),
],
})
);

喜歡這篇文章,請幫忙拍拍手喔 🤣

share