Back

Reactアプリが重い本当の理由:Web Workersでサクサクにする

Reactアプリが重い本当の理由:Web Workersでサクサクにする

開発者なら分かるはず。データ処理系の機能を作ってて、大量のリストフィルタリングとか、CSVパースとか、複雑な計算とか。開発環境で100件なら一瞬で終わる。でも本番で50,000件になった途端、UI全体が3秒固まる。ユーザーがボタン押す。反応なし。もう一回押す。まだ反応なし。で、一気に全部動く。

メインスレッドがパンク寸前、ユーザーは離脱中。

これJavaScriptの宿命。シングルスレッドだから。全てのアニメーション、クリックハンドラ、APIレスポンス、重い計算がたった一つのスレッドで順番待ちしてる。重い処理がスレッド独占すると、せっかくの60fps UIが0fpsになる。ブラウザはフレーム描画も、クリック処理もできなくなる。

解決策は?Web Workers。 重い処理をメインスレッドから完全に切り離す。UIスレッドはレンダリングとユーザー対応に集中、バックグラウンドスレッドが並列で計算回す。

でも問題がある。Web Workerのチュートリアルって「2つの数字足してみよう」レベルで終わるものが多い。実際のReactで使おうとすると詰まる:

  • 複雑なデータ構造どうやって共有する?
  • TypeScriptの型は?
  • エラー処理ちゃんとやるには?
  • Workerでnpmパッケージ使える?
  • どういうアーキテクチャにすればいい?

この記事で全部カバーする。深掘りしていこう。

メインスレッドの問題を理解する

直す前に、まず理解。JavaScriptは「メインスレッド」っていう単一スレッドで動く。こいつが処理するもの:

  1. JavaScript実行 (あなたのコード)
  2. DOM更新 (レンダリング)
  3. ユーザー入力 (クリック、キー入力)
  4. タイマーとアニメーション (requestAnimationFrame, setTimeout)
  5. ネットワークコールバック (fetchのレスポンス)

500msかかる関数を実行すると、他の全部が待ち状態。ブラウザは再描画できない。クリックも処理できない。ユーザーから見たらページ固まってる状態。

// メインスレッドを~500msブロック function heavyComputation(data) { // 50,000アイテム処理するイメージ return data.map(item => { // 重い変換 return expensiveOperation(item); }); } // これ実行するとUI固まる const result = heavyComputation(hugeDataset);

「async/await使えばいいじゃん」と思うかもしれないけど、ここでは効かない:

// まだ固まる!asyncはI/O待ちだけに有効、CPU処理は無意味 async function stillBlocking(data) { // この計算はまだメインスレッドで動く return data.map(item => expensiveOperation(item)); }

asyncは外部の何かを待つ時だけ有効なんだよね (ネットワーク、ディスク)。CPU食う処理には本物の並列処理が必要。それがWeb Workers。

Web Workersの基本

Web Workerはメインスレッドと並列で動く別のJavaScriptスレッド。独自のイベントループ、グローバルスコープを持ってて、大事なのはDOMにアクセスできない

┌─────────────────────────────────────────────────────────────┐
│                     ブラウザプロセス                          │
├─────────────────────────┬───────────────────────────────────┤
│      メインスレッド        │         ワーカースレッド           │
├─────────────────────────┼───────────────────────────────────┤
│  • DOMアクセス可能       │  • DOMアクセス不可                │
│  • windowオブジェクト    │  • selfオブジェクト               │
│  • ユーザーイベント       │  • 重い計算                       │
│  • レンダリング          │  • データ処理                     │
│  • Reactの状態           │  • バックグラウンドタスク           │
├─────────────────────────┴───────────────────────────────────┤
│              postMessage() / onmessage                       │
│         (structured cloningで通信)                           │
└─────────────────────────────────────────────────────────────┘

スレッド間通信はpostMessage()onmessageで行う。データは「structured clone algorithm」でスレッド間でコピーされる。共有じゃなくてコピー。だいたいのJavaScript型に対応したディープコピーだと思えばOK。

一番シンプルなWorker:

// worker.js self.onmessage = function(event) { const data = event.data; const result = heavyComputation(data); self.postMessage(result); }; function heavyComputation(data) { // 重い処理 return data.map(item => item * 2); }
// main.js const worker = new Worker('worker.js'); worker.onmessage = function(event) { console.log('結果:', event.data); }; worker.postMessage([1, 2, 3, 4, 5]);

シンプルでしょ?でも疑問が出てくる。Reactではどう使う?TypeScriptは?複雑なロジックは?実践的なソリューション作っていこう。

