Web Worker Technology Sharing - Low-level API Encapsulation

Communication API

Worker communication API is very simple, there are only postMessageand onmesssagetwo, and the communication process is shown in the figure: image.pngWorker communication API is simple, but they are event-driven, and onmesssagemust receive messages through callback functions, and multiple communications of the same type must be matched to their respective The response usage onmesssagecan only be distinguished by a certain sign, which leads to a messy code organization, lack of ease of use, increased debugging difficulties, and reduced efficiency of troubleshooting. Therefore, the business use of Web Worker will generally be related to the native communication API. Encapsulate, such as encapsulation as a Promoise call.

Note :

  • All code organization is based on worker-loaderwriting form, other forms should be modified according to specific rules.
  • There are many ways to transmit postMessage data. The content of the article is based on the Structured Clone type. For more information on communication, please refer to To be added

Communication Promise Encapsulation

The idea of ​​Prmoise encapsulation is very simple, that is, put postMessage, onmesssageinto new Promise, and then onmesssageresolve the result in the callback function of , and reject the error when there is an error.

main.js package

With the previous problem analysis and the above one-time communication ideas, let's analyze the conventional packaging ideas, first of all main.js:

  1. Formally, a new method needs to be called, the method returns an Promiseobject , the Promiseobject is called internally to postMessagesend a message, then wait for onmesssagethe return result, and call resolveor rejectchange the Promisestate according to the result.
  2. Logically, when creating a callback function that calls orresolve when receiving a message, a unique message ID needs to be created in order to indicate which message is being replied to, and a mapping relationship between ID and callback is established, which is stored globally.rejectpostMessage
  3. After the message ID is passed, it will finally onmesssagebe returned with the result result at , find the corresponding callback according to the ID, process the result, and finally change the Promisestate and return the result.

code show as below:

// 创建唯一 ID 工具函数
import { generateUUID } from '@/utils';

/**
 * @description: Promise 化 Worker 类,使用此类需要在 worker.js 中 postMessage 时 带上 messageId,或者使用 registerPromiseWorker 类
 * @param { worker } worker 实例
 * @return {*}
 */
export const PromiseWorker = class {
  #callbacks = {};
  worker = null;
  constructor(worker) {
    this.worker = worker;
    worker.addEventListener('message', (e) => {
      const msg = e.data;
      if (!Array.isArray(msg) || msg.length < 2) return; // 不是符合规范的 message
      const [messageId, error, result] = msg;
      const callback = this.#callbacks[messageId];
      if (!callback) return; // 没找到对应的回调函数,不是符合规范的 message
      delete this.#callbacks[messageId];
      callback(error, result);
    })
  }
  postMessage(initMsg) {
    const messageId = generateUUID();
    const msg = [messageId, initMsg];
    return new Promise((resolve, reject) => {
      this.#callbacks[messageId] = function(error, result) {
        if (error) return reject(new Error(error));
        resolve(result);
      }
      this.worker.postMessage(msg);
    })
  }
}

复制代码

It can also be based on onmessageencapsulation , the ideas are similar, the analysis is as follows:

  1. A new method is required createPromise, the parameter receives a message ID, returns one Promise, and Promiseis called internally onmessage.
  2. A new method is needed postMessage, which is called internally and createPromisereturns one Promise, triggers at the same time onmessage, and then executes the real postMessagemethod to send a message. The parameter also needs to carry a unique message ID, and finally Promisereturns it and waits for Promisethe result, which is onmessagethe result of .
  3. createPromiseInternally, according to ID matching, it is judged whether it conforms to the specification and is the required result. If it is not required, it will not be processed and Promisecontinue to be in the pendingstate. Otherwise, it will be Promiseset to the fulfilledstate or resolvedstate according to the result.

code show as below:

// 创建唯一 ID 工具函数
import { generateUUID } from '@/utils';

/**
 * @description: Promise 化 Worker 类,使用此类需要在 worker.js 中 postMessage 时 带上 messageId,或者使用 registerPromiseWorker 类
 * @param { worker } worker 实例
 * @return {*}
 */
 export const PromiseWorker = class {
  worker = null;
  constructor(worker) {
    this.worker = worker;
  }
  _createPromise(id) {
    return new Promise((resolve, reject) => {
      const listener = (e) => {
        const msg = e.data; // [messageId, error, result]
        if (Array.isArray(msg) && msg.length >= 2 && msg[0] === id) {
          const error = msg[1];
          const result = msg[2];
          if (error) return reject(new Error(error));
          resolve(result);
          this.worker.removeEventListener('message', listener);
        }
      }
      this.worker.addEventListener('message', listener);
    })
  }
  postMessage(initMsg) {
    const messageId = generateUUID();
    const promise = this._createPromise(messageId);
    const msg = [messageId, initMsg];
    this.worker.postMessage(msg);

    return promise;
  }
}
复制代码

worker.js wrapper

Then there is worker.js. After the calculation is completed, you only need to bring the requested ID in the response reply:

export const WorkerThreadController = class {
  #callback = null;
  constructor(callback) {
    this.#callback = callback;
    this.worker = self;
    this.worker.onmessage = this.onmessage.bind(this);
  }
  async onmessage(e) {
    const payload = e.data;
    if (!Array.isArray(payload) || payload.length !== 2) return; // 不是符合规范的 message
    const [messageId, message] = payload;
    const result = await this.#callback(message);
    this.worker.postMessage([messageId, result]);
  }
}
复制代码

