やあ、お疲れ様。最近、プロダクトのパフォーマンス改善に頭を悩ませているみたいじゃないか。
「ユーザーインタラクションのレスポンスが悪い」「アニメーションがカクつく」「Core Web VitalsのINP(Interaction to Next Paint)の数値がどうしても改善しない」……。
フロントエンドエンジニアなら誰もが一度はハマるこの沼、大抵の元凶は「メインスレッドの過労死」だ。JavaScriptの実行、DOMのパース、スタイル計算、レイアウト、そして描画(ペイント・コンポジット)。これら全部をたった一つのメインスレッドという名の「ワンオペ窓口」で処理させようとするから、処理が渋滞を起こしてブラウザがフリーズする。
特に、Canvasを使ったリッチなデータビジュアライゼーション、ゲーム、複雑なUIアニメーションなどをメインスレッドでゴリゴリ回している現場は要注意だ。JavaScriptで重い計算や描画ループを回した瞬間に、ユーザーのスクロールやクリックが一切受け付けられなくなる。最悪のユーザー体験だよね。
そこで今日君に伝授するのが、この「OffscreenCanvas」という秘密兵器だ。
DOMからCanvasを切り離し、完全に別スレッド(Web Worker)へ描画処理をオフロードする。この技術の本質と、現場で即座に使える実践的な実装パターンを叩き込んでいこう。
—
1. Webブラウザの裏側で何が起きているのか?
まず、ブラウザの内部アーキテクチャの基本に立ち返ろう。
通常、`
メインスレッドの役割は多忙を極める。
1. ユーザー入力の検知とイベントハンドラの実行(click, scroll, keydownなど)
2. JavaScriptの実行(フレームワークの再描画、API通信、状態管理)
3. DOMの構築とCSSOMの解析
4. レイアウト(Reflow)とペイント(Repaint)、コンポジット
これら全てを60fps(1フレームあたり約16.67ms)のタイムリミット内に収めなければならない。ここに重いCanvasの描画処理や、毎フレームのピクセル操作が割り込んできたらどうなるか? 1フレームの処理時間が跳ね上がり、画面がカクつく(ドロップフレーム)のは当然だし、ユーザーがボタンを押しても反応しない「メインスレッドのブロック」が完成する。
OffscreenCanvasがもたらすパラダイムシフト
ここで登場するのが `OffscreenCanvas` だ。
仕様としては比較的新しいものだが、主要ブラウザ(Chrome, Firefox, Safari)はすでに十分なサポートを網羅している。
OffscreenCanvasの最大の強みは、文字通り「DOMから切り離されたCanvas」である点だ。
DOMに依存しないため、こいつをWeb Worker(ワーカースレッド)の中に丸ごと持ち込むことができる。
[ メインスレッド ] [ Web Worker スレッド ]
- DOM / CSSOM – 重い計算処理
- ユーザー入力 (INP最適化) – OffscreenCanvas による描画ループ
- UIの描画コントロール (requestAnimationFrame も動く!)
│ │
└───── transferControlToOffscreen() ───┘
つまり、重い描画処理やアニメーションのループをすべて裏のバックグラウンドスレッドに追いやってしまい、メインスレッドは「ユーザーからの入力受付とUIの軽い制御」だけに専念させることができるわけだ。これこそが、現代のハイパフォーマンスWebフロントエンドの王道アーキテクチャだよ。
—
2. 実務で使うための実装パターン
百聞は一見にしかずだ。実際にコードを見ていこう。
今回は、メインスレッドでDOM上の `
ステップ1: メインスレッド側のコード (main.js)
メインスレッドの仕事は驚くほどシンプルだ。キャンバスの参照をWorkerに渡し、あとは関知しない。
// main.js – メインスレッド
// 役割:DOMの管理と、WorkerへのCanvasの移譲のみを行う
async function initCanvasWorker() {
const canvas = document.getElementById(‘my-canvas’);
if (!canvas.transferControlToOffscreen) {
console.error(‘このブラウザはOffscreenCanvasをサポートしていません。’);
return;
}
// DOM要素から描画の主導権を完全に剥奪し、転送可能なオブジェクト(OffscreenCanvas)に変換する
// ※ 注意:一度 transferControlToOffscreen を呼ぶと、メインスレッド側でgetContext()は呼べなくなる
const offscreen = canvas.transferControlToOffscreen();
// Web Workerを初期化
const worker = new Worker(‘./canvas-worker.js’, { type: ‘module’ });
// 移譲した OffscreenCanvas と初期設定データを Worker へ転送
// 第2引数の転送リスト(transfer list)に含めることで、メインスレッドからWorkerへ所有権が移動する
worker.postMessage({
type: ‘init’,
canvas: offscreen,
width: canvas.clientWidth,
height: canvas.clientHeight
}, [offscreen]);
// メインスレッド側は、ユーザーからのインタラクションなどを自由に処理できる状態を維持
window.addEventListener(‘resize’, () => {
// リサイズ時などの指示も postMessage で Worker に伝えるだけでOK
worker.postMessage({
type: ‘resize’,
width: canvas.clientWidth,
height: canvas.clientHeight
});
});
}
initCanvasWorker();
ステップ2: ワーカースレッド側のコード (canvas-worker.js)
次に、Worker側のコードだ。ここではなんと、Worker内であるにもかかわらず `requestAnimationFrame` が使えるというWeb標準の強力な恩恵を受けられる。
// canvas-worker.js – ワーカースレッド
// 役割:メインスレッドをブロックせずに、ひたすら描画演算を行う
let ctx = null;
let width = 0;
let height = 0;
let animationId = null;
// メインスレッドからのメッセージを受信
onmessage = function(e) {
const { type, canvas, width: w, height: h } = e.data;
if (type === ‘init’) {
width = w;
height = h;
// OffscreenCanvas から 2Dコンテキストを取得(メインスレッドと同じAPIが使える)
ctx = canvas.getContext(‘2d’);
canvas.width = width;
canvas.height = height;
// 描画ループを開始
startRenderLoop();
} else if (type === ‘resize’) {
width = w;
height = h;
if (ctx) {
ctx.canvas.width = width;
ctx.canvas.height = height;
}
}
};
let posX = 0;
let posY = 0;
function render() {
// 背景をクリア
ctx.fillStyle = ‘rgba(15, 23, 42, 0.2)’; // 残像効果を狙った半透明クリア
ctx.fillRect(0, 0, width, height);
// 適当な図形を描画(ここで重い計算や複雑なグラフィックスを回してもメインスレッドは無傷)
ctx.fillStyle = ‘#38bdf8’;
ctx.beginPath();
ctx.arc(
width / 2 + Math.sin(posX) 100,
height / 2 + Math.cos(posY) 100,
30,
0,
Math.PI 2
);
ctx.fill();
posX += 0.05;
posY += 0.03;
// ワーカースレッド内でも requestAnimationFrame が利用可能!
animationId = requestAnimationFrame(render);
}
function startRenderLoop() {
if (animationId) cancelAnimationFrame(animationId);
render();
}
どうだい? この設計であれば、仮にWorker側で何千個ものパーティクルを計算して描画のループが重くなったとしても、メインスレッドはビクともしない。ユーザーがページをスクロールしたり、ボタンをクリックしたりしたときのカクつきは見事に消え去るというわけだ。
—
3. 現場でハマりがちな「落とし穴」とベストプラクティス
さて、ここまで聞いて「よし、明日から全部OffscreenCanvasに置き換えてやろう!」と思ったそこの君、ちょっと待ってくれ。シニアとして、現場で絶対に踏むべきではない「地雷」をいくつか共有しておこう。
1. `transferControlToOffscreen` の不可逆性
一度メインスレッド側のCanvasに対して `transferControlToOffscreen()` を実行してしまうと、そのCanvasはもうメインスレッド側で `getContext()` を叩くことができなくなる。
「やっぱり一部の処理はメインスレッドで書きたい」といった気まぐれは通用しない。設計段階できっちり「描画は100% Workerに寄せる」と割り切る必要がある。
2. DOMへの直接アクセスはWorker内では不可能
Web Workerの鉄則だが、Workerスレッド内には `document` や `window` といったDOMグローバルが存在しない。
もしCanvasのサイズ変更などを親(メインスレッド)のレイアウト変更に連動させたい場合は、ResizeObserverなどでメインスレッド側でサイズを監視し、変更があったら都度 `worker.postMessage()` で新しい寸法を通知してやるアーキテクチャが必要になる。
3. フォントや外部リソースのロード問題
Canvas内でテキスト(`ctx.fillText` 等)を描画する場合や、画像をロードして `drawImage` する場合、Worker内から直接アセットを読み込むアプローチが必要になる(`OffscreenCanvas` では `createImageBitmap()` を使うのが一般的だ)。
メインスレッド側であらかじめ画像を `fetch` または `Image` オブジェクトで読み込み、`ImageBitmap` に変換してから Worker へ転送するのが実務では定石だ。
// メインスレッド側で画像を読み込み、ImageBitmapにしてからWorkerへ送る例
const response = await fetch(‘./avatar.png’);
const blob = await response.blob();
const imageBitmap = await createImageBitmap(blob);
worker.postMessage({ type: ‘setImage’, bitmap: imageBitmap }, [imageBitmap]);
—
まとめ
OffscreenCanvasは、単なる「ちょっとしたパフォーマンス改善のテクニック」ではない。
Webアプリケーションにおける「レンダリング処理のマルチスレッド化」という、モダンブラウザアーキテクチャをフル活用するための強力な武器だ。
特に、ダッシュボードツール、Web上で動くエディタ、データ可視化ツール、そしてリッチなUIインタラクションを持つSaaSプロダクトにおいて、メインスレッドの負荷軽減はプロダクトのコンバージョン率やユーザー評価に直結する死活問題になり得る。
「なんか最近、この画面の動き重いな……」と感じたら、DOMの再レンダリングを疑う前に、「描画処理そのものをWorkerへ追い出せないか?」という視点を持ってみてほしい。視野がぐっと広がるはずだ。
さて、理論はここまでだ。早速次のスプリントで、重いCanvas処理を抱えているあのコンポーネントをOffscreenCanvasに書き換えて、チームをあっと言わせてやろうぜ。何か詰まったらいつでも相談にのる。実装、頑張ってくれ!

コメント