モダンReactでWeb Workersセットアップ

Vite, Next.js, Create React App使ってるなら、それぞれWorkerサポートがちょっと違う。全部で動くモダンな方法を紹介する。

方法1: Blob URLでインラインWorker

シンプルなケースならインラインコードでWorker作れる:

// utils/createWorker.ts export function createWorkerFromFunction<T, R>( fn: (data: T) => R ): (data: T) => Promise<R> { const workerCode = ` self.onmessage = function(e) { const fn = ${fn.toString()}; const result = fn(e.data); self.postMessage(result); }; `; const blob = new Blob([workerCode], { type: 'application/javascript' }); const workerUrl = URL.createObjectURL(blob); const worker = new Worker(workerUrl); return (data: T): Promise<R> => { return new Promise((resolve, reject) => { worker.onmessage = (e) => resolve(e.data); worker.onerror = (e) => reject(e); worker.postMessage(data); }); }; }

使い方:

const processInWorker = createWorkerFromFunction((numbers: number[]) => { return numbers.map(n => n * n).reduce((a, b) => a + b, 0); }); // Workerで動く! const sum = await processInWorker([1, 2, 3, 4, 5]);

制限: 関数は他モジュールのimportやクロージャ使えない。完全に自己完結してる必要がある。

方法2: ViteネイティブWorkerサポート

Viteは?workerサフィックスでWorkerサポートを提供してる:

// workers/dataProcessor.worker.ts export interface WorkerInput { data: number[]; operation: 'sum' | 'average' | 'max'; } export interface WorkerOutput { result: number; processingTime: number; } self.onmessage = (event: MessageEvent<WorkerInput>) => { const start = performance.now(); const { data, operation } = event.data; let result: number; switch (operation) { case 'sum': result = data.reduce((a, b) => a + b, 0); break; case 'average': result = data.reduce((a, b) => a + b, 0) / data.length; break; case 'max': result = Math.max(...data); break; } const output: WorkerOutput = { result, processingTime: performance.now() - start, }; self.postMessage(output); };
// hooks/useDataProcessor.ts import { useCallback, useRef, useEffect } from 'react'; import DataProcessorWorker from '../workers/dataProcessor.worker?worker'; import type { WorkerInput, WorkerOutput } from '../workers/dataProcessor.worker'; export function useDataProcessor() { const workerRef = useRef<Worker | null>(null); useEffect(() => { workerRef.current = new DataProcessorWorker(); return () => workerRef.current?.terminate(); }, []); const process = useCallback((input: WorkerInput): Promise<WorkerOutput> => { return new Promise((resolve, reject) => { if (!workerRef.current) { reject(new Error('Worker not initialized')); return; } workerRef.current.onmessage = (e) => resolve(e.data); workerRef.current.onerror = (e) => reject(e); workerRef.current.postMessage(input); }); }, []); return { process }; }

方法3: ComlinkできれいなAPI

Google Chrome LabsのComlinkを使うとWorkerが普通のasync関数みたいに使える:

// workers/imageProcessor.worker.ts import * as Comlink from 'comlink'; const api = { async processImage(imageData: ImageData): Promise<ImageData> { // 重い画像処理 const pixels = imageData.data; for (let i = 0; i < pixels.length; i += 4) { // グレースケール const avg = (pixels[i] + pixels[i + 1] + pixels[i + 2]) / 3; pixels[i] = avg; // R pixels[i + 1] = avg; // G pixels[i + 2] = avg; // B } return imageData; }, async analyzeData(data: number[]): Promise<{ mean: number; stdDev: number; median: number; }> { const sorted = [...data].sort((a, b) => a - b); const mean = data.reduce((a, b) => a + b, 0) / data.length; const squaredDiffs = data.map(x => Math.pow(x - mean, 2)); const variance = squaredDiffs.reduce((a, b) => a + b, 0) / data.length; return { mean, stdDev: Math.sqrt(variance), median: sorted[Math.floor(sorted.length / 2)], }; }, }; Comlink.expose(api); export type WorkerApi = typeof api;
// hooks/useImageProcessor.ts import { useEffect, useRef } from 'react'; import * as Comlink from 'comlink'; import type { WorkerApi } from '../workers/imageProcessor.worker'; export function useImageProcessor() { const workerRef = useRef<Comlink.Remote<WorkerApi> | null>(null); useEffect(() => { const worker = new Worker( new URL('../workers/imageProcessor.worker.ts', import.meta.url), { type: 'module' } ); workerRef.current = Comlink.wrap<WorkerApi>(worker); return () => worker.terminate(); }, []); return workerRef.current; } // コンポーネントで使う function ImageEditor() { const processor = useImageProcessor(); const handleProcess = async () => { if (!processor) return; // 普通のasync関数呼び出しに見える! const result = await processor.processImage(imageData); setProcessedImage(result); }; }

