2023 年的 Web Worker 項目實踐

前言

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 下來參照着看~

參考資料

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章