⚡

「UIが固まる」を解決!Vite + Web Workerでサクサク描画を実現

に公開

はじめに

Webアプリで重い処理を実行すると、ローディング中にUIが固まってしまう経験はないでしょうか。
JavaScriptは基本的にシングルスレッドで動作するため、CPUを占有するような処理が走ると描画やイベント処理がブロックされ、アニメーションやスクロールがカクつきます。

そこで登場するのが Web Worker。
メインスレッドとは別スレッドでJavaScriptを実行できる仕組みで、重い処理をオフロードすることでUIを滑らかに保てます。

この記事では、Vite + プレーンなJavaScriptでWeb Workerの効果を体感できるサンプルプロジェクトを作成し、メインスレッド実行との違いを比較します。


ゴール

  • メインスレッド実行 → UIが固まる様子を確認
  • Worker実行 → UIが滑らかに動く様子を確認
  • Workerの初期化、メッセージ送受信、進捗表示、キャンセル処理の基本を理解する

環境構築

npm create vite@latest web-worker-demo -- --template vanilla
cd web-worker-demo
npm install

ファイル構成

web-worker-demo/
  ├─ index.html
  ├─ style.css
  ├─ main.js
  └─ worker.js

実装

index.html

UIにはスピナー、スライダー、実行ボタンなどを用意します。

<!doctype html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Web Worker Demo</title>
    <link rel="stylesheet" href="/style.css" />
  </head>
  <body>
    <h1>Web Worker 体感デモ</h1>
    <p>回転ローダーがカクつくかで効果を体感できます。</p>

    <label>
      負荷(上限Nで素数カウント):
      <input id="inputN" type="number" value="30000000" />
    </label>

    <div>
      <button id="btnMain">メインスレッドで実行</button>
      <button id="btnWorker">Workerで実行</button>
      <button id="btnCancel" disabled>Workerキャンセル</button>
    </div>

    <div>結果: <span id="result">—</span></div>
    <div>経過時間: <span id="elapsed">—</span></div>

    <div class="spinner"></div>
    <input id="range" type="range" min="0" max="100" value="50" />

    <script type="module" src="/main.js"></script>
  </body>
</html>

style.css

シンプルなスタイルとスピナーのアニメーション。

body {
  font-family: sans-serif;
  padding: 20px;
}
.spinner {
  width: 80px;
  height: 80px;
  border-radius: 50%;
  border: 8px solid #ddd;
  border-top-color: #333;
  animation: spin 0.8s linear infinite;
  margin: 16px 0;
}
@keyframes spin {
  to {
    transform: rotate(360deg);
  }
}

main.js

メインスレッドでの処理とWorkerの呼び出し。

let worker;
let currentJobId = 0;

const $ = (id) => document.getElementById(id);

const inputN = $("inputN");
const btnMain = $("btnMain");
const btnWorker = $("btnWorker");
const btnCancel = $("btnCancel");
const elResult = $("result");
const elElapsed = $("elapsed");

function countPrimesUpTo(n) {
  let count = 0;
  for (let i = 2; i <= n; i++) {
    let prime = true;
    const limit = Math.floor(Math.sqrt(i));
    for (let d = 2; d <= limit; d++) {
      if (i % d === 0) {
        prime = false;
        break;
      }
    }
    if (prime) count++;
  }
  return count;
}

// メインスレッドで実行
btnMain.addEventListener("click", () => {
  const N = Number(inputN.value);
  const t0 = performance.now();
  const result = countPrimesUpTo(N);
  const t1 = performance.now();
  elResult.textContent = `${result.toLocaleString()} 個`;
  elElapsed.textContent = `${(t1 - t0).toFixed(1)} ms`;
});

// Worker初期化
function ensureWorker() {
  if (worker && worker._alive) return;
  if (worker) worker.terminate();
  worker = new Worker(new URL("./worker.js", import.meta.url), { type: "module" });
  worker._alive = true;

  worker.onmessage = (ev) => {
    const { type, jobId, data } = ev.data || {};
    if (type === "done" && jobId === currentJobId) {
      elResult.textContent = `${data.count.toLocaleString()} 個`;
      elElapsed.textContent = `${data.elapsedMs.toFixed(1)} ms`;
      btnCancel.disabled = true;
    }
  };
}

btnWorker.addEventListener("click", () => {
  const N = Number(inputN.value);
  ensureWorker();
  currentJobId++;
  btnCancel.disabled = false;
  elResult.textContent = "計算中…";
  elElapsed.textContent = "—";
  worker.postMessage({ type: "start", jobId: currentJobId, N });
});

btnCancel.addEventListener("click", () => {
  if (!worker) return;
  worker.postMessage({ type: "cancel" });
  btnCancel.disabled = true;
});

worker.js

Worker側で計算を実行し、結果をメインスレッドへ返します。

let cancelled = false;

self.onmessage = (ev) => {
  const { type, jobId, N } = ev.data || {};
  if (type === "start") {
    cancelled = false;
    run(jobId, N);
  } else if (type === "cancel") {
    cancelled = true;
  }
};

function countPrimesRange(a, b) {
  let count = 0;
  for (let i = Math.max(2, a); i <= b; i++) {
    let prime = true;
    const limit = Math.floor(Math.sqrt(i));
    for (let d = 2; d <= limit; d++) {
      if (i % d === 0) {
        prime = false;
        break;
      }
    }
    if (prime) count++;
  }
  return count;
}

async function run(jobId, N) {
  const t0 = performance.now();
  let count = 0;
  const chunkSize = 200000;
  const totalChunks = Math.ceil(N / chunkSize);

  for (let chunk = 0; chunk < totalChunks; chunk++) {
    if (cancelled) return;
    const start = chunk * chunkSize + 1;
    const end = Math.min((chunk + 1) * chunkSize, N);
    count += countPrimesRange(start, end);
    await new Promise((r) => setTimeout(r, 0));
  }

  const elapsedMs = performance.now() - t0;
  postMessage({ type: "done", jobId, data: { count, elapsedMs } });
}

UIフリーズの仕組み

JavaScriptはメインスレッドで処理と描画を同じイベントループで行います。
重い処理が長時間ブロックすると、描画処理も待たされてしまい、UIが固まります。


メインスレッドとWeb Workerの関係

Web Workerは別スレッドで動作し、メインスレッドとはpostMessageでデータをやり取りします。
これにより、メインスレッドは描画処理を続けながら重い計算を進められます。


実行結果

  • メインスレッドで実行
    • スピナーが止まる
    • スライダーが引っかかる
    • 完了までUIが無反応
  • Workerで実行
    • スピナーが回り続ける
    • スライダー操作が滑らか
    • 計算結果が表示されるまでUIが操作可能

実践で使う際のポイント

  • Transferableオブジェクトでコピーコスト削減
  • チャンク分割で長時間処理でもUI更新可能
  • キャンセル機能でUX改善
  • OffscreenCanvasと併用して描画も非同期化

まとめ

  • Web Workerで重い処理を別スレッドに移すとUIが滑らかに
  • Viteでは new URL('./worker.js', import.meta.url) で簡単にESM Workerを読み込める
  • 画像処理や大量データ処理などでも応用可

Discussion