卷 II · 框CH 05深度 05/24

契约的三种流派:接口、事件、声明

插件要告诉宿主「我提供什么」,有三种根本不同的方式。它们看起来只是风格差异,实际上决定了你的插件系统能不能懒加载、能不能在不跑代码的情况下展示功能、以及三年后能不能改得动。这一章把三派摆开,然后讲反方向的那半张契约 —— 宿主给插件的那个 ctx 里该有什么。

接口派事件派声明派ctx 设计

同一件事,三种写法

需求还是那个:插件想往命令面板里加一条「统计字数」。三派分别这么写。

流派一:实现接口

宿主定义一个接口,插件提供一个实现类。宿主拿到对象后按接口调用它。

// 宿主定义
interface NoteCommand {
    val id: String
    val title: String
    fun run(ctx: HostContext)
}

// 插件实现
class WordCount : NoteCommand {
    override val id = "wc.count"
    override val title = "统计字数"
    override fun run(ctx: HostContext) {
        ctx.ui.status("共 ${ctx.notes.read().length} 字")
    }
}

代表:Mihon 的 Source / SourceFactory、IntelliJ 那些 interface 型扩展点、Java SPI。

特点:类型系统全程护航。插件少实现一个方法编译就过不了;宿主给接口加方法时,所有插件立刻编译报错(这既是优点也是缺点)。契约的形状是「一个对象,有这些能力」。

最适合:插件要提供的是一整套相关行为,而不是零散的一两个动作。Mihon 的 Source 要实现「热门列表怎么取、搜索怎么发、章节列表怎么解析、图片地址怎么抽」十几个方法 —— 它们必须是一伙的,拆成十几个独立回调反而难用。

流派二:注册回调

宿主提供注册函数,插件在自己的初始化代码里调用它们。

// Obsidian 风格
export default class MyPlugin extends Plugin {
  async onload() {
    this.addCommand({
      id: 'wc-count',
      name: '统计字数',
      callback: () => { /* … */ },
    });

    this.registerEvent(this.app.workspace.on('file-open', (f) => { /* … */ }));
    this.addRibbonIcon('dice', '掷骰子', () => { /* … */ });
  }
}

代表:Obsidian、jQuery 插件、大部分 Node 生态的中间件。

特点:最灵活、最直观、上手最快。插件可以根据运行时条件决定注册什么(「如果用户装了 X,我就多加一条命令」)—— 这是接口派和声明派都做不到的。

但灵活的代价很硬:宿主必须执行插件的代码,才知道它提供了什么。这一条会连锁引发三个后果:

  • 无法懒加载 —— 要显示命令列表就得先把所有插件跑一遍;
  • 商店页面无法准确列出插件功能 —— 那些信息只存在于运行时;
  • 安全上,「知道它要干什么」和「让它开始干」是同一个动作,没有中间地带

流派三:声明数据

插件在清单里写下数据,代码只提供实现。

// VS Code 风格:package.json
{
  "activationEvents": ["onCommand:myExt.wcCount"],
  "contributes": {
    "commands": [
      { "command": "myExt.wcCount", "title": "统计字数" }
    ],
    "menus": {
      "editor/context": [
        { "command": "myExt.wcCount", "when": "editorHasSelection" }
      ]
    }
  }
}
// 代码里只管实现,不管「有没有这条命令」
function activate(context) {
  context.subscriptions.push(
    vscode.commands.registerCommand('myExt.wcCount', () => { /* … */ })
  );
}

IntelliJ 是同一个思路,只是用 XML:

<extensions defaultExtensionNs="com.intellij">
  <localInspection language="JAVA"
      implementationClass="com.example.MyInspection"
      displayName="别这么写"
      enabledByDefault="true"
      level="WARNING"/>
</extensions>

特点:宿主读一份 JSON / XML 就能知道插件提供什么,一行插件代码都不用跑。菜单能画出来、命令面板能列出来、设置界面能生成、商店页面信息准确 —— 全部在「零执行」状态下完成。

代价:不灵活。清单是静态的,插件没法说「如果今天是周三就多加一条命令」。而且写起来啰嗦 —— 同一个命令的 id 要在清单和代码里各写一遍,容易写错(VS Code 的插件作者都踩过「清单里写了但代码没注册」这个坑)。

◆ 三派的真正分野:宿主不跑代码能知道多少

把三派按「零执行信息量」排序,一切就清楚了:

声明派:宿主知道插件提供的全部功能清单(命令、菜单、配置项、视图、文件类型关联……),只是不知道它们具体怎么实现。

