Service Worker - 概念篇
===
Service Worker終結[小恐龍](https://imgur.com/eV5lQMY)的利器
## 支援度 [Can I Use](https://caniuse.com/#feat=serviceworkers)
![](https://i.imgur.com/td8dMJg.png)
根據[Is Service Worker Ready](https://jakearchibald.github.io/isserviceworkerready/index.html)網站顯示,如下圖所示各主流瀏覽器最低的支援版本。
![](https://i.imgur.com/gEsRP0z.png)
## Service Worker 是 Web Worker 的一種
### Web Worker
* Javascript 程式碼一般而言是在 「主執行緒」上執行
* Web Worker 將耗時的運算處理從主執行緒中剝離
* Main Thread更加專注於頁面渲染和互動
* 耗時的運算獨立在Web Worker的Worker Thread中執行
* Web Worker 不可操作 DOM 也不能取用 WINDOW
* Thread 之間 使用 Message 溝通
* postMessage(data) 方法 發送訊息
* onmessage(data) 事件處理函式 接收訊息
![](https://i.imgur.com/Eb1vLM2.png)
```javascript
// app.js
const worker = new Worker('worker.js');
worker.postMessage(something);
worker.addEventListener('message', event => {
// use processed data
const processedData = event.data;
console.log(processedData);
};
worker.addEventListener('error', error => {
throw error;
};
```
```javascript
//worker.js
self.addEventListener('message', event => {
// do work with data
const processedData = event.data + 'processed';
self.postMessage(processedData);
};
```
補充說明
> 以下二種用法都是可行的
> 1. object.onmessage = function() {myScript};
> 2. object.addEventListener("message", myScript);
>
> 差別在於
> 1. addEventListener 可掛載疊加多個 event handler 但 onXXX 一次只能掛載一個
> 2. 二者都應該要指定 function 的型別,但 onXXX 指定不對的型別不會報錯,但 addEventListener 會報錯
> 3. addEventListener 多了一個 useCapature 參數可以做控制
> 不指定時預設為false,false為Bubbling;true為Capture
>
> 參考
> 1. https://www.w3schools.com/jsref/event_onmessage_sse.asp
> 2. https://stackoverflow.com/questions/6348494/
## Service Worker 的角色
* Service Worker 就是一個做「網路代理用」的 Javascript Worker
* 它會攔截所有由 Application 發出的 HTTP 請求,並選擇如何給出響應
![](https://i.imgur.com/ujPyFSO.png)
![](https://i.imgur.com/VGfzc4H.png)
## Service Worker 的必要規格
* 必須是在 Https 環境下 (除了 localhost 及 127.0.0.1)
* Service Worker 提供強大的代理功能
* Https 能提供 加密傳輸、身份認證
* ES6 Promise
* HTML5 Fetch API
* HTML5 Cache API
## Service Worker 的生命週期
* register:註冊 Service Worker
* install:安裝 Service Worker
* activate:判斷是否需要更新
* fetch:攔截 request
## 註冊 Service Worker
```javascript
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('./sw.js')
.then(reg => {
// 註冊成功
console.log('Registration succeeded. Scope is ' + reg.scope);
})
.catch(error => {
// 註冊失敗
console.log('Registration failed with ' + error);
});
}
```
> Service Worker 在註冊完成後,瀏覽器才會開始執行背景安裝。
> 而不管註冊成功或失敗,可使用 Callback 做後續處理。
```
navigator.serviceWorker.register('/sw.js', { scope: '/test/' });
// 表示限定在「localhost/test/」之下運行
```
> 沒有設定Scope,運作範圍預設為('/'),整個Domain
> 若有設定,運作的範圍則在指定的path之下
## 安裝 Service Worker
### Install Event
```javascript
self.addEventListener('install', event => {
// 這裡的 event 就是 install 的 event
console.log('installing…');
event.waitUntil(promise);
});
```
event 會等到 waitUntil 傳入的 promise 物件成功resolve後,
才會結束install事件並進入到下一個activate階段。
### Cache Storage
可以透過 ```caches``` 屬性來使用 Cache Storage,
可以透過 chrome 的 devtools (Application > Cache Storage)看到儲存的Cache
![](https://i.imgur.com/G7tlMiU.png)
使用 ```caches.open``` 方法,來取得 cache 物件。
需要傳入自定的 cache 的名稱,之後會返回一個 Promise 物件
```javascript
caches.open('mycachename') // return promise
```
接著可以等這個 promise 成功後,取得 cache 物件。
如果瀏覽器內原本就有儲存,則會取得原本的,如果沒有則會建立一個新的 cache 物件並取得。
```javascript
caches.open('mycachename')
.then(cache => {
// 取得 cache object
// 可以針對 cache object 做資料操作
})
```
> 可以把 CacheStorage 想像成一個資料庫
> 而 Cache 則是資料表,我們需要先取得我們要的資料表再把資料寫入。
Cache 物件的 ```addAll``` 方法,會傳入 資源的URL字串的陣列並返回一個 Promise 物件。
```javascript
cache.addAll(['/', '/index.html', '/images/test.png']) // 返回 Promise 物件
```
### 完成Install要做的事
```javascript
const filesToCache = [
'/',
'/index.html'
];
const cacheName = 'cache-v1';
self.addEventListener('install', event => {
event.waitUntil(
caches
.open(cacheName)
.then(cache => {
return cache.addAll(filesToCache);
});
);
});
```
## 判斷是否需要更新
```javascript
self.addEventListener('activate', event => {
console.log('activated…');
event.waitUntil(promise);
});
```
event 會等到 waitUntil 傳入的 promise 物件成功resolve後,
才會結束activate事件並進入到下一個fetch階段。
### 在activate階段處理的cache操作
可以在這個週期進行清除快取的動作,避免網站會一直存取舊的 cache 檔案
```javascript
self.addEventListener('activate', event => {
event.waitUntil(
caches.keys()
.then(cacheNames => {
let promiseArr = cacheNames.map(item => {
if (item !== cacheName) {
// Delete that cached file
return caches.delete(item);
}
});
return Promise.all(promiseArr);
})
); // end event.waitUntil
});
```
## 攔截 request
### Fetch API
```javascript
const request = new Request('網址', {method: 'GET'});
fetch(request)
.then(response => {
// 處理 response
}).catch(error => {
// 錯誤處理
});
```
fetch 執行之後會送出 Request,如果得到回應就會回傳帶有 Response 的 Promise 物件,使用 then 將回傳值傳遞下去
https://www.oxxostudio.tw/articles/201908/js-fetch.html
### 使用 fetch 事件,攔截 fetch API執行的 Request
```javascript
self.addEventListener('fetch', event => {
console.log('event.request:', event.request);
});
```
這裡的 event.request 就是網頁即將要的送出 HTTP request
### 在 fetch 事件內將得到 Response 做 Cache
* 使用 ```event.respondWith()``` 做代換處理,回傳 ```Promise``` 型態的物件
```javascript
self.addEventListener('fetch', event => {
event.respondWith(
// 代換處理 return Promise
);
});
```
* 透過 ``` caches.match()```,傳入 event.request,可以從 Cache Stroage 中找到曾經被 Cache 過 Response 的 Request
```javascript
self.addEventListener('fetch', event => {
event.respondWith(
caches.match(event.request)
.then(response => {
return response;
});
);
});
```
* 如果 Request 沒有被 Cache 過 Response,則用 fetch api 把 HTTP Request 真的送出,在得到 Response 後進行 Cache
```javascript
self.addEventListener('fetch', event => {
event.respondWith(
caches.match(event.request).then(cachedResponse => {
if (cachedResponse) {
// 如果已經被 cached:則回傳 cache 裡的 Response
return cachedResponse;
} else {
// 若沒有被 cached:則發出http Request,
// 取得 http Response 後進行 cache
// 再回傳 http Response
return fetch(event.request).then(httpResponse => {
const httpResponseToCache = httpResponse.clone();
caches.open('cache-v1').then(cache => {
cache.put(event.request, httpResponseToCache);
});
return httpResponse;
}); // end fetch(event.request)
} // end if
}) // end caches.match()
); // end event.respondWith()
});
```
### 與 Install 事件的差別
* 在 ```install``` 事件 Pre-Cache ```靜態資源檔案```
* 在 ```fetch``` 事件 Cache ```動態資料內容```
## App Shell
* 讓使用者打開 App 時就能迅速地看到 Web App 的基本界面
* 它是一個 App 的空殼,僅包含頁面Layout所需最基本的HTML 、CSS和JS等靜態資源檔案
* App Shell 的 資源 Cache 會在 ``` Install``` 事件中完成
* 顯示完成App Shell後,再載入動態的資料內容
* 動態的資料內容的 Cache 會在 ```fetch``` 事件做處理
## IndexedDB 與 Cache Storage
### Web Storage 的比較
![](https://i.imgur.com/YmZahky.png)
### Google Developer 建議的 Cache 儲存方式
* 對於 App 需要在離線狀態時存取的網路資源(如,App Shell)使用 Cache API
* 資料和應用程式的狀態(如,App Data)使用 IndexedDB
* Cache API 和 IndexedDB 都是非同步 (Promise based),且均可以在 web workers、window、service workers 這三種環境裡運作。
### IndexedDB 特性
* IndexedDB是一個嵌入在瀏覽器中有Transaction機制的資料庫。
* IndexedDB的管理圍繞JSON物件集合的概念,類似NoSQL資料庫MongoDB等
* IndexedDB透過Index來最佳化對儲存物件的存取。
### IndexedDB與Relational Database的比較
![](https://i.imgur.com/oSuIvwC.jpg)
![](https://i.imgur.com/uc2ZK89.png)
### objectStore
objectStore 是 indexedDB 的資料存儲機制,和 SQL 的Table地位一致。
每一條記錄包含了key 和value。
![](https://i.imgur.com/iNVt7Gy.png)
* 把真正的資料儲存在Value中,使用Key來取得值,所以Key就是Value的識別、縮寫或標記
![](https://i.imgur.com/FAnsdLH.jpg)
* 如果所儲存的值是JavaObject,則Key會包含在JavaObject的屬性中,使用```keyPath```的方式指定Key的值
```javascript
db.createObjectStore('mystore', { keyPath: 'id' });
```
* 如果所儲存的值是String或是ArrayBuffer,Value就不會有Key,此時需要使用autoIncrement
```
db.createObjectStore('mystore', { autoIncrement: true })
```
### index 與 objectStore
index是依附於objectSotre,他實際上也是一個objectStore
```javascript
let bookstore = db.createObjectStore('bookstore', { keyPath: 'id' });
bookstore.createIndex('price', 'book.price', { unique: false });
```
* 如果 unique 為 true,當keypath要存入Index的值重複時,就會拋出錯誤
### transaction
* indexedDB裡面,強制規定任何讀寫操作,都必須在一個Transaction中進行
* 如果整個嘗試過程沒有出錯,最後會用commit,讓整個更新生效
* 但是如果在嘗試過程中出錯了,就會abort,之前做過的更新會被丟棄,數據不會發生改變
### IndexedDB 的 Wrapper
因為 IndexedDB 的 底層API 較為抽象,操作起來較為複雜
可透過以下 Wrapper 來簡化操作
* idb.js https://github.com/jakearchibald/idb
以Promise實作的IndexedDB套件
* Dexie.js https://dexie.org/
將IndexedDB封裝成類似於sql操作的Library
### 將 fetch 事件用 idb.js 改寫
```javascript
const dbPromise = idb.open('articles', 1, db => {
if(!db.objectStoreNames.contains('article')) {
db.createObjectStore('article', {keyPath: 'id'});
}
}); // end idb.open
```
``` javascript
const cacheMatch => (table, id) {
return dbPromise.then(db => {
const transaction = db.transaction(table, 'readonly');
const store = transaction.objectStore(table);
return store.getAll();
});
}
const cachePut => (table, data){
return dbPromise.then(db => {
const transaction = db.transaction(table, 'readwrite');
const store = transaction.objectStore(table);
store.put(data);
return transaction.complete;
});
}
```
```javascript
self.addEventListener('fetch', event => {
// 要使用的indexedDB做Cache的動態資料源網址
const articleID = 1;
const url = `https://a.b.c/article/{articleID}`;
if(event.request.url.includes(url)){
event.respondWith(
fetch(event.request)
.then(httpResponse => {
const httpResponseToCache = httpResponse.clone();
const httpResponseData = httpResponseToCache.json();
for(let key in httpResponseData){
cachePut('article', httpResponseData[key]);
} // end for
return httpResponseToCache;
}).catch(error => {
const cachedResponse = cacheMatch('article', articleID);
return cachedResponse;
});
); // end event.respondWith()
} else{
// 其他資源可繼續使用 Cache Storage 來 Cache Response
}
});
```
### 將 fetch 事件用 Dexie.js 改寫
```javascript
const db = new Dexie("post_cache");
db.version(1).stores({
article: 'key, response, timestamp'
});
```
```javascript
const cacheMatch => (table, id) {
const store = db[table];
return store.get(id);
}
const cachePut => (table, data, id) {
const store = db[table];
db.transaction('rw', store, async () => {
// Transaction Scope
const entry = {
key: id,
response: data,
timestamp: Date.now()
};
await store.put(entry, id)
}).then(() => {
console.log("Transaction committed");
}).catch(error => {
console.error(error.stack);
});
}
```
```javascript
self.addEventListener('fetch', event => {
// 要使用的indexedDB做Cache的動態資料源網址
const articleID = 1;
const url = `https://a.b.c/article/{articleID}`;
if(event.request.url.includes(url)){
event.respondWith(
fetch(event.request)
.then(httpResponse => {
const httpResponseToCache = httpResponse.clone();
const httpResponseData = httpResponseToCache.json();
cachePut('article', httpResponseData, articleID);
return httpResponseToCache;
}).catch(error => {
return cacheMatch('article', articleID);
});
); // end event.respondWith()
} else{
// 其他資源可繼續使用 Cache Storage 來 Cache Response
}
});
```
## Caching Strategies
針對不同的使用情境,不同的對資源的請求回應速度及類型,會劃分成好幾種 Caching Strategies
### Cache Only Strategies
> Workbox 策略名稱 Cache Only
只使用Cache,如果Cache被清理或沒有對應的Response,就會失敗。
![](https://i.imgur.com/p8ARvN2.png)
### Cache with Network Fallback Strategies
> Workbox 策略名稱 Cache First
* 適用於Cache不常變動的靜態資源,例如:圖片、CSS。
* 或是更新時效不頻繁的動態資源,例如:店家列表等。
![](https://i.imgur.com/ptl2VHb.png)
### Network With Cache Fallback Strategies
> Workbox 策略名稱 Network First
適用於更新時效頻繁的動態資源,但網路狀況不穩定的話,不一定是好方案
![](https://i.imgur.com/9jCb8Gw.png)
### Cache then Network Strategies
> Workbox 策略名稱 Stale-While-Revalidate
「從cache中盡快的獲得用戶所需要的資源」並且「同時也透過網路去fetch該資源」
若fetch回來的資源為較新的版本,則取代從cache中獲得的資源來傳給用戶。
是Network with Cache Fallback Strategies的進階版。
因為當網路不穩定時,用戶不在需要等待過長的時間
![](https://i.imgur.com/s8aaBDf.png)
## Workbox
網址:https://developers.google.com/web/tools/workbox
Google 的開發團隊針對 Cache 及 Caching Strategies 提供更便捷的管理框架
可用於生成Service Worker,透過設置Config檔案,簡化整個 Service Worker 的處理過程
是 sw-precache 和 sw-toolbox 的繼任者,且提供更多的彈性跟功能集合
有預先定義好針對各種應用場景的多種 Caching Strategies,也可依需求客製與自訂。
## Why Workbox
* Precaching:預先Cache靜態資源
> 使用```workbox.precaching```模組
* Runtime caching:Runtime時Cache
* Strategies:管理與控制 Caching Strategies
* Request routing:管理與控制 資源請求的路由
> 使用```workbox.routing```模組做路由控制
> 使用```workbox.strategies```模組 取得或自訂的緩存策略控制
> 按照各種應用情境完成 Runtime caching。
* Background sync:背景同步
* Helpful debugging:更好的除錯
## Workbox do precaching
Precache 的工作是在 Service Worker ```install``` 時候通過 Cache API 完成
Workbox 利用 ```precacheAndRoute``` 方法,傳入帶有**版本資訊**的資源網址清單
```javascript
import {precacheAndRoute} from 'workbox-precaching';
precacheAndRoute([
{url: '/index.html', revision: '383676' },
{url: '/styles/app.0c9a31.css', revision: null},
{url: '/scripts/app.0d5770.js', revision: null},
]);
```
> 請注意,CSS和JS的revision屬性設置為null。因為它們在檔名中已經帶有版本資訊。
> 通常CSS跟JS都會是透過打包工具Webpack產生,所以可設定帶有版本資訊。
## Workbox do runtime caching
### 配對 Routing 的形式
Match a request with ```workbox-routing```
* string
* regular expression
* callback
#### Matching a Route with a String
用字串配對 Routing 是最容易理解的,但也是最不靈活的選擇。
將請求的URL與Routing的字串進行比較
如果它們相等,則請求將使用該Routing的處理程序。
```javascript
import {registerRoute} from 'workbox-routing';
registerRoute(
'https://some-other-origin.com/logo.png',
handler
);
```
#### Matching a Route with a Regular Expression
當您有一組Routing的URL要做配對時,正則表達式是最好的選擇。
正則表達式將針對完整URL進行測試。如果有配對到,則會觸發該路線。
這提供了很大的靈活性。
* 針對 特定副檔名 做 Routing 處理
```javascript=
import {registerRoute} from 'workbox-routing';
registerRoute(
new RegExp('\\.js$'),
jsHandler
);
registerRoute(
new RegExp('\\.css$'),
cssHandler
);
```
* 針對 URL Pattern 做 Routing 處理
```javascript=
import {registerRoute} from 'workbox-routing';
// 例如,遵循該格式的blog路徑,/blog/<year>/<month>/<post title>
registerRoute(
new RegExp('/blog/\\d{4}/\\d{2}/.+'),
handler
);
```
> 正則表達式必須從URL的開頭開始配對,而不是與URL的任何部分進行配對
> 使用 ```.+``` 做為前綴,讓配對的方式可以是相同domain或cross domain的**通用路徑模式**
```javascript
import {registerRoute} from 'workbox-routing';
registerRoute(
new RegExp('.+\\.js$'),
jsHandler
);
registerRoute(
new RegExp('.+\\.css$'),
cssHandler
);
registerRoute(
new RegExp('.+/blog/\\d{4}/\\d{2}/.+'),
handler
);
```
#### Matching a Route with a Function
如果以上的方式都無法達成配對的需求,還可以使用 function 的形式
```javascript
import {registerRoute} from 'workbox-routing';
const matchFunction = ({url, event}) => {
// Return true if the route should match
return false;
};
registerRoute(
matchFunction,
handler
);
```
### Handle Request
通過兩種方式處理 Request:
* 使用```workbox-strategies``` 提供的 Workbox 策略。
* 使用自己定義的 callback function 做處理
#### Handling a Route with a Workbox Strategy
```javascript
import {registerRoute} from 'workbox-routing';
import * as strategies from 'workbox-strategies';
registerRoute(
match,
new strategies.StaleWhileRevalidate()
);
registerRoute(
match,
new strategies.NetworkFirst()
);
registerRoute(
match,
new strategies.CacheFirst()
);
registerRoute(
match,
new strategies.NetworkOnly()
);
registerRoute(
match,
new strategies.CacheOnly()
);
```
每個strategies還能夠透過設定plugin的方式,來做客製化
```javascript
import {StaleWhileRevalidate} from 'workbox-strategies';
new StaleWhileRevalidate({
// Use a custom cache for this route.
cacheName: 'my-cache-name',
// Add an array of custom plugins (e.g. `ExpirationPlugin`).
plugins: [
...
]
});
```
#### Handling a Route with a Custom Callback
```javascript
import {registerRoute} from 'workbox-routing';
const customHandler = async ({url, event}) => {
return new Response(`Custom handler response.`);
};
registerRoute(match, customHandler);
```
如果你在matchFuction 有回傳值,則回傳值會被pass到handler的傳入參數中 ```params```
```javascript
import {registerRoute} from 'workbox-routing';
const match = ({url, event}) => {
return {
name: 'Workbox',
type: 'guide',
};
};
const customHandler = async ({url, event, params}) => {
// Response will be "A guide to Workbox"
return new Response(
`A ${params.type} to ${params.name}`
);
};
registerRoute(match, customHandler);
```
### Workbox Plugins
Workbox 的 Plugin 是為了讓 Caching Strategies 更加靈活
例如,我們可以使用```workbox.expiration.Plugin``` 來管理Cache的數量以及Cache的時效
```javascript
workbox.routing.registerRoute(
/\.(?:png|gif|jpg|jpeg|svg)$/,
workbox.strategies.cacheFirst({
cacheName: 'images' ,
plugins: [
new workbox.expiration.Plugin({
maxEntries : 60 ,
maxAgeSeconds: 30 * 24 * 60 * 60
}),
],
})
);
```
除了官方提供的Plugin,也可以透過 LifeCycle Hooks 自訂Custom Plugins
```javascript
const myPlugin = {
cacheWillUpdate: async ({request, response, event}) => {
return response;
},
cacheDidUpdate: async ({cacheName, request, oldResponse, newResponse, event}) => {
// ...
},
cacheKeyWillBeUsed: async ({request, mode}) => {
return request;
},
cachedResponseWillBeUsed: async ({cacheName, request, matchOptions, cachedResponse, event}) => {
return cachedResponse;
},
requestWillFetch: async ({request}) => {
return request;
},
fetchDidFail: async ({originalRequest, request, error, event}) => {
// ...
},
fetchDidSucceed: async ({request, response}) => {
return response;
}
};
```
參考:https://developers.google.com/web/tools/workbox/guides/using-plugins
## Service Worker Fetch Event 再探討
Fetch 事件 允許攔截任何Browser的Http Request,我們可依照需求去修改Http Response,甚至建立自定義的Http Response來回應。而不跟Server要求回應。
以下有二個應用,可以搭配使用在Fetch Event上,做到更好的網頁回應速度。
### WebP
WebP 是 Google 推出的一種圖檔規格,他能減少檔案大小,但達到和JPEG格式相同的圖片品質,因此可減少圖片檔案在網路上的傳送時間。
支援WebP格式的Browser,目前還只有Chrome、Edge、Opera跟Android
![](https://i.imgur.com/T8ZCoQK.png)
支援 WebP 格式的 Browser 會在每個 Http Request 添加 accept:image/webp 來告知 Client端它支持 WebP格式
```javascript
self.addEventListener('fetch', event => {
if(/\.jpg$|.png$).test(event.request.url) {
let supportsWebp = false;
if (event.request.headers.has('accept')) {
const accept = event.request.headers.get('accept');
supportsWebp = accept.includes('webp');
}
if (supportsWebp) {
const httpRequest = event.request.clone();
const returnUrl = httpRequest.url.substr(
0,
httpRequest.url.lastIndexOf('.')
) + '.webp';
event.respondWith(
fetch(returnUrl, {mode: 'no-cors'})
);
}
}
});
```
Webp提供了比原來圖檔尺寸更小且不失質量的圖片格式,透過Service Worker可攔截原始圖檔後,只要Client端的Browser支援Webp格式,就使用Webp格式返回,而不支持Webp的就返回原始格式(漸進式增強)。
可大幅提升網頁生成的速度,提供更好的使用者體驗。
### Save-Data Http Request Header
當 Http Request Header 中的 Save_data 被使用者設置為 on 就明確表示用戶選擇使用客戶端簡化數據使用模式
當與Server進行通信時允許使用替代內容以減少下載的數據,例如較小的圖檔及影片、禁用輪詢和自動更新等。
透過 fetch event 來修改及決定是否返回網站的輕量級版本。
```javascript
self.addEventListener('fetch', event => {
if (event.request.headers.get('save-data')) {
if (event.request.url.includes('font.googleapis.com')) {
// 不提供網頁字型,使用本機可用字型
}
}
});
```
## 讓 Web App 越來越接近 Native App
以下是之後會再補充的內容
### Background Sync
### Push Notification
### Add To Home Screen & Manifest File