Web Worker 详细介绍
如果我们有一些处理密集型的任务,但是不想让它们在主线程上运行(那样会使浏览器/UI变慢),这时候我们可能会希望 JavaScript 可以以多线程的方式操作。
虽然 JavaScript 是单线程了,但是在浏览器中单线程不是组织我们的代码运行的唯一方法。在 HTML5 中提供了一个特性叫做 Web Worker。它与 js 语言本身是没有任何关系的,也就是说,JavaScript 当前并没有任何特性可以支持多线程运行。
就比如在浏览器中可以提供多个 JavaScript 引擎实例,每个都在自己的线程上,并允许我们在每个线程上运行不同的程序。我们的程序中分离的线程块儿中的每一个都称为一个“(Web)Worker”。这种并行机制叫做“任务并行机制”,它强调将我们的程序分割成块儿来并行运行。
在我们的主程序或者另一个 worker 中,可以使用下面的方式初始化一个 worker:
let worker = new Worker('http://xxx/worker.js');
上面代码中的 url 指向一个 js 文件,它会被加载到一个 Worker 中,然后浏览器会启动一个分离的线程,让这个文件在这个线程上作为独立的程序运行。
这种用这样的URL创建的Worker称为“专用(Dedicated)Wroker”。但与提供一个外部文件的 URL 不同的是,我们也可以通过提供一个 Blob URL(另一个 HTML5 特性)来创建一个“内联(Inline)Worker”;它实质上是一个存储在单一(二进制)值中的内联文件。
Worker 不会与我们的主程序共享任何作用域或者资源,而是通过一种事件消息机制进行通信。
Worker 对象中存在事件监听器和触发器,它允许我们监听 Worker 发出的事件及主动向 Worker 发送事件。
监听 worker 中的事件(只有 message、messageError 两个事件)
worker.addEventListener('message', function(evt) {
// ...
})
发送事件:
worker.postMessage('message to worker');
终止 Worker,它并不会等待 Worker 去完成它剩余的操作,而是立刻停止。
worker.terminate();
在 worker 内部,消息的监听、发送都是一样的:
addEventListener( "message", function(evt){
// evt.data
} );
postMessage( "message from worker" );
要从创建一个 Worker 的程序中立即杀死它,可以在 Worker对象上调用 terminate() 方法。突然杀掉一个 Worker 线程不会给它任何机会结束它的工作,或清理任何资源。这跟我们关闭浏览器的标签页来杀死一个页面相似。
如果我们在浏览器中有两个或多个页面(或者打开同一个页面的多个标签页),试着从同一个文件 URL 中创建Worker,实际上最终结果都是是完全分离的 Worker,并不会共享同一个 Worker。
可以在调试工具的 Sources 标签中的 threads 看到对应的线程:
Web Worker 的常见用途:
- 处理密集型的数学计算
- 大数据集合的排序
- 数据操作(压缩,音频分析,图像像素操作等等)
- 高流量网络通信
使用 Blob URL
加载 Web Worker 文件的时候同样会受到同源策略的限制,对于不同源的文件或者在本地访问,会报错(Uncaught DOMException)。
但是可以 Blob URL 的方式导入:
对于使用 file 协议(本地访问)的文件来说,可以直接将 worker 文件内容先放入到 <script>
标签中,之后解析成 Blob 格式,再以这个 Blob 生成一个 URL 传递给 Worker:
<!-- 在 html 中的单独写一个 script 标签 -->
<script type="unidentify" id="worker">
addEventListener( "message", function(evt){
// evt.data
});
postMessage( "message from worker" );
</script>
看到上面代码中的 type 值了吗,它不能被识别出来(也就是不能为下面几个值:text/javascript、text/ecmascript、application/ecmascript、application/javascript、text/vbscript),否则在加载 HTML 的时候就被直接执行。
之后通过 DOM 操作获取到 script 中的内容进行处理:
const blob = new Blob([document.getElementById('worker').textContent]);
const url = window.URL.createObjectURL(blob);
let worker = new Worker(url);
还有一种情况就是加载远程CDN文件,这时候就需要使用 ajax 或者 fetch 等技术了:
fetch('https://xxxx/worker.js')
.then((response) => response.blob())
.then((blob) => {
const url = URL.createObjectURL(blob);
new Worker(url);
});
Worker 环境
在一个 Worker 中,我们不能访问主程序中的任何资源,也就是不能访问主程序中的任何全局变量,同时,在 Worker 中也不存在 DOM 相关的 api 或者其他其他资源。它是一个完全分离的线程,是一个比较 “干净” 的 js 环境。
同时在 Worker 内部,可以实施网络操作(Ajax,WebSocket)和设置定时器。另外,Worker 可以访问它自己的几个重要全局变量/特性的拷贝,包括 navigator,location,JSON,和 applicationCache。内部的全局变量名为 self。
默认情况吓在 Worker 中需要通过 importScripts() 方法加载其他 js 文件,使用 import 的话会报错:
import 'foo.js'
// Uncaunght SyntaxError: Cannot use import statement outside a module
importScripts('foo.js');
这些脚本会被同步地加载,这意味着在文件完成加载和运行之前,importScripts 方法的调用会阻塞 Worker 的执行。
在这种情况下我们在其他文件中定义的方法就需要保存在 self 对象或者在 Worker 文件中定义的一个一个变量中,才能在 Worker 方法中使用:
// worker
var global = {};
// 因为是同步加载的,因此定义的变量最好写在文件开头
importScripts('foo.js');
// foo.js
global.foo = 'bar'
为了支持加载模块,HTML标准的开发人员为 Worker 这些构造函数添加了第二个参数,第二个参数是一个具有type属性的对象,其默认值为 “classic”,可以设置成 module 支持在 Worker 中加载模块:
// 主程序
const worker = new Worker('./worker.js', { type: 'module'});
// worker.js
import { foo } from 'foo.js'
// foo.js
export const foo = 'bar';
数据传输
在 Web Worker 常见用途中,基本都有一个共同性质,就是它们使用事件机制来传递大量的信息。
在 Worker 的早期,将所有数据序列化为字符串是唯一的选择。除了对大数据量进行序列化时速度变慢以外,另外一个主要缺点是,数据是被拷贝的(而不是共享的),这意味着内存用量翻了一倍(以及在后续垃圾回收上的流失)。
现在有其他更好的选择:
- structuredClone():“结构化克隆算法(Structured Cloning Algorithm)”,用于拷贝/复制这个对象。这个算法相当精巧,甚至可以处理带有循环引用的对象复制。但用这种方式我们依然面对着内存用量的翻倍。
const obj = {
key: 'value'
}
const clone = structuredClone(obj);
- 可转移对象:对大的数据集合而言这是一个更好的选择,它使对象的“所有权”被传送,而对象本身没动。一旦我们传送一个对象给 Worker,它在原来的位置就空了出来或者不可访问 —— 这消除了共享作用域的多线程编程中的难题。当然,所有权的传送可以双向进行。选择使用可转移对象不需要我们做太多;任何实现了Transferable 接口的数据结构都将自动地以这种方式传递。
在 JavaScript 中,可转移对象(Transferable Objects)是指 ArrayBuffer 和 MessagePort 等类型的对象,它们可以在主线程和 Web Worker 线程之间相互传递,同时还可以实现零拷贝内存共享,提高性能。这是由于可转移对象具有两个特点:
可共享:可转移对象本身没有所有权,可以在多个线程之间共享,实现零拷贝内存共享。
可转移:调用 Transferable API 时,可转移对象会从发送方(发送线程)转移到接收方(接收线程),不再存在于原始线程中,因此可以避免内存拷贝和分配等开销。
要注意的是,使用可转移对象时必须小心处理,因为一旦对象被转移,原线程将不再拥有该对象的所有权,因此在发送线程中不能再访问该对象。此外,在接收线程中使用可转移对象时,也需要根据需求进行显式释放,否则可能会导致内存泄漏和其他问题。
可转移对象:
- ArrayBuffer
- MessagePort
- ReadableStream
- WritableStream
- TransformStream
- WebTransportReceiveStream
- WebTransportSendStream
- AudioData
- ImageBitmap
- VideoFrame
- OffscreenCanvas
- RTCDataChannel
const original = new Uint8Array(1024);
const clone = structuredClone(original);
console.log(original.byteLength); // 1024
console.log(clone.byteLength); // 1024
original[0] = 1;
console.log(clone[0]); // 0
// 直接转移 Uint8Array 实例会报错,因为它不是可转移的对象
// const transferred = structuredClone(original, {transfer: [original]});
// 转移 Uint8Array.buffer.
const transferred = structuredClone(original, { transfer: [original.buffer] });
console.log(transferred.byteLength); // 1024
console.log(transferred[0]); // 1
// 在转移之后原来的 Uint8Array.buffer 不能再使用
console.log(original.byteLength); // 0
Worker 共享
在浏览器中可以通过多个标签页加载同一个页面,那么这时候能不能只创建一个单独的中心化 Worker 来让多个页面实例共享同一个 Worker,降低系统资源的使用量?
浏览器提供了一个 SharedWorker 类可以实现这种需求:
let worker = new SharedWorker('http://xxx/worker.js')
因为一个共享 Worker 可以连接或被连接到多个程序实例或网页,Worker 需要一个方法来知道消息来自哪个程序。这种唯一的标识称为“端口(port)”,所以调用端程序必须使用 Worker 的 port 对象来通信:
worker.port.addEventListener('message', function(evt) {
// ...
})
worker.port.postMessage('message to worker');
当然,最重要的是端口的连接必须要先初始化:
worker.port.start();
在共享 Worker 内部,有一个额外的事件必须被处理:“connect”。这个事件为这个特定的连接提供端口对象。保持多个分离的连接最简单的方法是在 port 上使用闭包,就像下面展示的那样,同时在 “connect” 事件的处理器内部定义这个连接的事件监听与传送:
var port;
addEventListener( "connect", function(evt){
// 为这个连接分配的端口
port = evt.ports[0];
port.addEventListener( "message", function(evt){
// ..
port.postMessage( .. );
// ..
} );
// 初始化端口连接
port.start();
});
// 关闭线程
port.close();
这方面最常见的资源限制是 websocket 链接,因为浏览器限制同时连接到一个服务器的连接数量。
polyfill
我们的代码可能运行在旧版本的浏览器中,它们可能不支持 Worker,那么就需要一个 polyfill。
JS 的异步能力(不是并行机制)来自于事件轮询队列,所以我们可以用计时器(setTimeout(…)等等)来强制模拟的 Worker 是异步的。然后只需要提供 Worker API就行了。
这里有一份 polyfill 列表https://github.com/Modernizr/Modernizr/wiki/HTML5-Cross-Browser-Polyfills#web-workers