ComlinkがpostMessageの面倒な処理を全部やってくれるからWorkerが自然に使える。

実践例: UI固まらないCSVパーサー

実用的なもの作ろう。CSVインポート機能を想像して。ユーザーが50MBの500,000行CSVをアップロード。Workerなしだとパース中5〜10秒UIが固まる。

// workers/csvParser.worker.ts import * as Comlink from 'comlink'; interface ParseOptions { delimiter?: string; hasHeader?: boolean; } interface ParseResult { headers: string[]; rows: string[][]; rowCount: number; parseTimeMs: number; } interface ProgressCallback { (progress: number): void; } const csvParser = { async parse( csvText: string, options: ParseOptions = {}, onProgress?: ProgressCallback ): Promise<ParseResult> { const start = performance.now(); const { delimiter = ',', hasHeader = true } = options; const lines = csvText.split('\n'); const totalLines = lines.length; const headers: string[] = []; const rows: string[][] = []; for (let i = 0; i < lines.length; i++) { const line = lines[i].trim(); if (!line) continue; const values = parseCsvLine(line, delimiter); if (i === 0 && hasHeader) { headers.push(...values); } else { rows.push(values); } // 1000行ごとに進捗報告 if (onProgress && i % 1000 === 0) { onProgress(Math.round((i / totalLines) * 100)); } } return { headers, rows, rowCount: rows.length, parseTimeMs: performance.now() - start, }; }, async filter( rows: string[][], columnIndex: number, predicate: string, onProgress?: ProgressCallback ): Promise<string[][]> { const result: string[][] = []; for (let i = 0; i < rows.length; i++) { const value = rows[i][columnIndex]; if (value?.toLowerCase().includes(predicate.toLowerCase())) { result.push(rows[i]); } if (onProgress && i % 1000 === 0) { onProgress(Math.round((i / rows.length) * 100)); } } return result; }, async aggregate( rows: string[][], groupByColumn: number, aggregateColumn: number ): Promise<Map<string, number>> { const groups = new Map<string, number[]>(); for (const row of rows) { const key = row[groupByColumn]; const value = parseFloat(row[aggregateColumn]); if (!isNaN(value)) { if (!groups.has(key)) { groups.set(key, []); } groups.get(key)!.push(value); } } const result = new Map<string, number>(); for (const [key, values] of groups) { result.set(key, values.reduce((a, b) => a + b, 0)); } return result; }, }; function parseCsvLine(line: string, delimiter: string): string[] { const result: string[] = []; let current = ''; let inQuotes = false; for (let i = 0; i < line.length; i++) { const char = line[i]; if (char === '"') { inQuotes = !inQuotes; } else if (char === delimiter && !inQuotes) { result.push(current.trim()); current = ''; } else { current += char; } } result.push(current.trim()); return result; } Comlink.expose(csvParser); export type CsvParser = typeof csvParser;
// hooks/useCsvParser.ts import { useEffect, useRef, useState, useCallback } from 'react'; import * as Comlink from 'comlink'; import type { CsvParser } from '../workers/csvParser.worker'; export function useCsvParser() { const workerRef = useRef<Comlink.Remote<CsvParser> | null>(null); const [progress, setProgress] = useState(0); const [isProcessing, setIsProcessing] = useState(false); useEffect(() => { const worker = new Worker( new URL('../workers/csvParser.worker.ts', import.meta.url), { type: 'module' } ); workerRef.current = Comlink.wrap<CsvParser>(worker); return () => worker.terminate(); }, []); const parseFile = useCallback(async (file: File) => { if (!workerRef.current) throw new Error('Worker not ready'); setIsProcessing(true); setProgress(0); try { const text = await file.text(); const result = await workerRef.current.parse( text, { hasHeader: true }, Comlink.proxy((p: number) => setProgress(p)) ); return result; } finally { setIsProcessing(false); setProgress(100); } }, []); return { parseFile, progress, isProcessing, parser: workerRef.current, }; }
// components/CsvImporter.tsx import { useState } from 'react'; import { useCsvParser } from '../hooks/useCsvParser'; export function CsvImporter() { const { parseFile, progress, isProcessing } = useCsvParser(); const [result, setResult] = useState<ParseResult | null>(null); const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => { const file = e.target.files?.[0]; if (!file) return; const parsed = await parseFile(file); setResult(parsed); }; return ( <div> <input type="file" accept=".csv" onChange={handleFileChange} disabled={isProcessing} /> {isProcessing && ( <div className="progress-bar"> <div className="progress-fill" style={{ width: `${progress}%` }} /> <span>処理中... {progress}%</span> </div> )} {result && ( <div className="result"> <p>{result.rowCount.toLocaleString()}行パース完了</p> <p>処理時間: {result.parseTimeMs.toFixed(2)}ms</p> <table> <thead> <tr> {result.headers.map((h, i) => ( <th key={i}>{h}</th> ))} </tr> </thead> <tbody> {result.rows.slice(0, 100).map((row, i) => ( <tr key={i}> {row.map((cell, j) => ( <td key={j}>{cell}</td> ))} </tr> ))} </tbody> </table> </div> )} </div> ); }

