Overview

I've been working on FidgetMap for a while now. I think I could speed up a lot of processes (including the basic render loop) by adding Web Workers to handle a lot of the rendering tasks asynchronously. The whole game is drawn by writing RGB data to arrays then compositing them onto a canvas, so it's really a perfect candidate for Web Workers. It's pretty wild to think it's come this far without any background threads but here we are.

I won't get into a whole history lesson about Web Workers, suffice to say they will let me pass a lot of rendering to a background thread (multiple background threads in my case) to free up the main execution thread. The whole game should be faster with certain tasks compartmentalized in the background.

The main part of the game is drawn directly to a canvas, as mentioned above, but the UI controls such as the menu and interactive buttons are written in React. No point in reinventing the wheel regarding text and flex-style rendering. So I built the project using create-react-app (with Typescript). I want to write my Web Workers in the same style, preferably in the same source code. But you can't just import Web Worker modules then make them into workers. You have to instantiate a worker with a URL to a separate JavaScript file (or bundle).

CRA is great. It hides any configuration and makes it super easy to start a React project. But it's not so great at advanced configuration like this, where you need to compile your app to one JS file and potentially several Web Workers into separate files. The usual recommendation is to eject from create-react-app. This lets you fiddle with configuration as needed, but with great power comes great responsibility. Once you've ejected, you have to maintain the configuration and scripts yourself. No more easy updating of one well tested project.

So how can we make different compile targets for different entry points? Parcel to the rescue.  ParcelJS is a zero-configuration build tool that works with Typescript out-of-the-box. You just say "hey Parcel, this entry file" and it Does The Thing™. Enough talk, let me show you how I did it.

The Solution

Let's start from the beginning. Use create-react-app to start a new project with Typescript. Go to your project directory and make a new app like so:

npx create-react-app workers-example --template typescript

Now move into the directory and let's install Parcel

cd workers-example
npm i --save-dev parcel

Rad, now we're technically working with two build systems; the one built into create-react-app and Parcel which we just added. Let's make a new directory in the root of the project called "workers". That's where we'll put our workers source code. It is in a separate directory from src since they will each be their own little bundle. We'll throw a sample worker in there, too.

mkdir workers
cd workers
touch sampleWorker.ts

Let's put something in our sample worker so we can see that it works

self.onmessage = (e: MessageEvent) => {
    self.postMessage("hello, world from the worker");
};

Now we'll add a new script to our package.json so we can build workers using npm. We will build all workers out to the public directory under a subdirectory so our app can easily use them from there.

{
// ...rest of file
  "scripts": {
    // ... rest of scripts
    "worker:build": "parcel build --dist-dir public/workers --"
  }
}

Okay, now we can build workers! Run this to see the magic:

npm run worker:build workers/sampleWorker.ts

Parcel will use sampleWorker.ts as the entry point. It will see that it's Typescript and do the right thing without any additional plugins or configuration. When the command finishes, you should find your newly built worker in public/workers along with a sourcemap. Nice!

See it in Action

Let's update the automatic App file so we can see our new worker in action. Copy/paste this into your App.tsx to interface with the worker we've built.

import React, { FC, useCallback, useEffect, useRef } from 'react';
const App: FC = () => {
  const workerRef = useRef<worker>();
  const sendMessageToWorker = useCallback(() => {
    workerRef.current?.postMessage({});
  }, []);
  useEffect(() => {
    workerRef.current = new Worker("/workers/sampleWorker.js");
    workerRef.current?.addEventListener('message', (event) => {
      alert('message received from worker: ' + JSON.stringify(event.data));
    });
    return () => workerRef.current?.terminate();
  }, []);
  return (
    <div>
      <button type="button" onClick={sendMessageToWorker}>
        Send message to worker
      </button>
    </div>
  );
}
export default App;

Taking It Farther - build automatically

Build the workers when app builds

Now we can build workers in the most basic way. It would be better if we didn't add the compiled workers to our source, instead building them along with the normal bundle. We can accomplish this fairly easily.

First, let's add the public workers directory (and parcel's cache directory) to our .gitignore so we don't commit them.

// in .gitignore
.parcel-cache
public/workers

Next, we'll add a command to build all the workers in your worker directory. Back in package.json...

{
// ...rest of file
  "scripts": {
    // ... rest of scripts
    "worker:build": "parcel build --dist-dir public/workers --",
    "workers:build:all": "npm run worker:build ./workers/*"
  }
}

Parcel once again saves the day. It will use each file in the workers directory as an entry point and create a separate bundle for each one. This lets you have fully compartmentalized workers. Each can import from node_modules and those dependencies will become part of the final bundle for each worker.

But we don't want to have to run a separate build command. We want it to do it along with the most basic npm run build. We can accomplish this by using another package called npm-run-all. This lets you run multiple npm commands at once, either in sequence or parallel.

npm i --save-dev npm-run-all

Now back in package.json, we're going to move the existing build command to a different name so we can make the normal build command do multiple things

{
// ...rest of file
  "scripts": {
    // ... rest of scripts
    "build": "npm-run-all --sequential workers:build:all rs:build",
    "rs:build": "react-scripts build",
    "worker:build": "parcel build --dist-dir public/workers --",
    "workers:build:all": "npm run worker:build ./workers/*"
  }
}

Alright! Now whenever you build, you'll build all your workers first then run the normal react-scripts build that creates your create-react-app package.

Now you can use your fully transpiled and bundled workers anywhere in your existing app package by doing:

    const worker = new Worker("/workers/sampleWorker.js");

Build the workers in development whenever they change

The final piece of the puzzle is building the workers when you run `npm start`. Parcel has a built-in "watch" command, but it assumes that your file is running in an environment with a `window` variable, which workers do not. So we can implement our own watcher easily using a package called `node-watch`. Start by installing it:

npm i --save-dev node-watch

Now create a new script called `watchWorkers.js`. I put this in a subdirectory called "scripts". Copy this into watchWorkers.js

var watch = require('node-watch');
var { spawn } = require('child_process');
// this will build the workers initially when the script is run
spawn(
    'npm',
    ['run', 'workers:build'],
    { stdio: 'inherit' }
);
watch('./workers/', { recursive: true }, function(evt, name) {
    // this will build each individual worker as they are updated
    spawn(
        'npm',
        ['run', 'worker:build', name],
        { stdio: 'inherit' }
    );
});

Now we have a script that will rebuild each worker as it changes. Once again, let's modify package.json to make it part of our normal process

{
// ...rest of file
  "scripts": {
    // ... rest of scripts
    "start": "npm-run-all --parallel workers:watch rs:start",
    "rs:start": "react-scripts start",
    "build": "npm-run-all --sequential workers:build:all rs:build",
    "rs:build": "react-scripts build",
    "worker:build": "parcel build --dist-dir public/workers --",
    "workers:build:all": "npm run worker:build ./workers/*",
    "workers:watch": "node ./scripts/watchWorkers.js"
  }
}

Now whenever you run `npm start`, you will run the normal react-scripts start which will watch for app bundle changes, but you'll also run your own worker watch which will retranspile each worker as they are updated.

Leave a Comment

Your email address will not be published. Required fields are marked *

en_USEN
Scroll to Top