1. 什么是Web Worker
简单来说,Web Worker是一个 在后台线程中运行的js脚本。
这里有两个关键:后台线程和js脚本。
1.1 后台线程
后台线程,顾名思义就是在web程序主执行线程之外的线程。在浏览器中,它就是浏览器主的渲染执行线程的后台线程;在服务器中,它就是服务器主的响应线程的后台线程。
一般来说,后台线程是为防止主线程阻塞而开的,所以后台线程执行的任务一般有两个特点:
- 计算复杂。后台进程所执行的一般是需要大量计算的场景(比如图像处理、视频解码等)。因为它们直接在主线程执行会花费很多时间,严重阻塞页面/服务器正常的工作(比如说造成页面卡死、服务器无响应),所以才会在后台线程执行。
- 线程沟通少。虽然后台线程有与主线程传递数据的方法,理论上后台线程可以一直和主线程保持数据交换。但是线程之间的沟通实际上开销是非常大的,同时出于线程通信安全相关的考虑,Web Worker传输信息会先用结构化克隆算法拷贝一份再传递,这也加大了沟通成本。所以后台线程与主线程的沟通频率应该是尽可能少的。
1.2 js脚本
Web Worker中能够执行所有的js标准函数集,是一个正常的js环境。但是它与主线程的js环境也有一些不同:
-
window => WorkerGlobalScope
- worker所处的全局上下文并不是window,而是WorkerGlobalScope,其中有很多window的方法是可用的:
名称 | 作用 |
---|---|
atob() | 解码base64 |
btoa() | 编码为base64 |
interval (setInterval() , clearInterval() ) | 定时器 |
timeout (setTimeout() , clearTimeout() ) | 定时器 |
structuredClone() | 结构化克隆(深拷贝) |
queueMicrotask() | 添加微任务到队列(类似于setTimeout(callback, 0)) |
animationFrame (requestAnimationFrame ,clearAnimationFrame ) | 下次重绘前执行动画 |
-
worker的全局顶层对象为
self
- var声明的全局变量可以通过
self.XXX
找到 - 可以用
self.addEventListener
来全局监听事件 - 实际上就是DedicatedWorkerGlobalScope
- var声明的全局变量可以通过
-
worker也有自己独有的函数
importScripts()
能在worker里面实现import
postMessage
能让worker与主线程进行通信
-
worker 无法进行 DOM 操作
- 一个例外是
OffscreenCanvas
,它是一个可以在worker环境中生效的canvas对象,在下文会详细介绍
- 一个例外是
2. 为什么是Web Worker
实际上在worker诞生前后,已经有非常多的方法来解决计算卡顿等问题。
2.1 时间分片
最开始,我们可以直接将大任务分成小任务,用时间分片技术来减少阻塞。比如下面这个处理图片的时间分片的例子:
我们把一个image分成一个个chunk,通过不断地setTimeout调用自身,把大任务分成小任务,同时中间可以插入处理其他任务不至于阻塞。
function processImage( callback, imageFn = i => {}, imageIn = [] ) {
const chunkSize = 1000;
let imageOut = [],pointer = 0;
processChunk();
function processChunk() {
const pointerEnd = pointer + chunkSize;
imageOut = imageOut.concat(imageFn( imageIn.slice( pointer, pointerEnd ) ));
if (pointerEnd < imageIn.length) {
pointer = pointerEnd;
setTimeout(processChunk, 0);
}
else if (callback) {
callback( null, imageOut );
}
}
}
但是这种方式只是避免了阻塞,实际上还是在一个CPU一个线程中进行处理,浪费了多核CPU的性能,并没有减少计算时间。
2.2 SharedArrayBuffer
上文提到了线程间通信效率的问题:数据是先拷贝一份再传输的。后续浏览器提出了相关的api来缓解这个问题:SharedArrayBuffer
SharedArrayBuffer
基于ArrayBuffer
的一个新的API,同样是二进制内容,可以在不同的Worker之间共享,并且不需要经过拷贝。也就是说两个进程中的两个对象实际上是一块内存。
这就需要考虑到进程间竞争的问题,为此,浏览器又实现了一套完整的原子操作API
这里出于篇幅考虑不对它们进行详细介绍,我们只需要了解到:这两者提供了一套完整的线程通信方案,这让WebAssembly更好的具有了多线程能力。
但是sharedArrayBuffer
并不能看作是Web Worker的替代品,而是一种补充。WebAssembly在使用时同样是使用worker来实现多线程
/// main.js ///
let moduleBytes = ...; // An ArrayBuffer containing the WebAssembly module above.
let memory = new WebAssembly.Memory({initial: 1, maximum: 1, shared: true});
let worker = new Worker('worker.js');
const mutexAddr = 0;
// Send the shared memory to the worker.
worker.postMessage(memory);
let imports = {env: {memory: memory}};
let module = WebAssembly.instantiate(moduleBytes, imports).then(
({instance}) => {
// Blocking on the main thread is not allowed, so we can't
// call lockMutex.
if (instance.exports.tryLockMutex(mutexAddr)) {
...
instance.exports.unlockMutex(mutexAddr);
}
});
/// worker.js ///
let moduleBytes = ...; // An ArrayBuffer containing the WebAssembly module above.
const mutexAddr = 0;
// Listen for messages from the main thread.
onmessage = function(e) {
let memory = e.data;
let imports = {env: {memory: memory}};
let module = WebAssembly.instantiate(moduleBytes, imports).then(
({instance}) => {
// Blocking on a Worker thread is allowed.
instance.exports.lockMutex(mutexAddr);
...
instance.exports.unlockMutex(mutexAddr);
});
};
2.3 GPGPU
WebGPU API 使 web 开发人员能够使用底层系统的 GPU(图形处理器)进行高性能计算并绘制可在浏览器中渲染的复杂图形。其中使用GPU进行计算的能力被称为GPGPU(General-Purpose computation on Graphics Processing Units)
WebGPU让我们可以利用GPU进行大规模的计算,当然这同样是在另外的线程中处理的,避免堵塞主流程。同时考虑到GPU在计算能力上大部分时候优于CPU,WebGPU有着比worker更好的性能。目前Three.js(3D绘图)和tfjs(机器学习)都已经支持WebGPU了。
可以说WebGPU绝对是计算密集任务未来的最佳选择,但是目前还处在实验性阶段,兼容性极低,基本只有谷歌浏览器能够使用
2.4 Web Worker 好在哪里?
相比于时间分片等技术,worker能够利用更多的CPU资源;
在SharedArrayBuffer等技术的支持下,我们可以在worker里使用多线程的wasm功能,加快计算
相比于WebGPU, worker在低端设备上能使用的资源更多(CPU > GPU),同时兼容性更好
3. Web Worker基本使用
3.1 创建
const worker = new Worker(path, options);
参数 | 说明 |
---|---|
path | 有效的js脚本的地址,必须遵守同源策略。无效的js地址或者违反同源策略,会抛出SECURITY_ERR 类型错误 |
options.type | 可选,用以指定 worker 类型。该值可以是 classic 或 module 。 如未指定,将使用默认值 classic |
options.credentials | 可选,用以指定 worker 凭证。该值可以是 omit , same-origin ,或 include 。如果未指定,或者 type 是 classic ,将使用默认值 omit (不要求凭证) |
options.name | 可选,在 DedicatedWorkerGlobalScope 的情况下,用来表示 worker 的 scope 的一个 DOMString 值,主要用于调试目的。 |
创建成功返回worker,失败报错,有以下三种类型
名称 | 说明 |
---|---|
SecurityError | 文档不允许启动 worker |
NetworkError | 脚本的MIME类型不为text/javascript |
SyntaxError | path 无法被解析(格式错误) |
3.2 关闭
/// main.js ///
worker.terminate();
/// worker.js ///
self.close();
close(); // 直接使用close()其实一样
在主线程或者worker线程都可以关闭worker
3.3 事件
可以在主进程和worker进程中使用addEventListener()
进行事件监听,目前有以下几种事件:
名称 | 触发在 | 获取event类型 | 说明 |
---|---|---|---|
error | 主线程 | Event | 在worker内部发生错误时触发 |
message | 主线程,worker线程 | MessageEvent | 在接受到 worker内部 / 主线程 传来的消息时 |
messageerror | 主线程,worker线程 | MessageEvent | 在worker收到一条无法被 反序列化 / 序列化 的消息是触发 |
rtctransform | worker线程 | RTCTransformEvent | 当编码的视频或音频帧已排队等待 WebRTC 编码转换处理时 |
addEventListener('message')
可以被简写为onmessage
序列化和反序列化是结构化克隆算法的一步,JSON.stringify()
就是一种序列化过程,简单的说就是将js对象转为可存储的一种形式,可以是JSON,也可以是其他类型。而反序列化,类似JSON.parse()
我们都知道如果传入值不符合JSON规范的话,JSON.parse()
是会失败的,messageerror的形成原理也一样,就是格式错误。
3.4 数据传递
进程信息使用postMessage()
,在主进程和worker进程都是一样的方法和参数要求。
postMessage(message, transfer)
message可以是任何值,如果可以被结构化克隆算法处理则正常传递,如果不可以则会导致messageerror;transfer则是一个可选的、会被转移所有权的可转移对象。转移之后当前传递进程无法访问操作该数据,接收到的进程则可以。
3.4.1 结构化克隆
结构化克隆算法通过递归对象构建克隆,类似于lodash.cloneDeep
,他可以用来处理包括嵌套对象在内的绝大部分数据。但是也有一些缺陷:
-
不能拷贝函数
-
不能拷贝DOM节点
-
不会保留一些特定参数
RegExp
对象的lastIndex
字段不会被保留- 属性描述符,setters 以及 getters(以及其他类似元数据的功能)同样不会被复制。例如,如果一个对象用属性描述符标记为 read-only,它将会被复制为 read-write,因为这是默认的情况下。
- 原形链上的属性也不会被追踪以及复制。
3.4.2 对象转移
对象转移从根本上来说,是将一块内存的读写权限转移到另一个上下文。一般情况下,这个上下文就是不同的线程,而对象转移实际上就是对象共享。
只有这几种对象能够被转移:ArrayBuffer, MessagePort, ReadableStream, WritableStream, TransformStream, AudioData (en-US), ImageBitmap VideoFrame (en-US), OffscreenCanvas ,RTCDataChannel
如果试图读写被转移的对象,就会报错:TypeError: Cannot perform XXX on detached XXX. detached表明了读写权限的转移
那么转移对象的意义是什么呢?就是减少结构化克隆所需的时间。直接将内存交给另一个线程,实际上就是简单的线程通信安全策略的两个极端(极端共有,极端独占)
3.4.3 SharedArrayBuffer
其实在绝大部分情况下,使用SharedArrayBuffer的成本大于收益。参考这篇文章,实际上绝大部分情况下传输用时都是很小的,至少也要到百MB的数据大小级别,传输时间才是需要被考虑的性能问题。而出现这种情况,在绝大多数情况下都是携带数据太多了,没有进行压缩编码。而如果使用sharedArrayBuffer,就意味着所有的读写操作都要用Atomics重写。
3.5 不同类型的worker
除了上文提到的最基本的worker,还有两种worker类型:SharedWorker 和 Service Worker
- SharedWorker 相比 Worker,主要的变化就是可以在多个浏览上下文(窗口、iframe、其他worker)中访问;同时它的全局作用域为
SharedWorkerGlobalScope
- Service Worker本质上是一个代理服务器,主要用来创建离线体验(比如谷歌浏览器经典的离线恐龙跑酷小游戏)。这与本文提到的worker使用场景相去甚远,本文就不详细介绍了
SharedWorker的api与worker有一定区别,但是整体使用功能相似:
worker | SharedWorker | |
---|---|---|
创建 | new Worker | new SharedWorker |
启动 | 无需 | port.start() |
发送消息 | postMessage() | port.postMessage() |
连接 | 无需 | onconnect() |
一个基本的SharedWorker使用方式如下:
/// main.js ///
const first = document.querySelector("#number1");
const result1 = document.querySelector(".result1");
if (!!window.SharedWorker) {
const myWorker = new SharedWorker("worker.js");
first.onchange = function () {
myWorker.port.postMessage([first.value]);
console.log("Message posted to worker");
};
myWorker.port.onmessage = function (e) {
result1.textContent = e.data;
console.log("Message received from worker");
console.log(e.lastEventId);
};
}
/// worker.js ///
onconnect = function (event) {
const port = event.ports[0];
port.onmessage = function (e) {
const workerResult = `Result: ${e.data[0] * e.data[0]}`;
port.postMessage(workerResult);
};
};
4. 使用场景①: 离屏渲染
关于离屏canvas,一个非常经典的demo是avoid jank
在主线程有canvas执行动画,同时执行斐波那契数列计算这样的计算密集任务,进而阻塞canvas执行
这时可以使用transferControlToOffscreen()
,将canvas的控制权转交给worker,让空闲的它来执行动画避免卡顿。
其中的关键在于:
/// main.js ///
const offscreen = document.querySelector('#canvas-worker').transferControlToOffscreen(); // 生成可转移对象
worker.postMessage({ msg: 'start', origin: urlParts.join('/'), canvas: offscreen }, [offscreen]); // transfer转移控制权
/// worker.js ///
self.onmessage = function(e) {
//...
animationWorker = new Animation(e.data.canvas.getContext('2d')); // 开启动画
animationWorker.start();
//...
}
但是这个demo有一个问题,其实它更应该把斐波那契拿去worker里面算,主线程不要阻塞。因为:
-
这里的计算密集任务是斐波那契数列计算。虽然在这里总共就两个任务两个线程,你分一下哪个都不会卡。但是如果除了canvas动画,主线程还有其他任务,那么它们同样会被阻塞。
-
从直觉上来讲,渲染这类与DOM操作相关的我们更认为应该放在主线程,而不是worker进程计算。虽然
offscreen
这个功能本身提供了这样的能力,但是从整体上来看worker还是主要处理计算相关。可能是考虑到了这一点,为了避免注意分散,找不到位置,作者将控制OffscreenCanvas的worker独立出来并给了id
那什么情况下,才会让OffscreenCanvas和渲染逻辑分离,和计算逻辑更紧密呢?我想到了一种方式:worker绘制图表,再传回主线程
4.1 Sparkline
sparkline,即迷你图,一般用于在表格中简单的展示趋势等信息。由于占地较小,一般没有坐标轴、交互操作等。
sparkline的实现有一个非常严重的问题:图表绘制工具一般只支持 canvas / svg 绘制图表,但是很明显页面中又不应该以这种形式呈现,因为:
- 在表格很大的情况下,过多的canvas / svg 会造成严重的性能问题。
- 在表格支持懒加载的情况下,滑动到表格底部才会开始请求数据,请求完数据才能异步开始绘制图表(比如使用
Promise.then()
),不仅图表会在表格显示之后一会才延迟显示,同时也会使代码难以阅读和维护。
这时我们可以考虑用Web Worker的OffscreenCanvas先提前从主线程获取数据,然后在其中绘制图表,将结果用toDataURL()
转为data64字符串,主线程获取这个字符串将图表展示为图片,避免在主线程出现过多的canvas
流程图如下所述,我们在worker中获取数据并通过OffscreenCanvas反复绘制图表,生成DataURL返回给主线程,主线程直接使用图片
从主线程的角度,这不过是从两个不同的接口获取数据(从数据库接口获取绝大部分原始数据,从worker接口获取DataURL数据),保证了主线程的低耦合性,后续考虑从后端直接获取dataURL需要更改的代码页不多。
最关键的是:主线程没有出现过哪怕一次的canvas绘制,最大程度的减少了主线程的渲染压力。同时也和前面的离屏渲染不同,并没有造成逻辑分离,因为我们本来就是要的图片而不是canvas。
5. 使用场景②:生成Excel文件
很多时候我们有需要让用户把页面数据下载为表格文件的需求。很多时候这是完全通过后端实现的,前端只负责发送请求。但是这个需求实际上也可以用xlsx.js
实现。
如果想要在前端实现,就必须考虑到数据量较大的情况下表格文件生成是否会阻塞主线程,这时候我们就可以使用Web Worker来实现。
/// main.js ///
const worker = new Worker(new URL('./utils/uploadExcel.js?raw', import.meta.url))
// 发送数据
worker.postMessage(list)
// 监听消息
worker.onmessage = ({ data }) => {
const a = document.createElement("a");
a.download = `${new Date().toLocaleTimeString()}.xlsx`;
a.href = URL.createObjectURL(new Blob([data], { type: "application/octet-stream" }));
a.click();
}
// 点击导出按钮触发getExcel
const getExcel = () => {
worker.postMessage("start")
}
/// worker.js ///
self.importScripts('https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.15.3/xlsx.core.min.js');
onmessage = function (event) {
const { data } = event
if (data === "start") {
let startTime = new Date();
slef.onmessage = ({data}) => {
console.log("开始整理数据-"+startTime);
const ws = XLSX.utils.aoa_to_sheet(data, { dense: true });
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws);
postMessage(XLSX.write(wb, {type: 'array', bookType: 'xlsx', bookSST: true, compression: true}));
let endTime = new Date()
console.log("结束正在将数据发送给主线程-"+endTime)
console.log(`传递总耗时间${Math.floor((endTime - startTime )/ 1000)}`);
});
}
}
其实这部分也可以与前文提到的表格sparkline配合:先用worker生成img插入表格,然后再用worker调用xlsx.js生成xlsx文件。这里的关键就是实现worker的嵌套以及相互的通信。
6. 使用前景:Partytown
由于Partytown还在beta版本不稳定,所以这里只简单介绍其使用前景。
Partytown是一个轻量的库,可以将第三方脚本移到Web Worker中,从而减少加载时间。同时不需要担心第三方脚本不可用,因为Partytown会同步读写主线程DOM操作,保证worker中的第三方脚本正常执行
在vue中使用Partytown实际上进行的操作有两步:
- 在构建的时候获取所有第三方脚本,将他们都打包到
public/~partytown/partytown.js
中 - 在
index.html
中引入
具体的步骤有三步:
npm install @builder.io/partytown
-
/// vite.config.js /// import path from "path"; import { partytownVite } from "@builder.io/partytown/utils"; export default ({ command }) => ({ build: { plugins: [ partytownVite({ dest: path.join(__dirname, "dist", "~partytown"), }), ], }, });
-
/// In package.json /// "scripts": { // ... "dev": "partytown && vite", "build": "partytown && vite build", "preview": "partytown && vite preview --port 4173", "partytown": "partytown copylib public/~partytown" }
详细的快了多少的数据并没有具体测试,因为目前还存在着许多问题。后续在考虑性能优化的时候可以尝试这个方向。
7. 总结
本文介绍了 web worker 的作用和基本使用,给出了两个使用场景(表格迷你图表、Excel文件生成)。最后还介绍了Partytown这个利用 web worker 进行性能优化的新工具。在后续会详细调研Partytown的能力,看看能不能帮助我们进行性能优化。