9.3 与数据模型的接缝
前两节把命令与命令栈建好了,但它们还没真正「碰到」任何东西——CommandStack 只认识一个抽象的 Command 接口。这一节补上最后一段:命令落到哪里。答案是在 8.2 文档模型与脏标记
里建立的场景文档:它是编辑的唯一目标,也是「脏标记 → 重绘」的源头。
这段接缝的关键在于不许绕路。如果某条命令图省事,直接去改文档内部的 Map,那么脏标记不会被打上、变更通知不会发出、视口不会重绘——用户看到的是「数据变了但画面没变」的幽灵 bug。所以接缝的第一条纪律是:命令只通过文档的公开方法落值,所有副作用都收拢在文档里。
文档模型:命令作用的唯一目标
文档模型做三件事:持有节点、记录脏标记、广播变更。节点是一棵由 parent / children 双向链接的树,每个节点带一个 props 表。脏标记用三档粒度区分「这次改动有多贵」:structure(增删 / 改层级,要重建层级树)、transform(位置 / 旋转 / 缩放,要重算世界矩阵)、property(其余属性,只刷新面板)。
每次落值都走同一条路:改内部状态 → #mark 打脏标 → #emit 递增 revision 并通知所有订阅者。revision 是单调递增的版本号,视图拿它判断「自上次绘制以来文档是否变过」。takeDirty() 是「取出并清空」的语义:视图每帧重绘前调一次,拿到这一帧所有变脏的节点,然后脏集合归零,下一帧只关心新变化。
下面 src/editor/document/scene-document.ts 层级 L1,完整可编译。
// file: src/editor/document/scene-document.ts
// tier: L1
// verify: npx vitest run src/editor/command/scene-commands.test.ts
//
// helio 编辑器:场景文档模型(8.2 的接缝对象)。它是命令作用的唯一目标,
// 也是「脏标记 → 重绘」的源头。命令只调这里的方法,从不直接改内部 Map,
// 于是任何一次编辑都必然经过 #mark(脏标记)与 #emit(通知视图)。
export type NodeId = number;
export type PropertyValue = number | string | boolean;
/** 脏标记:结构变更要重建层级,变换变更要重算世界矩阵,属性变更只刷新面板。 */
export type DirtyFlag = 'structure' | 'property' | 'transform';
/** 变换类属性:改它们要重算世界矩阵,故单独打 'transform' 标。 */
const TRANSFORM_KEYS: ReadonlySet<string> = new Set(['position', 'rotation', 'scale']);
export interface SceneNode {
readonly id: NodeId;
name: string;
parent: NodeId | null;
readonly children: NodeId[];
readonly props: Map<string, PropertyValue>;
}
/** 一次文档变更的通知。视图层据此决定重绘哪一块。 */
export interface ChangeEvent {
readonly kind: DirtyFlag;
readonly node: NodeId;
readonly detail: string;
readonly revision: number;
}
const EMPTY: ReadonlySet<DirtyFlag> = new Set();
/**
* 场景文档。所有编辑都经过这里,因此脏标记与变更通知不会漏发。
* 命令栈(9.1 / 9.2)持有本对象,do / undo 都通过下面这些方法落到模型上。
*/
export class SceneDocument {
readonly #nodes = new Map<NodeId, SceneNode>();
readonly #dirty = new Map<NodeId, Set<DirtyFlag>>();
readonly #listeners = new Set<(e: ChangeEvent) => void>();
#nextId = 1;
#revision = 0;
get revision(): number { return this.#revision; }
get size(): number { return this.#nodes.size; }
/** 预分配一个不会被复用的 id(供 AddNodeCommand 在构造时预留)。 */
allocateId(): NodeId { return this.#nextId++; }
node(id: NodeId): SceneNode | undefined { return this.#nodes.get(id); }
/** 用指定 id 插入节点(撤销后重做要复用同一 id,故与 createNode 分开)。 */
insertNode(id: NodeId, name: string, parent: NodeId | null): SceneNode {
if (this.#nodes.has(id)) throw new Error(`节点 ${id} 已存在`);
const node: SceneNode = { id, name, parent, children: [], props: new Map() };
this.#nodes.set(id, node);
if (id >= this.#nextId) this.#nextId = id + 1;
if (parent !== null) {
const p = this.#nodes.get(parent);
if (!p) throw new Error(`父节点 ${parent} 不存在`);
p.children.push(id);
this.#mark(parent, 'structure');
}
this.#mark(id, 'structure');
this.#emit('structure', id, `add ${name}`);
return node;
}
createNode(name: string, parent: NodeId | null = null): NodeId {
const id = this.allocateId();
this.insertNode(id, name, parent);
return id;
}
/** 删除叶子节点(有子节点时拒绝,避免留下悬空引用)。 */
destroyNode(id: NodeId): void {
const node = this.#nodes.get(id);
if (!node) throw new Error(`节点 ${id} 不存在`);
if (node.children.length > 0) throw new Error(`节点 ${id} 仍有子节点,不能删除`);
if (node.parent !== null) {
const p = this.#nodes.get(node.parent)!;
const i = p.children.indexOf(id);
if (i >= 0) p.children.splice(i, 1);
this.#mark(node.parent, 'structure');
}
this.#nodes.delete(id);
this.#dirty.delete(id);
this.#emit('structure', id, 'remove');
}
getProperty(id: NodeId, key: string): PropertyValue | undefined {
return this.#nodes.get(id)?.props.get(key);
}
setProperty(id: NodeId, key: string, value: PropertyValue): void {
const node = this.#nodes.get(id);
if (!node) throw new Error(`节点 ${id} 不存在`);
node.props.set(key, value);
const flag: DirtyFlag = TRANSFORM_KEYS.has(key) ? 'transform' : 'property';
this.#mark(id, flag);
this.#emit(flag, id, `${key}=${String(value)}`);
}
deleteProperty(id: NodeId, key: string): void {
const node = this.#nodes.get(id);
if (!node) throw new Error(`节点 ${id} 不存在`);
node.props.delete(key);
this.#mark(id, 'property');
this.#emit('property', id, `del ${key}`);
}
dirtyOf(id: NodeId): ReadonlySet<DirtyFlag> { return this.#dirty.get(id) ?? EMPTY; }
/** 取出并清空脏集合。视图在每帧重绘前调用一次。 */
takeDirty(): Map<NodeId, DirtyFlag[]> {
const out = new Map<NodeId, DirtyFlag[]>();
for (const [id, flags] of this.#dirty) out.set(id, [...flags]);
this.#dirty.clear();
return out;
}
/** 订阅变更(返回解绑函数)。这就是「命令 → 脏标记 → 重绘」里重绘的那一环。 */
onChange(fn: (e: ChangeEvent) => void): () => void {
this.#listeners.add(fn);
return () => { this.#listeners.delete(fn); };
}
#mark(id: NodeId, flag: DirtyFlag): void {
let set = this.#dirty.get(id);
if (!set) { set = new Set(); this.#dirty.set(id, set); }
set.add(flag);
}
#emit(kind: DirtyFlag, node: NodeId, detail: string): void {
this.#revision++;
const e: ChangeEvent = { kind, node, detail, revision: this.#revision };
for (const fn of this.#listeners) fn(e);
}
}
注意 insertNode 与 createNode 是两个方法。createNode 分配新 id,insertNode 用指定 id插入——后者是为命令准备的:AddNodeCommand 在构造时就预留好 id,撤销后重做必须复用同一号,否则外部拿着旧 id 的引用会全部失效。
具体命令:SetProperty 与 AddNode
有了文档,命令就只是「一段可逆的文档操作记录」。SetPropertyCommand 在构造时捕获旧值,do 写新值、undo 写回旧值(旧值不存在时删除键);AddNodeCommand 在构造时预留 id,do 插入、undo 删除。两者都不碰文档内部结构,只调 setProperty / insertNode / destroyNode,于是脏标记与通知自动发生。
下面 src/editor/command/scene-commands.ts 层级 L1,完整可编译。
// file: src/editor/command/scene-commands.ts
// tier: L1
// verify: npx vitest run src/editor/command/scene-commands.test.ts
//
// helio 编辑器:作用于场景文档的具体命令。命令是「文档操作的记录」——
// 它只持有一份最小状态(旧值 / 新值 / 预留 id),do 与 undo 都通过
// SceneDocument 的方法落到模型上,因此脏标记与变更通知必然被触发。
import type { Command } from './command.ts';
import { SceneDocument, type NodeId, type PropertyValue } from '../document/scene-document.ts';
/** 改一个属性。构造时传入旧值,使 undo 无需回读文档。 */
export class SetPropertyCommand implements Command {
readonly label: string;
readonly #doc: SceneDocument;
readonly #id: NodeId;
readonly #key: string;
readonly #from: PropertyValue | undefined;
readonly #to: PropertyValue;
constructor(
doc: SceneDocument, id: NodeId, key: string,
from: PropertyValue | undefined, to: PropertyValue, label?: string,
) {
this.#doc = doc;
this.#id = id;
this.#key = key;
this.#from = from;
this.#to = to;
this.label = label ?? `设置 ${key}`;
}
do(): void { this.#doc.setProperty(this.#id, this.#key, this.#to); }
undo(): void {
if (this.#from === undefined) this.#doc.deleteProperty(this.#id, this.#key);
else this.#doc.setProperty(this.#id, this.#key, this.#from);
}
byteSize(): number { return 64 + this.#key.length; }
/** 同一节点同一属性的连续赋值可合并:旧值取前一条的,新值取本条。 */
mergeWith(prev: Command): Command | null {
if (!(prev instanceof SetPropertyCommand)) return null;
if (prev.#doc !== this.#doc || prev.#id !== this.#id || prev.#key !== this.#key) return null;
return new SetPropertyCommand(this.#doc, this.#id, this.#key, prev.#from, this.#to, prev.label);
}
}
/** 便捷工厂:构造时从文档读当前值作为 undo 目标,命令因此是自包含的。 */
export function setProperty(
doc: SceneDocument, id: NodeId, key: string, to: PropertyValue, label?: string,
): SetPropertyCommand {
return new SetPropertyCommand(doc, id, key, doc.getProperty(id, key), to, label);
}
/** 新增节点。构造时预留 id,do / redo 复用同一 id,撤销后重做不会换号。 */
export class AddNodeCommand implements Command {
readonly label: string;
readonly #doc: SceneDocument;
readonly #id: NodeId;
readonly #name: string;
readonly #parent: NodeId | null;
constructor(doc: SceneDocument, name: string, parent: NodeId | null, label?: string) {
this.#doc = doc;
this.#id = doc.allocateId();
this.#name = name;
this.#parent = parent;
this.label = label ?? `新增 ${name}`;
}
get id(): NodeId { return this.#id; }
do(): void { this.#doc.insertNode(this.#id, this.#name, this.#parent); }
undo(): void { this.#doc.destroyNode(this.#id); }
}
setProperty 工厂值得说明:它在构造时从文档读当前值当 from。这保证了「构造无副作用」——读是只读的,写留到 do。而 mergeWith 里用 prev.#from 做合并后命令的旧值,靠的是 JS 私有字段的「同类实例可互访」特性,这样合并后的命令一步撤销能回到最初的值,而不是回到上一步。
命令 → 脏标记 → 重绘
把三层串起来,一次编辑的完整链路是:
- UI 构造命令(如
setProperty(doc, id, 'material', 'brick')),此时只读旧值、不改文档; stack.push(cmd)调cmd.do(),命令调doc.setProperty(...);- 文档改内部状态,
#mark打上property脏标,#emit递增revision并逐个通知订阅者; - 订阅者(视口、属性面板、层级树)收到通知,或立即重绘,或标记「本帧需重绘」;
- 帧末视图调
takeDirty()取走并清空脏集合,完成一次「命令 → 脏标记 → 重绘」的闭环。
撤销走的是同一条链路:undo() 也调文档方法,所以同样会打脏标、发通知。这是接缝设计的好处——撤销不需要额外的刷新逻辑,它只是「朝反方向的另一次编辑」。反过来,如果命令绕开文档直接改 Map,这条链路就断了,这正是本节开头强调的纪律。
脏标记的粒度直接影响性能:改一个 material 只打 property,视图只需刷新属性面板;改 position 打 transform,要重算该节点的世界矩阵;增删节点打 structure,要重建层级与拾取加速结构。三档分开,是为了让视图按最省的代价响应。
可运行产物:脏标记与重绘触发的真跑
文档模型与命令都是纯逻辑,可在 Node 里真跑。src/editor/command/scene-commands.test.ts 层级 L1,覆盖:SetProperty 打 property 脏标并各发一次通知(do 与 undo 各一次)、变换属性打 transform 而非 property、AddNode 的 undo / redo 复用同一 id、经命令栈合并后一次撤销回到最初、takeDirty 取走即清空、解绑监听后不再收到通知。命令 npx vitest run src/editor/command/scene-commands.test.ts。
$ npx vitest run src/editor/command/scene-commands.test.ts
✓ src/editor/command/scene-commands.test.ts (6 tests)
Test Files 1 passed (1)
Tests 6 passed (6)
本机实跑 6 条全过,tsc --noEmit 零错误。最有价值的两条是「do 与 undo 各发一次通知」(证明撤销走的是同一条脏标记链路)和「AddNode 重做复用同一 id」(证明命令是自包含的、不依赖文档当前状态)。
小结
- 命令必须通过文档的公开方法落值,绝不直接改内部结构,否则脏标记与重绘会被绕过。
- 文档模型用三档脏标区分改动代价:
structure(层级)、transform(世界矩阵)、property(面板);revision单调递增,供视图判断是否变过。 insertNode(指定 id)与createNode(分配 id)分开,是为命令复用 id 服务。- 一次编辑的闭环是「命令 do → 文档打脏标 → 发通知 → 视图重绘 →
takeDirty清空」;撤销走同一条链路。 - 脏标粒度决定重绘代价,这为下一章「反射驱动的属性面板」按属性类型刷新打下基础。
下一章进入第 10 章,把文档里的属性用反射暴露给编辑器 UI:属性面板如何自动生成控件、如何按类型映射编辑器、多选时如何批量编辑。
阅读导航:上一节:9.2 事务、合并与撤销栈 · 下一节:10.1 反射驱动的属性面板 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。