卷 II · 框CH 06深度 06/24

动手 ①:起一台宿主

讲了五章,现在造。这一章结束时你会有一台真的插件宿主 —— 你在网页上现写一段插件源码,点「装载」,它会被编译成一个函数、被执行、被要到一个对象,然后它注册的命令真的出现在命令面板里,点下去真的执行。全部秘密只有三行。

动手new Functionactivate错误边界

先看它跑

下面文本框里是一段插件源码。宿主会把这段字符串变成一个真的函数、执行它、拿到它导出的对象,再调用它的 activate()

点「装载并激活」,然后看命令面板里多出来的那个按钮 —— 那是你刚刚这段代码注册的。改点什么(比如把标题改成别的,或者多加一条命令),再装一次:

玩几分钟。特别推荐试试这两件事:

  • 故意写错语法(删掉一个括号)→ 看宿主怎么报错,以及宿主自己没崩
  • 在 activate 里直接抛异常(写一行 throw new Error('炸'))→ 同样,宿主活着。

下面把这台机器拆开。

全部秘密:三行

「加载插件」这件事听起来很玄,实际上是这三行:

// ① 一段字符串,变成一个真的函数
const factory = new Function('module', 'exports', 源码字符串);

// ② 执行它,让它把东西挂到 module.exports 上
const mod = { exports: {} };
factory(mod, mod.exports);

// ③ 拿到插件对象,把宿主的能力交给它
mod.exports.activate(ctx);

就这样。你已经会写插件加载器了,剩下十八章都是在给这三行打补丁。

但这三行里每一行都有讲究,值得拆开看。

第 ① 行:为什么是 new Function,不是 eval

两者都能把字符串变成可执行代码,但作用域完全不同:

function loadPlugin(src) {
  const hostSecret = '宿主的内部状态';

  eval(src);                  // ← 插件能看见 hostSecret!
  new Function(src)();        // ← 插件看不见,它只有全局作用域
}

eval 是在当前作用域里执行代码,插件能读到(甚至改写)宿主函数内的每一个局部变量。new Function 编译出来的函数只能看见全局作用域和显式传给它的参数

所以真实世界里没有人用 eval 加载插件。第 9 章会把这两个(外加模块包装)真的跑一遍,你能亲眼看到差别。

顺带一提:这也是为什么第 ① 行要写成 new Function('module', 'exports', src) 而不是 new Function(src) —— 我们要把 moduleexports 作为参数塞进去,这样插件源码里就能直接用它们,而它们是我们控制的对象。

第 ② 行:module.exports 这个形状是从哪来的

你可能觉得 module.exports 这个约定有点古老。它确实是 CommonJS 的东西,但它在插件系统里活得非常好,而且不是巧合

  • Obsidian 的插件就是 module.exports = class MyPlugin extends Plugin {...}
  • VS Code 的插件是 module.exports = { activate, deactivate }
  • Node 生态的一切中间件都是这个形状。

为什么?因为它把「插件是一段代码」变成了「插件是一段代码执行后留下的一个对象」。这个转换极其关键:

一段代码你只能执行;一个对象你可以检查、可以保存、可以按需调用、可以在卸载时丢掉。有了这个对象,宿主才能问它「你有 activate 吗?有 deactivate 吗?」,才能在它身上建立生命周期。

第 ③ 行:activate(ctx) 才是真正的契约

为什么不让插件的代码在顶层直接干活?也就是说,为什么不让它写成这样:

// ✗ 顶层直接干活
ctx.ui.addCommand('wc.count', '统计字数', () => { /* … */ });

而是要包一层 activate

// ✓ 包一层
module.exports = {
  activate(ctx) {
    ctx.ui.addCommand('wc.count', '统计字数', () => { /* … */ });
  },
  deactivate() { /* … */ }
};

三个理由,每个都很实在:

① 「加载」和「启动」分开了。宿主可以先把插件加载进来(拿到对象、检查它长得对不对),过一会儿再决定要不要真的启动它。这是第 21 章懒激活的地基。

② ctx 只能由宿主给。如果插件在顶层干活,它得从哪里拿到 ctx?只能是全局变量 —— 那意味着所有插件共享同一个 ctx,宿主就没法给不同插件不同的权限了。activate(ctx) 让宿主可以为每个插件单独定制一个能力对象(第 17 章的整个权限系统就建在这上面)。

③ 有了对称的 deactivate有开始就该有结束,这是第 13 章的主题。

宿主那一侧的完整代码

把三行放回上下文。这是本书那台宿主的真实代码(就是上面 demo 在跑的那份,只去掉了后面几章才加的部分):

Host.prototype.activate = function (id) {
  const rec = this.plugin(id);
  if (!rec || rec.state === 'active') return rec;

  // ① 编译:字符串 → 函数
  let factory;
  try {
    factory = new Function('module', 'exports', rec.source);
  } catch (e) {
    rec.state = 'error';
    rec.error = '语法错误:' + e.message;
    this.log('[宿主] ✗ ' + id + ' 加载失败 —— ' + rec.error);
    return rec;                                   // ← 宿主活着
  }

  // ② 执行 + ③ 激活
  const mod = { exports: {} };
  try {
    factory(mod, mod.exports);
    rec.exports = mod.exports;
    if (typeof rec.exports.activate === 'function') {
      rec.exports.activate(this.makeApi(rec));    // ← 每个插件一个专属 ctx
    }
    rec.state = 'active';
    this.log('[宿主] ✓ 已激活 ' + id);
  } catch (e) {
    rec.state = 'error';
    rec.error = e.message;
    this.log('[宿主] ✗ ' + id + ' 激活时抛出 —— ' + rec.error);
  }
  return rec;                                      // ← 宿主还是活着
};

三十行不到,其中一半是错误处理。那一半才是重点。

