前言—

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 年过去了,现在使用已经完全没有问题啦

打开网易新闻 查看精彩图片

问题 2,主要考虑了以下 3 点:

  1. Worker API的局限性:同源限制、无 DOM 对象、异步通信,因此适合不涉及 DOM 操作的任务

  2. Worker的使用成本:创建时间 + 数据传输时间;考虑到可以预创建,可以忽略创建时间,只考虑数据传输成本,这里可参考 19 年的一个测试 Is postMessage slow [1] ,简要结论是比较乐观的,大部分设备和数据情况下速度不是瓶颈

  3. 任务特点:需要是可并行的多任务,为了充分利用多核能力,可并行的任务数越接近 CPU 数量,收益会越高。多线程场景的收益计算,可以参考Amdahl公式,其中F是初始化所需比例,N是可并行数:

    打开网易新闻 查看精彩图片

综上结论是,可并行的计算密集型任务适合用Worker来做。

不过 github 上我搜罗了一圈,也发现有一些不局限于此,颇有创意的项目,供大家打开思路:

  1. redux 挪到了 worker 内 [2]

  2. dom 挪到了 worker 内 [3]

  3. 可使用多核能力的框架 [4]

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[5] 胜出,它也是个年纪很大的库了,最早的代码提交在 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(fn: (depts: typeof TDepts, ...args: T) => Promise | R, ...args: T) {
const pool = workerpool.pool('./worker.js')
return pool.exec('runWithDepts', [String(fn)].concat(args))
}

然后就可以在使用时获取依赖提示:

打开网易新闻 查看精彩图片

其他问题

新增了worker以后,出现了windowworker两种运行环境,如果你恰好和我一样需要兼容node端运行,那么运行环境就是三种,原本我们通常判断 window 环境使用的也许是typeof window === 'object'这样,现在不够用了,这里可以改为 globalThis 对象,它是三套环境内都存在的一个对象,通过判断globalThis.constructor.name的值,值分别是'Window' / 'DedicatedWorker'/ 'Object',从而实现环境的区分

总结—

通过使用workerpool,添加依赖管理和构建公共worker调用函数,我们实现了一套按需调用,灵活强大的worker使用方式。

在京东羚珑的程序化设计项目中,通过把 skia 图形绘制部分逐步改造为worker内调用,我们实现了整体服务耗时降低 75% 的效果,收益还是非常不错的。

文中涉及的代码示例都已放在 github[6] 上,内有vitewebpack两个完整实现版本,感兴趣的小伙伴可以 clone 下来参照着看~

参考资料—

Is postMessage slow: https://dassur.ma/things/is-postmessage-slow/

[2]

redux 挪到了 worker 内: https://blog.axlight.com/posts/off-main-thread-react-redux-with-performance

[3]

dom 挪到了 worker 内: https://github.com/ampproject/worker-dom

可使用多核能力的框架: https://github.com/neomjs/neo

[5]

workerpool: https://github.com/josdejong/workerpool

[6]

github: https://github.com/Silencesnow/worker-demo-2022

[7]

MDN Web Workers API: https://developer.mozilla.org/zh-CN/docs/Web/API/Web_Workers_API

[8]

workerpool: https://github.com/josdejong/workerpool

[9]

前端项目上 Web Worker 实践: https://www.youtube.com/watch?v=AEpG-3XXrjk

[10]

Web Worker 文献综述: https://juejin.cn/post/6854573213297410062

《2022 中国开源开发者报告》下载