
Comlink 讓 Web Worker 不再難用
大家有沒有遇過這種情境,點了按鈕後,整個頁面就瞬間凍結,久久沒有回應,就像那個停留在「哈哈去洗澡」的對話框一樣。( ´•̥̥̥ ω •̥̥̥` )
這通常是因為有段很重的計算邏輯跑在主執行緒(Main Thread),把 JavaScript 的單執行緒塞爆了。
解法大家都知道,那就是 Web Worker,把粗活丟給別的執行緒去跑。
不過 Web Worker 原生的 API 用起來嘛...
路人:「和你一樣煩人?」
鱈魚:「說好得尊重呢?( ˘・з・)」
今天要來介紹一個神奇小工具 Comlink,讓 Web Worker 用起來像呼叫普通的 async function 一樣爽。( •̀ ω •́ )✧
Web Worker 是什麼
Web Worker 說穿了就是瀏覽器提供的「背景打工仔」機制。
主執行緒就像是老闆,專門處理畫面渲染、使用者互動這些重要事情,不能被打擾。
Worker 就是打工仔,被指派去做耗時的重活,例如大量資料運算、影像處理、加解密等等,完成後再把結果回報給老闆。
兩邊溝通使用的是 postMessage 與 onmessage。
聽起來簡單易懂對吧?( ・ิω・ิ)
原生 postMessage 有多麻煩
來看看傳統的 Web Worker 用法。
假設我們有個很重的計算,需要把一個大陣列的每個元素做費波那契數列計算。
路人:「和鱈魚一樣重嗎?」
鱈魚:「重...重才值錢啊。ლ(・´ェ`・ლ)」
首先建立打工仔檔案:
src/workers/heavy-calc.worker.ts
function fibonacci(n: number): number {
if (n <= 1)
return n
return fibonacci(n - 1) + fibonacci(n - 2)
}
self.onmessage = (event: MessageEvent<number[]>) => {
const list = event.data
const result = list.map((n) => fibonacci(n))
self.postMessage(result)
}然後在主執行緒呼叫:
const worker = new Worker(
new URL('../workers/heavy-calc.worker.ts', import.meta.url),
{ type: 'module' }
)
worker.postMessage([40, 41, 42])
worker.onmessage = (event: MessageEvent<number[]>) => {
console.log('結果:', event.data)
}路人:「這樣看起來還好嘛,哪裡麻煩了?」
鱈魚:「問題來了,現實中打工仔通常不只做一件事,老闆也不只發一個命令。」
各位社畜一定很懂吧?ლ(・´ェ`・ლ)
如果我想讓同一個 Worker 支援多種計算,就要自己手刻一套訊息分發機制:
// worker 端
self.onmessage = (event: MessageEvent) => {
const { type, payload, id } = event.data
if (type === 'fibonacci') {
const result = payload.map(fibonacci)
self.postMessage({ id, result })
}
else if (type === 'sort') {
const result = [...payload].sort((a, b) => a - b)
self.postMessage({ id, result })
}
// 越來越多 else if...
}// 主執行緒
let callId = 0
const pendingMap = new Map<number, (result: unknown) => void>()
worker.onmessage = (event: MessageEvent) => {
const { id, result } = event.data
pendingMap.get(id)?.(result)
pendingMap.delete(id)
}
function callWorker(type: string, payload: unknown) {
return new Promise((resolve) => {
const id = callId++
pendingMap.set(id, resolve)
worker.postMessage({ type, payload, id })
})
}
const result = await callWorker('fibonacci', [40, 41])這根本是在自己刻一個 RPC 框架,而且錯誤處理、TypeScript 型別、cleanup 邏輯都還沒算進去。Σ(ˊДˋ;)
Comlink 出場
Comlink 是 Google Chrome 團隊開發的小工具,它做的事情很單純,就是把上面那堆 postMessage 的麻煩事全部包起來。
讓你可以在主執行緒直接呼叫 Worker 裡面的 function,就像呼叫普通的 async function 一樣。
先安裝:
npm install comlink先來看改寫後的打工仔:
src/workers/heavy-calc.worker.ts
import { expose } from 'comlink'
function fibonacci(n: number): number {
if (n <= 1)
return n
return fibonacci(n - 1) + fibonacci(n - 2)
}
const api = {
calcFibonacci(list: number[]): number[] {
return list.map((n) => fibonacci(n))
},
sortNumbers(list: number[]): number[] {
return [...list].sort((a, b) => a - b)
},
}
expose(api)
export type WorkerApi = typeof api然後在主執行緒:
import type { WorkerApi } from './workers/heavy-calc.worker'
import { wrap } from 'comlink'
const worker = new Worker(
new URL('./workers/heavy-calc.worker.ts', import.meta.url),
{ type: 'module' }
)
const api = wrap<WorkerApi>(worker)
// 直接呼叫,完全感覺不到 postMessage 的存在
const result = await api.calcFibonacci([40, 41, 42])
const sorted = await api.sortNumbers([3, 1, 4, 1, 5, 9])是不是很讚啊!之前那堆 callId、pendingMap、訊息分發邏輯全部不見了。( •̀ ω •́ )✧
而且因為有 export type WorkerApi,TypeScript 的型別提示也完整保留了!ヽ(●`∀´●)ノ
在 Vue 中整合 Comlink
知道 Comlink 怎麼用之後,所以該怎麼在 Vue 中應用呢?
推薦寫成 Composable,讓 Worker 的生命週期跟著元件走,不用手動管理。
建立 Composable
src/composables/use-heavy-calc-worker.ts
import type { WorkerApi } from '../workers/heavy-calc.worker'
import { wrap } from 'comlink'
import { onScopeDispose, shallowRef } from 'vue'
export function useHeavyCalcWorker() {
const worker = new Worker(
new URL('../workers/heavy-calc.worker.ts', import.meta.url),
{ type: 'module' }
)
const api = wrap<WorkerApi>(worker)
const isLoading = shallowRef(false)
// 元件解除時自動清理 Worker
onScopeDispose(() => {
worker.terminate()
})
async function calcFibonacci(list: number[]) {
isLoading.value = true
try {
return await api.calcFibonacci(list)
}
finally {
isLoading.value = false
}
}
return {
isLoading,
calcFibonacci,
}
}在元件中使用
src/components/HeavyCalc.vue
<template>
<div>
<q-btn
:loading="isLoading"
color="primary"
label="開始計算"
@click="handleCalc"
/>
<div v-if="result">
結果:{{ result }}
</div>
</div>
</template>
<script setup lang="ts">
import { shallowRef } from 'vue'
import { useHeavyCalcWorker } from '../composables/use-heavy-calc-worker'
const { isLoading, calcFibonacci } = useHeavyCalcWorker()
const result = shallowRef<number[]>()
async function handleCalc() {
result.value = await calcFibonacci([38, 39, 40, 41, 42])
}
</script>這樣就完成了!主執行緒完全不會被卡住,使用者點按鈕之後畫面繼續流暢運作,繁重任務在背景處理,默默壓榨 Worker。( ´థ౪థ)b
Worker 共享還是獨立?
有個小細節值得討論,Worker 的建立方式會影響它的生命週期與資源使用。
每個元件自己的 Worker
剛才的做法是每個元件實例都建立一個新的 Worker,優點是元件解除後 Worker 自動跟著清掉,不會有資源洩漏問題。
缺點是如果同一個頁面有很多元件都需要這個 Worker,就會建立很多 Worker 實例,消耗比較多資源。
全域共享 Worker
如果同一個打工仔需要被很多地方使用,可以用 VueUse 提供的 createSharedComposable 把剛才的 Composable 升級成全域共享版本。
createSharedComposable 會讓所有呼叫這個 Composable 的元件共用同一個狀態實例,當最後一個使用它的元件解除後,才會執行清理邏輯。
src/composables/use-heavy-calc-worker.ts
import type { WorkerApi } from '../workers/heavy-calc.worker'
import { createSharedComposable } from '@vueuse/core'
import { wrap } from 'comlink'
import { onScopeDispose, shallowRef } from 'vue'
function _useHeavyCalcWorker() {
// 整個 app 只建立一個 Worker
const worker = new Worker(
new URL('../workers/heavy-calc.worker.ts', import.meta.url),
{ type: 'module' }
)
const api = wrap<WorkerApi>(worker)
const isLoading = shallowRef(false)
onScopeDispose(() => {
worker.terminate()
})
async function calcFibonacci(list: number[]) {
isLoading.value = true
try {
return await api.calcFibonacci(list)
}
finally {
isLoading.value = false
}
}
return { isLoading, calcFibonacci }
}
export const useHeavyCalcWorker = createSharedComposable(_useHeavyCalcWorker)元件的使用方式完全不用改,但現在不管幾個元件呼叫 useHeavyCalcWorker,都只會建立一個 Worker 實例。( •̀ ω •́ )✧
不過要注意的是,Worker 是單執行緒的,如果同時發出多個請求,它們會排隊執行。(´● ω ●`)
簡單判斷原則
- 計算量輕、元件少 → 每個元件自己的 Worker,簡單乾淨
- 計算量重、多處使用 → 全域共享 Worker,節省資源
- 需要同時跑多個任務 → 建立 Worker Pool(這個比較進階,之後有機會再寫)
使用限制
Comlink 雖然好用,但還是有些需要注意的地方。
只能傳遞可序列化的資料
Worker 之間的通訊底層還是依賴 postMessage,所以傳遞的資料必須是可以被 Structured Clone Algorithm 複製的類型。
Function、DOM 元素、class 實例(method 會消失)這類都沒辦法傳遞。
如果需要傳遞大型 ArrayBuffer,可以使用 Comlink 提供的 transfer 工具:
import { transfer, wrap } from 'comlink'
const api = wrap<WorkerApi>(worker)
const buffer = new ArrayBuffer(1024 * 1024)
// transfer 會轉移所有權,主執行緒就不再能使用這個 buffer
await api.processBuffer(transfer(buffer, [buffer]))Worker 內無法存取 DOM
打工仔不能碰 DOM,這是天條,只能做純粹的資料計算。
TypeScript 設定
Vite 專案通常不需要額外設定,但如果遇到 Worker 內的 TypeScript 型別問題,可以在 tsconfig.json 加上:
{
"compilerOptions": {
"lib": ["ESNext", "DOM", "WebWorker"]
}
}總結 🐟
- Web Worker 讓耗時計算不阻塞主執行緒,頁面保持流暢
- 原生
postMessage需要手刻訊息分發機制,相當麻煩 - Comlink 的
expose與wrap把這些苦活全包了,直接像呼叫 async function 一樣使用 - 搭配 Vue Composable 使用,可以讓 Worker 生命週期跟著元件走
- Worker 只能傳遞可序列化的資料,不能操作 DOM
感謝您讀到這裡,如果您覺得有收穫,歡迎分享出去。有錯誤還請多多指教 (*´∀`)~♥