Web Worker的一些实践

概述

前段时间做了个区块链的前端演示Demo,为了能够更好地演示真实的挖矿效果,Demo中引入了Web Worker,采用一个线程作为一个矿工的形式去模拟挖矿,本文记录一下使用Web Worker过程中遇到的一些注意事项,以及个人认为比较好的实践。

Web Worker简介及基本用法

每一个JavaScript程序员在第一节课看简介的时候就知道,JavaScript是单线程的。大多数时候我们都需要用回调的方式来写异步的代码,由于其底层依然是基于事件轮询的,也就意味着JavaScript不适合做复杂而耗时的运算,比如一些人工智能的算法,或者基于PoW机制的区块链的区块运算等。

但这种情况随着HTML5在现代浏览器中的普及而有所改观,HTML5中的Web Worker就为浏览器环境下的JavaScript提供了一种多线程机制,虽然高强度的密集型计算还有待考验,但Web Worker的出现确实让前端人员多了一个选择。

由于对视图的异步操作会带来各种各样的冲突问题,因此HTML5规定Web Worker没有Window对象,因此它不能操作DOM,这使得它的能力大大受限,但也更加安全,总地来说,Web Worker适用的场景主要是一些需要前端做大量计算的场合,当这些计算无法避免时,正确地使用Web Worker能有效地避免页面假死的现象。

我们先来看一下Web Worker的基本用法。

1
2
3
4
5
// worker.js
onmessage = function(e) {
console.log(`Message received from main script: '${e.data}'.`);
postMessage('Message received.');
};
1
2
3
4
5
6
// index.js
const w = new Worker('worker.js');
w.postMessage('hello worker'); // send a message to worker
w.onmessage = function(e) {
console.log(`Message received from worker: '${e.data}'`)
};
1
2
<!-- index.html -->
<script src="./index.js"></script>

worker.js有一个onmessage处理事件和一个用于发消息的postMessage的全局函数,当收到主线程发来的消息时,onmessage会被自动触发执行,我们可以通过e.data获得发送过来的数据。

在index.js中,我们使用Worker创建了一个对象,注意Worker构造函数接收的参数是Worker文件的路径,获得Worker对象之后,具体的操作和worker.js类似,只是这里的postMessageonmessage都是属于Worker对象的方法。

可以看出Web Worker的基本用法并不复杂,当然还有Web Worker之间的消息传递(MessageChannel),Web Worker的关闭(主线程中w.terminate()或者Worker中的close())等使用细节,读者可以根据需要去查阅MDN的相关资料

在Webpack构建的工程中使用Web Worker

显然,如果我们只能通过上述的形式使用Web Worker,是很难将其放到现代前端工程项目中来管理的,比如Webpack中模块化的管理,我们需要在Web Worker中也能用到各种模块,并且最好能直接通过引入的方式将Worker文件导入并创建Web Worker对象。

我在遇到这个问题的时候,最开始想的是将Web Worker以字符串的形式引入到相关模块中(既然Worker构造函数接收的参数是worker文件的路径,那肯定也有某种方式能通过字符串来创建Worker),实际上也确实存在这样的方法,我们可以通过Blob和URL的方式,将字符串创建为一个类文件对象,通过这种形式将该对象传递给Worker构造函数,从而创建对象。

示例代码如下:

1
2
3
4
5
6
7
8
9
const workerStr = 'self.onmessage = e => { console.log(e.data); postMessage("hi!"); };';
const blob = new Blob([workerStr], { type: 'application/javascript' });
const worker = new Worker(URL.createObjectURL(blob));
worker.onmessage = e => console.log(`message received from worker: ${e.data}`);
worker.postMessage('message from main thread.');

// output:
// message from main thread.
// message received from worker: hi!

但是这种方式依然不能满足我们的要求,首先,将逻辑全部手动转成字符串本身比较难受,虽然可以通过读文件的方式去实现,但是总觉得这种方式太过原始;其次,就算通过读文件的方式,我们依然没有办法在Worker中利用其它模块的代码,也就是是说不能引入文件。

