PJCHENder 未整理筆記

[PWA] 網頁資料儲存與 Cache API 基礎

2018-03-13

[PWA] 網頁資料儲存與 Cache API 基礎

@(JavaScript)[WebAPIs, PWA, Progressive Web Apps - The Complete Guide]

keywords: cache, web storage, waitUntil(), fetch, fetchEvent.respondWith()

目錄

[TOC]

觀念

網頁資料的儲存方式(Web Storage)

儲存網頁資料的方式有許多種,根據不同的 Data Model、Persistence、Browser Support、Transactions、Sync/Async 可以分出許多不同種類的儲存方式,在 Google Developer: Web Storage Overview 中詳細列出了各種資料庫和儲存方式的比較,簡單來說:

  • 對於 App 需要在離線狀態時存取的網路資源(如,App Shell)建議使用 Cache API
  • 其他使用者相關的資料和應用程式的狀態(如,App Data)都使用 IndexedDB(搭配 promises wrapper

基於效能的理由,通常我們會選擇非同步的資料儲存方式,其中 Cache APIIndexedDB 都是屬於非同步 Async 的;而 LocalStorage 和 SessionStorage 則是 同步 Synchronous 的,缺乏 web worker 的支援,且空間較為有限。

Imgur

儲存空間

在 Chrome 中,儲存空間是根據每個來源 origin 而非每個 API 來決定,一個網頁來源(origin)會共享各種形式的儲存空間(IndexedDB, Cache API, localStorage 等等),實際上的儲存空間會根據裝置和儲存條件而有不同,,可以透過 Quota Management API 來查詢可用的空間。

Imgur

Cache API 的基本觀念與操作

基本觀念

在支援的瀏覽器中,Cache API 可以在 window, iframe, worker 或 Service Worker 中透過 JavaScript 操作,在 Service Worker 的好處是可以在背景執行,因此離線時還是可以運作。這個 Cache 只儲存 Key-Value Pairs,Key 是發出去的 HTTP requestValue 則是 HTTP Response

以下幾點是須留意的:

  • Cache 的 Key 會是 HTTP Request Object 不是 String,代入的參數如果是字串時,則會先透過 new Request(request) 轉成 Request 物件
  • Cache API 雖然是被定義在 Service Worker 的規範中,但它不一定要搭配 Service Worker 才能使用。
  • 除非主動請求,否則 Cache 中的項目並不會自動更新;除非被刪除否則它們不會過期。
  • 瀏覽器會盡可能管理磁碟空間,但當儲存太多資料時,它可能會刪除某些來源(origin) 的 Cache。

Imgur

基本操作

1
2
3
// 檢查 Cache API 在該瀏覽器是否可用
const cacheAvailable = 'caches' in self; // true / false
const cacheAvailable = 'caches' in window;
1
2
3
4
5
6
7
8
9
// 建立或開啟 Cache
caches.open('my-cache').then((cache) => {

// 建立 cache, HTTP Request Object
cache.add('./src/js/app.js')

// 找出某個 cache
cache.match(request).then((response) => console.log(request, response));
});

Static Caching / Precaching

keywords: ExtendableEvent.waitUntil(), fetchEvent, fetchEvent.respondWith()

要留意的是,Service Worker 只有在它檔案有變更的時候才會被置換和安裝,因此在 Service Workers 中的 installation phase 很適合拿來快取一些靜態檔案和 APP 的外殼(App Shell)。

這些在 Service Worker Installation Phase 中所快取的檔案,可以在稍後縱使沒網路的情況下也可以提取(fetch)

Imgur

APP Shell 指的是 APP 當中不太會變更的部分,也就是外觀看起來的樣子,像是 toolbar, navbar, header, footer 等部分。

cache.add() 的時候除了快取主要載入的檔案(例如,./index.html)外,也要快取 ./ 才會有效:

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
38
39
40
41
42
// sw.js

// Installation Phase
self.addEventListener('install', function (event) {
event.waitUntil(
// 建立或開啟 cache
caches.open('static')
.then(cache => {
// 建立 cache, HTTP Request Object
// cache.add('./') // 這是必要的
cache.addAll([
'./',
'./index.html',
'./src/js/app.js',
'./src/js/feed.js',
'./src/js/material.min.js',
'./src/css/app.css',
'./src/css/feed.css',
'./src/images/main-image.jpg',
'https://fonts.googleapis.com/css?family=Roboto:400,700',
'https://fonts.googleapis.com/icon?family=Material+Icons'
])
})
)
})

// Fetch Event
self.addEventListener('fetch', function (event) {
// event.respondWith 內需放 HTTP Response Object
event.respondWith(
caches.match(event.request) // event.request is a HTTP Request Object
.then(response => {
if (response) {
// 如果 cache 中找得到配對的 Request Object,則回傳該 Response Object
return response
} else {
// 如果 cache 中找不到,則發出 fetch 請求
return fetch(event.request)
}
})
)
})

建立快取後,這些被快取的 Request Object 和 Response Object 都會被存在 Application 中的 Cache Storage:

Imgur

Dynamic Caching

前面我們已經透過 Static Caching / Precaching 來儲存整個 App 的 Shell;Dynamic Caching 的功用是儲存使用者造訪過,但沒有被 Precahing 過的 Request

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
// sw.js

self.addEventListener('fetch', function (event) {
// event.respondWith 內需放 HTTP Response Object
event.respondWith(
caches.match(event.request) // event.request is a HTTP Request Object
.then(response => {
if (response) {
// 如果 cache 中找得到配對的 Request Object,則回傳該 Response Object
return response
} else {
/**
* Dynamic Caching:儲存使用者瀏覽過,但在 Precaching 中沒有儲存的 Request
*/
let fetchRequest = event.request.clone() // IMPORTANT: clone the request
return fetch(fetchRequest)
.then(res => {
return caches.open('dynamic')
.then(cache => {
cache.put(event.request, res.clone()) // IMPORTANT: clone the response
return res
})
.catch(err => {
if (err) { console.log(err.name + ': ' + err.message) }
})
})
}
})
)
})

Updating Cache

Add Cache Version

當有任何 Cache 中的檔案有變動時,我們需要透過變更 Service Worker 的檔案內容,來促使這個 APP 更新(重新進入 Installation Phase),這時候最適合修改的地方是 Installation Phase 中的 caches.open(<service_worker_version)

有一點是需又留意的,透過修改這裡的版號,並不會清除舊版本的 cache ,這樣的好處是當使用者沒有載到新版的 cache 時,一樣可以使用舊版的 cache 而不會壞掉;壞處時當我們在 fetch event 的時候透過 caches.match() 搜尋 HTTP Request Object 是否存在 Cache 中時,caches.match() 會找到並使用舊的版本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// sw.js

/**
* Add Cache Version to invoke App update
**/
self.addEventListener('install', function (event) {

event.waitUntil(
caches.open('static-v1.0.1') // Add Cache Version in here
.then(cache => {
console.log('[Service Worker] Precaching App Shell...')

cache.addAll([
// ...
])
})
)
})

Cleanup Old Cache

上面提到,只透過在 Installation Phase 更改 caches.open() 中的 Cache 版本並不會刪除舊版的 Cache,這麼做可確保 App 不會無故壞掉,但卻會在 fetech event 中的 caches.match() 載到舊版的 Cache。

因此,最好刪除舊版 Cache 最好的時間點會是在 Activation Phase,因為這個階段只有在使用者關閉所有的頁籤,重新開始此頁面時才會觸發:

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
const CACHE_NAME = {
static: 'static-v0.1',
dynamic: 'dynamic-v0.1'
}

self.addEventListener('activate', function (event) {
console.log('[Service Worker] Activating Service Worker ...', event)

/**
* Remove Outdated Cache
*/
event.waitUntil(
caches.keys().then(keyList => {
return Promise.all(keyList.map(key => {
// Promise.all 接收帶有 Promise 的陣列
if (!Object.values(CACHE_NAME).includes(key)) {
// 如果 Cache 中的 Key 沒有包含在 CACHE_NAME(新版),則刪除
console.log('[Service Worker] Removing old cache:', key)
return caches.delete(key)
}
}))
})
)
return self.clients.claim()
})

API 說明

Cache API

1
2
3
4
// caches.open(<cacheName>) 會建立或開啟一個 Cache,並回傳帶有 Cache Object 的 Promise Object
caches.open(cacheName).then(function(cache) {
// Do something with your cache
});
1
2
3
4
// caches.keys() 會回傳當前有的 Cache Key 陣列
caches.keys().then(function(keyList) {
//do something with your keyList
});
1
2
3
4
// caches.delete(cacheName) 刪除對應到的 cache,若成功刪除則 resolve true,否則 false 的 Promise
caches.delete(cacheName).then(function(boolean) {
// your cache is now deleted
});

Cache.add(<request>)cache.put(<request>, <response>) 的差別在於:

  • cache.add() 會先發 fetch 接著把得到的 Response Object 存到 Cache 的 value 中
  • cache.put() 不會發 fetch ,因此要自己帶入 Request Object 和 Response Object 到此方法中。

CacheStorage API @ MDN > Web technologies for developers > WebAPIs
Cache @ MDN > Web technologies for developers > WebAPIs

ExtendableEvent.waitUntil()

透過 extendableEvent.watiUntil() 告訴該事件處理器說這個事件還在處理中,也可以用來偵測此程序(work)是否成功執行完畢。在 Service Workers 中,waitUntil() 會告訴瀏覽器,除非 Promise 完成,否則程序(work)都還在持續中,因此如果希望它希望程序執行完畢,不應該終止 Service Worker。

Installation Phase:在 Service Workers 中的 install event 中使用 waitUntil() 可以讓 Service Woker 停在 installing 階段。如果 Promise 是傳送 rejectwaitUntil() 的話,安裝階段會被視為失敗,並且放棄 Service Worker 的安裝,這可以確保 Service Worker 不會在所有相依的 cache 尚未成功載入前,就被認為已經安裝完畢。

Activation Phase:在 Service Workers 中的 activate event 中使用 waitUntil() 可以用來作為 fetchpush 的緩衝,這給 Service Worker 時間來更新資料庫和刪除過期的 caches,因此其他的事件可以確保使用的資料是最新的。

The waitUntil() method must be initially called within the event callback, but after that it can be called multiple times, until all the promises passed to it settle.

ExtendableEvent.waitUntil() @ MDN

fetchEvent.respondWith()

透過 fetchEvent.respondWith(<Response Object>) ,可以讓請求的事件(fetch event)促發時不會立刻請求檔案,而是可以進一步處理:

1
2
3
4
5
6
7
/**
* Fetch Event in Service Worker
**/
self.addEventListener('fetch', function (event) {
// event.respondWith 內需放 HTTP Response Object
event.respondWith(fetch(event.request))
})

上圖是沒有經過 event.respondWith() 的結果,檔案的請求會是從 index.html 發送;下圖是透過 event.respondWith 發出的 fetch 請求,檔案的請求會是從 Service Worker 發送:

Imgur

Fetch Event @ MDN

常見問題

為什麼需要使用 response.clone()

由於 request 是一個 stream 只能被**使用(consumed)一次,因此若我們需要在 cache 和 browser fetch 中各使用一次這個 request,我們就需要複製(clone)**它。

由於 response 是一個 stream 只能被**使用(consumed)一次,因此若我們需要在 cache 和 browser 中都使用這個 request,我們就需要複製(clone)**它。

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
38
39
40
self.addEventListener('fetch', function (event) {
// event.respondWith 內需放 HTTP Response Object
event.respondWith(
caches.match(event.request)
.then(function (response) {
// 如果 Cache 中已經有這個 Request Object,則直接回傳 Cache 中的 Response Object
if (response) {
return response;
}

// IMPORTANT: Clone the request. A request is a stream and
// can only be consumed once. Since we are consuming this
// once by cache and once by the browser for fetch, we need
// to clone the response.
var fetchRequest = event.request.clone();

return fetch(fetchRequest).then(
function (response) {
// Check if we received a valid response
if (!response || response.status !== 200 || response.type !== 'basic') {
return response;
}

// IMPORTANT: Clone the response. A response is a stream
// and because we want the browser to consume the response
// as well as the cache consuming the response, we need
// to clone it so we have two streams.
var responseToCache = response.clone();

caches.open(CACHE_NAME)
.then(function (cache) {
cache.put(event.request, responseToCache);
});

return response;
}
);
})
);
});

Why does fetch request have to be cloned in service worker? @ StackOverflow

範例程式碼

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
// fetchEvent

addEventListener('fetch', event => {
// 對於非 GET 的請求讓瀏覽器做預設的行為
if (event.request.method != 'GET') return;

// 自己處理請求
// responseWith 中要帶入 Response Object
event.respondWith(async function() {

// 先試著從 caches 中看看有沒有資料
const cache = await caches.open('dynamic-v1');

const cachedResponse = await cache.match(event.request); // event.request 是 Request Object

if (cachedResponse) {
// 如果在 cache 中有找到配對的 Request Object,則回傳,同時更新 entry
event.waitUntil(cache.add(event.request));
return cachedResponse;
}

// 如果在 cache 中沒有找到配對的 Request Object,則使用預設的網際網絡
return fetch(event.request);
}());
});

Fetch Event @ MDN

參考

掃描二維條碼,分享此文章