從 Mock Service Worker 源碼中學習
前言
嗨大家好,我是 Johnny,最近閒暇時我在想,到底 msw 是如何做到 web 與 service worker 之間的溝通,一直以為 msw 只是單純發個請求給 service worker 後,service worker 再直接把配對到的內容丟回給 web 這樣,但看完 msw 的源碼後才發現,我還是太嫩了QQ,人家根本不只是這麼單純的丟過去丟回來而已...
Mock 操作種類
相信各位前端開發者們,都有用過各種 Mock 服務來進行測試與開發,但其中的原理,根據不同工具其底層的架構跟作法都不太一樣,舉例來說,常見有以下幾種 mock 的原理作法
- 套件內部攔截:像是
axios內建有 Requestinterceptor讓開發者在使用 axios 工具實際送出 request 之前就攔截返回預先訂制的 response content,好處是不改動任何原生底層的 api,壞處是除了 axios 以外,無法攔截直接調用原生 api 的場景 - 底層替換攔截:這類以
mock.js為代表,直接替換最底層的 XHR 物件,藉此在不改動第三方套件的情況下攔截所有請求,好處是不論使用何種第三方套件,只要底層是使用 XHR 發出請求就都可以攔截到,但缺點是 XHR 物件在 server 端並不存在,要使用的話必須對 server 端進行相容處理 - 獨立 mock server:這類以
mockoon等為代表(雖然 mockoon 有 serverless 工具,但畢竟還是要部署在一個地方比如 amazon, vercel 等才能使用),或是其他直接啟動一個 server 來回應請求,這種方式比較不算在單純前端 mock 的範圍內,畢竟已經是直接啟動一個 server 了...,那就不單單只是所謂純前端的事了,好處是可以拿到最真實的 api 請求的 request, response 物件,壞處是你要為此多啟動一個 server - mock service worker:以
msw為代表,透過啟動一個service worker為中間層,攔截所有從當前網頁發出的請求並返回,優點是除了可以模擬到真實 api 請求的 request, response 物件,同時不需要額外多啟動一台 server,缺點是如果網頁本身已經有其他的 service worker 可能會需要想辦法合併兩者,這種作法相對前三者較為新
MSW 2.0
看完以上四種 mock 原理,前面三個相信已經有很多大神們解釋過了,今天我們要來透過瀏覽 msw 的源碼來了解這種相對新的作法究竟如何做到的?
正式理解源碼前,我們首先來對整個 msw 的使用有個基本概念,撰寫此文時剛好 msw 2.0 released 了,就直接看最新的內容
安裝
安裝省略直接看官網...
產生 msw 的 mockServiceWorker.js
安裝完成後,透過 msw 提供的 cli 指令在指定位置產生 service worker file,產生的這個 sw file 是給 msw 使用,後面會來仔細看裡面究竟寫了啥
$ npx msw init ./public --save
定義 msw interceptor
// handler.js
// 1. Import the "HttpResponse" class from the library.
import { http, HttpResponse } from 'msw'
export const handlers = [
http.get('/resource', () => {
// 2. Return a mocked "Response" instance from the handler.
return HttpResponse.json({
msg: 'Hello world!'
})
}),
]
在 Browser 端使用
雖然 msw 也相容直接在 nodejs 端使用,但這邊先以 Browser 端做介紹
// mock.js
import { setupWorker } from 'msw/browser'
import { handlers } from './handlers'
const worker = setupWorker(...handlers)
worker.start() // 實際可根據環境自由選擇是否引入與執行啟動
在網頁中啟動
接著在瀏覽器上開啟網頁,如果有看到 data 內容就表示 mock 成功摟
// main.js(entrypoint of you web app)
import './mock';
(async () => {
const res = await fetch('/resource');
const data = await res.json();
console.log(data);
})();
mockServiceWorker.js
msw 直接從源碼本身著手會稍微有難度,因為牽涉到 nodejs, browser 端的處理,畢竟這邊主要是想理解 service worker 的部分處理機制,而 service worker 本身只存在於 browser 端,所以這邊單純以瀏覽 browser 相關的 code 為主,以下只是部分節錄
// 用來儲存 active 的 clientId
const activeClientIds = new Set()
// 定義 service worker 收到 message
self.addEventListener('message', async function (event) {
const clientId = event.source.id
const client = await self.clients.get(clientId)
const allClients = await self.clients.matchAll({
type: 'window',
})
switch (event.data) {
// keepAlive 避免 service worker 休眠去了
case 'KEEPALIVE_REQUEST': {
sendToClient(client, {
type: 'KEEPALIVE_RESPONSE',
})
break
}
// 當 web 載入 service worker 時添加 active client
// (可能同時開很多個 web page 連上同一個 service worker)
case 'MOCK_ACTIVATE': {
activeClientIds.add(clientId)
// 通知 web 端載入並紀錄 clientId 完成
sendToClient(client, {
type: 'MOCKING_ENABLED',
payload: true,
})
break
}
// 當 web 端中斷連線時須移除 active clientId
case 'CLIENT_CLOSED': {
activeClientIds.delete(clientId)
const remainingClients = allClients.filter((client) => {
return client.id !== clientId
})
// Unregister itself when there are no more clients
if (remainingClients.length === 0) {
self.registration.unregister()
}
break
}
}
})
self.addEventListener('fetch', function (event) {
const { request } = event
// 省略一大段...
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
}
// 取得主要的 client
// 發出請求的 client 並不一定就是註冊 worker 的那個 client
// 在回應請求時應該使用後者(註冊 worker 的那個 client)
async function resolveMainClient(event) {
const client = await self.clients.get(event.clientId)
if (client?.frameType === 'top-level') {
return client
}
const allClients = await self.clients.matchAll({
type: 'window',
})
return allClients
.filter((client) => {
return client.visibilityState === 'visible'
})
.find((client) => {
return activeClientIds.has(client.id)
})
}
async function getResponse(event, client, requestId) {
const { request } = event
// 複製 request,因為可能已經被使用
// (i.e. 比如 body 可能已經被送到 client)
const requestClone = request.clone()
function passthrough() {
const headers = Object.fromEntries(requestClone.headers.entries())
return fetch(requestClone, { headers })
}
// 省略一大段 passthrough 判斷...
// 通知 main client 端請求已被攔截
// 這裡會等到 client 端處理好整個 response 後繼續執行
// 主要是透過 sendToClient 中的 MessageChannel 雙向 sync 溝通
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],
)
// 根據 main client 端回傳的 message 決定是 intercept 或 passthrough
// respondWithMock 會實際產生一個 HTTP Response object 並丟回給發出請求的 client
switch (clientMessage.type) {
case 'MOCK_RESPONSE': {
return respondWithMock(clientMessage.data)
}
case 'MOCK_NOT_FOUND': {
return passthrough()
}
}
return passthrough()
}
// 透過 MessageChannel 與 client 端進行 sync 雙向溝通
// 把 port2 丟給 client,讓 client 可透過 port2 與 port1(當前 worker) 溝通
// 透過 MessageChannel 可以藉由 promise 讓 function 等待 client 回傳結果
// 而不是讓 client 透過 postMessage 回傳,因為 postMessage 無法讓 worker 直接 await 等待結果
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)),
)
})
}
async function respondWithMock(response) {
// Setting response status code to 0 is a no-op.
// However, when responding with a "Response.error()", the produced Response
// instance will have status code set to 0. Since it's not possible to create
// a Response instance with status code 0, handle that use-case separately.
if (response.status === 0) {
return Response.error()
}
const mockedResponse = new Response(response.body, response)
Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, {
value: true,
enumerable: true,
})
return mockedResponse
}
這個檔案就是在前面透過 msw cli 產生的 service worker,其主要作用是處理整個 service worker 的初始化與後續 message, fetch 請求的攔截,在收到 client 端發來的請求時,透過 MessageChannel 與 main client 溝通獲得對應的 response message content,最後再傳回給 client
setupWorker.ts
這個檔案是在 client 初始化整個 service worker 的入口,可以找到這個 start method
export class SetupWorkerApi {
// 省略一大坨...
private createWorkerContext(): SetupWorkerInternalContext {
const context: SetupWorkerInternalContext = {
// 省略一大坨...
};
this.startHandler = context.supports.serviceWorkerApi
? createFallbackStart(context)
: createStartHandler(context) // 下一個關鍵入口在這~
return context
}
public async start(options: StartOptions = {}): StartReturnType {
this.context.startOptions = mergeRight(
DEFAULT_START_OPTIONS,
options,
) as SetupWorkerInternalContext['startOptions']
return await this.startHandler(this.context.startOptions, options)
}
}
透過這個 start method,循線找到 createStartHandler
createStartHandler.ts
export const createStartHandler = (
context: SetupWorkerInternalContext,
): StartHandler => {
return function start(options, customOptions) {
// 處理來自 service worker 名叫 `REQUEST` 的 message
// 這裡就對應上了上面的 getResponse 裡的 sendToClient "REQUEST"
context.workerChannel.on(
'REQUEST',
createRequestListener(context, options), // 下一個關鍵入口在這~
)
const instance = await getWorkerInstance(
options.serviceWorker.url,
options.serviceWorker.options,
options.findWorker,
)
const [worker, registration] = instance
context.worker = worker
context.registration = registration
context.events.addListener(window, 'beforeunload', () => {
if (worker.state !== 'redundant') {
// 通知 Service Worker 當前 client 將關閉
context.workerChannel.send('CLIENT_CLOSED')
}
// 確保 keepAlive interval 關閉,避免 memory leaks
window.clearInterval(context.keepAliveInterval)
})
// 啟動 keepAlive interval
context.keepAliveInterval = window.setInterval(
() => context.workerChannel.send('KEEPALIVE_REQUEST'),
5000,
)
}
}
createStartHandler 主要會掛載處理 message REQUEST,並啟動 keepAlive 機制,接著進入到 createRequestListener 看看具體是怎麼處理 request 的吧
createRequestListener.ts
export const createRequestListener = (
context: SetupWorkerInternalContext,
options: RequiredDeep<StartOptions>,
) => {
return async (
event: MessageEvent,
message: ServiceWorkerMessage<
'REQUEST',
ServiceWorkerIncomingEventsMap['REQUEST']
>,
) => {
// WorkerChannel 為 msw 另外定義的一個 class ,傳入一個 port,可透過該 port 傳送 message 給對應的 port(這兩個 ports 是透過 MessageChannel 產生的一對 port)
const messageChannel = new WorkerChannel(event.ports[0])
const requestId = message.payload.id
const request = parseWorkerRequest(message.payload)
const requestCloneForLogs = request.clone()
try {
// 下一個進階入口在這~處理 request 並產生對應 response 丟回 onMockedResponse
await handleRequest(
request,
requestId,
context.requestHandlers,
options,
context.emitter,
{
async onMockedResponse(response, { handler, parsedResult }) {
// 複製 mocked Response 讓 body 可被讀取為 buffer 並傳送給 worker
const responseClone = response.clone()
const responseInit = toResponseInit(response)
/**
* @note Safari doesn't support transferring a "ReadableStream".
* Check that the browser supports that before sending it to the worker.
*/
if (context.supports.readableStreamTransfer) {
const responseStream = response.body
messageChannel.postMessage(
'MOCK_RESPONSE',
{
...responseInit,
body: responseStream,
},
responseStream ? [responseStream] : undefined,
)
} else {
// As a fallback, send the response body buffer to the worker.
const responseBuffer = await responseClone.arrayBuffer()
messageChannel.postMessage('MOCK_RESPONSE', {
...responseInit,
body: responseBuffer,
})
}
},
},
)
} catch (error) {
if (error instanceof Error) {
// 處理任何未知錯誤
messageChannel.postMessage('MOCK_RESPONSE', {
status: 500,
statusText: 'Request Handler Error',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: error.name,
message: error.message,
stack: error.stack,
}),
})
}
}
}
}
createRequestListener 主要會使用 worker 傳來的 payload 創建一個 Request object,接著透過 handleRequest 把 request 拿去比對取得對應的 response,最後再透過 messageChannel 把 response 傳遞回 worker,到此整個 msw 的 Service Worker 訊息交換機制算是理解完成了
流程總結
簡單總結下流程,整個訊息交換過程從 client 端開始
- client 發出 activate 請求,把自己註冊進 service worker 中
- 當某個 client 端發出 request 後,service worker 攔截請求並把 request 詳細資料傳回給 main client 同時帶著一個 MessageChannel 的 port2
- main client 收到 service worker 傳送的 message
REQUEST後,在本地查找對應的 response - 不論有無找到,最後 main client 都會將結果透過 MessageChannel 的 port2 把 response 內容傳回 service worker
- service worker 收到 main client 的 response 內容,構建成一個 HTTP Response object 後傳回給發出請求的 client
感想
這次心血來潮跑去閱讀 msw 關於 browser side 的 service worker 用法,真的是獲益良多,看完後甚至都可以(已經)直接自幹一個簡易版本的 msw 了...,除了 service worker 的一些特性外,最重要的是之前完全沒聽過 MessageChannel 這東西,透過這次學習總算學到了這東西,雖然不知道實際開發中還可以用在哪些地方,後續再來研究看看,能在日後的開發上實際使用上的場景
那這次技術分享就到這拉,感謝各位的收看,如果喜歡我的分享文章也歡迎分享給更多人看看摟,下篇見拉,掰掰~=V=
