# 前端效能優化 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)

### 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

:::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。
* 兩者的過期時間設定成不一樣的時間,搭配不同的快取策略,可以達到不同的效果。

* 如果 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)