In order to increase the robustness of the framework, we should also add error handling and the ability to handle the case where the callback return value is still Promise in worker.js. The full version code is as follows:

export const WorkerThreadController = class {
  #callback = null;
  constructor(callback) {
    this.#callback = callback;
    this.worker = self;
    this.worker.onmessage = this.onmessage.bind(this);
  }
  /**
   * @description: 处理 onmessage 事件
   * @param {*} e 传递事件
   * @return {*}
   */  
  async onmessage(e) {
    const payload = e.data;
    if (!Array.isArray(payload) || payload.length !== 2) return; // 不是符合规范的 message
    const [messageId, message] = payload;
    let result = null;
    try {
      const callbackResult = await this.#callback(message);
      result = { res: callbackResult };
    } catch (e) {
      result =  { err: e };
    }
    if (result.err) this._postMessage(messageId, result.err);
    else if (!this._isPromise(result.res)) this._postMessage(messageId, null, result.res);
    else {
      result.res
        .then(res => this._postMessage(messageId, null, res))
        .catch(err => this._postMessage(messageId, err))
    }
  }
  /**
   * @description: 判断是不是 Promise
   * @param {*} Obj 判断对象
   * @return {*}
   */
  _isPromise(obj) {
    return !!obj && (typeof obj === 'object' || typeof obj === 'function') && typeof obj.then === 'function';
  }
  /**
   * @description: 封装 postMessage 方法
   * @param {*} messageId
   * @param {*} error
   * @param {*} result
   * @return {*} [messageId, error, result]
   */  
  _postMessage(messageId, error, result) {
    if (error) {
      console.error('Worker caught an error:', error);
      this.worker.postMessage([messageId, { message: error.message }]);
    } else {
      this.worker.postMessage([messageId, null, result])
    }
  }
}
复制代码

Example of use

至此,一个简单好用的 Promise Worker 管理模块则就建立完成了,用户使用和普通代码几乎无差异,对通信过程无感知。使用示例如下:

// main.js
import { PromiseWorker } from '@/utils/web-worker.js';
import Worker from './xxx.worker.js';

const workerPromise = new PromiseWorker(new Worker());
const workerRes = await workerPromise.postMessage('...');

// worker.js
import { WorkerThreadController } from '@/utils/web-worker.js';

new WorkerThreadController(async (event) => {
  return '...';
});
复制代码

一次性通信

考虑到现在流行的一些类库都有类似的封装,比如 vue-worker 中的 run 方法,创建一个一次性的 Worker,运行完毕就销毁,此方法类似于 Promise.resolve(),但在是在另一个线程中。
这里我们也来在PromiseWorker的封装中增加一个 run 方法,如下:

export const PromiseWorker = class {
  ...
  ...
  ...
  async run(initMsg) {
    try {
      const result = await this.postMessage(initMsg);
      this.worker.terminate();
      return result;
    } catch(e) {
      this.worker.terminate();
      throw e;
    }
  }
}
复制代码

这里要注意:浏览器创建销毁线程都是有代价的,线程的频繁新建会消耗资源,而每个 Worker 线程会有约 1M 的固有内存消耗,因此大多数场景下,Worker 线程应该用作常驻的线程,开发中优先复用常驻线程。
一次性执行,只适合特定场景,比如预加载网站数据,多页面应用切换页面加速等等场景。

通信 RPC 封装

基于 Promise 的封装已经大大增加了 Web Worker 的易用性,但是我们还必须考虑 worker.js 中逻辑复杂度的问题,如果一个 Worker 中要处理多种不同的逻辑,那 worker.js 中的代码组织将会变得异常繁杂,难以维护。
理想中我们希望 Web Worker 作为插件与框架之间通过接口定义进行通信,就像我们调用一个后端 API 一样,这样可以保证开发风格的一致化,也可以对同一个 Worker 进行最大限度的扩展,如下以 API 直接调用的形式:

/* main.js */
const workerInstance = new Worker('./worker.js');
export async function getData(args) {
  await workerInstance.getData(args);
}
// 使用
async function xxx() {
 const data = await getData()
}


/* worker.js */
class RemoteAPI {
  constructor() {}
  getData() {}
  serializeData() {}
}
复制代码

How to achieve the above effect, speaking of this, in fact, most of the experienced friends may already have the answer, that is RPC (Remote Procedure Call) remote procedure call, a brief summary is to call the remote function like a local function. , "Remote" usually refers to the server, here is the Worker, that is, the client calls the functions in the Worker just like calling local functions.
The process of encapsulating Worker based on RPC is very complicated. This article only introduces the principle and simple implementation. In actual projects, it is recommended that you use third-party libraries, such as comlink , workerize-loader , and alloy-worker , to avoid stepping on pits.
For those who don't know much about RPC, you can read this article in about 5 minutes, which is very clear and easy to understand.

Implementation explanation

There are two routes for RPC implementation, one is code generation described by the intermediate API (such as GRPC), and the other is to use reflection/dynamic characteristics to construct remote calls at runtime, based on the dynamic characteristics of javascript, for the second implementation RPC has advantages (the implementation is very short).
Let's take comlink as an example to explain the implementation idea. Comlink is implemented based on the proxy feature of es6. Using this feature, remote.xxx can be converted into a unified function call method.

Summarize

refer to

  1. Comlink usage & source code analysis | Easily write Web Workers
  2. Distributed Architecture Core RPC Principle
  3. Web Worker Literature Review
  4. One of the plug-in architectures of the web system - web-worker-rpc

Guess you like

Origin juejin.im/post/7084573495975215111