上面两个问题确实处理起来很麻烦,不过笔者经过一番调查,发现强大的JavaScript社区早就为我们提供了解决方案,Webpack有一个名叫worker-loader的三方插件完美地解决了上述两个问题。

worker-loader的使用方式如下:

1
2
3
4
5
6
7
// index.js
import Worker from 'worker-loader!./your-worker.js';
const worker = new Worker();
worker.postMessage();
worker.onmessage = e => {
// do something.
};

当然,除了内联使用Webpack插件,我们也可以将其写在webpack.config.js配置文件里,具体的使用方法,读者可以在参考2中找到具体信息。

实际上,worker-loader的底层实现也就是利用上面的转成字符串的方法,只不过关于模块化的处理,Webpack已经帮它做了,而该插件的主要工作就是将Worker代码包装成一个特定类,该类被实例化之后只返回特定文件中的Worker新建出来的对象,这点我们从上述代码中可以看得出来,new Worker()的时候,这里并没有传入一个路径,说明这个并不是原生的那个Worker对象,而是被worker-loader包装过的一个对象。

无论如何,有了worker-loader这个插件,我们完全可以像写普通的模块文件那样写Worker文件,也可以直接在Worker文件中引入其他模块,这极大地方便了代码的可维护性和可扩展性。

Web Worker异步逻辑的优化

最直观的策略模式

很明显,直接调用postMessage和onmessage这两个方法太原始了,异步接口比较多的情况下,线程通信管理起来会很麻烦,为了能够更方便和规范地使用Web Worker,我们需要做一点点简单的封装。

1
2
3
4
5
6
7
8
9
10
11
12
// worker.js
const apiManager = {
sayHello() {
console.log('hello world');
}
echo(world) {
postMessage(`message received from worker: ${world}`);
}
// more APIs.
};

onmessage = e => void(apiManager[e.data.type].call(this, e.data.payload));

这里将Web Worker中的所有用于对接外部线程(这里只有主线程)的接口都通过策略模式管理起来了,这样做的好处就是能直接在主线程中调用指定Web Worker中的逻辑模块(即apiManager中的方法),当然,为了配合Web Worker,主线程中也应该做一些相应的改变。

1
2
3
4
5
6
7
8
9
10
// index.js
import Worker from 'worker-loader!./worker.js';
const worker = new Worker();
worker.postMessage({
type: 'echo',
payload: 'world',
});
worker.onmessage = e => {
console.log(`message from worker: ${e.data}`);
};

现在,我们可以通过给worker.postMessage指定符合格式的参数,来实现调用Web Worker中暴露出来的API了。

但是这种方式仍然有些问题,echo方法返回到主线程中的信息处理起来依然很原始,仔细一想,这个场景不就像AJAX一样吗,我们能不能用下面这种形式来调用Web Worker中的逻辑模块呢?

1
2
3
worker.postMessage(action, result => {
// do something
});

这种形式明显更舒服,原生的postMessage肯定是不支持这种方式的,因此我们可以继续考虑进一步封装,使用回调的方式来优化代码。

基于回调的方式

修改后的代码量稍微多了一点,不过也并不算复杂,主线程的逻辑修改如下。

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
// index.js
import Worker from 'worker-loader!./worker.js';
class WorkerManager {
constructor(worker) {
this.callbacks = {};
this.worker = worker;
this.worker.onmessage = e => {
const { id, result, } = e.data;
this.callbacks[id].call(this, result);
delete callbacks[id];
};
}
postMessage(action, callback) {
const id = WorkerManager.msgId++;
this.worker.postMessage(Object.assign({ id, }, action));
this.callbacks[id] = callback;
}
}
WorkerManager.msgId = 0;

const worker = new WorkerManager(new Worker());
worker.postMessage({
type: 'echo',
payload: 'world',
}, result => void(console.log(`message from worker: ${result}`)));

这里的WorkerManagerpostMessage通过代理原生的postMessage,给每一次发送的消息都加了一个id字段,这个字段记录了回调函数的在callbacks对应的键,当Web Worker处理完成并返回的时候,也需要带上这个idWorkerManager就能找到对应的回调函数了。

