コース進捗 コース目次 18レッスン中 18件を公開中
第I部:描く前に描画面を選ぶ——プロダクト、ピクセル、座標
第II部:ピクセル世界に頭脳を与える——モデル、スケジューリング、入力、ツール
第III部:「ドラッグできる」から「信頼できる」へ——操作、文字、Asset、復旧
第IV部:マスターの判断——Performance、Worker、GPU、SDK、共同編集、AI
5歳児にもわかるゲームから始めよう
積み木で小さな舞台を作ります。青い紙は背景、3体の人形は俳優、透明板上の黄色い円は「誰が選択されているか」です。一人の子どもを舞台監督、もう一人をTicket Sellerにします。観客が瞬きをするたびにTicket Sellerが「俳優全員、もう一度最初から演じて」と叫んだら、どうなるでしょう。全員が忙しくなり、Ticketを売れません。代わりに、背景、俳優、黄色い円が本当に変わったときだけ対応するLampを点け、監督がそれを見て次の開幕前に1回だけ処理するようにします。
まず予想してください。同じ1秒間に「黄色い円のLamp」が10回連続して点いたら、10回開幕する必要があるでしょうか。それとも1回でしょうか。監督が帰宅した後もLampは監督を呼び出せるでしょうか。Animationする車が動き続ける間、Lampはどう点き続けるべきでしょう。
- ReactのTicket HallToolbarとInspector
- Dirty Lampを点けるどのLayerが変わったか記録する
- 次のFrameで開幕する複数の通知をまとめる
- 対称に仕事を終えるLamp、Listener、Observerを解除する
本章で伝える唯一の真実は、ReactがProduct UIを管理し、Canvas Engineが高頻度Interactionと描画を管理する。両者の境界は明確でなければならないということです。
おもちゃをCanvasに翻訳する
| 舞台遊びの概念 | Engineeringの概念 | 所有者 |
|---|---|---|
| Ticket Hall | React / Next.js UI | Toolbar、Inspector、Dialog |
| 舞台Kernel | Imperative Canvas Engine | Document、Input、Camera、Renderer |
| 赤・黄・青のLamp | Dirty Flag / Change Set | Render Scheduler |
| 次の開幕 | requestAnimationFrame() | Browser Frame Scheduling |
| 背景紙、俳優、透明な円 | Layered Canvas / Render Pass | Renderer |
| Animation用Stopwatch | Animation Clock | Scheduler/Simulation |
| Ticket Boardの「2個を選択中」 | 低頻度Derived State | External Store → React |
| 監督の出勤/退勤 | setup / cleanup | Effect Lifecycle |
比喩には限界があります。BrowserはrequestAnimationFrameが正確に16.67msごとに実行されるとは保証しません。Background Tabでは通常Pauseまたは頻度低下が起きます。React Strict ModeのDevelopment Checkは「2人の監督が永久に同時勤務する」という意味ではなく、対称性を検査するためsetup→cleanup→setupを追加で実行します。Layered Canvasは複数の本物の<canvas>を意味することも、1つのCanvasに複数Passがあることもあります。前者は局所的な再描画を減らせますが、Compositing、Dimension同期、MemoryのCostが増えます。
まず誤った直感を捨てる
- 「最もDeclarativeにするため、React renderのたびにEngineを再構築する」。EngineはListener、Observer、rAFを所有します。繰り返し構築するとDocumentを失い、ResourceがLeakします。
- 「InspectorをReal-timeにするため、すべての
pointermoveでsetStateする」。240HzのInputがProduct UI全体を駆動してはいけません。Engine内でTransient StateをUpdateし、Product価値のある低頻度Snapshotだけを公開します。 - 「永久rAF loopが最も簡単だ」。Static Editorでは無意味に電力を消費します。DefaultはInvalidation-Basedにし、本物のAnimation中だけContinuousにします。
- 「Strict Modeの二重初期化はReactのBugだからStrictModeを削除する」。非対称なcleanupを発見しているのです。Resource Ownershipを修正します。
- 「Backgroundから戻ったら巨大な
dtですべてのAnimationを追いつかせる」。JumpやSimulation Spiralが起きます。Variable TimestepをClampし、Fixed TimestepのCatch-up Step数も制限します。 - 「Client ComponentはどんなServer Objectでも受け取れる」。Server/Client Boundaryを越えるpropsはSerialize可能でなければなりません。Context、Function、Map、Engine Instanceは渡せません。
本番用バックパック
前提となる契約
前提は第5章のRuntimeDocument/Session分離です。Engineは構築時に既存Canvas、Overlay root、Read-only Document Storeを受け取ります。破棄は冪等です。Reactが受け取るのはJSONでSerialize可能な初期WireDocumentだけで、Mount後にEngineを作ります。EngineはStableなDerivedSnapshot={selectionCount,canUndo,saveStatus}を公開し、Pointer座標やDocument全体を毎Frame Reactへ送りません。
正式な知識:Scheduler
requestAnimationFrame(callback)は、次のBrowser Paint前にCallbackをScheduleし、共通のHigh-resolution Time BaseからTimestampを渡します。CallbackはOne-shotであり、Continuous Renderingでは再度Requestする必要があります。Invalidation-Based RenderingはStateがDirtyになったときFrameをRequestし、複数回のInvalidationをまとめます。Dirty FlagはBackground、Scene、OverlayをBitmaskで表せます。Change SetはどのRecordが変わったかを運び、将来Layered CanvasやCacheで必要な部分だけをUpdateできるようにします。
Render Passは第3章の順序を維持します。複数のPhysical CanvasはHost DimensionとCameraを共有しなければなりません。DefaultのStatic StateではSchedulerにPending Frameがありません。Animation開始時にcontinuous=trueとし、停止後はDirtyでなければRequestしません。Duplicate Loop Preventionの中心は、唯一のPending rAF IDを保存し、非nullなら新しくRequestしないことです。
Animation Clockは「毎Frame 1を足す」ものではありません。Variable Timestepは上限でClampしたdtをVisual Animationへ渡します。Fixed TimestepはAccumulatorを使い、固定StepでDeterministic SimulationをUpdateしてから補間描画します。Background TabのPause後はdtが巨大になることがあります。最大Deltaと最大Catch-up Step数を制限し、Visibility変更時にTimeをRebaseすることもできます。Time PolicyはEngineに属し、React stateへ置きません。
正式な知識:React / Next.js
Next.js App Routerでは、DOM/Canvasを使うEntry Fileへ'use client'を付けます。refがCanvasとOverlay DOM Nodeを提供します。Browser Paint前に完了すべきHost/Engine SetupにはuseLayoutEffectが適し、通常のNetwork SubscriptionやTelemetryにはuseEffectを使えます。どちらもsetup/cleanupが対称でなければなりません。DevelopmentのStrict ModeはEffect setupとcleanupを追加実行します。正しい結果は、任意の瞬間にActive Engine/Loopが最大1つであることです。
External StoreはuseSyncExternalStore(subscribe,getSnapshot,getServerSnapshot)を通して低頻度でStableなSnapshotをReactへ公開します。Dataが変わらないとき、getSnapshotは同じObjectを返し、Infinite renderを防がなければなりません。DOM Overlayには通常のAbsolute Position Childを使えるほか、createPortalでText EditorをOverlay rootへ送れます。Visual PositionはEngineから低頻度または必要時にProjectionし、FocusとInputは引き続きDOMが管理します。
Imperative Engine Lifecycleは次のとおりです。Effect mountで作成→Document subscription/Pointer/ResizeをBinding→最初のinvalidate。cleanupではrAFをCancelし、Unsubscribeし、Listenerを削除し、Hostを破棄します。React rerenderはmountではなく、Documentを再構築してはいけません。SSR中はModule Top-levelからwindow、document、devicePixelRatio、getContextへAccessしてはいけません。
根拠と互換性
- MDN:requestAnimationFrame()
- MDN:Page Visibility API
- React:useEffect
- React:useLayoutEffect
- React:StrictMode
- React:useSyncExternalStore
- React:createPortal
- React:
'use client'
上記Sourceの確認日は2026-08-29です。BackgroundでのrAF PolicyはBrowserが決めるため、Testを固定Frequencyへ依存させてはいけません。ReactのDevelopment CheckはProduction Buildと異なりますが、cleanupの対称性は両方で成立しなければなりません。
この章のエンジニアリング増分
開始点: React Component内で直接描画し、Pointer Moveごとにstateを書きます。到達点: 独立したScheduler/EngineをNodeでTestでき、ShellはDOM Hostと低頻度Subscriptionだけを担当し、任意の瞬間にActive Loopが1つだけあります。
canvas-lab/src/lab/ch06/
render-scheduler.ts
canvas-engine.ts
CanvasEditorShell.tsx
render-scheduler.test.ts
CanvasEditorShell.test.tsx
完全なSchedulerは、invalidation、continuous、fixed/variable clock、冪等な破棄をまとめて表現します。
export const Dirty = { Background: 1, Scene: 2, Overlay: 4, All: 7 } as const;
export type Frame = {
now: number;
variableDt: number;
dirty: number;
fixedSteps: number;
alpha: number;
};
export type FramePlatform = {
request(callback: FrameRequestCallback): number;
cancel(id: number): void;
};
const browserFrames: FramePlatform = {
request: (callback) => window.requestAnimationFrame(callback),
cancel: (id) => window.cancelAnimationFrame(id),
};
export class RenderScheduler {
private pending: number | null = null;
private dirty = 0;
private continuous = false;
private disposed = false;
private lastNow: number | null = null;
private accumulator = 0;
private readonly fixedStep = 1000 / 60;
constructor(
private readonly render: (frame: Frame) => void,
private readonly platform: FramePlatform = browserFrames,
) {}
invalidate(mask: number): void {
if (this.disposed) return;
this.dirty |= mask;
this.ensureFrame();
}
setContinuous(value: boolean): void {
if (this.disposed || this.continuous === value) return;
this.continuous = value;
if (value) this.ensureFrame();
}
rebaseClock(): void {
this.lastNow = null;
this.accumulator = 0;
}
dispose(): void {
if (this.disposed) return;
this.disposed = true;
if (this.pending !== null) this.platform.cancel(this.pending);
this.pending = null;
this.dirty = 0;
this.continuous = false;
this.rebaseClock();
}
private ensureFrame(): void {
if (this.pending !== null || this.disposed) return;
this.pending = this.platform.request((now) => this.onFrame(now));
}
private onFrame(now: number): void {
this.pending = null;
if (this.disposed) return;
const rawDt = this.lastNow === null ? 0 : now - this.lastNow;
const variableDt = Math.max(0, Math.min(100, rawDt));
this.lastNow = now;
this.accumulator = Math.min(250, this.accumulator + variableDt);
let fixedSteps = 0;
while (this.accumulator >= this.fixedStep && fixedSteps < 5) {
this.accumulator -= this.fixedStep;
fixedSteps += 1;
}
if (fixedSteps === 5 && this.accumulator >= this.fixedStep) {
this.accumulator %= this.fixedStep;
}
const dirty = this.dirty;
this.dirty = 0;
this.render({ now, variableDt, dirty, fixedSteps, alpha: this.accumulator / this.fixedStep });
if (this.continuous || this.dirty !== 0) this.ensureFrame();
}
}
Fixed Stepが5回へ達したら、1 Step以上のBacklogを捨て、1 Step未満の余りだけを残します。そうしなければ、「1 Frameあたり最大5 Step」でもBackgroundで生じた負債を後続の多数Frameへ持ち越し、遅いCatch-up Spiralを作りかねません。Business LogicがStepを捨てられないなら、無限に追いつこうとするのではなくPauseしてAuthoritative Timeへ再同期します。
Engineは高頻度Resourceを所有し、Change SetをLayerへMapします。Snapshot Objectは値が変わったときだけ置き換えます。
import { Dirty, RenderScheduler, type FramePlatform } from './render-scheduler';
export type DocumentChangeSet = Readonly<{
backgroundChanged: boolean;
changedShapeIds: readonly string[];
}>;
export type DocumentStore = {
getSnapshot(): unknown;
subscribe(listener: (change: DocumentChangeSet) => void): () => void;
};
export type DerivedSnapshot = Readonly<{
selectionCount: number;
canUndo: boolean;
saveStatus: 'saved' | 'dirty';
}>;
export class CanvasEngine {
private scheduler: RenderScheduler;
private snapshot: DerivedSnapshot = { selectionCount: 0, canUndo: false, saveStatus: 'saved' };
private listeners = new Set<() => void>();
private abort = new AbortController();
private unsubscribeDocument: () => void;
private destroyed = false;
constructor(
private readonly canvas: HTMLCanvasElement,
documentStore: DocumentStore,
private readonly renderPasses: (dirty: number) => void,
frames?: FramePlatform,
) {
this.scheduler = new RenderScheduler((frame) => this.renderPasses(frame.dirty), frames);
this.unsubscribeDocument = documentStore.subscribe(this.onDocumentChange);
canvas.addEventListener('pointermove', this.onPointerMove, { signal: this.abort.signal });
document.addEventListener('visibilitychange', this.onVisibility, { signal: this.abort.signal });
this.scheduler.invalidate(Dirty.All);
}
private onPointerMove = (_event: PointerEvent): void => {
if (!this.destroyed) this.scheduler.invalidate(Dirty.Overlay);
};
private onVisibility = (): void => {
if (document.visibilityState === 'visible') {
this.scheduler.rebaseClock();
this.scheduler.invalidate(Dirty.All);
}
};
private onDocumentChange = (changeSet: DocumentChangeSet): void => {
this.publish({ ...this.snapshot, canUndo: true, saveStatus: 'dirty' });
let dirty = Dirty.Overlay;
if (changeSet.backgroundChanged) dirty |= Dirty.Background;
if (changeSet.changedShapeIds.length > 0) dirty |= Dirty.Scene;
this.scheduler.invalidate(dirty);
};
setSelection(ids: ReadonlySet<string>): void {
this.publish({ ...this.snapshot, selectionCount: ids.size });
this.scheduler.invalidate(Dirty.Overlay);
}
subscribe = (listener: () => void): (() => void) => {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
};
getSnapshot = (): DerivedSnapshot => this.snapshot;
private publish(next: DerivedSnapshot): void {
if (
next.selectionCount === this.snapshot.selectionCount &&
next.canUndo === this.snapshot.canUndo &&
next.saveStatus === this.snapshot.saveStatus
)
return;
this.snapshot = next;
for (const listener of this.listeners) listener();
}
destroy(): void {
if (this.destroyed) return;
this.destroyed = true;
this.unsubscribeDocument();
this.abort.abort();
this.scheduler.dispose();
this.listeners.clear();
}
}
ここでDocumentStore.subscribeは、第5章のCommand BoundaryにあるRead-only Subscription Portです。Engineはrecordsを直接変更しません。Change Setは単なる「変わった」というBooleanではありません。Background変更とShape ID変更を別のDirty LayerへMapするからこそ、後でLayered Canvas、Cache、Local Invalidationを安全に追加できます。Production StoreではUnsubscribe Functionも冪等にすべきです。Engineのdestroy()はそれを1回だけ呼ぶことを保証します。
ShellはClient Boundaryであり、initialDocumentはJSONであってRuntime Mapではありません。次のCodeに、必要な4 Layer StructureとPortalを示します。
'use client';
import { useLayoutEffect, useRef, useState, useSyncExternalStore } from 'react';
import { createPortal } from 'react-dom';
import { CanvasEngine, type DerivedSnapshot, type DocumentStore } from './canvas-engine';
type WireDocument = { records: readonly { id: string; type: string }[] };
const empty: DerivedSnapshot = { selectionCount: 0, canUndo: false, saveStatus: 'saved' };
const subscribeEmpty = (): (() => void) => () => undefined;
const getEmpty = (): DerivedSnapshot => empty;
const staticStore = (document: WireDocument): DocumentStore => ({
getSnapshot: () => document,
subscribe: () => () => undefined,
});
const defaultCreateEngine = (canvas: HTMLCanvasElement, document: WireDocument): CanvasEngine =>
new CanvasEngine(canvas, staticStore(document), () => undefined);
export function CanvasEditorShell({
initialDocument,
createEngine = defaultCreateEngine,
}: {
initialDocument: WireDocument;
createEngine?: (canvas: HTMLCanvasElement, document: WireDocument) => CanvasEngine;
}) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const overlayRef = useRef<HTMLDivElement>(null);
const [engine, setEngine] = useState<CanvasEngine | null>(null);
useLayoutEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const next = createEngine(canvas, initialDocument);
setEngine(next);
return () => {
next.destroy();
};
}, [createEngine, initialDocument]);
const snapshot = useSyncExternalStore(
engine?.subscribe ?? subscribeEmpty,
engine?.getSnapshot ?? getEmpty,
getEmpty,
);
return (
<section className="canvas-editor-shell">
<div className="canvas-host">
<canvas ref={canvasRef}>業務Process Canvas</canvas>
</div>
<div ref={overlayRef} className="dom-overlay-layer" />
<nav className="toolbar" aria-label="Canvas Tool">
<button type="button">選択</button>
<button type="button">Rectangle</button>
</nav>
<aside className="inspector" aria-live="polite">
{snapshot.selectionCount}個を選択中;{snapshot.saveStatus}
</aside>
{overlayRef.current
? createPortal(<textarea aria-label="編集中のText" />, overlayRef.current)
: null}
</section>
);
}
defaultCreateEngineはComponent外で定義しなければなりません。Default Arrow FunctionをParameter Listへ書くと、renderのたびに新しいReferenceになります。Engine作成後のsetEngineが次のrenderを起こし、Dependency変更によって再び破棄/再構築され、Loopになります。実際のApplicationでもCustom createEngineとinitialDocumentのReferenceをStableにします。Server Componentが渡すのはPlain JSONだけです。Parentが毎回新しいObjectを作るならDocument IDでStoreをLoadし、Effect Dependencyを抑えて本当のData Updateを無視してはいけません。cleanupはunmount時にReact stateを書きません。そのsetupが実際に作ったInstanceだけを破棄します。
Fake Frameを使うScheduler Testで、10回のinvalidationが1つのRequestへまとまり、破棄でCancelされることを証明します。
import { describe, expect, it, vi } from 'vitest';
import { Dirty, RenderScheduler, type FramePlatform } from './render-scheduler';
describe('RenderScheduler', () => {
it('coalesces dirty changes and owns one active request', () => {
let callback: FrameRequestCallback | null = null;
const cancel = vi.fn();
const platform: FramePlatform = {
request: vi.fn((cb) => {
callback = cb;
return 41;
}),
cancel,
};
const render = vi.fn();
const scheduler = new RenderScheduler(render, platform);
for (let i = 0; i < 10; i += 1) scheduler.invalidate(Dirty.Overlay);
expect(platform.request).toHaveBeenCalledTimes(1);
(callback as FrameRequestCallback)(100);
expect(render).toHaveBeenCalledWith(expect.objectContaining({ dirty: Dirty.Overlay }));
expect(platform.request).toHaveBeenCalledTimes(1);
scheduler.invalidate(Dirty.Scene);
scheduler.dispose();
scheduler.dispose();
expect(cancel).toHaveBeenCalledTimes(1);
});
it('clamps a background-tab time jump', () => {
const callbacks: FrameRequestCallback[] = [];
const platform: FramePlatform = {
request: (cb) => {
callbacks.push(cb);
return callbacks.length;
},
cancel: () => undefined,
};
const render = vi.fn();
const scheduler = new RenderScheduler(render, platform);
scheduler.setContinuous(true);
callbacks.shift()!(10);
callbacks.shift()!(10_000);
expect(render.mock.calls[1][0].variableDt).toBe(100);
expect(render.mock.calls[1][0].fixedSteps).toBeLessThanOrEqual(5);
expect(render.mock.calls[1][0].alpha).toBeLessThan(1);
scheduler.dispose();
});
});
npx vitest run src/lab/ch06/render-scheduler.test.tsを実行し、2 passedを期待します。React Testing Libraryでは<StrictMode>内でShellを2回mount/unmountし、create count === destroy count、同時active最大値が1であることをAssertします。React Profilerを使って5秒間Dragし、Canvasは高頻度にUpdateする一方、Shellのrender回数はselectionCount/saveStatusが変わったときだけ増えることを確認します。
舞台へ戻ると、Ticket Sellerが表示するのは「いくつ選択したか、Undoできるか」だけです。俳優が1mm動くたびにTicket全体を印刷し直すことはありません。舞台監督は10回の同色通知を次の1回の開幕へまとめ、退勤時にはすべての鍵を返します。
わざと壊す
| 注入する故障 | 症状 | 根拠 | 修正 | Regression Test | 復旧 |
|---|---|---|---|---|---|
| Strict Mode setupでcleanupしない | DevelopmentでListener/Loopが二重になる | active counter最大値が2 | Effectが冪等なdestroyを返す | Strict mount cycle最大active=1 | 残存Instanceを消す |
| Mount/Unmountを2回行う | 2回目はEventが二重発火する | listener/render呼び出しが倍になる | EngineがAbortControllerを独占する | 2 Cycle後create=destroy | 再mountして利用可能か確認する |
| ResizeObserverを解放しない | Pageを離れてもCallbackされる | detached nodeがretainedになる | Host.destroyでdisconnectする | heap/spyが0になる | 残存Hostを手動破棄する |
| Pointer listenerが重複する | 1回の移動で複数回invalidateする | Event traceが重複する | signalで一括解除する | 1 Eventが1 Entry | 古いcontrollerをabortする |
| rAF loopが重複する | IdleでもCPUが高く、Frame数が倍になる | pending ID/Performance trace | 唯一のpending guard | 10回invalidateして1 request | 余分なIDをcancelする |
SSR Top-levelでwindowを読む | Server Buildがwindow is not defined | SSR stack trace | 'use client' + Effect内でAccess | server render/import test | Module Side Effectを削除する |
| Pointer MoveをReact Stateへ書く | Pageが毎秒120–240回renderする | React Profiler | 高頻度TransientをEngineに残す | 5秒Dragのrender budget | 悪いstate bridgeを削除する |
| Backgroundの巨大dt | 復帰時に瞬間移動/停止する | dt≈数万ms | clamp、fixed step cap、rebase | 10→10000 time test | Animation Clockを再起動する |
毎回、故障Switchを1つだけ有効にし、Active Engine、Pending rAF、Listener、Observer、React Commit数を記録します。修正後はUnit Test、Strict Lifecycle Test、Browser Profilerを実行します。最後に意図的にunmountし、すべての数値が0へ戻って初めて復旧完了です。
根拠を示して合格する
| Gate | 自動/手動 | 根拠 |
|---|---|---|
| Active Loopは常に1つ | 自動 | pending rAF Unit Testとactive counter |
| EngineをReactなしでTestできる | 自動 | SchedulerはPure Node Test、EngineはjsdomでReact importなし |
| React rerenderがDocumentを再構築しない | 自動/手動 | Profiler + Inspector renderでEngine create countが増えない |
| 高頻度InteractionがPageをrerenderしない | 手動 | Pointer traceは多く、React commitは少ない |
| Lifecycleが対称 | 自動 | Strict setup/cleanup、Observer/listener/rAFがすべて1:1 |
| Background復帰を制御できる | 自動 | dt clamp、fixed steps cap、visibility rebase test |
- Invalidation、Dirty Flag、Change Set、固定Passを実装した。
- ContinuousはAnimation中だけ有効で、Static時にはrAFがない。
- Fixed/Variable TimestepとBackground Tab Policyに決定論的なTestがある。
-
<CanvasEditorShell>にHost、DOM Overlay、Toolbar、Inspectorが含まれる。 -
'use client'、ref、Effect/LayoutEffect、Strict Mode、External Store、Portal、Serialization Boundaryの役割が明確である。 - Engineをmountで作成、unmountで破棄し、Reactへ公開するのは低頻度Derived Stateだけである。
5歳児に説明する
「React」「Engine」「rAF」「Dirty Flag」「Effect」という言葉を使わずに答えてください。観客が瞬きをするたびに、すべての俳優が演じ直してはいけないのはなぜでしょう。監督が帰宅した後、別の監督が密かにLampを点け続けていないと、どう確認するでしょう。
専門用語を使わない合格回答を開く
瞬きでは背景も俳優も黄色い円も変わらないので、演じ直すのは力の無駄です。何かが変わったら、対応する色のLampを点けます。短い間に10回点いても、次の開幕でまとめて処理します。俳優が走り続ける間は一時的にLampを点け続け、止まったら消せます。監督は出勤時に、Timer、定規、Doorbellという鍵の束を受け取ります。退勤時には一つずつ返さなければなりません。検査では、出勤してすぐ退勤し、もう一度出勤してもらいます。どの瞬間にも鍵を持つ人が一人だけなら、密かに働く2人目はいません。