Next.jsでWeb WorkerとComlinkを使う。JavaScriptとTypeScriptに対応。

Next.js で Web Worker または Comlink を使えるようにしましょう。これで思い処理により画面が遅くならないようにできます。コードは Javascript と TypeScript に対応しています。 コードはこちらにあります。

Workers

Image by 272447 from Pixabay

Web アプリケーションのパフォーマンス改善

Web Worker は Web コンテンツがプログラムをバックグラウンドのスレッドで実行させることができる手段です。JavaScript はメインスレッドが1つだけであり、その中で重い処理が動くと画面がフリーズして、ユーザエクスペリエンスが極端に落ちてしまいます。それを回避するためにはいくつか手段があります。

  • 重い処理のロジックを改善する。
  • 重い処理の結果をキャッシュする。
  • 重い処理をサーバサイドで実施する。
  • React や Vue などで再レンダリングする箇所を絞る。

これ以外にもクライアントサイドのスペックを上げるなどの方法もありますが、実際にアプリケーションのパフォーマンス改善を経験した方はお分かりのように限界があります。どうしても処理が速くならなかったり、アプリケーション自体の作り直しが視野に入ったりもして、解決に対するコスト感が大きくなります。その中で現状維持という選択を取らざるを得ないこともあります。

それらとは別の方面での解決策が、Web WorkerまたはWebAssemblyを利用するということになります。

どちらの方法もメインスレッドとは別のスレッドを使うものです。

WebAssembly は最近のブラウザで利用できるようになった新しい種類のコードの実行環境です。最大のメリットはネイティブに近いパフォーマンスで動作する低レベルなアッセンブリ言語を扱えることです。これは Python にとっての CPython のようなものです。Python はよく他の言語に比べて遅いと言われることもあります。その解決策として C 言語でコンパイルされたコードを呼び出すという手法があります。この手法自体はかなりの効果が期待できますが、C 言語に精通するという必要もあります。 さて WebAssembly はもちろん C 言語も使えますが、同様に Rust や TypeScript に近い AssemblyScript も利用可能であるなど比較的導入がしやすくなっています。

今回、この記事では Web Worker について詳しく見ていくことにします。

Web Worker

Web Worker が動作する Worker スレッドはユーザインタフェースの動作を妨げることなく、タスクを実行することができます。Worker とメインスレッドとの連絡手段はメッセージを送ることです。メインスレッドとそこで生成された Worker はイベントハンドラーを通じてお互いに連絡を取り合います。

また別スレッドあるため、Worker のコードはメインとは別のファイルに記載され、ファイル名は.worker.jsまたは.worker.tsという形式になります。

  1. メインスレッドでは別ファイル(../worker/test.worker.js)に定義された Worker を生成し、メッセージを送ります。

    var myWorker = new Worker("../worker/test.worker.js");
    myWorker.postMessage([10, 33]);
  2. Worker では onmessage イベントハンドラーで、メインスレッドからのメッセージを受け取り、何かの処理をしてから postMessage でメインスレッドにメッセージを返信します。

    onmessage = function (e) {
      console.log("Message received from main");
      var workerResult = "Result: " + e.data[0] * e.data[1];
    
      console.log("Posting message back to main");
      postMessage(workerResult);
    };
  3. メインスレッドでは事前に onmessage ハンドラを定義しておくと、Worker が送ったメッセージを受信することができます。

    myWorker.onmessage = function (e) {
      result.textContent = e.data; // It would be 'Result: 330'
      console.log("Message received from worker");
    };
  4. Worker が不要になった時(画面の切り替えなど)には次のコードで即座に Worker スレッドが終了します。

    myWorker.terminate();

ここまでの内容が Web Worker についての簡単な説明になります。他にも SharedWorker や ServiceWorker など便利なクラスも用意されています。詳しくはこちらを参考にしてください。

Comlink

Web Worker は非常に便利で強力ではありますが、メッセージングの仕組みだけで複雑なアプリケーションを作ろうとすると、メインと Worker の両方でメッセージに基づいた状態を管理する必要ができてしまいます。これはコストが高いものになります。

このような問題を解決するために MessageChannel と postMessage を抽象化するための軽量なライブラリとして開発されたのが Comlink です。

Comlink は Web Worker を通常のオブジェクトと同じように扱えるようにしました。 具体的な例を見てみましょう。

  1. Worker で処理を定義する。初期値が0、1ずつ増加させるメソッドだけを持つオブジェクトを定義し、Comlink を通じて expose します。このファイルをworker.jsとします。

    import * as Comlink from "comlink";
    
    const obj = {
      counter: 0,
      inc() {
        this.counter++;
      },
    };
    
    Comlink.expose(obj);
  2. メインスレッドで Worker を生成し、Comlink でラップします。そして Worker の現在のカウントを確認した後に、増加させてその値を確認します。

    import * as Comlink from "comlink";
    async function init() {
      const worker = new Worker("worker.js");
      // WebWorkers use `postMessage` and therefore work with Comlink.
      const obj = Comlink.wrap(worker);
      alert(`Counter: ${await obj.counter}`);
      await obj.inc(); // increase the count.
      alert(`Counter: ${await obj.counter}`);
    }
    init();

この例から分かることは、Web Worker を通常のクラスのように扱えるようになったことです。 これであれば、実際のプロジェクトに導入するハードルも下がります。

それでは続いては Next.js のプロジェクトに Web Worker と Comlink を導入してみましょう。

Next.js に Web Worker を導入する

Web Worker を導入する方法としてはNext.js 公式の例を使います。

これは Next.js にworker-pluginを導入することです。

