在现代前端项目中使用 Worker

本文最后更新于:2021年10月11日 凌晨

场景

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

主要场景包括

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

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

浏览器

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

1
2
3
4
5
6
7
8
// hello.worker.ts
import { expose } from 'comlink'

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

expose(hello)
1
2
3
4
5
6
7
8
9
// 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 线程的代码,然后在其中使用逻辑判断。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 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 }
1
2
3
4
5
// 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 的开发体验。

大致使用方式如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 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>
}
1
2
3
4
5
6
7
8
9
10
// 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)
1
2
// index.ts
export * from './hello.worker'

参考


在现代前端项目中使用 Worker
https://blog.rxliuli.com/p/0171bcd51b1343d6a9ad54557d567c46/
作者
rxliuli
发布于
2020年12月30日
许可协议