DEV Community

Ben Soutendijk
Ben Soutendijk

Posted on

Using Web Workers in Vue 3

Recently, I have been tasked with porting a complicated library of calculations to model fluid dynamics. My target language and framework is Typescript + Vue 3. As I got deeper into building out the feature, the amount of calculations the program needed to perform grew exponentially. At first, we needed to take the user input and run a set of calculations showing them the result. Next, that user input was used as a basis for charting the results over some range of an input. And most recently, the task has been to take the user input, then perform a sensitivity analysis on multiple control points.

By this point, each step in the analysis is running the whole gamut of calculations hundreds of times taking exponentially larger amounts of time. "Premature optimization is the root of all evil." says Donald Knuth, and whether or not it is strictly true, I had abided by it in my construction of the calculation library until now. In other words, this piece of crap was taking forever to work.

Web Workers

Web Workers are the common implementation of concurrency in web browsers, and will be the main tool used to speed up a large set of CPU intensive tasks such as the ones I am working with.

To get started with web workers, I recommend reading their documentation. Let's start by mocking out a basic worker.ts file.

export type WorkerRequest = {
  id: string;
  a: number[][];
  b: number[][];
};

export type WorkerResponse = {
  id: string;
  result: number[][];
};

addEventListener("message", (e: MessageEvent<WorkerRequest>) => {
  const response: WorkerResponse = {
    id: e.data.id,
    result: multiplyMatrices(e.data.a, e.data.b)
  };

  postMessage(response);
});

function multiplyMatrices(a: number[][], b: number[][]): number[][] {
  const result: number[][] = new Array(a.length);

  for (let i = 0; i < a.length; i++) {
    result[i] = new Array(b[0].length);
    for (let j = 0; j < b[0].length; j++) {
      result[i][j] = 0;
      for (let k = 0; k < a[0].length; k++) {
        result[i][j] += a[i][k] * b[k][j];
      }
    }
  }

  return result;
}
Enter fullscreen mode Exit fullscreen mode

A few things to note:

  • Declaring the request and response types will be useful when writing the component code that makes the request and receives the response.
  • Adding an id to each request will be important later so that we can use JavaScript Promises effectively

And that's about as simple as it will get in terms of Web Workers. The multiplyMatrices() function is computationally heavy and a good candidate for concurrency.

Vue Plugin

In this section, I will take for granted that you are using Vite to bundle your Vue 3 app. You can read more about Vite and Web Workers here.

Web Workers are very easy to implement with Vite, simply add ?worker to end of your import statement like so.

import MyWorker from './worker.ts?worker'
Enter fullscreen mode Exit fullscreen mode

The next question is, where do we instantiate this new MyWorker class? I felt that this is a good opportunity to use a Vue 3 plugin.

import type { Plugin } from "vue";
import type { WorkerResponse, WorkerRequest } from "./matrixWorker";

import MatrixWorker from "./matrixWorker?worker";
import { matrixWorkerKey } from "./injectionKeys";

type WorkerPluginOptions = {
  minWorkers?: number;
  maxWorkers?: number;
};

const plugin: Plugin = {
  install: (app, options: WorkerPluginOptions) => {
    const MIN_WORKERS = options?.minWorkers ?? 1;
    const MAX_WORKERS = options?.maxWorkers ?? navigator.hardwareConcurrency - 1;

    const workers: Worker[] = [];
    const workerPool: Worker[] = [];
    const messageQueue: WorkerRequest[] = [];
    const resolvers: Record<string, (value: any) => void> = {};

    for (let i = 0; i < MIN_WORKERS; i++) {
      addWorker();
    }

    window.onunload = () => {
      for (const worker of workers) {
        worker.terminate();
      }
    };

    function multiplyMatricesAsync(a: number[][], b: number[][]) {
      const id = Math.random().toString();

      return new Promise<number[][]>((resolve) => {
        resolvers[id] = resolve;

        const request: WorkerRequest = {
          id,
          a,
          b
        };

        queueMessage(request);
      });
    }

    function queueMessage(query: WorkerRequest) {
      messageQueue.push(query);
      processNextQuery();
    }

    function processNextQuery() {
      adjustWorkerPool();

      if (workerPool.length > 0 && messageQueue.length > 0) {
        const worker = workerPool.shift();
        const msg = messageQueue.shift();

        worker?.postMessage(msg);
      }
    }

    function adjustWorkerPool() {
      if (messageQueue.length > workerPool.length) {
        addWorker();
      } else if (messageQueue.length < workerPool.length) {
        removeWorker();
      }
    }

    function addWorker() {
      if (workers.length < MAX_WORKERS) {
        const worker = new MatrixWorker();

        worker.addEventListener("message", (event: MessageEvent<WorkerResponse>) => {
          const resolve = resolvers[event.data.id];
          resolve(event.data.result);
          delete resolvers[event.data.id];

          workerPool.push(worker);
          processNextQuery();
        });

        workers.push(worker);
        workerPool.push(worker);
      }
    }

    function removeWorker() {
      if (workers.length > MIN_WORKERS) {
        const worker = workerPool.pop();

        if (worker && workers.includes(worker)) {
          workers.splice(workers.indexOf(worker), 1);
        }

        worker?.terminate();
      }
    }

    app.provide(matrixWorkerKey, { multiplyMatricesAsync });
  }
};

