Web Worker実践ガイド - ブラウザで並列処理を実現する
Web Worker実践ガイド
JavaScriptはシングルスレッドで動作しますが、Web Workerを使用することで別スレッドでコードを実行し、メインスレッドをブロックせずに重い処理を行えます。
本ガイドでは、Web Workerの基本から実践的な使い方まで、パフォーマンス改善の具体例とともに解説します。
Web Workerとは
Web Workerは、Webアプリケーションでバックグラウンドスレッドを作成し、メインスレッドと並行して処理を実行できる機能です。
主な特徴
- 別スレッドで実行 - UIをブロックしない
- メッセージベース通信 - 構造化クローンによるデータ転送
- 制限された環境 - DOMアクセス不可
- 独立したグローバルスコープ - メインスレッドと分離
Web Workerが適しているケース
// ✅ Web Workerに適した処理
- 大量データの計算処理
- 画像・動画の処理
- データの暗号化・復号化
- 大きなファイルの解析
- リアルタイムデータの処理
- 複雑なアルゴリズムの実行
// ❌ Web Workerに不適切な処理
- DOM操作
- window, documentオブジェクトへのアクセス
- 小さく速い処理(オーバーヘッドが大きくなる)
- 頻繁なメインスレッドとの通信が必要な処理
基本的な使い方
1. シンプルなWorker作成
// worker.js
self.addEventListener('message', (event) => {
const result = event.data * 2
self.postMessage(result)
})
// main.js
const worker = new Worker('worker.js')
worker.addEventListener('message', (event) => {
console.log('結果:', event.data) // 20
})
worker.postMessage(10)
2. TypeScriptでのWorker
// worker.ts
const ctx: Worker = self as any
ctx.addEventListener('message', (event: MessageEvent) => {
const data: number = event.data
const result = data * 2
ctx.postMessage(result)
})
// main.ts
const worker = new Worker(new URL('./worker.ts', import.meta.url), {
type: 'module'
})
worker.addEventListener('message', (event: MessageEvent<number>) => {
console.log('結果:', event.data)
})
worker.postMessage(10)
3. 型安全なWorker通信
// worker-types.ts
export interface WorkerRequest {
type: 'calculate' | 'process' | 'transform'
data: any
}
export interface WorkerResponse {
type: 'success' | 'error'
result?: any
error?: string
}
// worker.ts
import { WorkerRequest, WorkerResponse } from './worker-types'
const ctx: Worker = self as any
ctx.addEventListener('message', (event: MessageEvent<WorkerRequest>) => {
const { type, data } = event.data
try {
let result: any
switch (type) {
case 'calculate':
result = performCalculation(data)
break
case 'process':
result = processData(data)
break
case 'transform':
result = transformData(data)
break
default:
throw new Error(`Unknown type: ${type}`)
}
const response: WorkerResponse = {
type: 'success',
result
}
ctx.postMessage(response)
} catch (error) {
const response: WorkerResponse = {
type: 'error',
error: error instanceof Error ? error.message : 'Unknown error'
}
ctx.postMessage(response)
}
})
function performCalculation(data: number): number {
return data * 2
}
function processData(data: any[]): any[] {
return data.map(item => item * 2)
}
function transformData(data: string): string {
return data.toUpperCase()
}
// main.ts
import { WorkerRequest, WorkerResponse } from './worker-types'
class TypedWorker {
private worker: Worker
constructor(workerUrl: string) {
this.worker = new Worker(workerUrl, { type: 'module' })
}
async execute<T = any>(request: WorkerRequest): Promise<T> {
return new Promise((resolve, reject) => {
const handleMessage = (event: MessageEvent<WorkerResponse>) => {
this.worker.removeEventListener('message', handleMessage)
if (event.data.type === 'success') {
resolve(event.data.result as T)
} else {
reject(new Error(event.data.error))
}
}
this.worker.addEventListener('message', handleMessage)
this.worker.postMessage(request)
})
}
terminate() {
this.worker.terminate()
}
}
// 使用例
const worker = new TypedWorker(new URL('./worker.ts', import.meta.url).href)
try {
const result = await worker.execute<number>({
type: 'calculate',
data: 10
})
console.log('計算結果:', result)
} catch (error) {
console.error('エラー:', error)
}
メッセージパッシング
1. 構造化クローン
// worker.ts
const ctx: Worker = self as any
ctx.addEventListener('message', (event) => {
const { type, payload } = event.data
// 複雑なデータ構造も転送可能
if (type === 'process') {
const result = {
processed: true,
data: payload.data.map((item: any) => item * 2),
timestamp: Date.now(),
metadata: {
version: '1.0',
items: payload.data.length
}
}
ctx.postMessage(result)
}
})
// main.ts
const data = {
type: 'process',
payload: {
data: [1, 2, 3, 4, 5],
options: {
multiply: true,
filter: false
}
}
}
worker.postMessage(data)
2. Transferable Objects(転送可能オブジェクト)
大きなデータを効率的に転送:
// メインスレッド
const buffer = new ArrayBuffer(1024 * 1024) // 1MB
const uint8Array = new Uint8Array(buffer)
// データを埋める
for (let i = 0; i < uint8Array.length; i++) {
uint8Array[i] = i % 256
}
// 転送(コピーではなく所有権を移動)
worker.postMessage({ buffer }, [buffer])
// この時点でbufferは使用不可(所有権がworkerに移った)
console.log(buffer.byteLength) // 0
// worker.ts
const ctx: Worker = self as any
ctx.addEventListener('message', (event) => {
const { buffer } = event.data
const uint8Array = new Uint8Array(buffer)
// 処理...
const sum = uint8Array.reduce((acc, val) => acc + val, 0)
// 結果と共にバッファを返す
ctx.postMessage({ sum, buffer }, [buffer])
})
3. SharedArrayBuffer(共有メモリ)
// メインスレッド
const sharedBuffer = new SharedArrayBuffer(1024)
const sharedArray = new Int32Array(sharedBuffer)
worker.postMessage({ sharedBuffer })
// Workerと同じメモリを参照
sharedArray[0] = 42
// worker.ts
const ctx: Worker = self as any
ctx.addEventListener('message', (event) => {
const { sharedBuffer } = event.data
const sharedArray = new Int32Array(sharedBuffer)
// メインスレッドと同じメモリを参照
console.log(sharedArray[0]) // 42
// Atomics APIで安全に操作
Atomics.add(sharedArray, 0, 1)
Atomics.notify(sharedArray, 0)
})
実践例
1. 画像処理Worker
// image-worker.ts
const ctx: Worker = self as any
interface ImageData {
data: Uint8ClampedArray
width: number
height: number
}
interface ProcessRequest {
type: 'grayscale' | 'blur' | 'brightness'
imageData: ImageData
options?: any
}
ctx.addEventListener('message', (event: MessageEvent<ProcessRequest>) => {
const { type, imageData, options } = event.data
try {
let processed: ImageData
switch (type) {
case 'grayscale':
processed = grayscale(imageData)
break
case 'blur':
processed = blur(imageData, options?.radius || 1)
break
case 'brightness':
processed = brightness(imageData, options?.factor || 1.2)
break
default:
throw new Error(`Unknown filter: ${type}`)
}
ctx.postMessage({ success: true, imageData: processed }, [processed.data.buffer])
} catch (error) {
ctx.postMessage({
success: false,
error: error instanceof Error ? error.message : 'Unknown error'
})
}
})
function grayscale(imageData: ImageData): ImageData {
const { data, width, height } = imageData
const processed = new Uint8ClampedArray(data.length)
for (let i = 0; i < data.length; i += 4) {
const avg = (data[i] + data[i + 1] + data[i + 2]) / 3
processed[i] = avg
processed[i + 1] = avg
processed[i + 2] = avg
processed[i + 3] = data[i + 3]
}
return { data: processed, width, height }
}
function brightness(imageData: ImageData, factor: number): ImageData {
const { data, width, height } = imageData
const processed = new Uint8ClampedArray(data.length)
for (let i = 0; i < data.length; i += 4) {
processed[i] = Math.min(255, data[i] * factor)
processed[i + 1] = Math.min(255, data[i + 1] * factor)
processed[i + 2] = Math.min(255, data[i + 2] * factor)
processed[i + 3] = data[i + 3]
}
return { data: processed, width, height }
}
function blur(imageData: ImageData, radius: number): ImageData {
// シンプルなボックスブラー実装
const { data, width, height } = imageData
const processed = new Uint8ClampedArray(data.length)
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
let r = 0, g = 0, b = 0, count = 0
for (let dy = -radius; dy <= radius; dy++) {
for (let dx = -radius; dx <= radius; dx++) {
const nx = x + dx
const ny = y + dy
if (nx >= 0 && nx < width && ny >= 0 && ny < height) {
const idx = (ny * width + nx) * 4
r += data[idx]
g += data[idx + 1]
b += data[idx + 2]
count++
}
}
}
const idx = (y * width + x) * 4
processed[idx] = r / count
processed[idx + 1] = g / count
processed[idx + 2] = b / count
processed[idx + 3] = data[idx + 3]
}
}
return { data: processed, width, height }
}
// 使用例(React)
import { useRef, useEffect } from 'react'
export function ImageProcessor() {
const workerRef = useRef<Worker>()
const canvasRef = useRef<HTMLCanvasElement>(null)
useEffect(() => {
workerRef.current = new Worker(
new URL('./image-worker.ts', import.meta.url),
{ type: 'module' }
)
return () => workerRef.current?.terminate()
}, [])
const processImage = async (file: File, filter: string) => {
const canvas = canvasRef.current
if (!canvas || !workerRef.current) return
const ctx = canvas.getContext('2d')!
const img = await createImageBitmap(file)
canvas.width = img.width
canvas.height = img.height
ctx.drawImage(img, 0, 0)
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
return new Promise((resolve, reject) => {
const worker = workerRef.current!
const handleMessage = (event: MessageEvent) => {
worker.removeEventListener('message', handleMessage)
if (event.data.success) {
const processed = event.data.imageData
const processedImageData = new ImageData(
processed.data,
processed.width,
processed.height
)
ctx.putImageData(processedImageData, 0, 0)
resolve(true)
} else {
reject(new Error(event.data.error))
}
}
worker.addEventListener('message', handleMessage)
worker.postMessage({
type: filter,
imageData: {
data: imageData.data,
width: imageData.width,
height: imageData.height
}
}, [imageData.data.buffer])
})
}
return (
<div>
<input
type="file"
accept="image/*"
onChange={(e) => {
const file = e.target.files?.[0]
if (file) {
processImage(file, 'grayscale')
}
}}
/>
<canvas ref={canvasRef} />
</div>
)
}
2. データ処理Worker Pool
// worker-pool.ts
export class WorkerPool {
private workers: Worker[] = []
private queue: Array<{
data: any
resolve: (value: any) => void
reject: (error: any) => void
}> = []
private availableWorkers: Set<Worker> = new Set()
constructor(
private workerUrl: string,
private poolSize: number = navigator.hardwareConcurrency || 4
) {
this.initialize()
}
private initialize() {
for (let i = 0; i < this.poolSize; i++) {
const worker = new Worker(this.workerUrl, { type: 'module' })
this.workers.push(worker)
this.availableWorkers.add(worker)
worker.addEventListener('message', (event) => {
const { result, error } = event.data
// Workerを再び利用可能にする
this.availableWorkers.add(worker)
// キューから次のタスクを取得
const nextTask = this.queue.shift()
if (nextTask) {
this.executeTask(worker, nextTask.data, nextTask.resolve, nextTask.reject)
}
})
}
}
async execute<T = any>(data: any): Promise<T> {
return new Promise((resolve, reject) => {
const availableWorker = this.getAvailableWorker()
if (availableWorker) {
this.executeTask(availableWorker, data, resolve, reject)
} else {
// 利用可能なWorkerがない場合はキューに追加
this.queue.push({ data, resolve, reject })
}
})
}
private getAvailableWorker(): Worker | null {
const worker = this.availableWorkers.values().next().value
if (worker) {
this.availableWorkers.delete(worker)
return worker
}
return null
}
private executeTask(
worker: Worker,
data: any,
resolve: (value: any) => void,
reject: (error: any) => void
) {
const handleMessage = (event: MessageEvent) => {
worker.removeEventListener('message', handleMessage)
worker.removeEventListener('error', handleError)
if (event.data.error) {
reject(new Error(event.data.error))
} else {
resolve(event.data.result)
}
// Workerを再び利用可能にする
this.availableWorkers.add(worker)
// キューから次のタスクを実行
const nextTask = this.queue.shift()
if (nextTask) {
this.executeTask(worker, nextTask.data, nextTask.resolve, nextTask.reject)
}
}
const handleError = (error: ErrorEvent) => {
worker.removeEventListener('message', handleMessage)
worker.removeEventListener('error', handleError)
reject(error)
this.availableWorkers.add(worker)
}
worker.addEventListener('message', handleMessage)
worker.addEventListener('error', handleError)
worker.postMessage(data)
}
terminate() {
this.workers.forEach(worker => worker.terminate())
this.workers = []
this.availableWorkers.clear()
this.queue = []
}
}
// 使用例
const pool = new WorkerPool(new URL('./data-worker.ts', import.meta.url).href, 4)
async function processLargeDataset(data: number[][]) {
const promises = data.map(chunk => pool.execute(chunk))
const results = await Promise.all(promises)
return results.flat()
}
// 使用後
pool.terminate()
SharedWorker vs Service Worker vs Web Worker
Web Worker
// 標準的なWorker
const worker = new Worker('worker.js')
worker.postMessage('hello')
// 特徴:
// - 作成したページ/タブ専用
// - 複数インスタンス可能
// - ページを閉じると終了
SharedWorker
// 複数のタブ/ウィンドウで共有
const worker = new SharedWorker('shared-worker.js')
worker.port.postMessage('hello')
// shared-worker.js
self.addEventListener('connect', (event) => {
const port = event.ports[0]
port.addEventListener('message', (e) => {
console.log('Message from page:', e.data)
port.postMessage('Hello back!')
})
port.start()
})
// 特徴:
// - 同じオリジンの複数ページで共有
// - すべてのページが閉じられるまで実行継続
// - リアルタイム通信に適している
Service Worker
// オフライン対応、プッシュ通知など
navigator.serviceWorker.register('/sw.js')
// sw.js
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open('v1').then((cache) => {
return cache.addAll(['/index.html', '/styles.css', '/app.js'])
})
)
})
self.addEventListener('fetch', (event) => {
event.respondWith(
caches.match(event.request).then((response) => {
return response || fetch(event.request)
})
)
})
// 特徴:
// - ネットワークリクエストを傍受
// - オフライン対応
// - プッシュ通知
// - バックグラウンド同期
パフォーマンス改善の実例
Before / After比較
// Before: メインスレッドで処理(UIがブロックされる)
function processLargeArray(data: number[]) {
const start = performance.now()
const result = data.map(item => {
// 重い計算
return heavyCalculation(item)
})
const end = performance.now()
console.log(`処理時間: ${end - start}ms`)
// UI がフリーズする
return result
}
// After: Workerで処理(UIは応答性を保つ)
async function processLargeArrayWithWorker(data: number[]) {
const worker = new Worker(new URL('./calculator-worker.ts', import.meta.url))
const result = await new Promise((resolve) => {
worker.addEventListener('message', (event) => {
resolve(event.data)
worker.terminate()
})
worker.postMessage(data)
})
// UI は応答性を保つ
return result
}
// calculator-worker.ts
const ctx: Worker = self as any
ctx.addEventListener('message', (event) => {
const data: number[] = event.data
const result = data.map(item => heavyCalculation(item))
ctx.postMessage(result)
})
function heavyCalculation(n: number): number {
// 重い計算のシミュレーション
let result = 0
for (let i = 0; i < 1000000; i++) {
result += Math.sqrt(n * i)
}
return result
}
実測パフォーマンス
// ベンチマークコード
async function benchmark() {
const data = Array.from({ length: 10000 }, (_, i) => i)
// メインスレッド
console.time('Main Thread')
const result1 = processLargeArray(data)
console.timeEnd('Main Thread')
// Main Thread: 2500ms (UI frozen)
// Worker
console.time('Web Worker')
const result2 = await processLargeArrayWithWorker(data)
console.timeEnd('Web Worker')
// Web Worker: 2300ms (UI responsive)
}
まとめ
Web Workerを効果的に使用することで:
- UIの応答性維持 - 重い処理でもスムーズな操作
- マルチコア活用 - CPUリソースの効率的利用
- ユーザー体験向上 - ストレスフリーなインタラクション
適切な場面でWeb Workerを活用し、モダンなWebアプリケーションのパフォーマンスを最大化しましょう。