JavaScript 是一門 single-threaded 的語言,這個設計在大多數情境下都能滿足需求:瀏覽器只服務單一使用者、Event Loop 處理掉了 I/O 等待,背後也有許多輔助執行緒在跑。但是當應用要在瀏覽器上做複雜計算(圖片處理、機器學習、加密…等)時,single-threaded 的限制就會浮現:在 Main Thread 上跑的耗時運算會讓頁面卡住、失去回應。

Web Worker 就是解開這項限制的關鍵 API。

什麼是 Web Worker#

Web Worker(Web Worker)是瀏覽器提供的 Web API,可以開出一條與 Main Thread(又稱 UI Thread)完全獨立的執行緒來跑 JavaScript。兩條 thread 不會互相阻塞,並可以透過 postMessageonmessage 互相溝通。對於想做平行運算的場景,這是少數的合法手段。

Web Worker 的限制#

聽起來很神,但實際上 Web Worker 有不少限制:

  • 不能存取 DOM、documentwindowparent 等 UI 相關物件。
  • Worker 與主程式採用 share-nothing 模型,必須透過 message 溝通。
  • 同源限制:Worker 載入的腳本必須來自同源。
  • 開 Worker 與通訊都會消耗 CPU 與記憶體,過度使用反而拖累效能。
  • 訊息溝通預設是 deep copy,資料量大時會成為效能瓶頸。

這些限制其實是為了避免 multi-threading 容易產生的 race condition,並維持 thread safe。Worker 仍可以使用 XMLHttpRequestsetTimeoutsetInterval 等基本 API。

基本使用方式#

下方以費氏數列為例,把運算放到 Worker 中執行:

// worker.js
const fibonacci = (n) => {
  if (n <= 0) return 0;
  if (n === 1) return 1;
  return fibonacci(n - 1) + fibonacci(n - 2);
};

self.onmessage = (e) => {
  const result = fibonacci(e.data);
  self.postMessage(result);
};
// main.js
if (window.Worker) {
  const worker = new Worker("./worker.js");

  worker.onmessage = (e) => {
    console.log("Result is...", e.data);
  };

  worker.onerror = () => {
    console.log("error");
  };

  document.querySelector("#calculate").onclick = () => {
    const number = document.querySelector("#number-input").value;
    worker.postMessage(number);
  };
}

由於計算被搬到另一條 thread,即使輸入很大的數字也不會影響頁面渲染流程。

資料的所有權轉移#

postMessage 預設是 deep copy,要傳遞較大的二進位資料時可以改用 ownership transfer,避免複製成本:

const buffer = new Uint8Array(1024 * 1024 * 32); // 32MB
for (let i = 0; i < buffer.length; i += 1) {
  buffer[i] = i;
}

worker.postMessage(buffer.buffer, [buffer.buffer]);

轉移之後,原本的 buffer 在主程式端就不再可用。實作了 Transferable 介面的物件才能轉移,例如 ArrayBufferMessagePortImageBitmapOffscreenCanvas

如果需要共享而非轉移,則可以考慮 SharedArrayBuffer,並搭配 Atomics 處理同步。

注意事項 SharedArrayBuffer(SharedArrayBuffer)因為 Spectre 等安全議題曾被多家瀏覽器停用,目前需要透過 cross-origin isolated 等特殊設定才能啟用。

適合 Web Worker 的場景#

亂用 Worker 反而會讓效能更差。下列任務通常較適合:

  • 真正需要大量運算的工作,例如複雜排序、索引建立。
  • 機器學習模型推論、加解密。
  • 圖片或影像處理。
  • 遊戲開發、Web AR/VR。
  • Long polling。
  • 搭配 WebAssembly (Wasm) 執行高效能模組。

換句話說,與 UI 流程無關、又特別耗時的工作就是 Web Worker 的舞台,這同時也達成了關注點分離。

原生 Web Worker 的訊息傳遞 API 寫起來不太直覺,在 React 等框架專案中尤其顯得突兀。Google 開發的 Comlink 是針對 Web Worker 的輕量封裝(約 1.1KB),可以讓我們以「呼叫類別與方法」的方式操作 Worker,而不必到處寫 postMessage,使程式碼更貼近一般物件操作。

與 Service Worker 與 Worklet 的關係#

除了 Web Worker,瀏覽器另外還有 Service Worker(Service Worker)與 Worklet(Worklet)兩種特殊執行緒:前者負責離線快取、推播等 PWA 功能;後者則用於繪製、音訊等專屬流程。它們都遵循相同的隔離原則,理解 Web Worker 之後再去看這兩者會輕鬆許多。

小結#

Main Thread 既要處理 UI、又要跑 JavaScript,當運算量大時容易出現卡頓。Web Worker 是在瀏覽器端使出「影分身」的方式,把高耗時的工作切出去,但通訊與初始化都有成本,需要評估後再使用。隨著瀏覽器持續強化,越來越多 Web Worker 的應用浮現,對前端工程師來說,掌握它能在效能與架構上多出許多可能性。

原文出處#