鸿蒙编辑器框架的插件 SDK:官方插件与第三方走同一条路
一、先立规矩:什么是插件,什么不是
给"插件"下的定义里,否定清单比肯定清单更有信息量。插件不是:
- 对框架内部的补丁;
- 对内核行为的运行时修改;
- 无类型的回调袋;
- 内部 API 的直接消费者。
配套的是一张三层边界图:Core(Document / Transaction / History / Schema)持有所有插件依赖的不变量,类型无关、永不被插件修改;SDK(IPlugin / PluginContext / 生命周期)定义 Core 与插件的契约;插件只消费契约、拥有自己的领域行为。
这条边界的意义用一个反例就能说明:如果图片插件可以绕过事务直接改 Document,那么"所有变更可撤销"的内核保证就出现了一个例外——而框架级保证的价值恰恰在于没有例外。插件的自由被限定在契约之内,换来的是内核的每个不变量对插件照样成立。
二、两套机制并存:数据式插件与 SDK 式插件
读源码首先会遇到一个"考古现场":plugin/ 目录里有两套插件机制。
其一:PluginSpec(数据式)——一个纯数据接口,内置功能用它:
export interface PluginSpec {
name: string;
blocks?: BlockSpec[]; // 贡献的块类型
inlines?: InlineSpec[];
styles?: StyleSpec[];
shortcuts?: ShortcutSpec[]; // 快捷键绑定
inputRules?: InputRuleSpec[]; // "# " → heading 这类输入规则
onMount?: (editor: Object) => void; // TODO(M1):换成类型化 IEditor
onDestroy?: (editor: Object) => void;
}
DefaultBlocksPlugin、DefaultStylesPlugin 这些内置装配器(每个只有 20 来行)就是它:声明自己带哪些 Spec,编辑器初始化时批量注册。
其二:IPlugin(SDK 式)——带完整生命周期的类接口,官方块插件和未来第三方插件用它。
为什么并存?因为这是迁移路径的中间态,而且是刻意设计的中间态:RFC-0003 的迁移原则写明"框架必须在迁移的每一步都可运行,不做大爆炸重构"。内置块(段落、标题、列表……)还住在 blocks/ 目录、由数据式插件装配;而 Card(PR-0022)、Callout(PR-0023)、Image(PR-0021)已按"由简到繁"的顺序逐个搬进 plugins/official/、改用 IPlugin。两套机制在 EditorConfig 上对应两个字段(plugins 与 officialPlugins),旧写法在 v1 全程可用。
三、五阶段生命周期:谁在什么时刻被允许做什么
IPlugin 接口与生命周期状态机(IPlugin.ts):
export interface IPlugin {
readonly manifest: PluginManifest; // 身份声明:name / version / displayName / description
initialize(context: PluginContext): void; // 接上下文,可读状态,不许注册
register(context: PluginContext): void; // 声明全部贡献:块、渲染分类、插入项
activate(): void; // 满血运行
deactivate(): void; // 停止响应
dispose(): void; // 释放资源
}
// 状态机:created → initialized → registered → active → deactivated → disposed
把 register 和 activate 拆成两个阶段是整套生命周期里最值钱的一刀:注册是声明式的(我贡献什么),激活是命令式的(我开始干活了)。这个切分换来三件事——注册全部完成后 Schema 才能冻结(第 05 篇的"注册表不可变"由此落地);禁用一个插件不必丢失它的注册数据;将来做依赖解析时,可以先解析全部注册关系、再按序激活。
一个诚实的实现细节:RFC-0003 里除 register 外各阶段方法都标了"可选",而当前 IPlugin 接口把五个方法全部定义为必需。MVP 阶段选择了更严格的契约——插件数量少时,强制全实现比可选分发更简单直观;接口"松绑"留给稳定化阶段。
四、PluginManager:状态门控与故障隔离
生命周期不是插件自己走的,是 PluginManager 编排的。三个编排决策值得细看:
决策一:状态门控。 每个阶段只处理处于正确状态的插件:
private registerAll(): void {
for (const entry of this.managed) {
if (entry.state !== 'initialized' || entry.context === undefined) continue; // 门控
try {
entry.plugin.register(entry.context);
entry.context.closeRegistration(); // ★ 注册完立刻关窗(见第五节)
entry.state = 'registered';
} catch (err) {
console.error(`Failed to register '${entry.plugin.manifest.name}': ${err}`); // 隔离
}
}
}
决策二:单插件故障不传染。 每个插件的每个阶段都包在独立的 try/catch 里,失败的插件停在当前状态(不再是 initialized,后续阶段自然跳过),其余插件照常走完生命周期。一个写崩了的第三方插件降级为"这个功能缺失",而不是整个编辑器起不来。停机时(stopAll)按逆序 deactivate、dispose——依赖别人资源初始化的插件,必须先于被依赖者销毁。
决策三:按加载顺序编排。 这里要如实对照 RFC:RFC-0003 第七部分规划了 manifest 依赖声明 + 拓扑排序 + 版本区间检查,而当前 MVP 按 load() 的先后顺序执行,同名插件去重后跳过。依赖解析在路线图上,尚未落地——这是"RFC 先行、实现分步"的又一个现场。
五、PluginContext:一道带时间窗的墙
PluginContext 是插件能看到的世界。当前 MVP 的接口面刻意收得很窄:
export class PluginContext {
get schema(): SchemaRegistry { ... } // 只读查询
get logger(): PluginLogger { ... } // 带插件名前缀的日志
registerBlock(spec): void; // ┐
registerRenderer(type, category): void; // ├─ 仅 Register 阶段可用
registerInsertItem(type, item): void; // ┘
}
注意它没有什么:没有 Document、没有事务管理器、没有历史栈、没有 Editor。插件在注册阶段能做的只有"声明贡献"。Document 读取、exec/transact 变更、事件订阅这些运行时 API,是 RFC-0004 之后规划的下一个 PR(PluginContext Runtime APIs)——当前版本的 SDK 是一块诚实的基石,而不是半成品的全景。
注册时间窗是这套设计的安全栓。PluginManager 在每个插件 register() 返回后立即调用 closeRegistration(),此后任何注册调用都会撞上一堵墙:
private assertRegistrationOpen(method: string): void {
if (!this._registrationOpen) {
throw new Error(`PluginContext.${method}() is only available during the Register phase`);
}
}
这就是第 05 篇埋的伏笔的谜底:Schema 的"注册后冻结"不是靠纪律,是靠机制——每个插件有自己独立的 context、自己的时间窗,窗口外的一切写入路径在代码里不存在。
还有个容易被读漏的细节:SchemaRegistry.registerBlock 遇到重复类型是抛错(第 05 篇),而 PluginContext.registerBlock 遇到重复类型是警告 + 跳过。同一个冲突,两层两种态度——注册表层面硬失败(开发期立即暴露),上下文层面软处理(插件对运行环境的防御性容忍)。边界层的职责之一,就是把内部的不变量转译成对外部更合适的错误策略。
六、一个真实的官方插件:CalloutPlugin 全文解剖
整个官方 Callout 插件的主体只有 44 行:
export class CalloutPlugin implements IPlugin {
readonly manifest: PluginManifest = CalloutManifest; // name: 'official-callout', version: 0.1.0
private context: PluginContext | undefined = undefined;
initialize(context: PluginContext): void {
this.context = context;
context.logger.info('Initialized');
}
register(context: PluginContext): void {
context.registerBlock(CalloutBlockSpec); // 注册块类型(含 UI 贡献)
context.registerRenderer('callout', 'editable'); // 声明渲染分类
}
activate(): void { this.context?.logger.info('Activated'); }
deactivate(): void { this.context?.logger.info('Deactivated'); }
dispose(): void { this.context = undefined; } // 释放 context 引用
}
三个值得咀嚼的点:
其一,注册的"一变多"。 registerBlock(CalloutBlockSpec) 这一行实际上同时完成了类型注册与三份菜单贡献——CalloutBlockSpec 里带着四个变体的插入项(💡 提示 / ✅ 成功 / ⚠️ 警告 / ❌ 错误)、斜杠菜单条目、样式选项,以及第 05 篇讲过的 attributes 声明(Action Sheet 属性面板的元数据)。插件代码里没有任何一处 UI 硬编码,全靠 Spec 声明(07 篇展开属性引擎)。
其二,渲染分类与块类型分开注册。 registerRenderer('callout', 'editable')——ArkUI 静态编译、不能动态加载组件,所以插件注册的是"callout 走 editable 分类的渲染路径",实际组件由渲染分发层按分类路由(第 08 篇展开)。这是鸿蒙平台约束在 API 形状上留下的指纹:Web 编辑器的"注册渲染函数"在静态编译世界里必须拆成"注册分类 + 应用侧路由"。
其三,克制的语义设计。 CalloutBlockSpec 的注释里有一句值得抄进任何设计文档的话:变体是语义(info/success/warning/error),"任意颜色自定义不被暴露"。要支持任意颜色只需把 variant 从枚举改成 string——一行的事,但作者拒绝了:语义变体保证四种视觉主题可控、文档互换时不褪色、且用户界面永远不需要调色板。SDK 给了插件充分的表现自由,好的插件知道在哪里不用它。
七、"Image test":用官方迁移验收 SDK
RFC-0003 定了一条验收标准,称之为 "Image test":如果官方图片插件无法只通过 Plugin SDK 实现,说明 SDK 不完整——补 SDK,而不是给官方插件开后门。
这不是口号,是已经执行过两轮的流程:ImagePlugin(PR-0021)是第一个 IPlugin 插件,迁移的显式目的就是"validate the Plugin SDK architecture";Card、Callout 相继跟进,每个都在迁移中暴露 SDK 缺口并回填(registerInsertItem 这类 API 就是这样长出来的)。官方插件与第三方插件自此共用同一个 IPlugin 接口、同一个 PluginContext、同一条生命周期流水线——没有任何特权路径。
这条纪律的深层价值:官方插件成了 SDK 的持续集成测试。每迁移一个官方块,就等于跑了一遍"第三方开发者接入"的全流程,SDK 的每个缺口都在伤害官方之前被官方自己发现。
八、现状与路线图,如实分开写
已落地(MVP,随 PR-0021~0023):
IPlugin五阶段生命周期 +PluginManager编排(状态门控、故障隔离、逆序停机);PluginContext注册类扩展点:块类型、渲染分类、插入项追加;注册时间窗机制;- 三个官方插件(Image / Card / Callout)完成迁移,
CalloutTheme之类的领域资产与插件同目录共存。
规划中(RFC-0003 已设计、实现未开始):
- PluginContext 运行时 API(文档读取、exec/transact、事件订阅)——下一个 PR 的目标;
- manifest 依赖声明、拓扑排序、版本区间、优先级;
- 能力系统(插件间经命名契约互通,替代直接依赖);
- 快捷键、输入规则、传输格式的 SDK 化注册;
- 斜杠菜单 / Markdown 输入的框架级插件(当前
SlashMenuPlugin、MarkdownInputPlugin还是骨架,斜杠菜单的 UI 暂由 Playground 演示层承担)。
把这两张清单分开放的意义在于:读源码时你可以准确知道哪些机制是"扛过真机验证的",哪些是"纸面设计待落地"——这对任何处于稳定化前夜的项目都成立。
九、小结
- 插件不是内核补丁:Core 持不变量、SDK 定义契约、插件消费契约,这条边界让"所有变更可撤销"对插件照样成立。
- 两套机制是迁移的中间态:数据式 PluginSpec 承载内置装配,SDK 式 IPlugin 承载官方与第三方块,框架每一步迁移都可运行。
- 注册与激活分离:声明式贡献与命令式启动拆开,Schema 冻结、安全禁用、依赖解析都有了挂点。
- 注册时间窗是机制不是纪律:每个插件独立的 PluginContext + closeRegistration,窗口外的写路径在代码里不存在。
- 官方迁移即验收:Image test 让官方插件充当 SDK 的持续集成测试,后门从制度上不存在。
下一篇把镜头对准 CalloutBlockSpec 里那个 attributes 字段——插件只声明元数据,Action Sheet 的属性面板自动生成,撤销重做免费获得。这是"声明式"路线在本框架里的第二次胜利。
下一篇:《鸿蒙编辑器框架的属性引擎:声明元数据,UI 自动生成》——从"每个插件三处硬编码"到"声明即 UI"的重构全记录,AttributeSpec 与 AttributeGroupSpec 的分工哲学。
更多推荐



所有评论(0)