コース進捗 コース目次 18レッスン中 18件を公開中
第I部:描く前に描画面を選ぶ——プロダクト、ピクセル、座標
第II部:ピクセル世界に頭脳を与える——モデル、スケジューリング、入力、ツール
第III部:「ドラッグできる」から「信頼できる」へ——操作、文字、Asset、復旧
第IV部:マスターの判断——Performance、Worker、GPU、SDK、共同編集、AI
5歳児にもわかるゲームから始めよう
家族写真を1枚撮り、写真と家族台帳Cardの束を机へ置きます。写真からわかるのは「ここに3人いる」ことです。一方Cardには、一人ひとりの名前、変わらない番号、誰がどの家族に属するか、誰が誰の前に並ぶか、誰と誰が手をつないでいるかが書かれています。写真から一人を切り取ってみます。その人の名前と家族関係は、Cardから自動的に消えるでしょうか。写真を水彩画へ交換すると、その人の番号を変える必要があるでしょうか。
まず予想してください。2人の子どもがどちらも「青ちゃん」と呼ばれる場合、名前だけで同じ一人を安定して見つけられるでしょうか。AのCardに「私の親はB」とあり、BのCardにも「私の親はA」とある場合、親を上へたどり続けると何が起きるでしょう。
- 一意な番号を発行する見た目が変わってもIdentityは変わらない
- 関係を登録するParent/Child、順序、接続
- 台帳を検査するOrphanとCycleを拒否する
- Projectionを選ぶCanvasまたはDOM
本章で伝える唯一の真実は、Document Modelが事実のSourceであり、RendererはProjectionにすぎないということです。PixelにはStable ID、Parent/Child関係、Permission、Connection、Business Identityは含まれません。
おもちゃをCanvasに翻訳する
| 家族台帳の遊び | Canvas Lab | 責任 |
|---|---|---|
| 台帳全体 | DocumentRecord | Document VersionとPage Entry |
| 各家族のPage | PageRecord | 1つのSceneとRoot Shapeの順序 |
| 人物Card | ShapeRecord | Stable ID、Type、Parent、Local Transform、Property |
| 家族写真/添付物 | AssetRecord | ImageなどのResource Metadata |
| 手をつなぐ関係 | BindingRecord | ConnectorとTargetのSemantic Connection |
| 親と子 | Parent/Child Scene Graph | Local TransformがAncestorを通ってWorldになる |
| 前列と後列 | Z-Order | DrawingとHitのPriorityを決める |
| Photographer一覧 | Renderer Registry | type → どうProjectionするか |
| 身長を測る道具 | Geometry Registry | type → Geometry Query |
| 今日誰を指しているか | Session Selection | Durable Documentには属さない |
比喩には限界があります。Shapeには現実の「親」がいない場合があり、Groupも人間ではありません。1つのAssetを複数のShapeが参照でき、BindingはParent/Childでもありません。現実の台帳Cardはあまり変わりませんが、Editor DocumentはCommandで頻繁にUpdateされます。この比喩が強調するのはIdentityとRelationshipであり、すべてのSubtreeをNested JSONで保存することではありません。RuntimeではNormalized Recordsを使い、ID単位でUpdateとValidationを行います。
まず誤った直感を捨てる
- 「Canvasに描けたなら保存もできている」。Bitmapが保存するのは色だけです。Reload、Resize、Semantic Data Exportの際にObjectを取り戻せません。
- 「
CanvasRenderingContext2DをShapeへ入れると便利だ」。ContextはSerializeできず、ModelをRendererへ固定します。ShapeにはDomain Dataだけを保存します。 - 「Selectionも一緒に保存すれば戻ったとき便利だ」。Selection、Hover、Cameraは通常Sessionです。Multiplayer Presenceはさらに別の層です。Documentへ混ぜるとUndo、共同編集、Persistenceを汚染します。
- 「Object IDにはArray Indexを使う」。並べ替え、挿入、共同編集でIndexは変わります。IdentityはStableかつImmutableでなければなりません。
- 「GroupはAbsolute座標を1つ保存すればよい」。子NodeにはLocal Transformが必要です。World TransformはScene Graphから得るDerived Stateであり、両方保存するとDriftします。
- 「未知のShapeはすぐ捨てる」。新ClientのDataを旧Clientが開いたとき、Silent DeleteはDocumentを破壊します。元Recordを隔離・保持し、Compatibility Placeholderを表示します。
本番用バックパック
前提となる契約
前提は第4章のMatrix2DとSpace Namingです。Canvas LabではDocumentにDurable Stateだけを含めます。SessionState={selection,camera,activeTool}は分離し、Presenceは本章のDocumentへ一切入りません。すべてのWriteはCommand Boundaryを通り、RendererはRead-only Snapshotを受け取ります。Business Domain ObjectのIDはCanvas RecordへAdaptした後も保持します。
正式な知識
Stable IDはImmutableなIdentityであり、Display Nameではありません。DocumentはPage、Shape、Asset、Bindingを集約します。PageはVisible Sceneを指定します。ShapeはTypeとPropertyを持つVisual Recordです。GroupはParent/ChildでScene Graphを構成します。AssetはBinary Resourceを独立管理します。BindingはConnectorやPortなど、ShapeをまたぐRelationshipを表現します。Parent IDと各PageのRoot ListがTree/Forestを作るため、CycleやDocumentをまたぐDangling Referenceを禁止しなければなりません。
Z-Orderを偶然のObject Iteration順序に任せてはいけません。各ParentのchildIds順序、または明示的に比較可能なKeyを使えます。Conflict時には決定論的なTie-breakが必要です。Local TransformはRecordへ保存し、World TransformはAncestor Chainから得るDerived Stateです。Selection Bounds、World Bounds、Visibility、Cached PathもDerived Stateであり、Source of Truthへ書き戻しません。
Domain Modelは注文、倉庫、承認などのBusiness Conceptを記述します。Visual ModelはRectangle、Text、Connection Portを記述します。その間にAdapterを置きます。Business Domain Object → Canvas Document Record → Geometry/Renderer → Pixels or DOMです。1つのBusiness Objectが複数Shapeに対応する場合もあれば、ShapeがBusiness上の意味を持たない付箋である場合もあります。両者を直接同一視してはいけません。
Normalized RecordsはWire上でRecord ArrayまたはID Dictionaryとして表現でき、Runtime IndexにはMapを使います。Deserializationでは最初にSchema Validationを行い、その後Indexを作ってDuplicate ID、未知Record、Orphan、Cycle、Order Conflictを検査します。Immutable Identityとは、「ID変更」が通常のUpdateではないという意味です。Copyするなら新IDを作り、Bindingを明示的に修復します。
Renderer Registryはshape.type → ShapeRenderer、Geometry Registryはshape.type → ShapeGeometryProviderを担当します。RendererがなくてもGeometryとDocumentは動作でき、RendererはUnknown Placeholderを返せます。Command Boundaryは「動かしたい」という意図をMoveShapeCommandへ変換し、PermissionとPreconditionを検証してAtomicにCommitします。UIやRendererがMapを勝手に変更することを防ぎます。
根拠と互換性
ここで扱うCore TypeはJavaScript Dataだけに依存し、Canvas/DOMには依存しません。そのためNode Testで実行できます。Wire FormatにはJSONで表現できる値だけを使います。Map、Set、DOMMatrix、FunctionをJSON Documentとして直接保存することはできません。Process内SnapshotへstructuredCloneを使っても、Long-term Persistence Formatと取り違えてはいけません。
上記Sourceの確認日は2026-08-29です。Long-term Compatibilityは明示的なSchema Versionと後のMigrationが担います。JS Engineの偶然のObject Property順序やPrototypeに依存してはいけません。
この章のエンジニアリング増分
開始点: 第3章の一時的なShape[]にselectedIdsが混ざっています。到達点: Durable DocumentとSessionを分離し、5種類のRecord、2つのRegistry、Validation、Command InterfaceをCanvasなしのNode環境で実行できます。
canvas-lab/src/lab/ch05/
records.ts
document-store.ts
registries.ts
document-store.test.ts
完全なCore TypeとValidatorです。
export type Transform = Readonly<{
x: number;
y: number;
rotation: number;
scaleX: number;
scaleY: number;
}>;
export type DocumentRecord = Readonly<{
id: string;
type: 'document';
schemaVersion: 1;
pageIds: readonly string[];
}>;
export type PageRecord = Readonly<{
id: string;
type: 'page';
name: string;
rootShapeIds: readonly string[];
}>;
export type ShapeRecord = Readonly<{
id: string;
type: 'shape';
shapeType: string;
pageId: string;
parentId: string | null;
childIds: readonly string[];
transform: Transform;
props: Readonly<Record<string, unknown>>;
businessObjectId: string | null;
}>;
export type AssetRecord = Readonly<{
id: string;
type: 'asset';
kind: 'image';
source: string;
width: number;
height: number;
}>;
export type BindingRecord = Readonly<{
id: string;
type: 'binding';
bindingType: string;
fromShapeId: string;
toShapeId: string;
props: Readonly<Record<string, unknown>>;
}>;
export type AnyRecord = DocumentRecord | PageRecord | ShapeRecord | AssetRecord | BindingRecord;
export type WireDocument = Readonly<{ records: readonly AnyRecord[] }>;
export type RuntimeDocument = Readonly<{
document: DocumentRecord;
pages: ReadonlyMap<string, PageRecord>;
shapes: ReadonlyMap<string, ShapeRecord>;
assets: ReadonlyMap<string, AssetRecord>;
bindings: ReadonlyMap<string, BindingRecord>;
unknown: readonly AnyRecord[];
}>;
export type SessionState = {
selectedShapeIds: ReadonlySet<string>;
camera: { x: number; y: number; zoom: number };
activeTool: string;
};
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function isStringList(value: unknown): value is readonly string[] {
return Array.isArray(value) && value.every((item) => typeof item === 'string');
}
function assertUnique(label: string, values: readonly string[]): void {
if (new Set(values).size !== values.length) throw new Error(`${label} contains duplicate IDs`);
}
function parseRecords(input: unknown): AnyRecord[] {
if (!isObject(input) || !Array.isArray(input.records))
throw new Error('Document payload must contain a records array');
const records: AnyRecord[] = [];
for (const value of input.records) {
if (!isObject(value) || typeof value.id !== 'string' || value.id.length === 0)
throw new Error('Every record needs a non-empty string id');
if (value.type === 'document') {
if (value.schemaVersion !== 1 || !isStringList(value.pageIds))
throw new Error(`Invalid document record: ${value.id}`);
} else if (value.type === 'page') {
if (typeof value.name !== 'string' || !isStringList(value.rootShapeIds))
throw new Error(`Invalid page record: ${value.id}`);
} else if (value.type === 'shape') {
const parentIsValid = value.parentId === null || typeof value.parentId === 'string';
const businessIdIsValid =
value.businessObjectId === null || typeof value.businessObjectId === 'string';
const transform = value.transform;
const transformIsValid =
isObject(transform) &&
['x', 'y', 'rotation', 'scaleX', 'scaleY'].every((key) => Number.isFinite(transform[key]));
if (
typeof value.shapeType !== 'string' ||
typeof value.pageId !== 'string' ||
!parentIsValid ||
!isStringList(value.childIds) ||
!transformIsValid ||
!isObject(value.props) ||
!businessIdIsValid
)
throw new Error(`Invalid shape record: ${value.id}`);
} else if (value.type === 'asset') {
if (
value.kind !== 'image' ||
typeof value.source !== 'string' ||
!Number.isFinite(value.width) ||
!Number.isFinite(value.height)
)
throw new Error(`Invalid asset record: ${value.id}`);
} else if (value.type === 'binding') {
if (
typeof value.bindingType !== 'string' ||
typeof value.fromShapeId !== 'string' ||
typeof value.toShapeId !== 'string' ||
!isObject(value.props)
)
throw new Error(`Invalid binding record: ${value.id}`);
} else {
throw new Error(`Unknown record type on ${value.id}`);
}
records.push(value as AnyRecord);
}
return records;
}
export function loadDocument(wire: unknown, knownShapeTypes: ReadonlySet<string>): RuntimeDocument {
const records = parseRecords(wire);
const ids = new Set<string>();
for (const record of records) {
if (ids.has(record.id)) throw new Error(`Duplicate id: ${record.id}`);
ids.add(record.id);
}
const documents = records.filter((r): r is DocumentRecord => r.type === 'document');
if (documents.length !== 1) throw new Error('Exactly one document record is required');
const pages = new Map(
records.filter((r): r is PageRecord => r.type === 'page').map((r) => [r.id, r]),
);
const allShapes = records.filter((r): r is ShapeRecord => r.type === 'shape');
const shapes = new Map(allShapes.map((r) => [r.id, r]));
const assets = new Map(
records.filter((r): r is AssetRecord => r.type === 'asset').map((r) => [r.id, r]),
);
const bindings = new Map(
records.filter((r): r is BindingRecord => r.type === 'binding').map((r) => [r.id, r]),
);
assertUnique('document.pageIds', documents[0].pageIds);
const documentPageIds = new Set(documents[0].pageIds);
for (const pageId of documentPageIds)
if (!pages.has(pageId)) throw new Error(`Missing page: ${pageId}`);
for (const page of pages.values()) {
if (!documentPageIds.has(page.id)) throw new Error(`Page ${page.id} is outside the document`);
assertUnique(`Page ${page.id} rootShapeIds`, page.rootShapeIds);
for (const rootId of page.rootShapeIds) {
const root = shapes.get(rootId);
if (!root || root.pageId !== page.id || root.parentId !== null)
throw new Error(`Broken root order ${page.id} -> ${rootId}`);
}
}
for (const shape of allShapes) {
const page = pages.get(shape.pageId);
if (!page || !documentPageIds.has(shape.pageId))
throw new Error(`Shape ${shape.id} has missing page ${shape.pageId}`);
assertUnique(`Shape ${shape.id} childIds`, shape.childIds);
if (shape.parentId) {
const parent = shapes.get(shape.parentId);
if (!parent) throw new Error(`Shape ${shape.id} has missing parent ${shape.parentId}`);
if (parent.pageId !== shape.pageId || !parent.childIds.includes(shape.id))
throw new Error(`Broken parent link ${shape.parentId} -> ${shape.id}`);
if (page.rootShapeIds.includes(shape.id))
throw new Error(`Child shape ${shape.id} also appears in root order`);
} else if (!page.rootShapeIds.includes(shape.id)) {
throw new Error(`Root shape ${shape.id} is missing from page order`);
}
for (const childId of shape.childIds) {
const child = shapes.get(childId);
if (!child || child.parentId !== shape.id || child.pageId !== shape.pageId)
throw new Error(`Broken child link ${shape.id} -> ${childId}`);
}
}
const visiting = new Set<string>(),
visited = new Set<string>();
const visit = (id: string): void => {
if (visiting.has(id)) throw new Error(`Group cycle at ${id}`);
if (visited.has(id)) return;
visiting.add(id);
for (const child of shapes.get(id)?.childIds ?? []) visit(child);
visiting.delete(id);
visited.add(id);
};
for (const id of shapes.keys()) visit(id);
for (const binding of bindings.values()) {
if (!shapes.has(binding.fromShapeId) || !shapes.has(binding.toShapeId))
throw new Error(`Binding ${binding.id} has a missing endpoint`);
}
const unknown = allShapes.filter((shape) => !knownShapeTypes.has(shape.shapeType));
return { document: documents[0], pages, shapes, assets, bindings, unknown };
}
export type DocumentCommand =
| { type: 'move-shape'; shapeId: string; dx: number; dy: number }
| { type: 'delete-shape'; shapeId: string };
export function executeCommand(runtime: RuntimeDocument, command: DocumentCommand): WireDocument {
if (command.type !== 'move-shape')
throw new Error('Delete requires a cascade policy and is not implemented');
const target = runtime.shapes.get(command.shapeId);
if (!target) throw new Error(`Unknown shape: ${command.shapeId}`);
const moved: ShapeRecord = {
...target,
transform: {
...target.transform,
x: target.transform.x + command.dx,
y: target.transform.y + command.dy,
},
};
const records: AnyRecord[] = [runtime.document, ...runtime.pages.values()];
for (const shape of runtime.shapes.values()) records.push(shape.id === moved.id ? moved : shape);
records.push(...runtime.assets.values(), ...runtime.bindings.values());
return { records };
}
parseRecordsは本章の最小限で動作するSchema Gateです。未知のrecord type、欠損Field、非有限Transformは、Mapを作る前に失敗します。一方、未知のshapeTypeはCompatibility Recordとして保持します。Production ProjectではこのCodeをZod、Valibot、JSON Schemaへ置換できますが、「先にValidationし、後でNormalizeする」というBoundaryは削除できません。rootShapeIds/childIdsはSibling Z-Orderも担います。UniquenessとParent/Child双方向の一貫性を両方検査しなければ、「Stableな順序」は願望にすぎません。
Delete Commandは現時点で意図的に拒否します。Parent、Child、Binding、Asset Referenceにcascade、reparent、rejectのどれを適用するかはADRで決める必要があり、SilentにOrphanを残してはいけません。これは「明示的なBoundary」であり、空実装を完成品に見せかけているのではありません。
Registry InterfaceはGeometryをCanvasから独立させます。
export type Bounds = { x: number; y: number; width: number; height: number };
export interface ShapeGeometryProvider {
localBounds(shape: ShapeRecord): Bounds;
containsLocalPoint(shape: ShapeRecord, point: { x: number; y: number }): boolean;
}
export interface ShapeRenderer<TTarget> {
render(shape: ShapeRecord, target: TTarget): void;
}
export class Registry<T> {
private entries = new Map<string, T>();
constructor(private readonly fallback?: T) {}
register(type: string, implementation: T): void {
if (this.entries.has(type)) throw new Error(`Duplicate registry entry: ${type}`);
this.entries.set(type, implementation);
}
resolve(type: string): T {
const value = this.entries.get(type);
if (value) return value;
if (this.fallback) return this.fallback;
throw new Error(`Missing registry entry: ${type}`);
}
}
Renderer Registryはcompatibility rendererをfallbackとして使います。Geometry Registryは通常、偽のGeometryを作るべきではないため、fallbackを渡さず、Capability欠損を明示的に失敗させられます。これにより、「未知のPlaceholderを描ける」ことを「未知のShapeを正確にHitできる」と誤解せずに済みます。
TestでDuplicate ID、Cycle、Identity、Renderer置換をCoverします。
import { describe, expect, it } from 'vitest';
import { loadDocument, type AnyRecord, type SessionState, type WireDocument } from './records';
import { Registry } from './registries';
const base = (): AnyRecord[] => [
{ id: 'doc', type: 'document', schemaVersion: 1, pageIds: ['page'] },
{ id: 'page', type: 'page', name: 'Main', rootShapeIds: ['order'] },
{
id: 'order',
type: 'shape',
shapeType: 'business-card',
pageId: 'page',
parentId: null,
childIds: [],
transform: { x: 10, y: 20, rotation: 0, scaleX: 1, scaleY: 1 },
props: { label: '注文' },
businessObjectId: 'order-42',
},
];
describe('document invariants', () => {
it('rejects duplicate immutable identities', () => {
const records = base();
records.push({ ...records[2] });
expect(() => loadDocument({ records } as WireDocument, new Set(['business-card']))).toThrow(
'Duplicate',
);
});
it('rejects a group cycle', () => {
const records = base();
records[1] = { ...records[1], rootShapeIds: [] } as AnyRecord;
records[2] = { ...records[2], parentId: 'group', childIds: ['group'] } as AnyRecord;
records.push({
id: 'group',
type: 'shape',
shapeType: 'group',
pageId: 'page',
parentId: 'order',
childIds: ['order'],
transform: { x: 0, y: 0, rotation: 0, scaleX: 1, scaleY: 1 },
props: {},
businessObjectId: null,
});
expect(() => loadDocument({ records }, new Set(['business-card', 'group']))).toThrow('cycle');
});
it('rejects duplicate root z-order entries before rendering', () => {
const records = base();
records[1] = { ...records[1], rootShapeIds: ['order', 'order'] } as AnyRecord;
expect(() => loadDocument({ records }, new Set(['business-card']))).toThrow('duplicate');
});
it('preserves identity and Session when a renderer is replaced', () => {
const runtime = loadDocument({ records: base() }, new Set(['business-card']));
const session: SessionState = {
selectedShapeIds: new Set(['order']),
camera: { x: 0, y: 0, zoom: 1 },
activeTool: 'select',
};
const before = JSON.stringify([...runtime.shapes.values()]);
const canvas = new Registry<(id: string) => string>(() => 'unknown placeholder');
const dom = new Registry<(id: string) => string>(() => '<div>unknown</div>');
canvas.register('business-card', (id) => `canvas:${id}`);
dom.register('business-card', (id) => `<article data-id="${id}" />`);
expect(canvas.resolve('business-card')('order')).not.toBe(
dom.resolve('business-card')('order'),
);
expect(JSON.stringify([...runtime.shapes.values()])).toBe(before);
expect(runtime.shapes.get('order')?.businessObjectId).toBe('order-42');
expect([...session.selectedShapeIds]).toEqual(['order']);
});
it('round-trips an unknown shape and resolves a compatibility renderer', () => {
const records = base();
records[2] = { ...records[2], shapeType: 'future-card', props: { future: 7 } } as AnyRecord;
const runtime = loadDocument({ records }, new Set(['business-card']));
const renderers = new Registry<(id: string) => string>((id) => `unknown:${id}`);
expect(runtime.unknown.map((record) => record.id)).toEqual(['order']);
expect(runtime.shapes.get('order')?.props).toEqual({ future: 7 });
expect(renderers.resolve('future-card')('order')).toBe('unknown:order');
});
});
npx vitest run src/lab/ch05/document-store.test.tsを実行し、5 passedを期待します。続けて第3章のVisual Testをもう一度実行します。Rendererは新しいRuntimeDocumentからRecordを読みますが、Pixelは同じままでなければなりません。台帳に戻ると、Photographerや画風を替えてもCard番号は変わらず、今日誰を指しているかも永久記録へは書かれません。
わざと壊す
| 注入する故障 | 症状 | 根拠 | 修正 | Regression Test | 復旧 |
|---|---|---|---|---|---|
| 子を処理せずParentを削除する | 子ShapeからWorldへの経路がない | validatorがmissing parentを報告する | reject/reparent/cascade policyを明示する | 各Delete戦略をTestする | Transaction Snapshotから復元する |
| A→B→AのGroup Cycle | RecursionがStack Overflowする、または停止する | DFS visiting setが検出する | Commit前にCycleを拒否する | 2 Node/Self Cycle Test | 悪いCommandを拒否する |
| Wire ArrayにDuplicate ID | 後の値が前の値をSilentに上書きする | Normalize前後でCountが異なる | Map作成前にIDを検査する | duplicate test | 悪いpayloadを隔離する |
| 同じParent内の順序が重複/衝突する | Drawing順序が不安定 | 2回のrender traceが異なる | 単一のchildIds順序とUniqueness検証 | permutation test | Stableな順序を再構築する |
| Shape Typeが未知 | 空白になる、またはCrashする | unknown Listが空でない | Recordを保持し、Placeholderを表示し、Read-onlyにする | round-trip unknown props | 元Recordを削除しない |
| Rendererがない | そのTypeだけProjectionできない | Registry resolveがfallbackへ到達する | compatibility renderer | Document/Geometry Testは引き続き動く | fallbackをLoadする |
| DocumentにSelectionを混ぜる | Save/共同編集でGhost Selectionが生じる | schemaにselectedが現れる | SessionStoreへ移す | Persistence Snapshotにselectionがない | Fieldを削除してMigrationする |
毎回、「注入→症状→Error Code/Record IDを検証→ValidationまたはTransactionを修正→Node Regression→最後の有効Documentから復旧」の順序に従います。Rendererのtry/catchでModel破損を飲み込んではいけません。事実上のErrorを視覚的な空白に見せかけてしまいます。
根拠を示して合格する
| Gate | 自動/手動 | 根拠 |
|---|---|---|
| DocumentがRendererに依存しない | 自動 | records moduleにDOM/Canvas importがなく、Node Testが通る |
| Renderer置換がDocumentを変えない | 自動 | Canvas/DOMの2実装の前後でJSONが同一 |
| Selectionを失わずPersistenceも汚染しない | 自動 | Sessionが独立して残り、Document SnapshotにSelectionがない |
| Persistence Formatを書き直さない | 自動 | 異なるRegistryが同じWireDocumentを読む |
| Business IDがStable | 自動/手動 | adapter/renderer swap後もorder-42のまま |
- 5種類のRecordと、Group、Parent/Child、Z-Order、Local Transformに明示的な契約がある。
- Domain Model、Visual Model、Geometry、Rendererの4層が互いになりすましていない。
- Normalized RecordsはIndex作成前にDuplicate IDを検査する。
- Derived StateとSession StateをDurable Documentへ書き込まない。
- Renderer/Geometry Registryを独立して置換でき、実装欠損には明示的なCompatibility Behaviorがある。
- Parent削除、Cycle、未知Shape、順序Conflict、Renderer欠損を注入し、すべて復旧した。
5歳児に説明する
「Document」「Renderer」「Scene Graph」「Registry」「Derived State」という言葉を使わずに答えてください。家族写真をCartoonへ替えても、子どもの身分証番号を変えてはいけないのはなぜでしょう。「今日誰を指しているか」を永久の家族台帳へ書いてはいけないのはなぜでしょう。
専門用語を使わない合格回答を開く
写真とCartoonは、同じ家族を違う見た目で表したものです。子どもは同じ子どもであり、名前、番号、家族関係は画材が変わっても変わりません。誰かを指すことは、この瞬間にあなたがしていることです。明日別の人が本を開いたとき、あなたの手の動きまで引き継ぐ必要はありません。描き方では変わらない名前と関係を永久のCardへ書き、今日の指差しは別の一時的な紙へ記します。そうすれば画家を替えても、閉じて開き直しても、人を取り違えません。