では実際に始めてみましょう。

  1. 新しくプロジェクトを作成する。

    • Javascript

      npx create-next-app
      # or
      yarn create next-app
    • TypeScript

      npx create-next-app --example with-typescript
      # or
      yarn create next-app --example with-typescript
  2. worker-pluginをインストールします。

    npm install worker-plugin
    # or
    yarn add worker-plugin
  3. ルートにあるnext.config.jsに次のコードを記載します。selfという変数を Global で利用できるようにします。

    const WorkerPlugin = require("worker-plugin");
    
    module.exports = {
      webpack: (config, { buildId, dev, isServer, defaultLoaders, webpack }) => {
        if (!isServer) {
          config.plugins.push(
            new WorkerPlugin({
              // use "self" as the global object when receiving hot updates.
              globalObject: "self",
            })
          );
        }
        return config;
      },
    };
  4. TypeScript を使う場合は、tsconfig.jsonlibswebworkerを追加してください。これにより Web Worker の記法を正しく認識してくれます。

     {
         "compilerOptions": {
             :
             "lib": ["dom", "es2017", "webworker"],
             :
         }
     }
  5. Worker をworkers/standard.worker.jsまたはworkers/standard.worker.tsに定義します。ここでは円周率を計算するメソッドを呼び出しています。

    import pi from "../utils/pi";
    
    addEventListener("message", (event) => {
      console.log("worker event message", event.target, event.type);
      postMessage(pi(event.data));
    });

    or

    import pi from "../utils/pi";
    
    addEventListener("message", (event: MessageEvent) => {
      console.log("worker event message", event.target, event.type);
      postMessage(pi(event.data));
    });

1.pages/index.jsxまたはpages/index.tsxに Worker を呼び出すコードを記載します。イベント処理に関することなので、useRefフックを用いて、worker を定義します。続いて、Worker を生成します。引数として、パスとモジュールとして読み込むというオプションを指定します。各種イベントを定義すれば完了です。

// for standard
const [latestMessage, setLatestMessage] = React.useState("");
const workerRef = React.useRef<Worker>();

React.useEffect(() => {
  // Standard worker
  workerRef.current = new Worker("../workers/standard.worker", {
    type: "module",
  });
  workerRef.current.onmessage = (evt) =>
    setLatestMessage(`WebWorker Response => ${evt.data}`);

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

const handleWork = React.useCallback(async () => {
  workerRef.current?.postMessage(100000);
}, []);

Next.js に Comlink を導入する

  1. Comlink をインストールします。

    npm install comlink
    # or
    yarn add comlink
  2. worker-pluginのインストール、next.config.jsの設定、TypeScript を使う場合のtsconfig.jsonの設定は Web Worker の場合と同様です。

  3. workers/comlink.worker.tsに Worker を作成します。ここでは非同期処理として、ランダムな単語を取得する APIを呼んでいます。オブジェクトを定義して、Comlink 経由で expose しているだけです。

    import * as Comlink from "comlink";
    
    export interface WorkerApi {
      getName: typeof getName;
    }
    
    const workerApi: WorkerApi = {
      getName,
    };
    
    async function getName() {
      const res = await fetch(
        "https://random-word-api.herokuapp.com/word?number=1"
      );
      const json = await res.json();
      return json[0];
    }
    
    Comlink.expose(workerApi);

    or

    import * as Comlink from "comlink";
    
    const workerApi = {
      getName,
    };
    
    async function getName() {
      const res = await fetch(
        "https://random-word-api.herokuapp.com/word?number=1"
      );
      const json = await res.json();
      return json[0];
    }
    
    Comlink.expose(workerApi);
  4. pages/index.jsxまたはpages/index.tsxに Worker を呼び出すコードを記載します。ここも Web Worker の生成までは同じです。Worker を生成した後に Comlink でラップすることと Worker の利用方法が異なります。Comlink の場合はイベントではなく、通常のオブジェクトとして扱うことができます。

    // for comlink
    const [comlinkMessage, setComlinkMessage] = React.useState("");
    const comlinkWorkerRef = React.useRef<Worker>();
    const comlinkWorkerApiRef = React.useRef<Comlink.Remote<WorkerApi>>();
    
    React.useEffect(() => {
      // Comlink worker
      comlinkWorkerRef.current = new Worker("../workers/comlink.worker", {
        type: "module",
      });
      comlinkWorkerApiRef.current = Comlink.wrap<WorkerApi>(
        comlinkWorkerRef.current
      );
      return () => {
        comlinkWorkerRef.current?.terminate();
      };
    }, []);
    
    const handleComlinkWork = async () => {
      const msg = await comlinkWorkerApiRef.current?.getName();
      setComlinkMessage(`Comlink response => ${msg}`);
    };

    or

    // for comlink
    const [comlinkMessage, setComlinkMessage] = React.useState("");
    const comlinkWorkerRef = React.useRef();
    const comlinkWorkerApiRef = React.useRef();
    
    React.useEffect(() => {
      // Comlink worker
      comlinkWorkerRef.current = new Worker("../workers/comlink.worker", {
        type: "module",
      });
      comlinkWorkerApiRef.current = Comlink.wrap(comlinkWorkerRef.current);
      return () => {
        comlinkWorkerRef.current.terminate();
      };
    }, []);
    
    const handleComlinkWork = async () => {
      const msg = await comlinkWorkerApiRef.current.getName();
      setComlinkMessage(`Comlink response => ${msg}`);
    };

最後に

コードはこちらにあります。

簡単な説明でしたが、Next.js や React など複雑なプラットフォーム上に組みあがっているパッケージ上で Web Worker を導入しようとすると色々と問題が起きると思います。その部分だけでも読んでくれた方が回避できればいいなと思います。

それでは!

Updated at: Wed Jul 01 2020

© 2020-presentTerms|Privacy