2023 年的 Web Worker 專案實踐

凹凸實驗室發表於2023-02-24

前言

Web Workers 是 2009 年就已經提案的老技術,但是在很多專案中的應用相對較少,常見一些文章討論如何寫 demo ,但很少有工程化和專案級別的實踐,本文會結合 Web Workers 在京東羚瓏的程式化設計專案中的實踐,分享一下在當下的 2023 年,關於 worker 融入專案的一些思考和具體的實現方式,涉及到的 demo 已經放在 github 上附在文末,可供參考。

先簡單介紹下 Web Workers,它是一種可以執行在 Web 應用程式後臺執行緒,獨立於主執行緒之外的技術。眾所周知,JavaScript 語言是單執行緒模型的,而透過使用 Web Workers,我們可以創造多執行緒環境,從而可以發揮現代計算機的多核 CPU 能力,在應對規模越來越大的 Web 程式時也有較多收益。

Web Workers 宏觀語義上包含了三種不同的 Worker: DedicatedWorker(專有worker)SharedWorker(共享Worker)ServiceWorker,本文討論的是第一種,其他兩種大家可以自行研究一下。

引入 Web Worker

當引入新技術時,通常我們會考慮的問題有:1、相容性如何? 2、使用場景在哪?

問題 1,Web Workers 是 2009 年的提案,2012 年各大瀏覽器已經基本支援,11 年過去了,現在使用已經完全沒有問題啦

caniuse

問題 2,主要考慮了以下 3 點:

  1. Worker API 的侷限性:同源限制、無 DOM 物件、非同步通訊,因此適合不涉及 DOM 操作的任務
  2. Worker 的使用成本:建立時間 + 資料傳輸時間;考慮到可以預建立,可以忽略建立時間,只考慮資料傳輸成本,這裡可參考 19 年的一個測試 Is postMessage slow ,簡要結論是比較樂觀的,大部分裝置和資料情況下速度不是瓶頸
  3. 任務特點:需要是可並行的多工,為了充分利用多核能力,可並行的任務數越接近 CPU 數量,收益會越高。多執行緒場景的收益計算,可以參考 Amdahl 公式,其中 F 是初始化所需比例,N 是可並行數:
    Amdahl公式

綜上結論是,可並行的計算密集型任務適合用 Worker 來做。

不過 github 上我搜羅了一圈,也發現有一些不侷限於此,頗有創意的專案,供大家開啟思路:

  1. redux 挪到了 worker 內
  2. dom 挪到了 worker 內
  3. 可使用多核能力的框架

Worker 實踐

介紹完 worker ,一個問題出現了:為什麼一個相容性良好,能夠發揮併發能力的技術(聽起來很有誘惑力),到現在還沒有大規模使用呢?

我理解有 2 個原因:一是暫無匹配度完美的使用場景,因此引入被擱置了;二是 worker api 設計得太難用,參考很多 demo 看,限制多配置還麻煩,讓人望而卻步。本文會主要著力於第二點,希望給大家的 worker 實踐提供一些成熟的工程化思路。

至於第一點理由,在如此卷的前端領域,當你手中已經有了一把好用的錘子,還找不到那顆需要砸的釘子嗎?

Worker 到底有多難用

下面是一個原始 worker 的呼叫示例,上面是主執行緒檔案,下面是 worker 檔案:

// index.js
const worker = new Worker("./worker.js");
worker.onmessage = function (messageEvent) {
  console.log(messageEvent);
};
// worker.js
importScripts("constant.js");
function a() {
  console.log("test");
}

其中問題有:

  1. postMessage 傳遞訊息的方式不適合現代程式設計模式,當出現多個事件時就涉及分拆解析和解決耦合問題,因此需要改造
  2. 新建 worker 需要單獨檔案,因此專案內需要處理打包拆分邏輯,獨立出 worker 檔案
  3. worker 內可支援定義函式,可透過importScript 方式引入依賴檔案,但是都獨立於主執行緒檔案,依賴和函式的複用都需要改造
  4. 多執行緒環境必然涉及同步執行多個 worker,多 worker 的啟動、複用和管理都需要自行處理

看完這麼多問題,有沒有感覺頭很大,一個設計這樣原始的 api,如何舒服的使用呢?

類庫調研

首先可以想到的就是藉助成熟類庫的力量,下面表格是較為常見的幾款 worker 類庫,其中我們可能會關注的關鍵能力有:

  1. 通訊是否有包裝成更好用的方式,比如 promise 化或者 rpc
  2. 是否可以動態建立函式——可以增加 worker 靈活性
  3. 是否包含多 worker 的管理能力,也就是執行緒池
  4. 考慮 node 的使用場景,是否可以跨端執行

類庫比較

比較之下,workerpool 勝出,它也是個年紀很大的庫了,最早的程式碼提交在 6 年前,不過實踐下來沒有大問題,下文都會在使用它的基礎上繼續討論。

有類庫加持的 worker 現狀

