前言
Web Worer 使得浏览器可以在 Worker 线程中运行脚本,而不会阻塞主线程。
Worker 线程中处理的数据大多数情况下最终是需要传到主线程中使用的。Worker 线程中的脚本和主线程的脚本之间通过 postMessage() 方法发送消息,通过监听 message 事件接受消息。如果一个 Worker 脚本执行一个单一的任务,那么主线程和 Worker 线程之间可以很方便地互相发送一次消息便能完成这个 Worker 脚本的任务。但是如果一个 Worker 脚本需要与主线程脚本频繁交互,甚至如果有一些第三方库需要在 Worker 线程中执行,而它又有很多 API 需要在主线程中决定如何去调用,这样的场景中怎样才能较好地管理 Worker 线程和主线程之间的消息传递呢?
worker-handler
worker-handler 旨在组织上述场景中两个线程之间的消息传递。
worker-handler 通过在 Worker 脚本中定义一系列 Action 函数来规定主线程可以要求 Worker 执行哪些操作。之后,在主线程中可以像进行网络请求一样向 Worker 发送请求和接收响应。有两种方式获得消息响应,可以通过 Promise 获取,就像 AJAX,一个请求对应一个响应,也可以通过 EventTarget 获取,就像 Server-sent events,一个请求可以收到多个响应,并且这两种响应方式可以在同一个请求中同时使用。
快速开始
以下是一个 worker-handler 最简的用法示例:
npm install worker-handler
// demo.worker.js
import { createOnmessage } from "worker-handler/worker";
// 传入 Actions 调用 createOnmessage 以创建 worker 的 onmessage 回调
onmessage = createOnmessage({
// 如果只使用 Promise 响应方式,推荐使用 async 函数定义 Action
async someAction() {
// Action 中可以执行任意异步内容
......
// 异步 Action 中返回的内容将作为响应内容以 promise 的形式传递给 Main
return "some messages";
}
});
// demo.main.js
import { WorkerHandler } from "worker-handler"; // 也可以从 "worker-handler/main" 中引入
// import workerUrl from "./demo.worker.ts?worker&url"; // in vite
// import workerInstance from "./demo.worker.ts?worker"; // in vite
const demoWorker = new WorkerHandler(
// 如果是在 vite 环境中,可以传入上面的 workerUrl 或 workerInstance
new Worker(new URL("./demo.worker.js", import.meta.url)) // webpack5 环境中以这种方式创建 Worker 实例
);
// 请求 Worker 执行 someAction
demoWorker.execute("someAction", []).promise.then((res) => {
// 接收 Action 中以 Promise 形式响应的内容
console.log(res.data);
}).catch((err) => {
// Action 中发生的错误会使得 promise 被 reject
console.log(err)
});
类型支持
在 typescript 中使用 worker-handler 时,定义好 Actions 的类型后,便可以在传递消息的发送端和接收端都能进行类型检测和提示,并且可以检测传递的消息是否可以被结构化克隆算法处理,是否需要处理可转移对象等。
以下是 typescript 中使用 worker-handler 的简单示例:
// demo.worker.ts
import { ActionResult, createOnmessage } from "worker-handler/worker";
/*
* 定义 Actions 的类型,之后有以下两处地方需要将其作为泛型参数传入:
* - 在 Worker 中使用 createOnmessage() 时
* - 在 Main 中使用 new WorkerHandler() 时
*/
export type DemoActions = {
// 定义一个名为 pingLater 的 Action,其返回值类型 ActionResult<string> 表示该 Action 可以传递给 Main 的消息类型为 string
pingLater: (delay: number) => ActionResult<string>;
};
onmessage = createOnmessage<DemoActions>({
// pingLater 执行后会在 delay 毫秒后将消息传递给 Main
async pingLater(delay) {
await new Promise((resolve) => {
setTimeout(() => {
resolve(null);
}, delay);
});
return "Worker recieved a message from Main " + delay + "ms ago.";
}
});
// demo.main.ts
import { WorkerHandler } from "worker-handler/main";
import { DemoActions } from "./demo.worker";
const demoWorker = new WorkerHandler<DemoActions>(
new Worker(new URL("./demo.worker.ts", import.meta.url))
);
demoWorker.execute("pingLater", [], 1000).promise.then((res) => {
console.log(res.data);
});
响应消息
Promise 形式
有没有觉得上面的 pingLater 中想要实现指定特定延迟后再发送消息还需要自己 new 一个 Promise 很麻烦?这当然不合理,但是别担心,Promise 形式的消息传递除了使用 Action 的返回值,还支持通过调用 Action 中的 this.$end() 方法,可以在回调函数中使用,且同样支持类型检测和提示。所以上面的示例更适合这么写:
// demo.worker.ts
import { ActionResult, createOnmessage } from "worker-handler-test/worker";
export type DemoActions = {
// 这里返回值类型被定义为 ActionResult<string | void>,表示传递的消息类型应为 string,并且该异步函数可能不会显式地返回一个值
pingLater: (delay: number) => ActionResult<string | void>;
};
onmessage = createOnmessage<DemoActions>({
async pingLater(delay) {
setTimeout(() => {
this.$end("Worker recieved a message from Main " + delay + "ms ago.");
}, delay);
}
});
this.$end() 方式适合 Action 在发出响应后仍需要继续执行的情况,或需要在 Action 中的回调函数中发出响应的情况,但是要注意,它无法在使用箭头函数定义的 Action 中使用。而函数返回值的方式适合当 Action 中所有逻辑执行完毕后再做出响应的情况,且可以在箭头函数中使用。
EventTarget 形式
如果需要一个请求对应多次响应,那么就需要使用 EventTarget 形式的响应。在 Action 中调用 this.$post() 可以将消息以 EventTarget 形式传递给主线程,同样, Action 不能被定义为箭头函数。
下面是一个同时使用 EventTarget 和 Promise 形式响应消息,并在主线程中接收它们的示例:
// demo.worker.ts
import { ActionResult, createOnmessage } from "worker-handler-test/worker";
export type DemoActions = {
// EventTarget 形式传递的消息类型也通过 Action 的返回值类型定义,ActionResult<string | void> 表示传递的消息类型是 string,并且该异步函数可能不会显式地返回一个值
pingInterval: (
interval: number,
isImmediate: boolean,
duration: number
) => ActionResult<string | void>;
};
// 调用 pingInterval() 后,每隔 interval 毫秒就会发送一次 EventTarget 形式的消息,在 duration 毫秒后会发送 Promise 形式的消息并关闭请求连接
onmessage = createOnmessage<DemoActions>({
async pingInterval(interval, isImmediate, duration) {
let counter = 0;
const genMsg = () => "ping " + ++counter;
if (isImmediate) this.$post(genMsg());
const intervalId = setInterval(() => {
this.$post(genMsg());
}, interval);
setTimeout(() => {
clearInterval(intervalId);
this.$end("no longer ping");
}, duration);
}
});
// demo.main.ts
import { WorkerHandler } from "worker-handler/main";
import { DemoActions } from "./demo.worker";
const demoWorker = new WorkerHandler<DemoActions>(
new Worker(new URL("./demo.worker.ts", import.meta.url))
);
demoWorker
.execute("pingInterval", [], 1000, false, 5000) // execute() 执行后会返回一个 MessageSource
.addEventListener("message", (e) => {
console.log(e.data);
}) // 如果使用 addEventListener() 的方式监听 MessageSource,则会将 MessageSource 本身再次返回,使得可以链式调用
.promise.then((res) => {
console.log(res.data);
});
执行 demo.main.ts 后,会在控制台输出如下内容:
调用 Action
在 Main 中执行 WorkerHandle 实例的 excute() 会与 Worker 产生一个连接,并执行一个 Action。
excute() 接收的第三个以后的参数会按顺序传递给 Worker 中对应的 Action。
第二个参数可以接收一个连接配置选项对象,包含 transfer 和 timeout 两个属性:
transfer是一个会被转移所有权到Worker中的的可转移对象数组。timeout是本次连接的超时时间。超时后该连接将会被关闭,不会再收到任何响应,且Action返回的Promise将转变为rejected状态。
也可以简化传参:
- 如果只需要使用
transfer,可以直接传入一个数组。 - 如果只需要使用
timeout,可以直接传入一个数字。 - 如果都不需要开启,那么可以传入以下任意值:
null、undefined、[]、小于或等于0的任何数字。
Transfer
如果传递的消息中包含可转移对象,那么需要对其进行所有权进行转移处理。
主线程传递给 Worker 时,通过 workerHandle.execute() 的第二个参数进行指定。
Worker 传递给主线程时,Action 中 this.$end() 和 this.$post() 的第二个参数都是用来指定 transfer 数组的。
在使用 Action 的返回值进行响应时,返回值如果是一个数组,那么该数组只能有两个项,第一项是传递的消息,第二项就是指定的 transfer 数组。这也导致,如果 Action 返回值中如果希望传递数组类型的消息,必须通过 [messageData, [...transferable]] 的形式,即使不需要处理 transfer,例如:
// demo.worker.ts
import { ActionResult, createOnmessage } from "worker-handler/worker";
export type DemoActions = {
getRandomNumsInArray: (amount: number) => ActionResult<number[]>;
};
onmessage = createOnmessage<DemoActions>({
async getRandomNumsInArray(amount) {
const numsArr = [];
for (let i = 0; i < amount; i++) {
numsArr.push(Math.round(Math.random() * 100));
}
// 如果这里是 "return numsArr",则 TS 类型检测不会通过
return [numsArr, []];
},
});
不过也不用担心不小心传错,当在 TS 中使用时,不符合要求的传参都会在写代码时就出现提示。
结语
以上基本涵盖了 worker-handler 的使用方式,具体的 API 可以在这里查看。
源码在这里,感兴趣的话给个 star 呗~