像进行网络请求一样使用 Web Worker

1,285 阅读5分钟

前言

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 viteconst 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 不能被定义为箭头函数。

下面是一个同时使用 EventTargetPromise 形式响应消息,并在主线程中接收它们的示例:

// 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

第二个参数可以接收一个连接配置选项对象,包含 transfertimeout 两个属性:

  • transfer 是一个会被转移所有权到 Worker 中的的可转移对象数组。
  • timeout 是本次连接的超时时间。超时后该连接将会被关闭,不会再收到任何响应,且 Action 返回的 Promise 将转变为 rejected 状态。

也可以简化传参:

  • 如果只需要使用 transfer,可以直接传入一个数组。
  • 如果只需要使用 timeout,可以直接传入一个数字。
  • 如果都不需要开启,那么可以传入以下任意值:nullundefined[]、小于或等于 0 的任何数字。

Transfer

如果传递的消息中包含可转移对象,那么需要对其进行所有权进行转移处理。

主线程传递给 Worker 时,通过 workerHandle.execute() 的第二个参数进行指定。

Worker 传递给主线程时,Actionthis.$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 呗~