Web Worker

Web Worker 为 Web 内容在后台线程中运行脚本提供了一种简单的方法,线程可以执行任务而不干扰用户界面。

它有以下特点:

  • 异步多线程 在主线程运行的同时,Worker 线程在后台运行,两者互不干扰
  • 同源限制 分配给 Worker 线程运行的脚本文件,必须与主线程的脚本文件同源,Worker 也仅能被生成它的脚本所使用
  • DOM 限制 Worker 线程所在的全局对象,与主线程不一样,无法读取主线程所在网页的 DOM 对象,也无法使用 documentwindowparent 这些对象。但是,Worker 线程可以访问 navigatorlocation 对象,可以使用大量 window 对象之下的东西,包括 WebSockets , IndexedDB
  • message通信机制 Worker 线程和主线程间的数据传递通过这样的消息机制进行——双方都使用 postMessage() 方法发送各自的消息,使用 onmessage 事件处理函数来响应消息
  • 支持web IO Worker 线程可以使用 XMLHttpRequest 进行网络I/O

基本用法

主线程

主线程采用 new 命令,调用 Worker() 构造函数,新建一个 Worker 线程

main.js
1
var worker = new Worker('work.js');

Worker() 构造函数的参数是一个脚本文件,该文件就是 Worker 线程所要执行的任务。由于 Worker 不能读取本地文件,所以这个脚本必须来自网络。
然后,主线程调用 worker.postMessage() 方法,向 Worker 发消息。

main.js
1
2
worker.postMessage('Hello World');
worker.postMessage({method: 'echo', args: ['Work']});

worker.postMessage() 方法的参数,就是主线程传给 Worker 的数据。它可以是各种数据类型,包括二进制数据。
接着,主线程通过 worker.onmessage 指定监听函数,接收子线程发回来的消息。

main.js
1
2
3
4
5
6
7
8
9
worker.onmessage = function (event) {
console.log('Received message ' + event.data);
doSomething();
}

function doSomething() {
// 执行任务
worker.postMessage('Work done!');
}

上面代码中,事件对象的data属性可以获取 Worker 发来的数据。
Worker 完成任务以后,主线程就可以把它关掉。

main.js
1
worker.terminate();

worker线程

Worker 线程内部需要有一个监听函数,监听message事件,监听函数的参数是一个事件对象,它的 data 属性包含主线程发来的数据。postMessage() 方法用来向主线程发送消息。

worker.js
1
2
3
this.onmessage(function (e) {
this.postMessage('You said: ' + e.data);
})

Worker 内部可以通过 importScripts() 来加载其他脚本,脚本的下载顺序不固定,但执行时会按照传入 importScripts() 中的文件名顺序进行。这个过程是同步完成的;直到所有脚本都下载并运行完毕,importScripts() 才会返回。

worker.js
1
importScripts('script1.js', 'script2.js');

Worker 线程内部可以调用 close() 结束自身

worker.js
1
close();

错误处理

主线程可以监听 Worker 是否发生错误。如果发生错误,Worker 会触发主线程的 error 事件。

main.js
1
2
3
worker.onerror(function(event) {
console.log('ERROR: Line ', e.lineno, ' in ', e.filename, ': ', e.message);
});

数据通信

在主页面与 worker 之间传递的数据是通过拷贝,而不是共享来完成的。传递给 worker 的对象需要经过序列化,接下来在另一端还需要反序列化。页面与 worker 不会共享同一个实例,最终的结果就是在每次通信结束时生成了数据的一个副本。大部分浏览器使用结构化拷贝来实现该特性。

主线程与 Worker 之间也可以交换二进制数据,比如 File、Blob、ArrayBuffer 等类型,也可以在线程之间发送。下面是一个例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
// 主线程
var uInt8Array = new Uint8Array(new ArrayBuffer(10));
for (var i = 0; i < uInt8Array.length; ++i) {
uInt8Array[i] = i * 2; // [0, 2, 4, 6, 8,...]
}
worker.postMessage(uInt8Array);

// Worker 线程
self.onmessage = function (e) {
var uInt8Array = e.data;
postMessage('Inside worker.js: uInt8Array.toString() = ' + uInt8Array.toString());
postMessage('Inside worker.js: uInt8Array.byteLength = ' + uInt8Array.byteLength);
};

但是,拷贝方式发送二进制数据,会造成性能问题。比如,主线程向 Worker 发送一个 500MB 文件,默认情况下浏览器会生成一个原文件的拷贝。为了解决这个问题,JavaScript 允许主线程把二进制数据直接转移给子线程,但是一旦转移,主线程就无法再使用这些二进制数据了,这是为了防止出现多个线程同时修改数据的麻烦局面。这种转移数据的方法,叫做 Transferable Objects。这使得主线程可以快速把数据交给 Worker,对于影像处理、声音处理、3D 运算等就非常方便了,不会产生性能负担。

如果要直接转移数据的控制权,就要使用下面的写法。

1
2
3
4
5
6
// Transferable Objects 格式
worker.postMessage(arrayBuffer, [arrayBuffer]);

// 例子
var ab = new ArrayBuffer(1);
worker.postMessage(ab, [ab]);

同页面的 Web Worker

通常情况下,Worker 载入的是一个单独的 JavaScript 脚本文件,但是也可以载入与主线程在同一个网页的代码。

1
2
3
4
5
6
7
8
9
<!DOCTYPE html>
<body>
<script id="worker" type="app/worker">
addEventListener('message', function () {
postMessage('some message');
}, false);
</script>
</body>
</html>

上面是一段嵌入网页的脚本,注意必须指定 <script> 标签的type属性是一个浏览器不认识的值,上例是 app/worker。
然后,读取这一段嵌入页面的脚本,用 Worker 来处理。

1
2
3
4
5
6
7
var blob = new Blob([document.querySelector('#worker').textContent]);
var url = window.URL.createObjectURL(blob);
var worker = new Worker(url);

worker.onmessage = function (e) {
// e.data === 'some message'
};

上面代码中,先将嵌入网页的脚本代码,转成一个二进制对象,然后为这个二进制对象生成 URL,再让 Worker 加载这个 URL。这样就做到了,主线程和 Worker 的代码都在同一个网页上面。

实例:Worker 线程完成轮询

有时,浏览器需要轮询服务器状态,以便第一时间得知状态改变。这个工作可以放在 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
function createWorker(f) {
var blob = new Blob(['(' + f.toString() +')()']);
var url = window.URL.createObjectURL(blob);
var worker = new Worker(url);
return worker;
}

var pollingWorker = createWorker(function (e) {
var cache;

function compare(new, old) { ... };

setInterval(function () {
fetch('/my-api-endpoint').then(function (res) {
var data = res.json();

if (!compare(data, cache)) {
cache = data;
self.postMessage(data);
}
})
}, 1000)
});

pollingWorker.onmessage = function () {
// render data
}

pollingWorker.postMessage('init');

上面代码中,Worker 每秒钟轮询一次数据,然后跟缓存做比较。如果不一致,就说明服务端有了新的变化,因此就要通知主线程。