Canvas vs DOM — 203個のコンポーネントで選んだ基準と実測データ
このトピックを深掘りする
Canvas vs DOM・インタラクティブUI
CanvasとDOMの使い分け・CSSインタラクション・カスタム物理エンジン・ポートフォリオ設計
全5本中 1 本目。
次に読む記事
203個のCanvas/DOMコンポーネントで踏んだバグ Top 10 — 実データから見えたパターン
203個のインタラクティブコンポーネントを開発する過程で実際に遭遇したバグをgit履歴から集計し、頻出パターンと修正手法をTop 10形式でまとめた。
CSSだけで作るインタラクティブUI — transform, transition, custom propertiesの実践
FidgetSpinner、InfiniteKnob、ToggleSwitchなどのDOM+CSSベースのインタラクティブUIで活用したtransform、transition、custom propertiesのCSS技法を実例で解説する。
自前物理エンジン180行の限界点 — Matter.jsを捨てた理由と、カバーできなかったこと
physics.tsの166行で2D物理エンジンを自作し、Matter.jsの300KBを4KBに削減した。重力・衝突・バウンドに絞り、拘束・摩擦・スタッキングを諦めたトレードオフを解説する。
シリーズ全体の流れを見ながら、次に読む記事へ進めます。 初めての方はホームへ →
はじめに
このポートフォリオサイトには211個のインタラクティブコンポーネントがある。流体シミュレーション、パックマン、ピアノ、ワードル、万華鏡——ジャンルはバラバラだが、すべてに共通する最初の設計判断がある。
Canvas で描くか、DOM で組むか。
211回この判断を繰り返した結果、分布はこうなった。
- Canvas: 129個(63%)
- DOM: 68個(33%)
- ハイブリッド: 14個(4%)
この比率は意図して設計したものではなく、個別の判断の積み重ねで自然にこうなった。振り返ると、判断基準は4つの軸に集約される。本記事ではその基準を、実際のコンポーネントを引きながら解説する。
判断基準: 4つの軸
CanvasかDOMかは「シミュレーション有無」「エンティティ数」「ピクセル精度」「アクセシビリティ要件」の4軸で決まる。
1. 数値シミュレーションの有無
最も明確な分岐点。連続的な物理計算やグリッドベースのシミュレーションがある場合、Canvas一択になる。
理由は単純で、DOMはそもそも「座標を直接操作する」ための仕組みではないからだ。transform: translate(x, y)で位置を動かすことはできるが、フレームごとに数百〜数千のエンティティの座標を更新し、それを反映させるのはDOMの設計思想に反している。
Canvas APIのfillRectやarcは、座標とサイズを渡すだけで即座に描画される。DOMのレイアウト計算(リフロー)を一切経由しない。この差は、60FPSを維持する必要があるシミュレーションでは決定的になる。
2. 描画エンティティ数
同時に画面上に存在するエンティティが50個を超えたらCanvas。これは経験則だが、かなり信頼できる閾値だ。
DOMノードが50個程度なら、ブラウザのレイアウトエンジンは問題なく処理する。しかし100を超えると、各ノードへのtransform更新がフレームバジェット(16.67ms)を圧迫しはじめる。特にモバイルでは顕著で、200ノードのtransform更新は10ms以上かかることがある。
CanvasならclearRect→描画ループで、エンティティ数が増えてもオーバーヘッドはほぼ線形だ。DOMのように「各ノードのスタイル再計算→レイアウト→ペイント→コンポジット」というパイプラインを通らない。
3. ピクセル精度
画像処理、カスタムフィルター、ピクセル単位の操作が必要な場合はCanvas。ImageDataで直接ピクセルバッファを操作できるのはCanvas固有の能力であり、DOMには代替手段がない。
4. アクセシビリティ要件
ここが唯一、DOMに軍配が上がるポイントだ。
テキストラベルをスクリーンリーダーで読む必要がある、キーボードだけで操作できる必要がある、フォーカス管理が必要——これらの要件があるなら、DOMを選ぶべきだ。
Canvasにaria-labelをつけることはできるが、Canvas内部の個別要素にフォーカスを当てることはできない。「このボタンを押した」「この選択肢を選んだ」というインタラクションは、ネイティブの<button>や<input>でなければ適切に表現できない。
Canvas を選んだ代表例
129個のCanvasコンポーネントに共通するのは「大量エンティティ」「物理シミュレーション」「ピクセル操作」のいずれかだ。
BubbleWrap — 768エンティティの同時管理
プチプチを潰すインタラクション。12×8 = 96個のバブルがあり、各バブルが潰れると8個のパーティクルが飛び散る。全バブルを一斉に潰すと768エンティティが同時に存在する。
DOMで768個の<div>を毎フレームtransform更新するのは現実的ではない。Canvasなら、各パーティクルの座標更新とarc描画で1フレームあたり2ms程度に収まる。
96バブル × 8パーティクル = 768エンティティ
Canvas: ~2ms/frame
DOM推定: ~18ms/frame(フレーム落ち確定)FluidSimulation — ImageDataによるピクセル操作
100×60のグリッド上でNavier-Stokes方程式を簡略化した流体シミュレーション。Float32Arrayで速度場と密度場を保持し、計算結果をImageDataのピクセルバッファに直接書き込む。
6,000セルの色情報を毎フレーム更新するのに、DOMでは6,000個の<div>のbackgroundColorを変更する必要がある。ImageData.dataへの直接書き込みなら、バッファ操作だけで済む。
グリッド: 100×60 = 6,000セル
データ構造: Float32Array(速度場×2 + 密度場)
描画: ImageData → putImageData(1回のAPI呼び出し)Boids — O(n²)近傍探索と175エージェント
Craig Reynoldsのボイドアルゴリズム。175体のエージェントが、分離・整列・結合の3つのルールに従って群れを形成する。
各エージェントは毎フレーム、他の全エージェントとの距離を計算する。175体なら175×174 = 30,450回の距離計算/フレーム。この計算結果を175個の三角形として描画する。
Canvas上の三角形描画はbeginPath→moveTo→lineTo×2→fillで完結する。DOMで三角形を表現するにはclip-pathやSVGが必要になり、175個の更新はさらに重くなる。
Pacman — 441セルの迷路とゴーストAI
21×21 = 441セルの迷路。パックマン本体、4体のゴースト、最大約200個のドット。合計で約650のエンティティが同時に存在する。
ゴーストのAI(Blinky: 追跡、Pinky: 先回り、Inky: 挟み撃ち、Clyde: 気まぐれ)は毎フレーム経路計算を行う。この計算結果をCanvasに描画する。迷路の壁、ドット、パックマンのアニメーション口、ゴーストの波打つ裾——すべてCanvasのパスとアークで描画している。
ParticleField — 4,950本の接続線
100個のパーティクルが浮遊し、近接するパーティクル同士を線で結ぶ。100個のうち2個を選ぶ組み合わせは100×99÷2 = 4,950通り。毎フレーム4,950回の距離計算を行い、閾値以内のペアに線を引く。
実際に描画される線は距離フィルタリング後で数百本程度だが、判定自体は4,950回必要だ。この本数の<line>SVG要素を毎フレーム更新するのはDOMの得意分野ではない。
DOM を選んだ代表例
68個のDOMコンポーネントは全て「ユーザーが選択・入力・操作する」タイプであり、キーボード操作とアクセシビリティが必須だった。
FidgetZone — キーボード操作可能な4ウィジェット
フィジェットスピナー、トグルスイッチ、スライダー、クリッカーの4つが並ぶ。各ウィジェットは独立しており、すべてキーボードで操作できる。
スピナーはSpaceキーで回転、トグルはEnterで切替、スライダーは矢印キーで移動、クリッカーはSpaceで押下。これらをCanvasで実装すると、キーボードフォーカスの管理を自前で実装する必要がある。DOMなら<button>とtabIndexで自然に実現できる。
エンティティ数は4つ。パフォーマンス上の問題はまったくない。DOMの方がコード量も少なく、アクセシビリティも高い。
MemoryCards — CSS 3D Transform
神経衰弱ゲーム。カードを裏返すアニメーションにrotateY(180deg)を使う。CSSの3D transformはGPUアクセラレーションが効き、60FPSのカードフリップがtransition一行で実現できる。
各カードは<button>として実装されており、Tabキーでフォーカスを移動し、EnterまたはSpaceで選択できる。マッチしたカードにはaria-disabled="true"が付与される。
Canvasでカードフリップを実装する場合、パース変換の計算を自前で行い、裏面と表面のテクスチャを座標変換して描画する必要がある。CSSのperspectiveとtransform-style: preserve-3dが無料で提供してくれる機能を、わざわざ再実装する理由はない。
GravityBadges — テキストラベルの可読性
"React"、"TypeScript"、"Tailwind CSS"といった技術スタックのバッジが、重力に従って落下し、跳ね返る。物理演算はsrc/utils/physics.tsの自前実装を使用している。
ここでDOMを選んだ決定的な理由はテキストの可読性だ。各バッジに書かれた技術名は、スクリーンリーダーで読めるべきだし、検索エンジンにもインデックスされるべきだ。CanvasのfillTextでテキストを描画すると、DOMツリーには存在しないため、アクセシビリティツリーからも消える。
バッジの数は最大でも30個程度。この数ならDOMのtransform更新でも十分60FPSを維持できる。
Wordle — 仮想キーボードとARIA
ワードルクローン。5文字×6行の入力グリッドと、QWERTY配列の仮想キーボードがある。
キーボードの各キーは<button>で、使用済みの文字には色が付く(緑: 正解位置、黄: 含まれる、灰: 不使用)。この色分けはaria-labelで「A: 正しい位置」のように補足される。入力済みの行はaria-disabled="true"になる。
ゲームとしてのインタラクションは「文字を選ぶ」「送信する」「結果を見る」であり、これらはすべてDOMのフォーム要素で自然に表現できる。Canvasで仮想キーボードを実装しても、キーボードナビゲーションの実装コストが増えるだけだ。
Sokoban — 絵文字グリッドとセマンティクス
倉庫番クローン。プレイヤー(🧑)、箱(📦)、ゴール(⭐)、壁(🧱)を絵文字で表現している。
各セルはrole="gridcell"を持ち、グリッド全体はrole="grid"でマークアップされている。プレイヤーの現在位置はaria-current="true"で示される。
このセマンティック構造はCanvasでは再現できない。Canvasに描画した絵文字は見た目上は同じだが、支援技術からは単なるビットマップでしかない。
アクセシビリティの差
Canvasコンポーネントに対してできるアクセシビリティ対応は限られている。
<canvas
role="img"
aria-label="流体シミュレーション。マウスまたはタッチで流体を操作できます"
tabindex="0"
/>これが実質的な上限だ。Canvas内部の個別要素(パーティクルやセル)にフォーカスを当てることはできないし、状態変化をスクリーンリーダーに通知することもできない。
一方、DOMコンポーネントではネイティブHTML要素のセマンティクスがそのまま使える。
<button
aria-pressed="true"
aria-label="C4の鍵盤"
onKeyDown={handleKeyDown}
>
C4
</button>この差は埋められない。ユーザーが「操作」するUIはDOM、ユーザーが「観察」するビジュアルはCanvas——これがアクセシビリティ観点での最もシンプルな基準だ。
211個のコンポーネントを振り返ると、DOMを選んだ68個はすべて「ユーザーが何かを選択・入力・操作する」タイプだった。Canvasを選んだ129個は「眺める・触れる・遊ぶ」タイプが大半を占める。
パフォーマンス戦略の違い
Canvas と DOM では、パフォーマンス最適化のアプローチが根本的に異なる。
Canvas: ビューポート外のrAF停止
Canvasコンポーネントの最大のパフォーマンスリスクは、画面外で回り続けるアニメーションループだ。211個のコンポーネントのうち129個がCanvasで、各々がrequestAnimationFrameループを持つ。ページをスクロールして画面外に出たコンポーネントが裏でCPUを消費し続けるのは許容できない。
解決策はIntersectionObserverだ。コンポーネントがビューポートから外れたらcancelAnimationFrameを呼び、再び表示されたら再開する。
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
startAnimation()
} else {
stopAnimation()
}
},
{ threshold: 0.1 }
)
if (canvasRef.current) observer.observe(canvasRef.current)
return () => observer.disconnect()
}, [])この最適化は129個のCanvasコンポーネント全てに適用している。
DOM: GPU レイヤーの活用
DOMコンポーネントでは、アニメーションをtransformとopacityに限定することでGPUアクセラレーションを得る。この2つのプロパティはコンポジットレイヤーで処理されるため、リフロー(レイアウト再計算)が発生しない。
/* 良い: コンポジットのみ */
.badge { transform: translate(var(--x), var(--y)); }
/* 悪い: リフロー発生 */
.badge { left: var(--x); top: var(--y); }GravityBadgesでは30個のバッジが物理演算に従って移動するが、すべてtransformで位置更新しているため、60FPSを安定して維持できる。left/topで実装していた初期バージョンでは、モバイルで35FPS程度まで落ちていた。
共通: useRef でReact再レンダーを回避
Canvas・DOM問わず、アニメーション状態をReactのstateで管理するのは避けるべきだ。setStateは再レンダーを引き起こし、60FPSのアニメーションでは毎秒60回の再レンダーになる。
代わりにuseRefを使う。Snakeゲームの実装が典型例だ。
const snakeRef = useRef<Point[]>([{ x: 10, y: 10 }])
const scoreRef = useRef(0)
const dirRef = useRef<Direction>('right')蛇の座標、スコア、方向——すべてuseRefで管理している。Reactは「ゲームオーバー」「スコア表示の更新」など、ユーザーに見せる必要があるタイミングだけ再レンダーする。
この戦略は211個のコンポーネント全体で統一している。アニメーションの内部状態はref、UIに反映する状態だけstate。この線引きを守ることで、Reactのレンダリングコストをほぼゼロに抑えている。
ハイブリッドは?
211個のうち、Canvas と DOM を併用しているのは14個(約4%)だけだ。
代表例はTerrariumとSolarSystem。Terrariumでは、生態系シミュレーション(虫・植物の移動と成長)をCanvasで描画し、温度・湿度のコントロールパネルをDOMのスライダーで実装している。SolarSystemでは、惑星の軌道計算と描画はCanvasで、惑星名のラベルとクリック時の情報パネルはDOMオーバーレイだ。
ハイブリッドが少ない理由は複雑さのコストにある。Canvas座標系とDOMのレイアウトを同期させるのは面倒だ。Canvasのリサイズ時にDOMオーバーレイの位置を再計算する必要があり、devicePixelRatioの扱いも二重になる。
迷ったらどちらかにコミットする。211回の判断の中で学んだ最も実用的なルールだ。
判断フローチャート
最後に、211回の判断を振り返って抽出したフローチャートをまとめる。新しいコンポーネントを作るとき、上から順に問いかける。
Q1: 連続的な物理シミュレーションか?
→ Yes → Canvas
→ No → Q2
Q2: 同時描画エンティティが50個以上か?
→ Yes → Canvas
→ No → Q3
Q3: ピクセル単位の操作(ImageData等)が必要か?
→ Yes → Canvas
→ No → Q4
Q4: テキスト/ラベルを読める必要があるか?
→ Yes → DOM
→ No → Q5
Q5: キーボードアクセシビリティが必須か?
→ Yes → DOM
→ No → Q6
Q6: CSS Transform/Transitionで十分か?
→ Yes → DOM(実装コスト低)
→ No → CanvasQ1〜Q3のいずれかがYesならCanvas、Q4〜Q5のいずれかがYesならDOM。どちらにも該当しない場合は、CSSで表現できるかどうかで判断する。CSSのtransitionやanimationで十分なら、わざわざCanvasのrequestAnimationFrameループを書く必要はない。
このフローチャートに従えば、211個の判断のうち約95%は同じ結論に到達する。残りの5%がハイブリッドになるケースだが、前述の通り「迷ったらコミット」で片方に寄せる方がメンテナンスコストは低い。
まとめ:Canvas vs DOMの選び方
211個のコンポーネントを作って得た最大の教訓は、CanvasとDOMは競合する技術ではなく、得意領域が異なる技術だということだ。
Canvasは数値の海を映像に変換する装置であり、DOMはユーザーとの対話を構造化する装置だ。どちらが優れているかではなく、何を作ろうとしているかで選択は決まる。
63% vs 33%という分布は、このポートフォリオが「見て楽しむ」タイプのインタラクションに偏っていることの反映でもある。もしフォーム中心のアプリケーションを作っていたら、比率は逆転していただろう。
技術選択に正解はないが、判断基準を言語化しておくと、211回目の判断も1回目と同じ速度でできる。このフローチャートが、同じ判断に直面する誰かの参考になれば幸いだ。
よくある質問
- Q. Canvas APIとDOMのどちらを選ぶべきですか?
- 連続物理シミュレーション、50個以上の同時描画エンティティ、ピクセル操作のいずれかがあればCanvas。キーボード操作やスクリーンリーダー対応が必要ならDOM。
- Q. Canvasコンポーネントのアクセシビリティ対策はどうすればよいですか?
- canvas要素にrole="img"とaria-labelを付与するのが実質的な上限。Canvas内部の個別要素にフォーカスを当てることはできないため、ユーザーが操作するUIはDOMで実装すべき。
- Q. DOMアニメーションで60fpsを維持するコツは何ですか?
- アニメーションをtransformとopacityに限定してGPUアクセラレーションを活用し、left/topによるリフロー発生を避ける。状態管理はuseRefで行い、React再レンダーを回避する。