これでCSVパースがバックグラウンドで動く。UIはヌルヌル。ユーザーは進捗見れる。固まらない。

でかいデータの扱い: Transferable Objects

スレッド間で大量データをコピーすると遅い。ArrayBuffer, MessagePort, OffscreenCanvastransferable objectsとしてコピーなしで移動できる:

// コピーじゃなくて所有権移転 const buffer = new ArrayBuffer(1024 * 1024 * 100); // 100MB // 遅い: バッファ全体コピー worker.postMessage({ buffer }); // 速い: 所有権移転 (メインスレッドでbuffer使えなくなる) worker.postMessage({ buffer }, [buffer]);

画像処理だとこれ効果デカい:

// workers/imageProcessor.worker.ts self.onmessage = async (e: MessageEvent) => { const { imageData } = e.data; // 画像処理 const processed = applyFilter(imageData); // コピーじゃなくて移転 const buffer = processed.data.buffer; self.postMessage({ imageData: processed }, [buffer]); };
// メインスレッド const offscreen = canvas.transferControlToOffscreen(); worker.postMessage({ canvas: offscreen }, [offscreen]);

並列処理のWorkerプール

Worker一個でもいいけど、複数で並列処理するともっと速い。シンプルなWorkerプール:

// utils/WorkerPool.ts export class WorkerPool<TInput, TOutput> { private workers: Worker[] = []; private queue: Array<{ data: TInput; resolve: (result: TOutput) => void; reject: (error: Error) => void; }> = []; private activeWorkers = new Set<Worker>(); constructor( private createWorker: () => Worker, private poolSize: number = navigator.hardwareConcurrency || 4 ) { for (let i = 0; i < poolSize; i++) { this.workers.push(this.createWorker()); } } async execute(data: TInput): Promise<TOutput> { return new Promise((resolve, reject) => { const availableWorker = this.workers.find( w => !this.activeWorkers.has(w) ); if (availableWorker) { this.runTask(availableWorker, data, resolve, reject); } else { this.queue.push({ data, resolve, reject }); } }); } private runTask( worker: Worker, data: TInput, resolve: (result: TOutput) => void, reject: (error: Error) => void ) { this.activeWorkers.add(worker); worker.onmessage = (e) => { resolve(e.data); this.activeWorkers.delete(worker); this.processQueue(); }; worker.onerror = (e) => { reject(new Error(e.message)); this.activeWorkers.delete(worker); this.processQueue(); }; worker.postMessage(data); } private processQueue() { if (this.queue.length === 0) return; const availableWorker = this.workers.find( w => !this.activeWorkers.has(w) ); if (availableWorker) { const { data, resolve, reject } = this.queue.shift()!; this.runTask(availableWorker, data, resolve, reject); } } async executeAll(items: TInput[]): Promise<TOutput[]> { return Promise.all(items.map(item => this.execute(item))); } terminate() { this.workers.forEach(w => w.terminate()); this.workers = []; } }

画像並列処理で使うと:

