Skip to main content

Web Worker 多线程环境

JS 是单线程的,所有任务只能在一个线程上完成,一次只能做一件事。

一、Web Worker 的定义

Web Worker 用于为 JS 创造多线程环境,允许主线程创建 Worker 线程,将一些任务分配给 Worker 线程运行。在主线程运行的同时,Worker 线程在后台运行,二者互不干扰,等到 Worker 线程完成任务,再把结果返回给主线程。当一些计算密集型或高延迟的任务被 Worker 线程负担后,主线程(通常负责 UI 交互)会变流畅。

Worker 线程一旦新建成功,就会始终运行,不会被主线程上的活动(比如用户点击按钮、提交表单)打断。这样有利于随时响应主线程的通信。但这也造成了 Worker 比较耗费资源,不应过度使用,而且一旦使用完就应该关闭。

Web Worker 使用注意事项
  • 同源限制:分配给 Worker 线程运行的脚本文件,必须与主线程的脚本文件同源;

  • 环境隔离:Worker 线程所在的全局对象与主线程不一样,无法读取主线程所在网页的 DOM 对象,也无法使用 documentwindowparent 这些对象(navigatorlocation 除外),全局对象可以通过 thisself 访问。

  • 通信联系:Worker 线程和主线程不在同一个上下文环境,它们不能直接通信,必须通过消息完成;

  • 脚本限制:Worker 线程不能执行 alert()confirm() 方法,但可以使用 XMLHttpRequest 对象发出 AJAX 请求;

  • 文件限制:Worker 线程无法读取本地文件,即不能打开本机的文件系统(file://),它所加载的脚本必须来自网络。

二、基本用法

1、主线程

1-1、新建 Worker 线程

主线程采用 new 命令,调用 Worker() 构造函数,新建 Worker 线程语法如下:

const myWorker = new Worker(jsUrl, options?);

参数:

  • jsUrl:脚本文件(由于 Worker 不能读取本地文件,这个脚本必须来自网络,且必须遵守同源政策),只能加载 JS 脚本,否则会报错;
  • options:配置对象,可指定 Worker 的名称,以区分多个 Worker 线程。

示例:

// 主线程
const myWorker = new Worker('worker.js', { name: 'myWorker' });

// Worker 线程
self.name // myWorker

Worker 线程对象的属性和方法:

  • Worker.postMessage():向 Worker 线程发送消息;
  • Worker.onmessage:指定 message 事件的监听函数,发送过来的数据在 Event.data 属性中;
  • Worker.onerror:指定 error 事件的监听函数;
  • Worker.onmessageerror:指定 messageerror 事件的监听函数,发送的数据无法序列化成字符串时,会触发这个事件;
  • Worker.terminate():立即终止 Worker 线程。

1-2、向 Worker 发消息

主线程调用 worker.postMessage() 方法向 Worker 发消息:

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

worker.postMessage() 方法的参数即主线程传给 Worker 的数据,可以是各种数据类型(包括二进制)

1-3、接收 Worker 消息

主线程通过 worker.onmessage 指定监听函数,接收子线程发回来的消息。

worker.onmessage = function (event) {
console.log('Received message ' + event.data);
doSomething();
}

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

上面代码中,事件对象的 data 属性可以获取 Worker 发来的数据。

1-4、关闭 Worker 线程

Worker 完成任务以后,主线程就可以把它关掉。

worker.terminate();

2、Worker 线程

Worker 线程内部需要一个监听函数,用于监听 message 事件:

self.addEventListener('message', function (e) {
self.postMessage('You said: ' + e.data);
}, false);

上面代码中,self 代表子线程自身,即子线程的全局对象。因此,等同于下面两种写法:

// 写法一
this.addEventListener('message', function (e) {
this.postMessage('You said: ' + e.data);
}, false);

// 写法二
addEventListener('message', function (e) {
postMessage('You said: ' + e.data);
}, false);

除了使用 self.addEventListener() 指定监听函数,也可以使用 self.onmessage 指定。

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

根据主线程发来的数据,Worker 线程可以调用不同的方法,举个例子:

self.addEventListener('message', function (e) {
const data = e.data;
switch (data.cmd) {
case 'start':
self.postMessage('WORKER STARTED: ' + data.msg);
break;
case 'stop':
self.postMessage('WORKER STOPPED: ' + data.msg);
self.close(); // Terminates the worker.
break;
default:
self.postMessage('Unknown command: ' + data.msg);
};
}, false);

上面代码中,self.close() 用于在 Worker 内部关闭自身。

3、Worker 加载脚本

Worker 内部如果要加载其他脚本,可使用 importScripts()

importScripts('script1.js');

该方法可以同时加载多个脚本:

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

4、错误处理

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

worker.onerror(function (event) {
console.log([
'ERROR: Line ', e.lineno, ' in ', e.filename, ': ', e.message
].join(''));
});

// 或者
worker.addEventListener('error', function (event) {
// ...
});

Worker 内部也可以监听 error 事件。

三、数据通信

主线程与 Worker 间的通信内容可以是文本,也可以是对象。注意这种通信是拷贝关系,是传值而非传址,Worker 对通信内容的修改,不会影响到主线程。

事实上,浏览器内部的运行机制是先将通信内容串行化,然后把串行化后的字符串发给 Worker,Worker 再将它还原。

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

// 主线程
const uInt8Array = new Uint8Array(new ArrayBuffer(10));
for (let 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 发送一个 500M 的文件,默认情况下浏览器会生成一个原文件的拷贝。

为了解决这个问题,JS 允许主线程把二进制数据直接转移给子线程,转移后主线程则无法再使用这些二进制数据,这是为了防止出现多个线程同时修改数据的麻烦局面。这种转移数据的方法叫 Transferable Objects。这使得主线程可以快速把数据交给 Worker,对于影像处理、声音处理、3D 运算等非常方便,不会产生性能负担。

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

// Transferable Objects 格式
worker.postMessage(arrayBuffer, [arrayBuffer]);

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

四、同页面的 Web Worker

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

<!DOCTYPE html>

<body>
<script id="worker" type="app/worker">
addEventListener('message', function () {
postMessage('some message');
}, false);
</script>
</body>

</html>

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

const blob = new Blob([document.querySelector('#worker').textContent]);
const url = window.URL.createObjectURL(blob);
const worker = new Worker(url);

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

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

五、Web Worker 的应用

1、HTML 中使用 Worker

<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8">
<title>Web Worker</title>
</head>

<body>
<script id="worker" type="app/worker">
console.log('1、worker self:', self);

self.onmessage = function(event) {
console.log('2、子线程收到消息:', event.data);
self.postMessage('get✔');
}
self.onerror = function (err) {
console.log('3、子线程异常:', err);
}

throw new Error('test error');
</script>
<script>
const blob = new Blob([document.querySelector('#worker').textContent]);
const url = window.URL.createObjectURL(blob);
const worker = new Worker(url);

worker.onmessage = function (event) {
console.log('4、主线程收到消息:', event.data);
}
worker.onerror = function (err) {
console.log('5、主线程收到子线程异常:', err);
}

worker.postMessage('Hello World');
</script>
</body>

</html>

控制台输出:

2、Webpack〡Vite 中使用

webpack 4 使用 Web Worker 需要安装 worker-loader,而 webpack 5 和 vite 原生支持 Web Worker。

3、Worker 线程完成轮询

有时浏览器需要轮询服务器状态,以便第一时间得知状态改变。这个工作可以放在 Worker 里面:

function createWorker(f) {
const blob = new Blob(['(' + f.toString() + ')()']);
const url = window.URL.createObjectURL(blob);
const worker = new Worker(url);
return worker;
}

const pollingWorker = createWorker(function (e) {
let cache;

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

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

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

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

pollingWorker.postMessage('init');

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

4、Worker 新建 Worker

Worker 线程内部还能再新建 Worker 线程。下面的例子是将一个计算密集的任务,分配到 10 个 Worker。

index.js
const worker = new Worker('worker.js');
worker.onmessage = function (event) {
document.getElementById('result').textContent = event.data;
};

上面 Worker 线程代码中,Worker 线程内部新建了 10 个 Worker 线程,并依次向这 10 个 Worker 发送消息,告知了计算的起点和终点。计算任务脚本的代码如下:

core.js
var start;
onmessage = getStart;
function getStart(event) {
start = event.data;
onmessage = getEnd;
}

var end;
function getEnd(event) {
end = event.data;
onmessage = null;
work();
}

function work() {
var result = 0;
for (var i = start; i < end; i += 1) {
// perform some complex calculation here
result += 1;
}
postMessage(result);
close();
}

5、Web Worker 其它使用场景

  • 射线追踪:射线追踪是一项通过追踪光线的路径作为像素来生成图片的渲染技术。Ray tracing 使用 CPU 密集型计算来模仿光线的路径。思路即模仿一些诸如反射,折射,材料等的效果。所有的这些计算逻辑可以放在 Web Worker 中以避免阻塞 UI 线程。点击查看使用 Web Workers 来进行射线追踪的示例

  • 加密:端到端的加密由于对保护个人和敏感数据日益严格的法律规定而变得越来越流行。加密有时候会非常地耗时,特别是如果当你需要经常加密很多数据的时候(比如,发往服务器前加密数据)。这是一个使用 Web Worker 的绝佳场景,因为它并不需要访问 DOM 或者利用其它魔法-它只是纯粹使用算法进行计算而已。一旦在 worker 进行计算,它对于用户来说是无缝地且不会影响到用户体验。

  • 预取数据:为了优化网站或者网络应用及提升数据加载时间,可以使用 Workers 来提前加载部分数据以备不时之需。不像其它技术,Web Workers 在这种情况不会影响程序的使用体验。

  • PWA:想在网络不稳定的情况下快速加载,则数据必须本地存储于浏览器中。这时候 IndexDB 及其它类似的 API 就派上用场了。大体上说,一个客户端存储是必须的。为了不阻塞 UI 线程的渲染,这项工作必须由 Web Workers 来执行。当使用 IndexDB 时,可以不使用 workers 而使用其异步接口,但是之前它也含有同步接口(可能会再次引入),这时就必须在 workers 中使用 IndexDB。

  • 拼写检查:一个基本的拼写检测器是这样工作的-程序会读取一个包含拼写正确的单词列表的字典文件。字典会被解析成一个搜索树以加快实际的文本搜索。当检查器检查一个单词的时候,程序会在预构建搜索树中进行检索。如果在树中没有检索到,则会通过提供替代的字符为用户提供替代的拼写并检测单词是否是有效-是否是用户需要的单词。这个检索过程中的所有工作都可以交由 Web Worker 来完成,这样用户就只需输入单词和语句而不会阻塞 UI,与此同时 worker 会处理所有的搜索和服务建议。