This article reprinted from Saul – mirone. Making. IO / 2019/12/17 /…

Web workers can add independently running threads to the browser that can communicate with the main thread. We can ensure the smoothness of the main thread by moving a large amount of computation that may block the main thread into the Web worker. However, the default invocation of the Web worker is cumbersome, so we can carry out some encapsulation according to our own needs. This paper discusses an event-based encapsulation mode.

The original call

Before we get started, let’s take a look at what it might look like to use the API in a Web worker directly. Suppose we now use JSON to pass data.

  • In the worker:
self.addEventListener('message', (e) => {
  const { data } = e;
  if(! data)return;
  dataHandler(data);
});
Copy the code
  • In the main thread:
const worker = new Worker('path/to/worker');
worker.postMessage({ data: 'some data' });
Copy the code

It looks very clear and doesn’t seem to need any encapsulation.

However, when the amount of data transferred increases and the frequency increases, there will be significant performance degradation.

Passing reference

When a Web worker passes data between threads, there are two ways:

  1. Structured cloning: The default is to clone a piece of data to the receiving thread rather than share the instance. Therefore, if there is a large amount of data, the cost of Clone will also increase.
  2. Transfer: This method is used to transfer data that implements the Transferable interface. The data is handed over to the context of the target thread and there is no replication, so performance is significantly improved.

See Google’s documentation for details

Currently, data types that implement the Transferable interface include: ArrayBuffer, MessagePort, ImageBitmap, OffscreenCanvas, so ArrayBuffer is the best choice for our current scenario of transferring JSON structured data.

So we need to implement a pair of encode, decode methods to convert data structures into arrayBuffers:

  • encode:
function encode<T> (data: T) :Uint16Array {
  const str = JSON.stringify(data);
  const buf = new ArrayBuffer(str.length * 2);
  const bufView = new Uint16Array(buf);
  bufView.set(str.split("").map((_, i) = > str.charCodeAt(i)));
  return bufView;
}
Copy the code
  • decode:
function decode<T = unknown> (buf: ArrayBufferLike) :T {
  return JSON.parse(
    String.fromCharCode.apply(
      null,
      (new Uint16Array(buf) as unknown) as number[]
    )
  );
}
Copy the code

So our code becomes:

  • In the worker:
self.addEventListener('message', (e) => {
  const { data } = e;
  if(! data)return;
  dataHandler(decode(data));
});
Copy the code
  • In the main thread:
const worker = new Worker('path/to/worker');
const arrayBuffer = encode(data);
worker.postMessage(arrayBuffer.buffer, [arrayBuffer.buffer]);
Copy the code

Provide a response for each invocation

In Web workers, after the main thread sends a message to the worker thread, it is no longer possible to track the status of the message, and only the child thread can actively call postMessage to inform the main thread of the status.

Suppose we now want the main thread to be notified when a child thread call ends, we can append an ID to each message to track the status of a call:

  • In the worker:
self.addEventListener('message', (e) => {
  const { data } = e;
  if(! data)return;
  const returnMessage = dataHandler(decode(data.message));
  const arrayBuffer = encode({ id: data.id, message: returnMessage });
  self.postMessage(arrayBuffer.buffer, [arrayBuffer.buffer]);
});
Copy the code
  • In the main thread:
const worker = new Worker('path/to/worker');
const id: string = uuid();
const arrayBuffer = encode({ id, message: data });
worker.postMessage(arrayBuffer.buffer, [arrayBuffer.buffer]);

const id2: string = uuid();
const arrayBuffer2 = encode({ id: id2, message: data });
worker.postMessage(arrayBuffer2.buffer, [arrayBuffer2.buffer]);
worker.onmessage = (e) => {
  const { data } = e;
  if(! data)return;
  const returnData = decode(data);
  if (id === returnData.id) {
    callback1(returnData.message);
  }
  
  if (id2 === message.id) {
    callback2(returnData.message); }}Copy the code

As you can see, we need to manually manage the codec and callback mapping for each message delivery, which is tedious to write. Let’s encapsulate some of the generic processing.

Worker invocation based on Promise

Observing the code, we always need to construct the id of the message and add the corresponding processing callback every time we send a message to the worker. It was a perfect match for Promise.

