鸿蒙编辑器框架的 Schema 体系:BlockSpec 注册与运行时校验
一、海关与菜单:一个注册表的两副面孔
先看 Schema 体系要同时回答的两个问题。
问题一:什么数据允许进文档? 编辑器的文档会被持久化到文件、同步到云端、在设备间流转。第 02 篇的 reset() 在反序列化时就会遇到这个问题:JSON 里出现一个 type: "fancyWidget" 的块,Schema 里根本没有这个类型——收,还是不收?没有运行时校验的编辑器只能照单全收,然后在渲染时崩溃、在序列化时污染、在协同时传染。
问题二:UI 怎么知道编辑器里有哪些块? 插入面板要列出可插入的块、样式菜单要列出可转换的目标、斜杠菜单要支持搜索插入。如果这些菜单各自维护一份列表,那么"新增一种块类型"就要改 N 处——漏改任何一处,就出现"类型注册了但菜单里没有"或反过来"菜单里有但类型没注册"的漂移。这类漂移 Bug 的讨厌之处在于编译器管不着它:两边都是合法的字符串。
ArkBlocks 的答案是一个 SchemaRegistry:所有块类型、行内类型、样式在使用前必须注册,注册后既充当校验器(问题一),又充当菜单聚合器(问题二)。一个数据结构,堵住两类问题。
二、解剖一个 BlockSpec:类型契约 + 实现 + UI 贡献
以框架里最复杂的内置类型 heading 为例,全文引用(blocks/heading/HeadingSpec.ts):
export const HeadingSpec: BlockSpec = {
config: { // ── 类型契约
type: 'heading',
propSchema: {
level: { type: 'number', default: 1, values: [1, 2, 3, 4, 5, 6] },
textAlignment: { type: 'string', default: 'left', values: ['left', 'center', 'right', 'justify'] },
container: { type: 'boolean', default: false },
collapsed: { type: 'boolean', default: false },
},
content: 'inline', // 内容模型
isContainer: true, // 可作嵌套容器
},
implementation: { /* serialize/parse:M7 里程碑接入 */ },
insertItems: [ // ── UI 贡献:插入面板条目
{ label: '标题 1', subtitle: '大标题', icon: 'H1', props: { level: 1 }, priority: 20 },
{ label: '标题 2', subtitle: '中标题', icon: 'H2', props: { level: 2 }, priority: 21 },
{ label: '标题 3', subtitle: '小标题', icon: 'H3', props: { level: 3 }, priority: 22 },
{ label: 'H3 容器', subtitle: '可折叠的容器标题', icon: '▼', props: { level: 3, container: true, collapsed: true }, priority: 23 },
],
styleOptions: [ /* H1/H2/H3/H3容器,样式转换菜单用 */ ],
slashMenuItems: [ /* Heading 1/2/3,斜杠菜单用 */ ],
};
一个 BlockSpec 是三层的合体:
config(契约层):类型名、属性声明(propSchema)、内容模型、是否容器。这是校验器消费的部分,也是文档数据合法性的判据。implementation(实现层):序列化/解析等行为钩子,按里程碑逐步填实。- UI 贡献字段(呈现层):
insertItems/styleOptions/slashMenuItems/layoutOptions/attributes/attributeGroups。全部可选——不声明,块照常工作,只是不出现在对应菜单里。
第三层是最容易被低估的设计。注意 insertItems 里的 props 字段:每个菜单条目就是一组 props 预设。"标题 1"和"标题 3 容器"是同一个类型的四个变体,差别只是插入时应用的 props 不同。菜单从此不需要知道"如何创建一个 H3 容器"的任何细节——它只需要把 { level: 3, container: true, collapsed: true } 这组预设交给插入命令。
三、propSchema:三个原语类型为什么够用
PropSchema 的定义朴素到令人怀疑:
export type PropType = 'string' | 'number' | 'boolean';
export interface PropSpec {
type: PropType; // 运行时类型标签
default: PropValue; // 默认值
values?: PropValue[]; // 可选取值白名单(可选)
}
export type PropSchema = Record<string, PropSpec>;
只有三种原语类型,没有嵌套对象、没有数组、没有联合。这是刻意的:块属性是文档数据的组成部分,会被序列化、同步、合并——属性值保持原语类型,深度比较、结构共享、差异计算全都便宜且无歧义。需要复杂配置的块(比如图片的裁剪参数),正确做法是拆成多个原语属性,而不是塞一个任意 JSON 对象进 props。类型系统的"小",换来的是数据模型的"稳"。
default 与 values 的分工也值得注意:default 服务于构造——应用代码用 PartialBlock 插块时,未声明的属性由 Schema 补默认值(调用方不必记得"heading 要给 level");values 服务于校验——声明了白名单,白名单之外的值在事务提交前就被拒收(level: 7 不是"渲染成普通文字",而是根本进不了文档)。
四、SchemaRegistry:三张 Map 与"注册即抛错"
注册表的主体是三张 Map(外加一个插件追加条目用的小数组):
export class SchemaRegistry {
private blockSpecs: Map<string, BlockSpec> = new Map(); // 类型名 → 块规格
private inlineSpecs: Map<string, InlineSpec> = new Map(); // 类型名 → 行内规格
private styleSpecs: Map<string, StyleSpec> = new Map(); // 样式名 → 样式规格
private extraInsertItems: InsertItemEntry[] = [];
}
三张 Map 的读接口是普通的 getXxxSpec,写接口只有一条规则:同名重复注册直接抛错:
registerBlock(spec: BlockSpec): void {
if (this.blockSpecs.has(spec.config.type)) {
throw new Error(`SchemaRegistry: block type '${spec.config.type}' is already registered`);
}
this.blockSpecs.set(spec.config.type, spec);
}
这条规则的价值在多插件场景才会显现:两个插件注册了同名块类型,与其让后者静默覆盖前者(前一个类型的文档从此变成"孤儿数据"),不如在初始化阶段就炸出声。配合下一篇要讲的插件注册时间窗(Schema 在初始化完成后冻结),"注册表内容不可变"从约定升级为机制。
五、校验三规则:B1、B2、B5 的执法现场
校验入口 validateBlock 对应 RFC-0001 的三条块级不变量,逐段看真实代码:
B1:类型必须已注册。 查不到 BlockSpec 直接拒绝,这是整个体系的总闸:
const spec = this.blockSpecs.get(block.type);
if (spec === undefined) {
errors.push(`[B1] Block type '${block.type}' is not registered in SchemaRegistry`);
return { valid: false, errors };
}
B2:属性逐 key 三道关卡。 validateProps 对 props 的每个 key 依次检查——先查 key 声明过没有,再查值类型对不对,最后查值在不在白名单里:
for (const key of Object.keys(props)) {
const propSpec = propSchema[key];
if (propSpec === undefined) { // 关卡一:未声明的 key 直接拒绝
errors.push(`[B2] Unknown prop '${key}' on block type '${type}'`);
continue;
}
const actualType = typeof props[key];
if (actualType !== propSpec.type) { // 关卡二:类型匹配
errors.push(`[B2] Prop '${key}' on '${type}' must be ${propSpec.type}, got ${actualType}`);
continue;
}
if (propSpec.values !== undefined && !propSpec.values.includes(props[key])) { // 关卡三:白名单
errors.push(`[B2] Prop '${key}' on '${type}' value ... is not in allowed values`);
}
}
关卡一是"关闭式"设计的精髓:props 是封闭记录而非开放袋子。{ level: 2, hackerField: 'x' } 这种塞私货的写法进不了文档。开放 props 在 Web 时代常见,但它的代价是文档格式随时间不可控地腐化——每个写入者都可以发明新字段,而没人负责清理。
B5:内容与声明匹配。 内容模型是 BlockSpec 的声明,content 字段是块的实际状态,两者必须一致:
const contentModel: ContentModel = spec.config.content;
if (contentModel === 'inline') {
if (block.content === undefined) {
errors.push(`[B5] ... content model 'inline' but content is undefined`);
}
} else if (contentModel === 'none') {
if (block.content !== undefined && block.content !== null) {
errors.push(`[B5] ... content model 'none' but content is provided`);
}
}
注意这里的方向性:第 02 篇说过 content: undefined 与 content: [] 语义不同——inline 模型拒绝的是 undefined(块没有行内内容能力却被赋了文本空缺),空数组是合法的;none 模型则拒绝任何内容。分隔线带着一个空数组"占位"?不合法。声明即边界,边界两侧都不含糊。
顺带交代行内层的不变量去向:链接 href 非空(I2)、自定义行内类型已注册(I3)、样式键已注册(I4)——这三条不在 SchemaRegistry 里查,而是在事务校验管道的阶段 3 对每个 insertInline / replaceInline 操作的 content 逐项检查(第 03 篇提过)。styleSpecs 这张 Map 正是 I4 得以成立的前提:样式本身也是要注册的类型,加粗、高亮、文字颜色都有名字和规格。
六、执法点回顾:同一套规则,多层站岗
单一不变量会在多个层次被重复检查,这不是冗余而是纵深防御,串联前三篇正好一张图:
事务校验阶段 2(schema stage)
└─ insertBlock / replaceBlock / wrapBlock → validateFullBlock(B1+B2+B5,含整棵子树递归)
└─ updateBlock / updateProps → validateProps(B2)
└─ splitBlock → 拆分目标类型已注册(B1)
事务校验阶段 3(operation stage)
└─ 委托 Document.validateXxx() → 内部再次调用 schema 校验
└─ 行内操作的 content 逐项检查 → I2 / I3 / I4
渲染与序列化
└─ 渲染分类查询、菜单聚合 → 只信已注册类型
校验发生在事务提交前,意味着非法数据没有"先入库再说"的机会——文档在任何一个提交时刻都满足全部不变量(第 02 篇 S 系列管结构,B/I 系列管类型,两层合起来才是完整的"物理定律")。
七、Schema 即 UI 的单一来源
回到开篇的问题二。注册表把 UI 贡献聚合出来的三个方法,是同一副模子(以插入面板为例):
getInsertItems(): InsertItemEntry[] {
const items: InsertItemEntry[] = [];
this.blockSpecs.forEach(spec => { // 1. 遍历所有已注册类型
for (const item of spec.insertItems ?? []) {
items.push(new InsertItemEntry(spec.config.type, item)); // 2. 附上归属类型
}
});
for (const extra of this.extraInsertItems) { // 3. 插件追加的条目
items.push(extra);
}
items.sort((a, b) => a.priority - b.priority); // 4. 按 priority 升序
return items;
}
四个细节构成这套设计的完整价值:
- 每条菜单项携带
blockType。UI 拿到列表就能直接派发"插入某类型 + 应用预设 props",不需要反查。 priority排序归框架管。块类型只声明自己条目的优先级,跨类型的全局排序由聚合器统一完成——段落的 priority 10 排在标题的 20 前面,这种全局偏好不需要任何类型知道全貌。registerExtraInsertItem打破所有权。插件可以给任何已注册类型(包括别人注册的)追加插入条目——比如一个 AI 插件在插入面板里给段落类型加一个"AI 续写"入口。扩展别人的类型而不修改别人的 Spec。- 漂移被结构性消灭。插入面板 =
getInsertItems()、样式菜单 =getStyleOptions()、斜杠菜单 =getSlashMenuItems()——三个菜单没有自己的块列表,遍历 Schema 即得。注册一个新类型,三个菜单同时出现它;没有注册,三个菜单同时没有它。不存在第二个真相需要同步。
同一份 Spec 里的属性声明还在喂给下一站:getAttributes() / getAttributeGroups() / hasAttributes() 这组查询是第 07 篇属性引擎的全部数据来源——Action Sheet 里那些自动生成的属性面板,此刻已经在 BlockSpec 的字段里埋好了种子。
八、诚实的现状清单
implementation.serialize/parse是 TODO(M7) 骨架——Markdown 序列化接入时逐类型实现;- 内容模型
'table'/'blocks'已在类型上预留,校验分支目前只落实了inline/none,表格与容器块模型属于后续里程碑; HeadingSpec.implementation里的注释原文保留着各里程碑的接入计划(M6 输入规则、M7 序列化),规格与代码的同源性由此可见。
九、小结
- 一张注册表,两副面孔:对内是类型契约(B1/B2/B5 + 行内 I 系列的执法依据),对外是 UI 菜单的数据源(三个聚合方法 + priority 全局排序)。
- props 是封闭记录:未声明的 key 直接拒绝,类型必须匹配,白名单外的值进不了文档——文档格式因此不随时间腐化。
- 原语类型是刻意的:string / number / boolean 三种属性类型换来序列化、比较、合并的便宜与无歧义。
- 变体即预设:一个类型通过多条
insertItems提供多个"身份",菜单条目只是 props 预设 + 展示信息。 - 漂移被结构性消灭:菜单不存块列表,Schema 是唯一真相;插件还能经
registerExtraInsertItem扩展他人类型。
下一篇我们看这些 Spec 是谁在什么时机注册进来的——插件的五阶段生命周期、受控的 PluginContext 边界,以及"官方插件与第三方必须走同一条路"的架构纪律。
下一篇:《鸿蒙编辑器框架的插件 SDK:官方插件与第三方走同一条路》——IPlugin 五阶段生命周期、PluginContext 的注册时间窗、以及一个真实官方插件(CalloutPlugin)的完整解剖。
更多推荐


所有评论(0)