Claude CodeでWeb Workerを実装する実践ガイド
Claude Codeで重い処理をWeb Workerへ逃がす設計、React/Vite実装、型付き通信、検証手順を解説。
ブラウザでCSV集計、画像処理、検索インデックス作成、ログ解析、大きなJSON変換をそのまま実行すると、画面の入力やスクロールが止まります。初心者が最初に迷うのは「処理を速くすること」だけを考えてしまう点です。実務では、速さよりも「UIを止めないこと」「メインスレッドとWorkerの責務を分けること」「Claude Codeに曖昧な実装をさせないこと」が重要です。
Web Workerは、JavaScriptの重い処理を別スレッドに逃がすためのブラウザ機能です。別スレッドとは、画面描画やクリック処理を担うメインの実行場所とは別の作業場所、という意味です。DOM、つまり画面上のHTML要素にはWorkerから直接触れません。Workerはデータを受け取り、計算し、結果を返す係に徹します。
この記事では、Vite + React + TypeScriptでそのまま使えるWorker実装を作ります。公式仕様はMDNのWeb Workers APIとMDNのTransferable objectsを参照してください。Viteでの読み込み方はVite公式のWorker説明、Claude Codeの基本はClaude Code公式ドキュメントを確認します。Claude Codeの作業ルールは内部記事のCLAUDE.mdベストプラクティスも合わせて読むと、指示の粒度を揃えやすくなります。
全体像
今回の設計は、メインスレッドが画面とユーザー操作を担当し、WorkerがCPU負荷の高い変換だけを担当する形にします。Claude Codeに依頼するときも、この分担を最初に書きます。
flowchart LR
UI["React UI"]
Hook["useDataWorker hook"]
Worker["data.worker.ts"]
Tasks["CSV / image / search / logs / JSON"]
UI --> Hook
Hook --> Worker
Worker --> Tasks
Worker --> Hook
Hook --> UI
この図のポイントは、WorkerがUIを知らないことです。Workerはボタン、フォーム、CSS、ルーティング、トースト通知を扱いません。入力データと処理種別を受け取り、型の決まった結果を返します。この境界を守ると、Claude Codeが勝手にDOM操作を書いたり、React stateをWorker側に持ち込んだりする事故が減ります。
使いどころは少なくとも5つあります。
| ユースケース | Workerに渡すもの | 返すもの |
|---|---|---|
| CSV集計 | CSV文字列 | 行数、列名、数値列の平均や最大値 |
| 画像処理 | ImageDataのバッファ | グレースケール化したバッファ |
| 検索インデックス | ドキュメント配列 | トークン化された軽量インデックス |
| ログ解析 | ログ文字列 | エラー数、警告数、上位メッセージ |
| 重いJSON変換 | ネストしたJSON | フラットなキーと値 |
逆に、軽いフィルタ、数十件の並び替え、1回だけの小さな変換にWorkerを使う必要はありません。Workerの起動、メッセージ送受信、データコピーにもコストがあります。Claude Codeには「まず本当にWorkerが必要な処理だけを分離して」と伝えるのが現実的です。
Claude Codeへの実装指示
最初のプロンプトは、成果物、禁止事項、検証方法をまとめます。ここを曖昧にすると、Workerの中でDOMを触るコードや、anyだらけの通信レイヤーが出やすくなります。
Vite + React + TypeScriptの既存画面にWeb Workerを追加してください。
対象:
- src/workers/worker-protocol.ts
- src/workers/data.worker.ts
- src/hooks/useDataWorker.ts
- src/components/WorkerDemo.tsx
要件:
- WorkerはCSV集計、画像グレースケール、検索インデックス、ログ解析、JSON flattenを扱う
- メッセージはTypeScriptのunion typeで定義する
- React側はhookでWorkerを生成し、unmount時にterminateする
- ImageDataのArrayBufferはtransferableとして渡す
- Worker内ではDOM、window、document、React stateに触れない
- Playwrightまたは手動検査の手順を追加する
確認:
- npm run typecheck
- npm run test
- npm run devでUIが固まらないこと
この指示では「作って」だけではなく、「何を作らないか」も書いています。Claude Codeは便利ですが、責務境界を渡さないと、ブラウザAPIの制約を無視したもっともらしいコードを出すことがあります。特にWorkerでは window や document が使えないため、禁止事項を先に書くのが効果的です。
型付きメッセージを作る
まず、メインスレッドとWorkerの契約を1ファイルに集めます。payload: anyで始めると、あとから処理が増えたときに型崩れが起きます。ここでは処理種別ごとに入力と出力を分けます。
// src/workers/worker-protocol.ts
export type CsvSummary = {
rows: number;
columns: string[];
numeric: Record<string, { count: number; average: number; max: number }>;
};
export type ImageResult = {
width: number;
height: number;
buffer: ArrayBuffer;
};
export type SearchDocument = {
id: string;
title: string;
body: string;
};
export type SearchIndex = {
documents: number;
tokens: Record<string, string[]>;
};
export type LogSummary = {
lines: number;
errors: number;
warnings: number;
topMessages: string[];
};
export type JsonFlatResult = Record<string, string | number | boolean | null>;
export type WorkerJob =
| { type: "csv:summary"; text: string; delimiter?: "," | "\t" }
| { type: "image:grayscale"; width: number; height: number; buffer: ArrayBuffer }
| { type: "search:index"; documents: SearchDocument[] }
| { type: "log:summary"; text: string }
| { type: "json:flatten"; value: unknown };
export type WorkerResultMap = {
"csv:summary": CsvSummary;
"image:grayscale": ImageResult;
"search:index": SearchIndex;
"log:summary": LogSummary;
"json:flatten": JsonFlatResult;
};
export type WorkerRequest = {
id: string;
job: WorkerJob;
};
export type WorkerResponse<T = unknown> =
| { id: string; ok: true; result: T }
| { id: string; ok: false; error: string };
このファイルはClaude Codeへのレビューでも中心になります。「新しい処理を足すときは WorkerJob と WorkerResultMap を更新する」と決めておけば、通信仕様が散らばりません。初心者ほど、Worker本体よりこの契約ファイルを丁寧に作るべきです。
Worker本体を実装する
次にWorker側です。CSV、画像、検索、ログ、JSONを1つのWorkerで処理しています。実務では処理が大きくなったら分割して構いませんが、最初は「UIから切り離す」ことを優先します。
// src/workers/data.worker.ts
import type {
CsvSummary,
ImageResult,
JsonFlatResult,
LogSummary,
SearchIndex,
WorkerRequest,
WorkerResponse,
} from "./worker-protocol";
const workerScope = self as unknown as DedicatedWorkerGlobalScope;
workerScope.onmessage = (event: MessageEvent<WorkerRequest>) => {
const { id, job } = event.data;
try {
const result = runJob(job);
const response: WorkerResponse = { id, ok: true, result };
const transfer = resultHasBuffer(result) ? [result.buffer] : [];
workerScope.postMessage(response, transfer);
} catch (error) {
const message = error instanceof Error ? error.message : "Worker failed";
workerScope.postMessage({ id, ok: false, error: message } satisfies WorkerResponse);
}
};
function runJob(job: WorkerRequest["job"]) {
switch (job.type) {
case "csv:summary":
return summarizeCsv(job.text, job.delimiter ?? ",");
case "image:grayscale":
return grayscale(job.width, job.height, job.buffer);
case "search:index":
return buildSearchIndex(job.documents);
case "log:summary":
return summarizeLogs(job.text);
case "json:flatten":
return flattenJson(job.value);
default:
return assertNever(job);
}
}
function summarizeCsv(text: string, delimiter: "," | "\t"): CsvSummary {
const rows = text.trim().split(/\r?\n/).filter(Boolean);
const headers = rows.shift()?.split(delimiter).map((cell) => cell.trim()) ?? [];
const numeric: CsvSummary["numeric"] = {};
for (const row of rows) {
row.split(delimiter).forEach((cell, index) => {
const key = headers[index] ?? `column_${index + 1}`;
const value = Number(cell);
if (!Number.isFinite(value)) return;
const current = numeric[key] ?? { count: 0, average: 0, max: Number.NEGATIVE_INFINITY };
current.count += 1;
current.average += (value - current.average) / current.count;
current.max = Math.max(current.max, value);
numeric[key] = current;
});
}
return { rows: rows.length, columns: headers, numeric };
}
function grayscale(width: number, height: number, buffer: ArrayBuffer): ImageResult {
const pixels = new Uint8ClampedArray(buffer);
for (let index = 0; index < pixels.length; index += 4) {
const gray = Math.round(pixels[index] * 0.299 + pixels[index + 1] * 0.587 + pixels[index + 2] * 0.114);
pixels[index] = gray;
pixels[index + 1] = gray;
pixels[index + 2] = gray;
}
return { width, height, buffer: pixels.buffer };
}
function buildSearchIndex(documents: Array<{ id: string; title: string; body: string }>): SearchIndex {
const tokens: SearchIndex["tokens"] = {};
for (const document of documents) {
const words = `${document.title} ${document.body}`
.toLowerCase()
.split(/[^a-z0-9]+/g)
.filter((word) => word.length >= 3);
for (const word of new Set(words)) {
tokens[word] = [...(tokens[word] ?? []), document.id];
}
}
return { documents: documents.length, tokens };
}
function summarizeLogs(text: string): LogSummary {
const lines = text.split(/\r?\n/).filter(Boolean);
const counts = new Map<string, number>();
let errors = 0;
let warnings = 0;
for (const line of lines) {
if (/\berror\b/i.test(line)) errors += 1;
if (/\bwarn(ing)?\b/i.test(line)) warnings += 1;
const normalized = line.replace(/\d{4}-\d{2}-\d{2}[^\s]*/g, "").replace(/\s+/g, " ").trim();
counts.set(normalized, (counts.get(normalized) ?? 0) + 1);
}
const topMessages = [...counts.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, 5)
.map(([message, count]) => `${count}x ${message}`);
return { lines: lines.length, errors, warnings, topMessages };
}
function flattenJson(value: unknown, prefix = ""): JsonFlatResult {
if (value === null || typeof value !== "object") {
return { [prefix || "value"]: value as string | number | boolean | null };
}
return Object.entries(value as Record<string, unknown>).reduce<JsonFlatResult>((acc, [key, child]) => {
const path = prefix ? `${prefix}.${key}` : key;
return { ...acc, ...flattenJson(child, path) };
}, {});
}
function resultHasBuffer(result: unknown): result is ImageResult {
return typeof result === "object" && result !== null && "buffer" in result && result.buffer instanceof ArrayBuffer;
}
function assertNever(value: never): never {
throw new Error(`Unsupported worker job: ${JSON.stringify(value)}`);
}
ここで重要なのは、画像処理だけ ArrayBuffer をtransferableとして返している点です。transferableは、コピーではなく所有権の移動に近い仕組みです。大きな画像や巨大なバイナリをコピーすると、それだけでUIが重くなります。移動したあとの元バッファは使えないため、メイン側で再利用しようとしない設計にします。
React hookで生成とcleanupを管理する
React側では、Workerの生成、待機中のPromise、エラー、終了処理をhookに閉じ込めます。コンポーネントごとに new Worker() を直接書くと、画面遷移後もWorkerが残る「終了漏れ」が起きやすくなります。
// src/hooks/useDataWorker.ts
import { useCallback, useEffect, useRef } from "react";
import type { WorkerJob, WorkerRequest, WorkerResponse } from "../workers/worker-protocol";
type PendingJob = {
resolve: (value: unknown) => void;
reject: (error: Error) => void;
};
export function useDataWorker() {
const workerRef = useRef<Worker | null>(null);
const pendingRef = useRef(new Map<string, PendingJob>());
const nextIdRef = useRef(0);
useEffect(() => {
const worker = new Worker(new URL("../workers/data.worker.ts", import.meta.url), {
type: "module",
});
workerRef.current = worker;
worker.onmessage = (event: MessageEvent<WorkerResponse>) => {
const response = event.data;
const pending = pendingRef.current.get(response.id);
if (!pending) return;
pendingRef.current.delete(response.id);
if (response.ok) {
pending.resolve(response.result);
} else {
pending.reject(new Error(response.error));
}
};
worker.onerror = (event) => {
for (const pending of pendingRef.current.values()) {
pending.reject(new Error(event.message));
}
pendingRef.current.clear();
};
return () => {
for (const pending of pendingRef.current.values()) {
pending.reject(new Error("Worker was terminated"));
}
pendingRef.current.clear();
worker.terminate();
workerRef.current = null;
};
}, []);
const runJob = useCallback(<T,>(job: WorkerJob, transfer: Transferable[] = []) => {
const worker = workerRef.current;
if (!worker) return Promise.reject(new Error("Worker is not ready"));
const id = `worker-job-${Date.now()}-${nextIdRef.current}`;
nextIdRef.current += 1;
return new Promise<T>((resolve, reject) => {
pendingRef.current.set(id, {
resolve: resolve as (value: unknown) => void,
reject,
});
worker.postMessage({ id, job } satisfies WorkerRequest, transfer);
});
}, []);
return { runJob };
}
このhookは素の postMessage を使っています。Comlinkを使う選択肢もありますが、初心者がClaude Codeにレビューさせるなら、まずはメッセージの流れが見える実装のほうが学びやすいです。RPC風にしたくなったらComlinkのREADMEを確認し、同じ責務分離を保ったまま置き換えます。
UIから5種類の処理を呼ぶ
最後にReactコンポーネントです。CSV、検索、ログ、JSONは文字列や配列を渡します。画像はCanvasから ImageData を取り出し、バッファをtransferableとしてWorkerに渡します。
// src/components/WorkerDemo.tsx
import { useRef, useState } from "react";
import { useDataWorker } from "../hooks/useDataWorker";
import type { CsvSummary, ImageResult, LogSummary, SearchIndex } from "../workers/worker-protocol";
const sampleCsv = `team,score,cost
alpha,91,1200
beta,84,950
gamma,96,1430`;
const sampleLogs = `2026-06-02T10:00:00Z INFO started
2026-06-02T10:01:00Z WARN cache miss
2026-06-02T10:02:00Z ERROR payment retry failed
2026-06-02T10:03:00Z ERROR payment retry failed`;
export function WorkerDemo() {
const { runJob } = useDataWorker();
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const [message, setMessage] = useState("Idle");
async function handleCsv() {
const summary = await runJob<CsvSummary>({ type: "csv:summary", text: sampleCsv });
setMessage(`CSV rows: ${summary.rows}, score average: ${summary.numeric.score.average.toFixed(1)}`);
}
async function handleSearch() {
const index = await runJob<SearchIndex>({
type: "search:index",
documents: [
{ id: "a", title: "CSV reports", body: "Aggregate revenue and cost columns" },
{ id: "b", title: "Log monitor", body: "Find warning and error messages quickly" },
],
});
setMessage(`Search index tokens: ${Object.keys(index.tokens).length}`);
}
async function handleLogs() {
const summary = await runJob<LogSummary>({ type: "log:summary", text: sampleLogs });
setMessage(`Errors: ${summary.errors}, warnings: ${summary.warnings}`);
}
async function handleImage() {
const canvas = canvasRef.current;
const context = canvas?.getContext("2d");
if (!canvas || !context) return;
context.fillStyle = "#2f80ed";
context.fillRect(0, 0, canvas.width, canvas.height);
context.fillStyle = "#f2994a";
context.fillRect(20, 20, 80, 80);
const imageData = context.getImageData(0, 0, canvas.width, canvas.height);
const buffer = imageData.data.buffer as ArrayBuffer;
const result = await runJob<ImageResult>(
{ type: "image:grayscale", width: imageData.width, height: imageData.height, buffer },
[buffer],
);
context.putImageData(
new ImageData(new Uint8ClampedArray(result.buffer), result.width, result.height),
0,
0,
);
setMessage("Image converted in worker");
}
async function handleJson() {
const result = await runJob<Record<string, unknown>>({
type: "json:flatten",
value: { user: { id: 1, plan: "pro" }, flags: { beta: true } },
});
setMessage(`Flattened keys: ${Object.keys(result).join(", ")}`);
}
return (
<section>
<div>
<button onClick={handleCsv}>Summarize CSV</button>
<button onClick={handleSearch}>Build search index</button>
<button onClick={handleLogs}>Analyze logs</button>
<button onClick={handleImage}>Process image</button>
<button onClick={handleJson}>Flatten JSON</button>
</div>
<p role="status">Worker finished: {message}</p>
<canvas ref={canvasRef} width={160} height={120} aria-label="Image processing preview" />
</section>
);
}
このUIは派手ではありませんが、検証には十分です。大切なのは、ボタンを押したあともステータス表示、入力、スクロールが固まらないことです。大量データで試す場合は、CSVなら数万行、ログなら数MB、検索なら数千ドキュメントから始めると差が見えやすくなります。
Playwrightと手動検査
Workerは「型が通る」だけでは不十分です。UIが固まらないか、結果が返るか、cleanupされるかを確認します。Playwrightの例はアプリに合わせてセレクタを調整してください。
// tests/worker-demo.spec.ts
import { expect, test } from "@playwright/test";
test("heavy worker jobs finish without blocking the page", async ({ page }) => {
await page.goto("http://localhost:5173/");
await page.getByRole("button", { name: "Summarize CSV" }).click();
await expect(page.getByRole("status")).toContainText("CSV rows");
await page.getByRole("button", { name: "Build search index" }).click();
await expect(page.getByRole("status")).toContainText("Search index tokens");
await page.getByRole("button", { name: "Analyze logs" }).click();
await expect(page.getByRole("status")).toContainText("Errors:");
});
Manual inspection checklist:
1. Open Chrome DevTools Performance tab.
2. Click each worker button with a large sample.
3. Confirm typing and scrolling still respond while the worker runs.
4. Navigate away from the component and confirm no new Worker remains.
5. Check that image processing transfers an ArrayBuffer and does not reuse the detached buffer.
Claude Codeには最後にレビューも依頼します。実装直後のレビュー指示は短く、観点を絞ります。
Review only the Web Worker implementation.
Find:
- code that touches DOM, window, or React state inside the worker
- untyped postMessage payloads
- missing terminate cleanup
- incorrect transferable usage
- bundler paths that fail in Vite
- responsibilities that should stay on the main thread
Return file paths, line numbers, and concrete fixes.
落とし穴
1つ目は、WorkerからDOMに触ろうとすることです。Workerは画面を直接更新できません。結果を返し、React側でstateを更新します。Claude Codeが document.querySelector をWorkerに書いたら、その時点で差し戻します。
2つ目は、postMessage の型崩れです。type: string と payload: any だけで始めると、処理追加のたびに壊れます。union typeで入力を固定し、戻り値も WorkerResultMap で管理します。
3つ目は、transferableの理解不足です。大きい ArrayBuffer はコピーせず移動できますが、移動後の元バッファは使えません。画像処理のあとに元の ImageData を再利用するコードは避けます。
4つ目は、Worker終了漏れです。Reactで画面を離れてもWorkerが残ると、メモリとCPUを消費し続けます。useEffect のcleanupで terminate() を呼び、待機中のPromiseもrejectします。
5つ目は、バンドラ設定です。Viteでは new Worker(new URL("../workers/data.worker.ts", import.meta.url), { type: "module" }) の形が安全です。文字列パスだけで書くと、本番ビルドやサブディレクトリ配信で壊れることがあります。
6つ目は、責務分離の失敗です。WorkerにAPI呼び出し、通知、ルーティング、UI文言まで入れると、再利用もテストも難しくなります。Workerは重い純粋処理、メインスレッドは画面と副作用、と分けます。
Claude Code Labの相談導線
個人開発なら、この記事のコードを小さな画面に貼って、CSVやログの実データで試すところから始めれば十分です。チームで使う場合は、Workerの設計だけでなく、CLAUDE.md、レビュー観点、Playwright検査、パフォーマンス計測、リリース前チェックまで揃える必要があります。
Claude Code Labでは、実際のリポジトリを題材にしたClaude Code研修・導入相談で、重いフロントエンド処理の切り出し、Worker化、レビュー指示、検証レシート作成まで一緒に整理できます。まず基礎を固めたい場合はClaude Code生産性Tipsも合わせて確認してください。
まとめと検証メモ
Web Workerは、魔法の高速化ツールではありません。UIを止める処理を、画面から切り離すための設計手段です。Claude Codeと組み合わせると実装は速くなりますが、型付きメッセージ、cleanup、transferable、責務分離を先に決めないと、見た目だけ動く危ないコードになります。
この記事で紹介した内容を実際に試した結果、CSV集計、検索インデックス、ログ解析は素の postMessage で十分扱えました。画像処理はtransferableを使うかどうかで体感が変わりやすく、ここが一番レビュー価値の高い箇所でした。公開前の確認として、コードフェンス、本文量、内部リンク、外部リンク、updatedDate を監査対象に入れています。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
無料PDFで基礎を固めたあと、すぐ使えるテンプレート集で試し、必要なら業務自動化や導入相談まで進められます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
Claude Code権限セーフティラダー: 初心者がallowを広げる順番
Claude Codeの権限をread-onlyからbuild、限定編集、deploy確認まで段階的に広げる安全な運用手順。
Claude Code Small PR Proof Pack: 小さなPRをレビュー可能にする証拠セット
Claude Codeの小さなPRに、差分・検証・公開URL・CTA・rollbackを添える実務チェックリスト。
Claude Codeのコミット前レビューゲート: 差分、テスト、CTAをまとめて止める型
Claude Codeでcommit前に差分をレビューする実践手順。build、公開URL、CTA、Gumroadリンク、未翻訳本文を検知します。