接口派:宿主知道插件实现了哪个接口(也就是知道它属于哪一类),但不知道具体提供什么。Mihon 从清单能读到「这个包里有 3 个 Source 类」,但要知道它们叫什么、支持什么语言,还是得实例化出来问。

事件派:宿主什么都不知道,直到把代码跑一遍。

而「零执行信息量」直接决定了三件事的上限:启动性能(能不能懒加载)、安全(能不能在执行前做决策)、生态透明度(商店能不能准确描述插件)。这就是为什么规模越大的插件系统,越往声明派靠

真实系统全都是混着用的

上面为了讲清楚,把三派切得很干净。实际上没有一个成熟系统只用一派

系统声明的部分接口 / 回调的部分
VS Code contributes:命令、菜单、配置、视图、语言、主题、快捷键 命令的实现体、语言服务器、树视图的数据提供者 —— 全是回调
IntelliJ plugin.xml:往哪个 EP 填、类名、显示名、默认开关 那个 implementationClass 指向的类,实现平台定义的接口
Mihon meta-data:Source 实现类的类名、扩展库版本、NSFW 标记 Source / SourceFactory 接口的十几个方法
Chrome manifest.json:权限、匹配规则、图标、菜单、注入时机 chrome.* API 上挂的各种监听器
Obsidian 只有 manifest.json 里的元信息(id / 名字 / 版本) 其余全部onload() 里的回调注册

看最后一行 —— Obsidian 是纯事件派,这解释了它的很多特征:插件功能极其自由(能做任何 API 支持之外的事),但也因此没有懒加载(每个启用的插件在启动时都要 onload() 一遍),商店页面上的功能描述全靠作者手写。

这是一致的取舍,不是疏漏:Obsidian 已经决定了不做沙箱、不限制插件,那么再做一套声明式贡献点,收益也不大 —— 反正插件想干的事早就超出任何清单能描述的范围了。

◆ 给你自己的系统选哪派

默认选「声明 + 回调」的混合,也就是 VS Code 那套:

凡是「用户在界面上能看见的东西」,走声明 —— 命令、菜单、配置项、图标、文件类型。因为这些正是你需要「不跑代码就能显示」的东西。

凡是「真正的逻辑」,走回调或接口 —— 命令按下去干什么、数据怎么解析。这些反正要执行代码才有意义。

只有当插件提供的是「一整套必须配套的行为」时,才用接口派(Mihon 的 Source 那种情况)。接口的好处是类型安全和内聚,坏处是加方法会破坏所有实现 —— 所以接口一旦发布,就只能加默认实现,不能加抽象方法。

反方向的那半张契约:ctx 里该有什么

上面讲的都是「插件告诉宿主什么」。还有同样重要的另一半:宿主给插件什么。

这就是那个 ctx / context / app 对象。它是插件唯一的能力来源,所以它的设计直接决定了整个系统的能力上限和风险上限。

四条原则,每条都是被真实事故换来的:

原则一:给能力,不要给对象

// ✗ 别这样:把内部对象直接交出去
ctx.app = theWholeApplication;   // 从此你的每个内部字段都是公开 API
ctx.db = sqliteConnection;       // 插件可以 DROP TABLE

// ✓ 这样:只给经过设计的能力
ctx.notes = {
  list: () => [...],             // 返回副本
  read: (id) => string,
  write: (id, text) => boolean,  // 有校验、有日志、有权限检查
};

每多暴露一个内部对象,你就多失去一份重构自由。而且插件作者会用到你根本没想到的角落,然后你改那个角落时就弄死了他们。

原则二:交出去的数据要么不可变,要么是副本

// ✗ 插件可以 push 进去、可以 length = 0
ctx.notes.list = () => this.notes;

// ✓
ctx.notes.list = () => this.notes.map(n => ({ id: n.id, title: n.title }));

注意上面那个副本还顺手做了另一件事:它只暴露了 idtitle,没有把整篇正文交出去。「列出笔记」和「读取正文」是两个不同的能力,应该能被分别授权 —— 这是下一步(权限)的基础。

原则三:每个能力都要能被单独关掉

这条是为第 17 章埋的。如果你的 ctx 是这样组织的:

ctx = {
  notes: { list, read, write },   // ← notes.read / notes.write 两种权限
  ui:    { addCommand, status },  // ← ui 权限
  net:   { fetch },               // ← net 权限
  fs:    { read },                // ← fs 权限(最危险)
}