export default plugin;
Enter fullscreen mode Exit fullscreen mode

The provide/inject feature also supports TypeScript which what I have done above. Inside a injectionKeys.ts file you might see:

import type { InjectionKey } from "vue";

export const matrixWorkerKey: InjectionKey<{
  multiplyMatricesAsync: (a: number[][], b: number[][]) => Promise<number[][]>;
}> = Symbol("matrixWorker");
Enter fullscreen mode Exit fullscreen mode

The plugin achieves the following

  • Declares a function that explicitly contains the logic for creating/terminating workers.
  • Creates the Promise/Resolvers structure so that the rest of our code handles async gracefully.
  • Works with Vite's HMR server for rapid development of our worker code.

To use our plugin, we will edit our main.ts file.

import "./assets/main.css";

import { createApp } from "vue";
import App from "./App.vue";
import router from "./router";

import matrixWorkerPlugin from "./matrixWorkerPlugin";

const app = createApp(App);

app.use(router);
app.use(matrixWorkerPlugin);

app.mount("#app");
Enter fullscreen mode Exit fullscreen mode

Now when you test your app, you should be able to open the devtools and see that a worker has spawned and is waiting for messages. You can see this in the Sources tab.

Composition API

Next, we will create a composable which will allow us to reuse the worker code and combine the worker's async logic with Vue 3 reactivity.

import type { MaybeRef } from 'vue'

import { ref, inject, watch } from 'vue'
import { matrixWorkerKey } from '../injectionKeys'

export const useMatrixWorker = (_a: MaybeRef<number[][]>, _b: MaybeRef<number[][]>) => {
  const a = ref(_a)
  const b = ref(_b)

  const result = ref<number[][] | null>(null)
  const fetching = ref(false)

  const matrixWorker = inject(matrixWorkerKey)

  watch(
    [a, b],
    async () => {
      fetching.value = true

      if (matrixWorker) {
        const rawA = JSON.parse(JSON.stringify(a.value))
        const rawB = JSON.parse(JSON.stringify(b.value))

        result.value = await matrixWorker.multiplyMatricesAsync(rawA, rawB)
      } else {
        throw new DOMException(
          'Worker is not defined. Check that you have properly installed the worker plugin.'
        )
      }

      fetching.value = false
    },
    { immediate: true }
  )

  return {
    result,
    fetching
  }
}
Enter fullscreen mode Exit fullscreen mode

A few things to check while making sure that our composable follows Vue 3 recommendations:

  • Filename and export begins with the word "use".
  • Function parameters are MaybeRefs, allowing for optional reactivity.
  • Function returns an object literal containing reactive objects.

This allows us to quickly add reactive instances of our worker to various components.

<script setup lang="ts">
import { useMatrixWorker } from '../composables/useMatrixWorker'
// ... delcare a and b variables as either refs or static values
const { result, fetching } = useMatrixWorker(a, b)
</script>

<template>
  <div v-if="fetching">Loading...</div>
  <div v-else-if="result">{{ result }}</div>
</template>
Enter fullscreen mode Exit fullscreen mode

Initially, the result will be null. However, wrapping the result in ref() will maintain reactivity as the value updates with new results from the worker.

You should now have a structured and effective web worker implementation in your Vue.js app. To recap we have:

  • Create basic Web Worker file for matrix multiplication.
  • Develop Vue 3 plugin to manage worker pool.
  • Implement worker creation and message queuing in plugin.
  • Create composable to integrate worker with Vue reactivity.
  • Use composable in Vue components for easy worker integration.

Top comments (0)