Class PromiseWorker {private messageMap: Map<string, Function> = new Map(); constructor(privatereadonly worker: Worker) {
    worker.onmessage = e => {
      const { data } = e;
      if(! data)return;
      const { id, message } = decode(data);
      const res = this.messageMap.get(id);
      if(! res)return;
      res(message);
      this.messageMap.delete(id);
    }
  }

  emit<T, U>(message: T): Promise<U> {
    returnnew Promise(resolve => { const id = uuid(); const data = encode({ id, message }); this.messageMap.set(id, resolve); this.worker.postMessage(data.buffer, [data.buffer]); }); }} // The registration method called by the child threadfunction register(handler: Function) {
  const post = (message) => {
    const data = encode(message);
    self.postMessage(data.buffer, [data.buffer]);
  }
  self.onmessage = async (e: MessageEvent) => {
    const { data } = e;
    if(! data)return;

    const { id, message } = decode(data);

    const result = (await mapping[type](message)) || "done";
    post({ id, message: result });
  };
}
Copy the code

When using:

  • In the worker:
register(async (message) => {
  const data = await someFetch(message);
  return someHandler(data);
})
Copy the code
  • In the main thread:
const worker = new Worker('path/to/worker');
const promiseWorker = new PromiseWorker(worker);
promiseWorker.emit(data).then(result => console.log(result));
Copy the code

It’s very convenient.

Implements event-style invocation

PromiseWorker is good enough, but there is some template code when we need to classify incoming messages and respond according to different types:

  • In the worker:
register(async (message) => {
  switch (message.type) {
    case 'ACTION_A':
      return handler1(message.data);
    case 'ACTION_B':
      return handler2(message.data);
    default:
      returnhandler3(message.data); }})Copy the code
  • In the main thread:
const worker = new Worker('path/to/worker');
const promiseWorker = new PromiseWorker(worker);
promiseWorker.emit({ type: 'ACTION_A', data: dataA }).then(result => console.log(result));
promiseWorker.emit({ type: 'ACTION_B', data: dataB }).then(result => console.log(result));
Copy the code

So we can use the event model to classify messages and respond by category by simply adding the Type field to the message model.

class WorkerEmitter {
  private messageMap: Map<
    string,
    { callback: Function; type: string | number }
  > = new Map();
  constructor(private readonly worker: Worker) {
    worker.onmessage = e => {
      const { data } = e;
      if(! data)return;

      const { id, message } = decode(data);
      const ret = this.messageMap.get(id);
      if(! ret)return;

      const { callback } = ret;

      callback(message);
      this.messageMap.delete(id);
    };
  }
  emit<T, U>(type: string | number, message: T): Promise<U> {
    return new Promise(resolve => {
      const id = uuid();
      const data = encode({
        id,
        type,
        message
      });
      this.messageMap.set(id, {
        type, callback: (x: U) => { resolve(x); }}); this.worker.postMessage(data.buffer, [data.buffer]); }); }terminate() { this.worker.terminate(); }}type WorkerInstance = {
  on(type: string, handler: Function): void;
};
function register(): WorkerInstance {
  const mapping: Record<string, Function> = {};
  const post = (message: Data): void => {
    const data = encode(message);
    self.postMessage(data.buffer, [data.buffer]);
  };
  self.onmessage = async (e: MessageEvent) => {
    const { data } = e;
    if(! data)return;

    const { type, id, message } = decode(data);

    const result = (await mapping[type](message)) || "done";
    post({ id, type, message: result });
  };

  return {
    on: (type, handler) => {
      mapping[type] = handler; }}; }Copy the code

A call:

  • In the worker:
const worker = register();

worker.on('ACTION_A', handler1);
worker.on('ACTION_B', handler2);
Copy the code
  • In the main thread:
const worker = new Worker('path/to/worker');
const workerEmitter = new WorkerEmitter(worker);
workerEmitter.emit('ACTION_A', dataA).then(result => console.log(result));
workerEmitter.emit('ACTION_B', dataB).then(result => console.log(result));
Copy the code

Since then, using web workers can be as easy as triggering events. The above source code is available on Github.


I’ve published this package on NPM: Worker-Emitter, which I can use as needed.


Pay attention to [IVWEB community] public number to check the latest technology weekly, today you are better than yesterday!