两个 try/catch:最便宜的隔离

注意上面有两个独立的 try/catch,它们抓的是完全不同的两类错误:

位置抓什么典型情况
第一个(包住 new Function 语法错误 —— 代码根本编译不了 少个括号、用了当前引擎不支持的语法、文件被截断了
第二个(包住执行和 activate) 运行时错误 —— 代码跑起来了但炸了 调了不存在的 API、访问了 undefined 的属性、自己 throw 了

分开的好处是报错能说人话:「这个插件语法就不对,作者根本没编译过」和「这个插件跑到一半炸了」,对用户和对插件作者来说是完全不同的信息。

◆ 这是隔离的第一级,也是性价比最高的一级

回想第 3 章那张表:隔离有四级,难度递增。这两个 try/catch 就是第一级 —— 防住「插件抛异常把宿主带崩」。

它的成本几乎为零(两个 try/catch),收益却极大:一个写坏的插件不会让整个应用崩溃,用户看到的是「插件 X 加载失败」而不是白屏。

所有插件系统都做了这一级,包括最不设防的 Obsidian。——但也只有这一级是免费的。防住「插件死循环卡死宿主」(第 15 章)需要付出整个架构,防住「插件作恶」(第 14、16 章)需要付出运行时边界。

如果你今天就要给自己的程序加插件,至少先把这两个 try/catch 加上。它是最低标准。

⚠ try/catch 挡不住的三件事(现在就要知道)

别被上面的乐观误导。这两个 try/catch 完全挡不住下面三种情况:

① 异步错误。插件在 setTimeout 或 Promise 里抛的异常,不在你的调用栈上,try/catch 抓不到 —— 它会变成一个全局的 unhandled rejection。宿主还需要一个全局兜底window.onerror / unhandledrejection),并且要想办法把错误归因到插件(很难,因为栈里未必有插件的标识)。

② 死循环。while(true){} 不抛异常,它就是不返回。try/catch 对此无能为力,你的整个程序就停在那一行(第 15 章会让你亲身体验)。

③ 破坏性副作用。插件在崩之前已经把宿主的某个数组清空了、把某个全局对象改掉了。catch 住的只是异常,已经造成的破坏不会回滚

此刻这台宿主还缺什么

诚实地列一下。上面这台宿主能用,但漏得像筛子

缺什么后果在哪一章补
没有清单宿主必须执行代码才知道插件是什么;没法拒绝、没法懒加载07、08
没有版本检查为旧版宿主写的插件会装上,然后在某个用户点按钮时炸08、19
没有权限ctx 里的所有能力对所有插件敞开,包括 fs.read17
没有真隔离插件三行代码就能拿到 globalThis;死循环能卡死一切14、15、16
卸载不干净停用后监听器还在,插件模块永远无法回收13
没有依赖管理插件之间无法协作,也无法处理加载顺序20

但请注意一件事:上面这台四十行的宿主,已经是一个完整可用的插件系统了。如果你的插件全部由自己团队编写、跑在受信任的环境里(比如一个内部工具的可插拔数据源),它就够了,剩下十八章你可以只当科普看。

后面所有的复杂度,都来自同一个前提的加深:插件的作者是陌生人。

装到自己身上

◆ Kotlin / JVM 版的同一台宿主

三行秘密在 JVM 上是这样的(这里用最简单的 jar 加载,Android 的 DEX 版本见第 11 章):

class Host {
    private val registry = Registry()

    fun activate(rec: PluginRecord): Boolean {
        // ① 「编译」在 JVM 上已经提前做完了(.class 文件),
        //    这里对应的是「加载类」
        val loader = URLClassLoader(arrayOf(rec.jarFile.toURI().toURL()), javaClass.classLoader)

        return try {
            // ② 拿到类,实例化 —— 对应 JS 里的「执行并取 module.exports」
            val cls = Class.forName(rec.manifest.mainClass, false, loader)
            val plugin = cls.getDeclaredConstructor().newInstance() as NotePlugin

            // ③ 交出能力
            plugin.activate(makeContext(rec))
            rec.instance = plugin
            rec.state = State.ACTIVE
            true
        } catch (e: ClassNotFoundException) {
            rec.error = "清单里写的主类找不到:${rec.manifest.mainClass}"; false
        } catch (e: ClassCastException) {
            rec.error = "主类没有实现 NotePlugin 接口"; false      // ← 第 10 章会讲这个异常的另一面
        } catch (e: Throwable) {
            rec.error = "激活时抛出:${e.message}"; false
        }
    }
}

结构一模一样:加载 → 实例化 → 交出能力,外面包着分类的错误处理。

但有两处 JVM 特有的坑,现在就该知道:

① catch 的是 Throwable 不是 Exception插件里的 StackOverflowErrorNoClassDefFoundErrorExceptionInInitializerError 都是 Error 而不是 Exception——只 catch Exception 的话,一个插件的静态初始化块出错就能把你整个应用带走。(但要注意:catch 住 OutOfMemoryError 之后你也做不了什么,那种情况该让它死。)

② 那个 as NotePlugin 的强转是最容易出问题的一行。它看起来只会在「插件没实现接口」时失败,但实际上还有一种更诡异的失败:插件实现了接口,但它加载的 NotePlugin 和宿主的 NotePlugin 不是同一个类。报错会是那句著名的「NotePlugin cannot be cast to NotePlugin」。第 10 章整章讲这件事。

这一章的一句话

加载插件只有三行 —— 编译字符串、执行拿对象、交出能力;剩下的一切复杂度,都来自「作者是陌生人」这一个前提。

下一章补上最要紧的那个缺口:清单。它让宿主在执行任何插件代码之前,就知道这个插件是谁、要什么、给什么 —— 也让宿主第一次有资格说「不」。