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