# 前端效能優化 Day18 X Service Workers Cache Part 1 ###### tags: `前端效能優化` :::info :::spoiler Reference <br /> * [今晚,我想來點 Web 前端效能優化大補帖! Day18 X Service Workers Cache](https://ithelp.ithome.com.tw/articles/10276666) ::: ## What is Service Worker * 一種特別的 Web Worker :::warning Web Worker * 讓網頁在背景執行緒(Thread)中執行程式,而不干擾使用者介面運行 * 可以在瀏覽器關閉時繼續在背景執行的能力 ::: * 為什麼特別? * 它是一層在瀏覽器與 network 層級之間的 proxy,擁有攔截使用者發出請求的能力(透過監聽 fetch 事件) * 可以拿來... * 快取 * 可以在攔截使用者發出的請求後決定要不要回傳快取的內容。 * 離線瀏覽 * Native App 一樣的推播功能(PWA) * Background Sync ## How to use Service Worker (lifecycle) ![image](https://hackmd.io/_uploads/HJPa4BmfA.png) ### Register first * 先檢查當前瀏覽器有沒有支援 SW * 如果有支援的話,註冊寫好的 SW 檔案 * 網站必須支援 HTTPS 或是 localhost * register 中有傳入第二個 scope 參數,這個 scope 代表 SW 可以作用的範圍,沒有傳入的話預設會是根目錄 ```javascript!= if ('serviceWorker' in navigator) { navigator.serviceWorker .register('./some path to sw/sw.js', { scope: '/ironman' }) // 註冊 Service Worker .then(function(reg) { console.log('Registration succeeded. SW working scope is ' + reg.scope); }) .catch(function(error) { console.log('Registration failed with ' + error); // 註冊失敗 }); ``` ### Lifecycle: event listener :::spoiler **Install** <br /> * 瀏覽器註冊我們寫好的 SW 檔案後,瀏覽器會在 background 啟動一個 Service Worker 並開始安裝 * 通常在安裝階段會快取一些靜態資源,以供離線瀏覽時使用 * 如果指定的檔案都快取成功,就會進入到下一個 Activate 階段。 * 如果有資源快取失敗,則整個安裝過程都會失敗,就會到 Error 階段,並等待下次重新 install。 ```javascript!= this.addEventListener('install', function(event) { // waitUntil 確保 SW 在安裝完成後才會去快取這些資源 event.waitUntil( // 指定快取的版本號 caches.open('v1').then(function(cache) { // 指定要快取的資源 return cache.addAll(['/ironman.js', '/ironman.css']); }), ); ``` :::warning Service Worker 處理 cache 的寫法是使用 [Cache Web API](https://developer.mozilla.org/en-US/docs/Web/API/Cache) ::: <br /> :::spoiler **Activate** <br /> * 完成安裝後 SW 就會接著啟動進入 Activate 的狀態並接管在自己 scope 下的頁面 * 通常會清除舊的快取等等。 * 如果頁面沒有被使用時,Service Worker 會進入停止(Terminated)的狀態,以節省記憶體的耗費。 ```javascript!= this.addEventListener('activate', function(event) { // do some work }); ``` ::: <br /> :::spoiler **Message** <br /> * 使用 postMessage 跟頁面做訊息的傳遞。 ```javascript!= this.addEventListener('message', function(e) { e.source.postMessage('Message: ' + e.data); }); function sendingMsg(msg) { return new Promise(function(resolve, reject) { const messageChannel = new MessageChannel(); messageChannel.port1.onmessage = function(e) { if (e.data.error) { reject(e.data.error); } else { resolve(e.data); } }; navigator.serviceWorker.controller.postMessage(msg, [messageChannel.port2]); }); } sendingMsg('message testing'); ``` ::: <br /> :::spoiler **Fetch** <br /> * fetch 事件讓 SW 得以攔截使用者發出的請求,成為瀏覽器與伺服器之間的一個 proxy。 * 應用 * 可以去快取看看有沒有使用者想要的資源,如果有的話就從快取回傳,如果沒有的話就再發出網路請求。 ::: ### MSW for example #### Setup MSW 1. Run `msw init` to create `mockServiceWorker.js` to register Service Worker. 2. Insert the following snippet in your App. ```javascript!= import { setupWorker } from "msw/browser"; import { http, HttpResponse } from "msw"; import React from 'react' import ReactDOM from 'react-dom' import { App } from './App' export const handlers = [ http.get("/resource", () => HttpResponse.json({ id: "abc-123" })), ]; const worker = setupWorker(...handlers); async function enableMocking() { if (process.env.NODE_ENV !== 'development') { return } // const { worker } = await import('./mocks/browser') // Use dynamic import to reduce bundle size in production. // `worker.start()` returns a Promise that resolves // once the Service Worker is up and ready to intercept requests. return worker.start() } enableMocking().then(() => { ReactDOM.render(<App />, rootElement) }) ``` ```javascript= ``` :::spoiler **Initiate MSW: Register Service Worker** <br /> 1. `setupWorker(...handlers).start()` 的 `start()` 為 `new SetupWorkerApi().start()`,為 `this.startHandler()` (`createStartHandler()`)回傳之函式。 * 可注意 `DEFAULT_START_OPTIONS` ```javascript!= // msw/src/browser/setupWorker/setupWorker.ts function setupWorker(...handlers: Array<RequestHandler>): SetupWorker { return new SetupWorkerApi(...handlers) } export class SetupWorkerApi extends SetupApi<LifeCycleEventsMap> implements SetupWorker { public async start(options: StartOptions = {}): StartReturnType { ... this.context.startOptions = mergeRight( DEFAULT_START_OPTIONS, // DEFAULT_START_OPTIONS.serviceWorker.url === '/mockServiceWorker.js' options, ) as SetupWorkerInternalContext['startOptions'] return await this.startHandler(this.context.startOptions, options) // `workerRegistration` returned from `createStartHandler()()` } constructor(...handlers: Array<RequestHandler>) { ... this.context = this.createWorkerContext() } private createWorkerContext(): SetupWorkerInternalContext { const context: SetupWorkerInternalContext = { ... this.startHandler = context.supports.serviceWorkerApi ? createFallbackStart(context) : createStartHandler(context) return context } } ``` 2. `createStartHandler()` 中,`options.serviceWorker.options` 被傳入 `getWorkerInstance()` 去創建 Service Worker instance. ```javascript!= // msw/src/browser/setupWorker/start/utils/createStartHandler.ts export const createStartHandler = ( context: SetupWorkerInternalContext, ): StartHandler => { return function start(options, customOptions) { const startWorkerInstance = async () => { ... const instance = await getWorkerInstance( options.serviceWorker.url, options.serviceWorker.options, options.findWorker, ) const [worker, registration] = instance ... return registration } const workerRegistration = startWorkerInstance().then( async (registration) => { ... return registration }, ) return workerRegistration } } ``` 3. 第 15 行,確認並取得既有的 Service Worker instance;第 32 行,如果沒有,則使用 `navigator.serviceWorker.register()` 創建一個新的 ```javascript!= // msw/src/browser/setupWorker/start/utils/getWorkerInstance.ts /** * Returns an active Service Worker instance. * When not found, registers a new Service Worker. */ export const getWorkerInstance = async ( url: string, options: RegistrationOptions = {}, findWorker: FindWorker, ): Promise<ServiceWorkerInstanceTuple> => { // Resolve the absolute Service Worker URL. const absoluteWorkerUrl = getAbsoluteWorkerUrl(url) const mockRegistrations = await navigator.serviceWorker .getRegistrations() .then((registrations) => ... ) ... const [existingRegistration] = mockRegistrations if (existingRegistration) { ... } // When the Service Worker wasn't found, register it anew and return the reference. const registrationResult = await until<Error, ServiceWorkerInstanceTuple>( async () => { const registration = await navigator.serviceWorker.register(url, options) return [ // Compare existing worker registration by its worker URL, // to prevent irrelevant workers to resolve here (such as Codesandbox worker). getWorkerByRegistration(registration, absoluteWorkerUrl, findWorker), registration, ] }, ) return registrationResult.data } ``` ::: <br /> :::spoiler **Request handlers: Fetch and Message Event** <br /> 1. 當 Fetch event 發生時,觸發 `handleRequest()`,並透過 `getResponse()` 來產生 mock response 並回傳給 client. ```javascript!= // mockServiceWorker.js self.addEventListener("fetch", function (event) { const { request } = event; ... // Generate unique request ID. const requestId = crypto.randomUUID(); event.respondWith(handleRequest(event, requestId)); }); async function handleRequest(event, requestId) { const client = await resolveMainClient(event); const response = await getResponse(event, client, requestId); ... return response; } ``` 2. `getResponse()` 的目的在於取得 mock response。透過 `sendToClient()`: * 將 request 送回 client 處理,得到 mock response,並傳回 Service Worker。 * 之後便是 (1) 提過者,從 Service Worker 將 mock response 傳回 client。 * 即 client 發出的 request 會被 Service Worker 攔截,SW 透過 Message Event 將 request 傳回 client 使用使用者定義的 handlers 處理產生 mock response,之後傳回 SW,SW 再回應 Fetch Event. :::warning * 這裡透過 new MessageChannel() 來完成 Message Event,共分為三步: * 第 47 行:建立 channel (`new MessageChannel()`) * 第 49 行:透過 `channel.port1.onmessage` 告訴 port1 在收到 message 時應該怎麼處理。 * 第 57 行:透過 `client.postMessage()`,第二參數帶 `channel.port2` 來建立溝通管道,確保 channel.port1 可以順利回傳訊息 * `client.postMessage()` 像是 emitter 的角色,類似 `Node.dispatchEvent()` ::: ```javascript!= // mockServiceWorker.js async function getResponse(event, client, requestId) { const { request } = event; ... // Notify the client that a request has been intercepted. const requestBuffer = await request.arrayBuffer(); const clientMessage = await sendToClient( client, { type: "REQUEST", payload: { id: requestId, url: request.url, mode: request.mode, method: request.method, headers: Object.fromEntries(request.headers.entries()), cache: request.cache, credentials: request.credentials, destination: request.destination, integrity: request.integrity, redirect: request.redirect, referrer: request.referrer, referrerPolicy: request.referrerPolicy, body: requestBuffer, keepalive: request.keepalive, }, }, [requestBuffer] ); switch (clientMessage.type) { case "MOCK_RESPONSE": { return respondWithMock(clientMessage.data); } ... } ... } function sendToClient(client, message, transferrables = []) { return new Promise((resolve, reject) => { const channel = new MessageChannel(); channel.port1.onmessage = (event) => { if (event.data && event.data.error) { return reject(event.data.error); } resolve(event.data); }; client.postMessage( message, [channel.port2].concat(transferrables.filter(Boolean)) ); }); } ``` ::: <br /> :::spoiler Why we need to use message event handler to deal with mocking logic? <br /> ::: ## Service Worker as a Cache ![image](https://hackmd.io/_uploads/H1MnHzUGA.png) :::warning Note: Note that **some browsers like Chrome have a memory cache layer in front of the service worker cache**. The details of the memory cache depend on each browser's implementation. Unfortunately, there is no clear specification for this part yet. ::: ## Why Service Worker Cache * 更富彈性地控制快取 * 快取有許多不同的 strategies,例如 Cache First 與 Network First 等。 * SW Caching 透過程式碼來決定攔截到網路請求後要做什麼處理,相較於變化性沒那麼大的 HTTP Caching,得以更富彈性地控制快取。 * 離線瀏覽 * 透過把一些重要的資源放到 SW 的快取裡,使用者在失去網路連線時還能夠看到快取的資源,而不是顯示網路連線錯誤的畫面,大大提升了使用者體驗。 :::warning 為什麼 HTTP Caching 沒辦法做到離線瀏覽呢? * Cache-Control 本身的設計就不是針對離線瀏覽 * HTTP Caching 需要經過伺服器與瀏覽器的共同協商,如果在離線前沒有造訪過一些頁面,就不會有那些頁面的快取,也就沒辦法實現離線瀏覽。 * Service Worker 則是可以透過程式決定要快取哪些資源,不一定要造訪過頁面才能將資源存到快取。 * 每種瀏覽器的行為不一樣,就算瀏覽器已經有該資源的快取,也沒辦法保證它ㄧ定會從快取拿而不是發出網路請求。 ::: ### Offline Cache Stategies :::spoiler **Cache only** <br /> * 無論如何只回傳快取版本。 * 一般來說不會直接採用這個方式,在快取沒有資源時回到 network 是比較常見也比較好的方式。 * 如果真的要使用,比較適合用在一些與 codebase 結合的靜態資源。 ```javascript!= self.addEventListener('fetch', function(event) { event.respondWith(caches.match(event.request)); }); ``` ::: <br /> :::spoiler **Network only** <br /> * 無論如何都會直接發出網路請求。 * 同樣一般來說不會直接使用這個方式,真的要使用的話比較適合一些不適合在 offline 使用的資源,例如一些 non-GET 的 request。 ```javascript!= self.addEventListener('fetch', function(event) { event.respondWith(fetch(event.request)); }); ``` ::: <br /> :::spoiler **Cache falling back to network** <br /> * 蠻常見的一種模式,先去快取找看看有沒有,有就回傳,沒有就發出 network request。 * 如果是 non-GET 的 requests 因為不能被快取,所以會直接發出請求。 * 如果希望網頁可以達到 offline first,這就會是一個蠻適合的 strategy。 ```javascript!= self.addEventListener('fetch', function(event) { event.respondWith( caches.match(event.request).then(function(response) { return response || fetch(event.request); }) ); }); ``` ::: <br /> :::spoiler **Network falling back to cache** <br /> * 如果資源的變化頻率很高,例如文章列表、分數排行榜等等,就適合以 network request 為優先以確保拿到最新的資料。 * 如果使用者失去網路連線,可以先回傳快取的舊資料,使用者體驗會比顯示 network error 好很多。 * 不過這種策略的缺點是當使用者沒有完全 offline 但網路連線非常微弱緩慢時,需要等待網路請求完成,這個等待會很漫長並嚴重影響使用者體驗。 ```javascript!= self.addEventListener('fetch', function(event) { event.respondWith( fetch(event.request).catch(function() { return caches.match(event.request); }) ); }); ``` ::: <br /> :::spoiler **Cache then network (Stale while revalidate)** <br /> * 同樣也十分適合變化頻率高的內容,當使用者對資源發出請求時,先直接回傳快取的版本,同時去網路抓取最新的內容,當有新的資料回傳時再更新快取。 * 在這個策略下使用者可以迅速的拿到資源,提升使用者的體驗,並且後續的請求都可以拿到更新後的資料。 ```javascript!= self.addEventListener('fetch', event => { const cached = caches.match(event.request); const fetched = fetch(event.request); const fetchedCopy = fetched.then(resp => resp.clone()); // 用 Promise.race 看 cache 跟網路請求誰先回傳(通常是 cache),如果 cache 沒資料就等 network event.respondWith( Promise.race([fetched.catch(_ => cached), cached]) .then(resp => resp || fetched) .catch(_ => new Response(null, {status: 404})) ); // 用 fetch 回來的資料更新快取 event.waitUntil( Promise.all([fetchedCopy, caches.open('cache-v1')]) .then(([response, cache]) => cache.put(event.request, response)) .catch(_ => {/* eat any errors */}) ); }); ``` ::: ### Apply it with HTTP and Service Worker Cache * 像有雙層快取防護一樣,第一層 cache miss 了不要怕,也許第二層會幫你守住防線。 * 如何搭配? * 設置快取的 Expire Time。 * 兩者的過期時間設定成不一樣的時間,搭配不同的快取策略,可以達到不同的效果。 ![image](https://hackmd.io/_uploads/HJDVCLQf0.png) * 如果 SW Caching 與 HTTP Caching 的過期時限都設置成一樣的話,基本上 HTTP Caching 會等於沒什麼用處。因為 SW Cache 和 HTTP Cache 會同時過期,所以ㄧ定會回到網路請求中。 * [Google Web.dev: Service worker caching and HTTP caching](https://web.dev/articles/service-worker-caching-and-http-caching)