透過使用 workerpool,我們可以在主執行緒檔案內新建 worker;它自動處理多 worker 的管理;可以執行 worker 內定義好的函式 a;可以動態建立一個函式並傳入引數,讓 worker 來執行。

// index.js
import workerpool from "workerpool";
const pool = workerpool.pool("./worker.js");
// 執行一個 worker 內定義好的函式
pool.exec("a", [1, 2]).then((res) => {
  console.log(res);
});
// 執行一個自定義函式
pool
  .exec(
    (x, y) => {
      return x + y;
    }, // 自定義函式體
    [1, 2] // 自定義函式引數
  )
  .then((res) => {
    console.log(res);
  });
// worker.js
importScripts("constant.js");
function a() {
  console.log("test");
}

但是這樣還不夠,為了可以舒適的寫程式碼,我們需要進一步改造

向著舒適無感的 worker 編寫前進

我們期望的目標是:

  1. 足夠靈活:可以隨意編寫函式,今天我想計算1+1,明天我想計算1+2,這些都可以動態編寫,最好它可以直接寫在主執行緒我自己的檔案裡,不需要我跑到 worker 檔案裡去改寫
  2. 足夠強大:我可以使用公共依賴,比如 lodash 或者是專案裡已經定義好的某些公共函式

考慮到 workerpool 具備了動態建立函式的能力,第一點已經可以實現;而第二點關於依賴的管理,則需要自行搭建,接下來介紹搭建步驟

  1. 抽取依賴,管理編譯和更新:

新增一個依賴管理檔案worker-depts.js,可按照路徑作為 key 名構建一個聚合依賴物件,然後在 worker 檔案內引入這份依賴

// worker-depts.js
import * as _ from "lodash-es";
import * as math from "../math";

const workerDepts = {
  _,
  "util/math": math,
};

export default workerDepts;
// worker.js
import workerDepts from "../util/worker/worker-depts";
  1. 定義公共呼叫函式,引入所打包的依賴並串聯流程:

worker 內定義一個公共呼叫函式,注入 worker-depts 依賴,並註冊在 workerpool 的方法內

// worker.js
import workerDepts from "../util/worker/worker-depts";

function runWithDepts(fn: any, ...args: any) {
  var f = new Function("return (" + fn + ").apply(null, arguments);");
  return f.apply(f, [workerDepts].concat(args));
}

workerpool.worker({
  runWithDepts,
});

主執行緒檔案內定義相應的呼叫方法,入參是自定義函式體和該函式的引數列表

// index.js
import workerpool from "workerpool";
export async function workerDraw(fn, ...args) {
  const pool = workerpool.pool("./worker.js");
  return pool.exec("runWithDepts", [String(fn)].concat(args));
}

完成以上步驟,就可以在專案任意需要呼叫 worker 的位置,像下面這樣,自定義函式內容,引用所需依賴(已注入在函式第一個引數),進行使用了。

這裡我們引用了一個專案內的公共函式 fibonacci,也引用了一個 lodashmap 方法,都可以在depts 物件上取到

// 專案內需使用worker時
const res = await workerDraw(
  (depts, m, n) => {
    const { map } = depts["_"];
    const { fibonacci } = depts["util/math"];
    return map([m, n], (num) => fibonacci(num));
  },
  input1,
  input2
);
  1. 最佳化語法支援

沒有語法支援的依賴管理是很難用的,透過對 workerDraw 進行 ts 語法包裝,可以實現在使用時的依賴提示:

import workerpool from "workerpool";
import type TDepts from "./worker-depts";

export async function workerDraw<T extends any[], R>(
  fn: (depts: typeof TDepts, ...args: T) => Promise<R> | R,
  ...args: T
) {
  const pool = workerpool.pool("./worker.js");
  return pool.exec("runWithDepts", [String(fn)].concat(args));
}

然後就可以在使用時獲取依賴提示:

依賴示意

  1. 其他問題

新增了 worker 以後,出現了 windowworker 兩種執行環境,如果你恰好和我一樣需要相容 node 端執行,那麼執行環境就是三種,原本我們通常判斷 window 環境使用的也許是 typeof window === 'object'這樣,現在不夠用了,這裡可以改為 globalThis 物件,它是三套環境內都存在的一個物件,透過判斷globalThis.constructor.name的值,值分別是'Window' / 'DedicatedWorker'/ 'Object',從而實現環境的區分

總結

透過使用 workerpool,新增依賴管理和構建公共 worker 呼叫函式,我們實現了一套按需呼叫,靈活強大的 worker 使用方式。

在京東羚瓏的程式化設計專案中,透過把 skia 圖形繪製部分逐步改造為 worker 內呼叫,我們實現了整體服務耗時降低 75% 的效果,收益還是非常不錯的。

文中涉及的程式碼示例都已放在 github 上,內有 vitewebpack 兩個完整實現版本,感興趣的小夥伴可以 clone 下來參照著看~

參考資料

相關文章