在现代前端项目中使用 Worker

本文最后更新于:2021年6月22日 晚上

场景

由于需要做一些 CPU 密集型的计算,为了优化性能,吾辈开始尝试使用 worker 将计算任务放到其它线程(主要还是为了避免主线程卡死)。

主要场景包括

  • 浏览器上的 WebWorker/SharedWorker:处理音频数据
  • nodejs 中的 worker_threads:解析 md/ts ast 然后处理

为什么不用 wasm?– 主要是由于它需要从零开始编写相关的代码,而非可以直接对现有的 js 代码稍微修改便能提高性能。

浏览器

目前 vite 对此默认支持浏览器 worker,不再需要任何配置,仅使用 comlink 简化使用体验即可。

// hello.worker.ts
import { expose } from "comlink";

export function hello(name: string) {
  return `hello ${name}`;
}

expose(hello);
// main.ts
import type { hello } from "./hello.worker";
import HelloWorker from "./hello.worker?worker";
import { wrap } from "comlink";
(async () => {
  const asyncHello = wrap<typeof hello>(new HelloWorker());
  const res = await asyncHello("liuli");
  console.log(res);
})();

nodejs

nodejs 本身提供的解决方案

nodejs 仍然像浏览器一样基于文件系统使用 worker_threads,但官方确实提供了一种有趣的解决方案 – 在同一个文件中包含主线程与 worker 线程的代码,然后在其中使用逻辑判断。

// worker.ts
import { isMainThread, parentPort, Worker } from "worker_threads";
import { expose, wrap } from "comlink";
import nodeEndpoint from "comlink/dist/umd/node-adapter";
import { wait } from "@liuli-util/async";
import { worker as helloType } from "./worker";

async function _hello(name: string) {
  await wait(100);
  return `hello ${name}`;
}

let hello: (name: string) => Promise<string>;

if (isMainThread) {
  hello = async (name: string) => {
    const worker = new Worker(__filename);
    try {
      const helloWorker = wrap<typeof helloType>(nodeEndpoint(worker));
      return await helloWorker(name);
    } finally {
      worker.unref();
    }
  };
} else {
  hello = _hello;
  expose(_hello, nodeEndpoint(parentPort!));
}

export { hello };
// main.ts
import { hello } from "./worker.js";
(async () => {
  console.log(await mixingThreadHello("liuli"));
})();

看起来很简洁,但这种方案也并非尽善尽美,吾辈实际使用时发现以下问题

  • 不支持开发环境测试 ts 代码,必须编译为 js 才能运行
  • 由于基于文件系统,所以打包需要手动分块,或者不进行打包
    rollup 参考 output.manualChunks

参考:nodejs Worker

使用打包工具处理的调研方案

  • rollup-plugin-web-worker-loader
    • 默认不会处理 ts 文件
    • worker 中包含依赖时不会打包
  • rollup-plugin-worker-factory
    • 没有看到 ts 的示例
    • 默认会修改 Worker
  • @surma/rollup-plugin-off-main-thread
    • 没有看到 ts 的示例

目前没有找到满意的插件,后续可能不得不写一个 rollup 插件。还是那句话,没有对比就没有伤害,如果没有 vite 对 ts+worker 的良好支持,或许吾辈还能忍受这种糟糕的开发体验。

尝试编写 rollup 插件 rollup-plugin-worker-threads,以在 nodejs 中对 worker_threads 提供类似 vite 的开发体验。

大致使用方式如下

// src/util/wrapWorkerFunc.ts
import { expose, Remote, wrap } from "comlink";
import path from "path";
import { isMainThread, parentPort, Worker } from "worker_threads";
import nodeEndpoint from "comlink/dist/umd/node-adapter";

/**
 * 包装需要放到 worker 中执行的函数
 * 1. 当检查到当前文件不是 js 文件时会直接返回函数
 * 2. 当检查到在主线程时执行时,使用 Worker 包装并执行它
 * 3. 当检查到在 Worker 线程时,使用 expose 包装它然后执行
 * 注:目前是每次都创建新的 Worker,也许可以考虑支持复用 Worker
 * @param ep
 */
export function wrapWorkerFunc<T extends (...args: any[]) => any>(
  ep: T
): Remote<T> {
  if (path.extname(__filename) !== ".js") {
    return ep as Remote<T>;
  }
  if (isMainThread) {
    return ((...args: any[]) => {
      const worker = new Worker(__filename);
      const fn = wrap<T>(nodeEndpoint(worker));
      return (fn(...args) as Promise<any>).finally(() => worker.unref());
    }) as Remote<T>;
  }
  expose(ep, nodeEndpoint(parentPort!));
  return ep as Remote<T>;
}
// src/hello.worker.ts
import { wait } from "@liuli-util/async";
import { wrapWorkerFunc } from "./util/wrapWorkerFunc";

async function _hello(name: string) {
  await wait(100);
  return `hello ${name}`;
}

export const hello = wrapWorkerFunc(_hello);
// index.ts
export * from "./hello.worker";

参考


本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!