下面是Web Worker的修改。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// worker.js
const handleMessage = (handler) => {
onmessage = e => {
const { id, type, payload, } = e.data;
const result = handler.call(this, { type, payload, });
postMessage({ id, result, });
};
};

const apiManager = {
sayHello() {
console.log('hello world');
}
echo(world) {
return `message received from worker: ${world}`;
}
// more APIs.
};

handleMessage(({ type, payload, }) => apiManager[type].call(this, payload));

这样,通过中间添加一层处理消息id的逻辑,就能实现记录记录回应异步调用的处理函数,从而实现Web Worker的回调,也就是能直接使用worker.postMessage(aciton, callback)的形式了。

使用Promise

有的同学可能会说,这都2018年了,还在用回调实在是太过时了,至少都得来个Promise吧。确实,不过有了Callback,Promise还会远吗?

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
// index.js
import Worker from 'worker-loader!./worker.js';
class WorkerManager {
constructor(worker) {
this.callbacks = {};
this.worker = worker;
this.worker.onmessage = e => {
const { id, result, } = e.data;
this.callbacks[id].call(this, result);
delete callbacks[id];
};
}
postMessage(action) {
const id = WorkerManager.msgId++;
return new Promise((resolve, reject) => {
this.worker.postMessage(Object.assign({ id, }, action));
this.callbacks[id] = result => {
resolve(result);
};
});
}
}
WorkerManager.msgId = 0;

const worker = new WorkerManager(new Worker());
worker.postMessage({
type: 'echo',
payload: 'world',
}).then(result => void(console.log(`message from worker: ${result}`)));

以上是修改后的index.js,而worker.js不需要做修改,这样就大功告成了。

这里顺便说一下回调函数改Promise的方法,记住要将形如:

1
2
3
4
5
6
7
function asyncFunc (args, callback) {
// do something
callback();
}
asyncFunc('hello', () => {
// do something
});

的函数改成Promise形式。首先去掉callback,然后返回一个Promise,Promise中合适的地方要resolve和reject,将原来的callback放到then中去处理,有参数的记得加上参数。

1
2
3
4
5
6
7
8
9
function asyncFunc (args) {
return new Promise((resolve, reject) => {
// do something
resolve();
});
}
asyncFunc('hello').then(() => {
// do something
});

关于使用Promise的形式去优化Web Worker的调用逻辑就简单介绍到这里,注意本文只是提供了一个简单实现的思路,并没有对错误进行处理,也只支持一个参数(payload参数,可以将其作为对象添加多个属性),实际使用的过程中还需要考虑更多细节。

那么,有没有这样一个库,能够让我们不用自己去造轮子,直接体验使用Promise处理Web Worker的好处呢?实际上在写区块链演示Demo的时候,这些坑我都踩过了,promise-worker提供了一个比较完整的实现,而promise-worker-bi则更进一步,能够从Web Worker中以同样的形式反向调用主线程中的逻辑。

一个浏览器端的多线程比特币的演示Demo

一个简单的比特币原型(区块链示例)

关于区块链的一些基础知识,不在本文的讨论范围之内,有时间的话回头会再开一篇文章讲讲区块链的一些基本知识以及上面示例实现的一些细节。

项目的源码我也放到GitHub上去了,感兴趣的同学可以去提Issue或者发邮件交流交流。

总结

本文主要总结了一下Web Worker在使用过程中,个人认为不错的一些实践,虽然并不涉及更复杂以及完善的用法(比如Worker和Worker之间的通信),但是目前我个人用Web Worker用得还比较少,所以本文就算是踩坑的整理吧,也希望以后能看到全面且深度利用Web Worker的有意思的开源框架或项目。

参考

  1. Using Web Workers - Web API - MDN
  2. webpack-contrib/worker-loader - GitHub
  3. nolanlawson/promise-worker - GitHub
  4. dumbmatter/promise-worker-bi - GitHub
  5. Blockchain Demo - 传不习乎
  6. oychao/blockchain-demo - GitHub