前言
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
呗~