• Simon van den Broeck

  • Frontend Developer

Discover three npm packages that help making Web Workers more easy to use

How to Tackle Web Workers

Let's start this blog with a description of Web Workers as defined by Mozilla:

"Web Workers makes it possible to run a script operation in a background thread separate from the main execution thread of a web application. The advantage of this is that laborious processing can be performed in a separate thread, allowing the main (usually the UI) thread to run without being blocked/slowed down."
 

Using Web Workers, we can bring concurrency to the otherwise single threated language that is JavaScript. 

This can be especially helpful when a lot of data has to be processed in the background, like parsing a larger API response or doing complex calculations. Doing these on the main thread could result in the application becoming unresponsive to user input, as all these events need to be handled using that one main thread. 

In this blog, we will take a closer look at some npm packages that help making them a bit more friendly for developers to use, as the base implementation can be quite cumbersome. 

Libraries 

We're going to look at three packages: 

To summarise:

  • greenlet: move a function into a thread
  • workerize-loader: move a module with async function exports into a thread
  • comlink-loader: move a module with any interface into a thread

These are sorted in order of complexity, size and utility (all ascending).

Greenlet 

Move an async function into its own thread, offering the same performance as direct Worker usage.

greenlet is a very simple library. It allows you to pass it an async function and it will produce a copy that runs within a Web Worker. An example:

import greenlet from 'greenlet';

let get = greenlet(async (url: string) => {
	let res = await fetch(url);
	return await res.json();
});

console.log(await get('/foo'));

 Now the fetch and json parsing will be done off the main thread, and only the result will be passed back to it.
This makes it very easy to create small workers on the fly.

Workerize-loader

A webpack loader that moves a module and its dependencies into a Web Worker, automatically reflecting exported functions as asynchronous proxies.

workerize-loader allows us to move an entire file into a web worker, but still use it in our main code as an async proxy.

worker.ts: (gets moved into a worker)

// block for `time` ms, then return the number of loops we could run in that time:
export function expensive(time: number) {
    let start = Date.now();
    let count = 0;
    while (Date.now() - start < time) count++
    return count
}

index.ts: Main application code

import worker from 'workerize-loader!./worker'

let instance = worker(); // `new` is optional

instance.expensive(1000).then(count => {
    console.log(`Ran ${count} loops`);
});

No changes to the webpack config are necessary, the loader syntax will bundle the `worker.ts` code automatically into a separate file.

Comlink-loader 

This is a webpack loader to offload modules to Worker threads seamlessly using Comlink.

 

 

 

comlink-loader in default mode looks very similar to workerize-loader, the import syntax is the same, however, you can export more than just functions:

worker.ts: (gets moved into a worker)

// Dependencies get bundled into the worker:
import rnd from 'random-int';

export function expensive(time: number) {
    let start = Date.now();
    let count = 0;
    while (Date.now() - start < time) count++
    return count
}

export class MyClass {
  constructor(value = rnd()) {
    this.value = value;
  }
  increment() {
    this.value++;
  }
  // Tip: async functions make the interface identical
  async getValue() {
    return this.value;
  }
}

index.ts: Main application code

import worker from 'comlink-loader!./worker'

// instantiate a new Worker with our code in it:
const instance = new worker();

// our module exports are exposed on the instance:
await instance.expensive(1000); // waits for one second

// instantiate a class in the worker (does not create a new worker).
// notice the `await` here:
const obj = await new instance.MyClass(42);
await obj.increment();
await obj.getValue();  // 43

comlink-loader also offers a singleton mode through webpack config that makes the integration more seamless.

It will only allow for a single worker for each module though, and you will have to make sure all your calls use await, but typescript will work out of the box because it looks like a regular import.

webpack.config.js

module.exports = {
  module: {
    rules: [
      {
        test: /\.worker\.(js|ts)$/i,
        use: [{
          loader: 'comlink-loader',
          options: {
            singleton: true
          }
        }]
      }
    ]
  }
}

Now, let's write a simple module that we're going to load into a Worker:

greetings.worker.ts:

export async function greet(subject: string): string {
  return `Hello, ${subject}!`;
}

We can import our the above module, and since the filename includes `.worker.ts`, it will be transparently loaded in a Web Worker!

index.ts:

import { greet } from './greetings.worker.ts';

async function demo() {
  console.log(await greet('dog'));
}

demo();

Gotcha's 

There are some caveats and gotcha's with Web Workers, that we need to take into account. 

Separate thread => separate context

Since the web worker runs in a completely separate context, all values passed from and to the Web Worker are copied over using the structured clone algorithm. They are not passed by reference!

No direct manipulation of DOM

The Web Worker thread has no way to access the main scripts DOM directly. Some `window` methods and properties are also not available.  For a list, see this link

References: 

Verwante Artikels