const pool = new WorkerPool( () => new Worker(new URL('./imageWorker.ts', import.meta.url)), 4 // 4つのWorker並列 ); // 100枚の画像を4つのWorkerで並列処理 const results = await pool.executeAll(images);

やりがちな失敗

失敗1: 小さいタスクにWorker使いすぎ

Workerにはオーバーヘッドがある。Worker生成、データシリアライズ、結果デシリアライズに時間かかる。小さい処理だとオーバーヘッドが本体より大きくなる。

目安: 16ms以上 (60fpsで1フレーム) かかる処理にだけWorker使う。

// ❌ 損する - オーバーヘッド > メリット worker.postMessage([1, 2, 3]); // 3つの数字足す // ✅ 得する - 十分重いからオーバーヘッド回収 worker.postMessage(bigArray); // 100,000アイテム処理

失敗2: Worker放置してリーク

Workerはメモリ食う。古いWorker終了せずに新しいの作り続けるとメモリリーク:

// ❌ メモリリーク function processData(data) { const worker = new Worker('worker.js'); worker.postMessage(data); // Worker終了してない! } // ✅ ちゃんと片付け function processData(data) { const worker = new Worker('worker.js'); worker.onmessage = (e) => { console.log(e.data); worker.terminate(); // 終わったら片付け }; worker.postMessage(data); }

Reactではcleanupで必ずterminate:

useEffect(() => { const worker = new Worker('worker.js'); workerRef.current = worker; return () => { worker.terminate(); // ✅ アンマウント時に片付け }; }, []);

失敗3: エラー処理してない

Workerのエラーはメインスレッドのエラーハンドラに自動で伝わらない:

// ❌ エラー握りつぶし worker.postMessage(data); // ✅ エラーちゃんと拾う worker.onerror = (error) => { console.error('Workerエラー:', error.message); // エラー処理 }; worker.onmessageerror = (error) => { console.error('メッセージエラー:', error); };

失敗4: Workerでもブロック

処理Workerに移したからブロックしないわけじゃない。Workerで5秒同期処理したら、その間他のメッセージ処理できない。

// ❌ 長い同期処理で他のメッセージ全部待ち self.onmessage = (e) => { const result = process5MillionItems(e.data); // 5秒ブロック self.postMessage(result); }; // ✅ チャンクに分けて適宜yield self.onmessage = async (e) => { const items = e.data; const results = []; const chunkSize = 10000; for (let i = 0; i < items.length; i += chunkSize) { const chunk = items.slice(i, i + chunkSize); results.push(...processChunk(chunk)); // 他のメッセージ処理できるようにyield await new Promise(resolve => setTimeout(resolve, 0)); // 進捗報告 self.postMessage({ type: 'progress', value: i / items.length }); } self.postMessage({ type: 'complete', results }); };

Workerが向かないケース

Workerが万能なわけじゃない。避けるべき場面:

  1. I/O処理でCPU処理じゃない時 - データfetch、DBクエリとか。async/await使えばいい。

  2. DOM触る必要がある時 - WorkerはDOM触れない。処理中にUI更新必要ならメインスレッドにメッセージ送る。

  3. データ転送コスト > 計算節約 - 100ms節約するために1GBシリアライズは割に合わない。

  4. もう十分速い時 - 16ms以内なら終わるならオーバーヘッドの方がでかい。

  5. 古いブラウザ対応でフォールバックない時 - ブラウザ対応確認してから。

パフォーマンス比較: Before vs After

100万個の数字ソートで実測:

// Workerなし function sortData() { const start = performance.now(); const sorted = data.sort((a, b) => a - b); console.log(`メインスレッド: ${performance.now() - start}ms`); console.log(`この間UI固まってる`); } // Workerあり async function sortDataWorker() { const start = performance.now(); const sorted = await workerSort(data); console.log(`Worker: ${performance.now() - start}ms`); console.log(`UIはヌルヌル`); }

普通のマシンでの結果:

  • Workerなし: 1,200ms (UI固まる)
  • Workerあり: 1,250ms (UIヌルヌル)

シリアライズのオーバーヘッドでWorkerの方が少し長い。でもUI絶対固まらないから体感は全然違う。

まとめ

Web Workersは一番使われてないブラウザAPIの一つ。ちゃんと使えばカクカクアプリをサクサクアプリに変えられる。

大事なポイント:

  1. メインスレッドは貴重 - レンダリングとユーザー対応用に空けとく。

  2. WorkerはCPU処理用 - データ処理、パース、計算、画像操作。

  3. Comlink使うとAPI綺麗 - Workerが普通のasync関数に見える。

  4. コピーじゃなくて転送 - でかいArrayBufferはtransferable objectsで。

  5. Workerプールで並列化 - 複数Workerで同時処理。

  6. 最適化前に計測 - Workerもオーバーヘッドある。得する時だけ使う。

  7. 片付け忘れずに - 終わったらterminate。

次にReactアプリが重い処理でカクついたら何すればいいかわかったはず。その処理Workerに投げて、メインスレッド空けて、ユーザーにサクサク体験を届けよう。

web-workersreactjavascriptperformancemultithreadingoptimization