那么加权限就只是在每个函数第一行插一句检查。反过来,如果你的能力是散着放的(ctx.readNotectx.doNetworkThingctx.getFile),你就没有一个自然的粒度去做权限。

按「能力域」分组,从第一天就做。这几乎不花成本,但让权限系统在你需要它的那天可以直接装上去。

原则四:给「注册」的 API 必须返回撤销凭证

// ✗ 注册完就没了,卸载时无从下手
ctx.ui.addCommand(id, title, fn);

// ✓ 返回一个可弃置对象
const d = ctx.ui.addCommand(id, title, fn);
// 或者更好:宿主帮插件自动登记,插件根本不用管

「更好」的那种,就是 Obsidian 的 this.registerEvent(...) 和 VS Code 的 context.subscriptions.push(...) 在做的事:把正确的写法做成最省事的写法,插件作者甚至不需要知道自己在防内存泄漏。第 13 章你会看到不这么做的下场。

⚠ ctx 设计里一个不显眼但致命的错

不要在 ctx 的方法签名里出现「插件那边定义的类型」。

比如你写了一个这样的扩展点:ctx.registerParser(parser: Parser),而 Parser 这个接口是宿主定义的 —— 这没问题。但如果某个方法长成 ctx.process(config: SomeLibConfig),而 SomeLibConfig 来自插件自带的第三方库,你就把宿主和那个库的版本焊死了。

在 JS 里这会导致「插件 A 传进来的对象,插件 B 那边不认识」;在 JVM 里后果更直接,会当场抛出第 10 章那个经典异常。

规则很简单:边界上只能出现宿主定义的类型,以及语言内置的基本类型。

一个能抄的 ctx 骨架

本书那台宿主的 ctx,去掉权限检查后长这样。它足够小,可以直接当模板用:

function makeApi(rec) {
  const man = rec.manifest;
  return {
    version: host.version,          // 让插件能判断宿主版本
    pluginId: man.id,               // 让插件知道自己是谁(日志用)

    notes: {                        // ── 能力域:内容
      list:  () => host.notes.map(n => ({ id: n.id, title: n.title })),
      read:  (id) => host.find(id)?.body ?? null,
      write: (id, text) => { /* … */ },
    },

    ui: {                           // ── 能力域:界面(全部返回可弃置对象)
      addCommand:   (id, title, run) => rec.track(reg.contribute('commands', {...})),
      addTransform: (name, fn, prio) => rec.track(reg.contribute('transforms', {...})),
      status:       (t) => { host.status = t; },
    },

    net: { fetch: (url) => { /* … */ } },     // ── 能力域:网络
    fs:  { read:  (path) => { /* … */ } },    // ── 能力域:本机文件(危险)

    events: {                       // ── 能力域:事件(自动登记,防泄漏)
      on: (ev, fn) => rec.track(host.on(ev, fn)),
    },

    log: (m) => host.log(`[插件:${man.id}] ${m}`),  // 给插件一个正经的日志出口
  };
}

最后那个 log 值得单独说:给插件一个正经的日志出口,比你想的重要得多。如果不给,插件作者就会用 console.log,于是用户的控制台里混着几十个插件的输出,谁也不知道哪条是谁的。给一个带 id 前缀的 ctx.log,你就白得了「按插件过滤日志」和「出问题时知道该找谁」这两个能力。

装到自己身上

◆ 三个可以马上做的决定

① 画一张两栏表。左栏「插件能告诉我什么」,右栏「我能给插件什么」。先各写五条。写不满五条的那一栏,说明你的插件系统在那个方向上还没想清楚 —— 大多数人右栏写得出,左栏写不出,因为没想过「宿主在不执行代码时需要知道什么」。

② 把左栏的每一条问一遍:这条必须执行代码才能知道吗?凡是回答「不必须」的,全部挪进清单,走声明派。这一步通常能让你的清单从「就三个字段」变成一个真正有用的东西。

③ 把右栏按「能力域」重新分组。不要 ctx.readNote()ctx.fetchUrl() 平铺,要 ctx.notes.read()ctx.net.fetch()这一步现在几乎不花时间,等你需要加权限时会省下一次大重构。

这一章的一句话

三派的真正差别不是风格,是「宿主不执行插件代码时能知道多少」—— 而这个信息量,决定了你的系统能不能懒加载、能不能在执行前做安全决策、以及商店里能不能诚实地描述一个插件。

下一章开始动手。我们用四十行代码起一台真的宿主,让它真的把一段字符串变成活的插件、真的注册命令、真的跑起来 —— 你可以在页面上改那段插件代码。