背景と導入の理由
ブラウザは、JavaScriptの実行、レイアウトの再計算、スタイルの解決、ガベージコレクション、画面の再描画などをすべて単一のスレッド(メインスレッド)上で処理します。滑らかな60 FPS(フレーム毎秒)を維持するために許される時間は極めて厳しく、1フレームあたりわずか16.67ミリ秒しかありません。この制限時間を超えてしまうと、瞬時にフレーム落ちが発生します。
負荷の高い処理を実行すると、この処理時間はあっという間に消費されます。例えば45MBのCSVファイルのパース、画像ピクセルデータの加工、フロッキングアルゴリズムのシミュレーションなどは、簡単にCPUを150〜500ミリ秒ほど占有してしまいます。その間、ページは完全にフリーズします。入力イベントはキューに溜まり、CSSアニメーションはカクつき、ユーザー体験を損なう深刻な描画の引っかかり(ジャンク)が発生します。
Web Workersは、純粋な計算処理をバックグラウンドスレッドへ移行させることで、この課題の一部を解決しました。しかし、WorkerからはDOMに直接アクセスできないという厄介な制約が残されていました。従来は、Workerで生成した生の画像バッファをpostMessage経由でメインスレッドに送り返す必要があり、UIスレッド側で最終的なctx.drawImage()の呼び出しを実行せざるを得ませんでした。このシリアライズを伴う往復通信自体が、レイテンシのオーバーヘッドを生んでいたのです。
OffscreenCanvas APIはこのボトルネックを根本から解消します。CanvasのレンダリングコンテキストをDOMツリーから切り離すことで、バックグラウンドWorkerから直接描画バッファへ書き込めるようになります。リアルタイムのテレメトリダッシュボード、WebGLシーン、ブラウザゲームなどにおいて、計算負荷の大きさに関係なく、ユーザー操作を常に軽快に保つことが可能になります。
環境構築
ここではViteを使って最小限のプロジェクトを構築します。Viteは標準でESモジュール形式のWeb Workerをサポートしているため、RollupやWebpackのWorkerプラグインを手動で設定する必要がありません。
ターミナルからVanilla JavaScriptのプロジェクトを作成します:
# 軽量なVanilla JavaScriptテンプレートを初期化
npm create vite@latest worker-canvas-demo -- --template vanilla
# プロジェクトディレクトリに移動
cd worker-canvas-demo
# プロジェクトの依存関係をインストール
npm install
プロジェクトのディレクトリ構成を以下のように整えます:
worker-canvas-demo/
├── index.html
├── src/
│ ├── main.js
│ └── worker.js
└── package.json
ローカルのVite開発サーバーを起動します:
npm run dev
実装と設定
ここでは実践的な負荷テストを構築します。ループ処理で3,000個のアニメーションパーティクルを描画するインタラクティブなCanvasと、クリック回数を記録するHTMLボタンを用意します。従来のシングルスレッド構成では、3,000個の座標更新によって40〜80ミリ秒の入力遅延が発生してしまいますが、Canvasの描画処理をバックグラウンドWorkerへオフロードすることで、ボタンクリックの応答時間は1ミリ秒未満に保たれます。
1. HTMLドキュメントの準備
index.htmlの内容を、Canvasコンテナとレスポンス検証用の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 & OffscreenCanvas デモ</title>
<style>
body {
font-family: system-ui, -apple-system, sans-serif;
display: flex;
flex-direction: column;
align-items: center;
background: #0d1117;
color: #f0f6fc;
margin: 0;
padding: 24px;
}
canvas {
border: 1px solid #30363d;
border-radius: 8px;
background: #010409;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
}
.controls {
margin-top: 20px;
display: flex;
gap: 16px;
align-items: center;
}
button {
padding: 10px 20px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
background: #238636;
border: 1px solid rgba(240, 246, 252, 0.1);
border-radius: 6px;
color: #ffffff;
transition: background-color 0.1s ease;
}
button:hover {
background: #2ea043;
}
button:active {
background: #1f702e;
}
span {
color: #8b949e;
font-size: 14px;
}
</style>
</head>
<body>
<h1>バックグラウンドスレッド パーティクルシミュレーター</h1>
<canvas id="viewport" width="800" height="500"></canvas>
<div class="controls">
<button id="counterBtn">クリック数: 0</button>
<span>すばやく連打してみてください。UIの操作は一切ブロックされません。</span>
</div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
2. メインスレッド側のコントローラー実装
src/main.js内で、transferControlToOffscreen()を使用してCanvasの制御権を切り離します。その後、そのオブジェクトをWorkerスレッドへ直接渡します。
重要な点として、worker.postMessage()の第2引数にTransferableオブジェクトとしてOffscreenCanvasの参照を渡します。これにより、負荷の大きい構造化クローンを回避し、メモリの基盤となる所有権そのものを転送できます。
// src/main.js
const canvas = document.getElementById('viewport');
const counterBtn = document.getElementById('counterBtn');
let count = 0;
// 高速カウンター:UIスレッドがイベントを即座に処理できるかを検証
counterBtn.addEventListener('click', () => {
count += 1;
counterBtn.textContent = `クリック数: ${count}`;
});
// ガード節:ブラウザがOffscreenCanvasの転送をサポートしているか確認
if (!('transferControlToOffscreen' in canvas)) {
counterBtn.disabled = true;
alert('お使いのブラウザはOffscreenCanvasをサポートしていません。');
} else {
// DOMから制御権を切り離す。これ以降メインスレッドでgetContext()を呼び出すとエラーが発生する
const offscreen = canvas.transferControlToOffscreen();
// ViteのESモジュール解決を利用してバックグラウンドWorkerをインスタンス化
const renderWorker = new Worker(
new URL('./worker.js', import.meta.url),
{ type: 'module' }
);
// Transferable配列を経由してCanvasの所有権をWorkerスレッドへ転送
renderWorker.postMessage(
{
type: 'INIT',
canvas: offscreen,
width: canvas.width,
height: canvas.height,
},
[offscreen]
);
}
3. バックグラウンド描画Workerの実装
src/worker.jsでは、メッセージを受信してOffscreenCanvasを取り出し、その2Dコンテキストを取得した上でrequestAnimationFrameループを開始します。近年の主要ブラウザ(Chrome 69以降、Firefox 105以降、Safari 16.4以降)は、Dedicated Workerコンテキスト内でのrequestAnimationFrameの直接実行に完全対応しています。
// src/worker.js
let ctx = null;
let width = 0;
let height = 0;
const PARTICLE_COUNT = 3000;
const particles = [];
class Particle {
constructor(w, h) {
this.x = Math.random() * w;
this.y = Math.random() * h;
this.vx = (Math.random() - 0.5) * 3;
this.vy = (Math.random() - 0.5) * 3;
this.radius = Math.random() * 2 + 1;
}
update(w, h) {
this.x += this.vx;
this.y += this.vy;
if (this.x < 0 || this.x > w) this.vx = -this.vx;
if (this.y < 0 || this.y > h) this.vy = -this.vy;
}
draw(context) {
context.beginPath();
context.arc(this.x, this.y, this.radius, 0, Math.PI * 2);
context.fillStyle = '#38bdf8';
context.fill();
}
}
function render() {
// 半透明の塗りつぶしにより効率的な残像エフェクトを生成
ctx.fillStyle = 'rgba(1, 4, 9, 0.25)';
ctx.fillRect(0, 0, width, height);
for (let i = 0; i < particles.length; i++) {
particles[i].update(width, height);
particles[i].draw(ctx);
}
// Workerスレッド内で直接スケジュールされるレンダーループ
requestAnimationFrame(render);
}
self.onmessage = (event) => {
const { type, canvas, width: w, height: h } = event.data;
if (type === 'INIT') {
width = w;
height = h;
ctx = canvas.getContext('2d');
for (let i = 0; i < PARTICLE_COUNT; i++) {
particles.push(new Particle(width, height));
}
requestAnimationFrame(render);
}
};
動作確認とパフォーマンス計測
実行中のViteデモをChrome、Firefox、またはEdgeで開き、スレッドの分離と描画の安定性を測定します。
1. 高負荷下でのイベント遅延をテストする
3,000個のパーティクルがCanvas上を移動する様子を確認しながら、「クリック数: 0」ボタンをすばやく連打します。Workerが16ミリ秒ごとに6,000回もの座標変更を計算し、3,000回ものパス描画コマンドを実行している最中であっても、ボタンのクリックハンドラーは2ミリ秒未満で発火します。入力の遅延は一切ありません。
2. Chrome DevToolsでワークロードをプロファイリングする
Chromeのプロファイラーを使用してスレッドの分離を検証します:
F12キーを押してDevToolsを開き、Performanceタブを選択します。- Recordをクリックし、カウンターボタンを3秒間連打してからStopをクリックします。
- スレッドのタイムラインを確認します:
- Main: スクリプト実行時間はほぼゼロを示します。タスクは1ミリ秒未満で完了し、約97%がアイドル時間となるため、DOMイベントの処理やスタイル適用に十分な余裕が確保されます。
- Dedicated Worker: パーティクル計算と
arc()描画のブロックが継続的に実行されていますが、UI側にLong Task(50ミリ秒以上)の警告フラグを発生させることはありません。
3. フレームレートの安定性を確認する
Ctrl + Shift + P(macOSではCmd + Shift + P)でコマンドメニューを開き、「Show Rendering」と入力してEnterキーを押します。下部パネルでFrame Rendering Statsを有効にします。リアルタイムのGPU HUDが表示され、フレーム落ちの急増もなく安定して60 FPSが維持されていることを確認できます。

