⏱️

フロントエンドの重い処理に対してプログレスバーを実装する with Web Worker

2023/12/09に公開

こんにちは、AIShift フロントエンドエンジニアの栗崎(@KK_sep_TT)です。
本記事はAIShift Advent Calendar 2023の 9 日目の記事になります。

フロントエンドで処理する重い処理

みなさんはフロントエンドで重い処理を書いたことがありますか?重い処理の多くはバックエンドで実行されるのであまりフロントで重い処理を書くことはないかもしれません。
しかしツール系のアプリや描画計算を行う必要があるようなWebアプリを作るときは重い処理をフロントで実行することがあります。(例えば計算機のアプリでは計算をフロントで行うこともあると思います)

フロントで重い処理を行うメリットはいくつかあります。

  • 通信が発生しないので高速にユーザに結果を届けられる
  • バックエンドサーバを用意しなくていい
  • ローカルで完結するのでセキュリティについて気にすることが少ない

最近のモダンブラウザは高度に最適化されたJavaScriptエンジンを備えており、JSでもかなりの速度が出ます。フロントのみで完結する処理はフロントで行ってしまった方がパフォーマンス良いケースも結構あると思います。(懸念点としてはフロントで実行するとユーザの端末のスペックに速度が依存してしまうという問題があります。)
さらに速度を求める場合、Web Assembly を使うという手段もあります。

重い処理中はプログレスバーを表示したい

重い処理中はユーザを待たせることになるので、処理の進捗をプログレスバーで表示したいです。本記事では以下のようなプログレスバーを実装します。

実装の壁

JSで重い処理をすると画面が固まってしまうという問題点があります。
これはJSが基本的にはシングルスレッドで動いているからです。普通にJSに処理を書くとそれはJSのメインスレッドで実行されます。JSのメインスレッドはページ内のすべてのJSを実行してUIの描画を行っており、忙しいスレッドです。このスレッドの中で重い処理を実行すると処理中はUIの描画を行うことができず、画面が固まってしまいます。これはUX的に良くないのはもちろん、本記事の目的であるプログレスバーの表示も不可能になってしまいます。
そこでWeb Workerの出番です。今回はWeb Workerを使って重い処理の中で進捗を表示するプログレスバーを実装します。

Web Worker とは

Web WorkerはJSのメインスレッドとは別のスレッドで実行されます。そのため、UIをブロックすることなく重い処理を実行できます。
https://developer.mozilla.org/ja/docs/Web/API/Web_Workers_API/Using_web_workers

実装

今回はReactで実装していきます。
簡易的に重いwhileループの関数を用意しました。ボタンがクリックされたときにheavyFunctionが実行されます。このままだと、画面が固まってしまい、プログレスバーを表示することができません。なのでこの処理をWeb Workerに切り出してプログレスバーを実装していきます。

App.tsx
import { useState } from "react";
import "./App.css";

function App() {
  // 重い処理の関数
  const heavyFunction = () => {
    let i = 0;
    while (i < 1000000000) i++;
  };

  return (
    <>
      <button onClick={heavyFunction}>run</button>
    </>
  );
}

export default App;

完成コード

いきなりですが、以下がWeb Workerによるプログレスバーの実装コードです。それぞれの詳細について説明していきます。

example.worker.ts
self.addEventListener("message", (e) => {
  const num = 1000000000;

  let i = 0;
  while (i < num) {
    i++;
    if (i % (num / 10) === 0)
      self.postMessage({
        type: "inProgress",
        payload: (i / num) * 100,
      });
  }

  self.postMessage({
    type: "complete",
    payload: i, // 本来は計算結果を返す
  });
});

App.ts
import { useEffect, useRef, useState } from "react";
import "./App.css";

function App() {
  const workerRef = useRef<Worker>();
  const [progressValue, setProgressValue] = useState(0);
  const [isInProgress, setIsInProgress] = useState(false);

  useEffect(() => {
    workerRef.current = new Worker(
      new URL("./workers/example.worker", import.meta.url)
    );

    workerRef.current.onmessage = function (event) {
      if (event.data.type === "inProgress") {
        setProgressValue(event.data.payload);
      }

      if (event.data.type === "complete") {
        setIsInProgress(false);
      }
    };

    return () => {
      workerRef.current?.terminate();
    };
  }, []);

  const heavyFunction = () => {
    setIsInProgress(true);
    setProgressValue(0);
    workerRef.current?.postMessage(10);
  };

  return (
    <>
      <button onClick={heavyFunction}>run</button>
      <div>{isInProgress && <progress value={progressValue} max="100" />}</div>
    </>
  );
}

export default App;

Web Worker側

Web Workerの中に重い処理を書いています。プログレスバー表示のために、進捗をメインスレッドに送信する必要があります。メインスレッドとのやり取りはmessageを介して行います。postMessage()apiを呼ぶことで任意のデータをメインスレッドに送ることができます。whileループの中でpostMessge()apiを呼んで進捗を伝えています。ここではtypeに"inProgress", payload に進捗度を入れています。typeを設定しているのはこのメッセージが進捗を表すものであることを分かるようにするためです。メッセージで送るデータが1種類でれば、このtypeは不要ですが、複数のタイプのメッセージを送るときは判別のためにtypeのようなパラメータが必要になります。
処理の最後には完了したことを伝えるためにtype: "complete"のメッセージを送信しています。payloadには今回はiを設定していますが、本来は重い処理の計算結果をここに入れることになると思います。

example.worker.ts
self.addEventListener("message", (e) => {
  const num = 1000000000;

  let i = 0;
  while (i < num) {
    i++;
    if (i % (num / 10) === 0)
      self.postMessage({
        type: "inProgress",
        payload: (i / num) * 100,
      });
  }

  self.postMessage({
    type: "complete",
    payload: i, // 本来は計算結果を返す
  });
});

メインスレッド(UI)側

重要なのは以下のworkerRef.current.onmessageの中の処理です。onmessageはWorker側がメッセージを送信したときに呼ばれるコールバックです。引数のevent.dataからメッセージの内容を受け取ることができます。先ほどinProgressタイプのメッセージとcompleteタイプの2種類のメッセージをWorkerで送信するようにしたので、メッセージを受け取るときにtypeによって処理を分岐しています。typeinProgressのときは進捗値を更新してプログレスバーの表示を更新します。

App.ts
...
    workerRef.current.onmessage = function (event) {
      if (event.data.type === "inProgress") {
        setProgressValue(event.data.payload);
      }

      if (event.data.type === "complete") {
        setIsInProgress(false);
      }
    };

    return () => {
      workerRef.current?.terminate();
    };
  }, []);
...

まとめ

今回はWeb Workerを用いてプログレスバーを実装しました。Web Workerを使うことでUIロックの問題を回避してプログレスバーを更新することができます。重い処理を実行するときにはユーザを不安にさせないためにも進捗を表示することはUX改善の有力な手法だと思います。簡単に実装できるので、フロントで重い処理を実行する必要があるときにはぜひWeb Workerを使ったプログレスバーを導入してみてください!

AI Shift Tech Blog

Discussion