动手 ①:起一台宿主
讲了五章,现在造。这一章结束时你会有一台真的插件宿主 —— 你在网页上现写一段插件源码,点「装载」,它会被编译成一个函数、被执行、被要到一个对象,然后它注册的命令真的出现在命令面板里,点下去真的执行。全部秘密只有三行。
先看它跑
下面文本框里是一段插件源码。宿主会把这段字符串变成一个真的函数、执行它、拿到它导出的对象,再调用它的 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) —— 我们要把 module 和 exports 作为参数塞进去,这样插件源码里就能直接用它们,而它们是我们控制的对象。
第 ② 行: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 完全挡不住下面三种情况:
① 异步错误。插件在 setTimeout 或 Promise 里抛的异常,不在你的调用栈上,try/catch 抓不到 —— 它会变成一个全局的 unhandled rejection。宿主还需要一个全局兜底(window.onerror / unhandledrejection),并且要想办法把错误归因到插件(很难,因为栈里未必有插件的标识)。
② 死循环。while(true){} 不抛异常,它就是不返回。try/catch 对此无能为力,你的整个程序就停在那一行(第 15 章会让你亲身体验)。
③ 破坏性副作用。插件在崩之前已经把宿主的某个数组清空了、把某个全局对象改掉了。catch 住的只是异常,已经造成的破坏不会回滚。
此刻这台宿主还缺什么
诚实地列一下。上面这台宿主能用,但漏得像筛子:
| 缺什么 | 后果 | 在哪一章补 |
|---|---|---|
| 没有清单 | 宿主必须执行代码才知道插件是什么;没法拒绝、没法懒加载 | 07、08 |
| 没有版本检查 | 为旧版宿主写的插件会装上,然后在某个用户点按钮时炸 | 08、19 |
| 没有权限 | ctx 里的所有能力对所有插件敞开,包括 fs.read | 17 |
| 没有真隔离 | 插件三行代码就能拿到 globalThis;死循环能卡死一切 | 14、15、16 |
| 卸载不干净 | 停用后监听器还在,插件模块永远无法回收 | 13 |
| 没有依赖管理 | 插件之间无法协作,也无法处理加载顺序 | 20 |
但请注意一件事:上面这台四十行的宿主,已经是一个完整可用的插件系统了。如果你的插件全部由自己团队编写、跑在受信任的环境里(比如一个内部工具的可插拔数据源),它就够了,剩下十八章你可以只当科普看。
后面所有的复杂度,都来自同一个前提的加深:插件的作者是陌生人。
装到自己身上
三行秘密在 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。插件里的 StackOverflowError、NoClassDefFoundError、ExceptionInInitializerError 都是 Error 而不是 Exception——只 catch Exception 的话,一个插件的静态初始化块出错就能把你整个应用带走。(但要注意:catch 住 OutOfMemoryError 之后你也做不了什么,那种情况该让它死。)
② 那个 as NotePlugin 的强转是最容易出问题的一行。它看起来只会在「插件没实现接口」时失败,但实际上还有一种更诡异的失败:插件实现了接口,但它加载的 NotePlugin 和宿主的 NotePlugin 不是同一个类。报错会是那句著名的「NotePlugin cannot be cast to NotePlugin」。第 10 章整章讲这件事。
这一章的一句话
加载插件只有三行 —— 编译字符串、执行拿对象、交出能力;剩下的一切复杂度,都来自「作者是陌生人」这一个前提。
下一章补上最要紧的那个缺口:清单。它让宿主在执行任何插件代码之前,就知道这个插件是谁、要什么、给什么 —— 也让宿主第一次有资格说「不」。