---
url: /blog-program/using-comlink.md
description: Web Worker 很棒，但 postMessage 用起來實在太痛苦了，讓 Comlink 來救場吧！( •̀ ω •́ )✧
---

![using-comlink](/using-comlink.webp){.cover}

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

```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)
}
```

然後在主執行緒呼叫：

```ts
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 支援多種計算，就要自己手刻一套訊息分發機制：

```ts
// 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...
}
```

```ts
// 主執行緒
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](https://github.com/GoogleChromeLabs/comlink) 是 Google Chrome 團隊開發的小工具，它做的事情很單純，就是把上面那堆 `postMessage` 的麻煩事全部包起來。

讓你可以在主執行緒直接呼叫 Worker 裡面的 function，就像呼叫普通的 async function 一樣。

先安裝：

```bash
npm install comlink
```

先來看改寫後的打工仔：

`src/workers/heavy-calc.worker.ts`

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

然後在主執行緒：

```ts
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`

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

```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`](https://vueuse.org/shared/createSharedComposable/) 把剛才的 Composable 升級成全域共享版本。

`createSharedComposable` 會讓所有呼叫這個 Composable 的元件共用同一個狀態實例，當最後一個使用它的元件解除後，才會執行清理邏輯。

`src/composables/use-heavy-calc-worker.ts`

```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](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm) 複製的類型。

Function、DOM 元素、class 實例（method 會消失）這類都沒辦法傳遞。

如果需要傳遞大型 ArrayBuffer，可以使用 Comlink 提供的 `transfer` 工具：

```ts
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` 加上：

```json
{
  "compilerOptions": {
    "lib": ["ESNext", "DOM", "WebWorker"]
  }
}
```

## 總結 🐟

* Web Worker 讓耗時計算不阻塞主執行緒，頁面保持流暢
* 原生 `postMessage` 需要手刻訊息分發機制，相當麻煩
* Comlink 的 `expose` 與 `wrap` 把這些苦活全包了，直接像呼叫 async function 一樣使用
* 搭配 Vue Composable 使用，可以讓 Worker 生命週期跟著元件走
* Worker 只能傳遞可序列化的資料，不能操作 DOM

感謝您讀到這裡，如果您覺得有收穫，歡迎分享出去。有錯誤還請多多指教 %(\*´∀\`)~♥%
