加载:把死代码变成活对象
一段插件源码,在被加载之前只是一串字符 —— 和一篇日记、一段乱码没有区别。让它变成进程里一个能被调用的对象,这一步各家用的原语不同,但都极其简单。难的是加载之后跟着来的三个后果:它能看见什么、它是谁、以及它能不能被赶走。
各家的加载原语
先把牌摊开。「把静态数据变成可调用对象」这件事,各个平台的原语是:
| 平台 | 原语 | 输入 | 输出 |
|---|---|---|---|
| 浏览器 JS | new Function(...)、import(url) | 源码字符串 / URL | 函数 / 模块命名空间对象 |
| Node | require()、vm.Script、import() | 文件路径 / 源码 | module.exports |
| JVM | ClassLoader.loadClass() + 反射 newInstance() | 字节码(jar / 目录) | 对象实例 |
| Android | DexClassLoader / PathClassLoader | DEX(apk / dex 文件) | 对象实例 |
| 原生(C/C++) | dlopen + dlsym / LoadLibrary | 动态库 .so / .dll | 函数指针 |
| WASM | WebAssembly.instantiate() | wasm 字节 | 导出函数表 |
| .NET | AssemblyLoadContext | 程序集 | 对象实例 |
形状完全一样:给它一坨字节,还你一个能调用的东西。不同的只是那坨字节的格式,和还给你的东西叫什么。
所以「怎么加载」不值得写一章。值得写一章的是加载方式带来的三个后果。
它能看见什么
这是选择加载原语时最直接的差别。JS 里有三种做法,我们把同一段「探针」代码分别放进去,看它能摸到什么。
探针会试着摸三样东西:宿主函数里的一个局部变量、全局对象、以及显式传给它的 ctx。下面这个 demo 是真跑的:
看第一行的差别 —— 这是全章最重要的一个观察。
eval:插件长在你的作用域里
function loadPlugin(src) {
const hostSecret = 'HOST-INTERNAL-42';
const db = openDatabase();
eval(src); // ← 插件能读到 hostSecret,能读到 db,能改它们
}
eval 在当前作用域里执行代码。这意味着插件能看见、能修改宿主函数内部的每一个局部变量。你精心设计的 ctx 完全没意义了 —— 它旁边那些变量它也全都拿得到。
所以真实世界里没有任何插件系统用 eval 加载插件。它唯一的合法用途是开发者工具里的「执行这段代码」。
new Function:只有全局 + 你显式传的参数
const factory = new Function('module', 'exports', src);
// ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑
// 插件能看见的,只有这些参数 + 全局作用域
new Function 编译出的函数,作用域链的父节点是全局作用域,不是创建它的地方。宿主的局部变量对它完全不存在。
这是所有 JS 插件系统实际在用的那一族(Node 的 vm.Script、浏览器的 import() 本质都在这一侧)。它是「作用域隔离」的正确答案。
模块包装:new Function + 一个受控的壳
就是第 6 章那台宿主用的做法:
const factory = new Function('module', 'exports', src);
const mod = { exports: {} };
factory(mod, mod.exports);
// 插件的产出,现在是 mod.exports 这个对象
这一层壳带来两个好处:插件的代码变成了「执行后留下一个对象」(第 6 章讲过为什么这很关键),以及你控制着传进去的每一个东西 —— 想加个 require 就加,想不给就不给。
Node 的 require 本质上就是这么干的,它包的壳更大一点:
// Node 内部大致是这样(简化)
(function (exports, require, module, __filename, __dirname) {
// ← 你的模块代码被塞在这里
});
你每天写的每一个 Node 模块,都在一个这样的函数里。那些「凭空出现」的 require、module、__dirname,就是这么被塞进来的 —— 它们不是全局变量,是参数。
三种方式都能看见 globalThis。
作用域隔离只解决了「插件不能摸到宿主的局部变量」,它完全没有解决「插件不能摸到全局的一切」。在浏览器里那意味着 document、fetch、localStorage;在 Node 里那意味着 process、require('fs')。
作用域隔离 ≠ 沙箱。这是第 14 章的引子,那一章会用三行真能跑的代码,从一个「什么都没给它」的 ctx 里把整个 globalThis 拿回来。
加载进来的东西,是「谁」
这个后果在 JS 里很轻,在 JVM 里能要人命。
先说 JS 侧的轻量版。同一个模块被加载两次,会发生什么?
const a = new Function('module','exports', src); const modA = {exports:{}}; a(modA, modA.exports);
const b = new Function('module','exports', src); const modB = {exports:{}}; b(modB, modB.exports);
modA.exports === modB.exports // false —— 两个不同的对象
modA.exports.SomeClass === modB.exports.SomeClass // false —— 两个不同的类!
const x = new modA.exports.SomeClass();
x instanceof modB.exports.SomeClass // false ← 就是这里出问题
同样的源码,加载两次,产出的是两套互不相认的东西。如果插件 A 和插件 B 各自打包了同一个库,然后互相传对象,instanceof 就会给出反直觉的答案。
Node 用「模块缓存」缓解了这个问题(同一个路径只加载一次),但一旦涉及不同版本、不同 node_modules 层级,同样的问题照样出现 —— 这就是 npm 生态里那些「你安装了两份 React」的经典事故。
而在 JVM 上,这个问题被抬到了语言规则的高度:
一个类的身份 = 全限定名 + 定义它的类加载器。
也就是说:com.example.Foo 被加载器 A 加载出来的类,和被加载器 B 加载出来的 com.example.Foo,在 JVM 眼里是两个毫不相干的类。名字一样只是长得像。
把一个转成另一个,你会得到那句所有 JVM 插件开发者都见过的报错:
java.lang.ClassCastException: com.example.Foo cannot be cast to com.example.Foo
下一章整章都在讲这件事,你会亲手把它复现出来,并搞明白为什么 Mihon 和 IntelliJ 明知有这个坑,还是要选那条会踩到它的路。
它能不能被赶走
这是三个后果里最麻烦的一个。加载是一个动作,卸载不是一个动作 —— 它是一个需要全程配合才能达成的状态。
为什么?因为「加载」这个动作本身会留下痕迹,而且痕迹不在你手上:
// 插件的 activate 里干了这些事
ctx.ui.addCommand('x', '…', fn); // ← fn 现在躺在宿主的 commands 数组里
ctx.events.on('note:open', handler); // ← handler 躺在宿主的监听器表里
setInterval(tick, 1000); // ← tick 躺在事件循环里
document.addEventListener('click', h); // ← h 躺在 DOM 上
window.myGlobalCache = new Map(); // ← 挂在全局上了
现在你想卸载这个插件。把 mod.exports 设成 null 有用吗?
完全没用。上面那五个位置每一个都持有一个指向插件闭包的引用,而每个闭包都引用着整个插件模块。只要有一处没清掉,整个插件(连同它加载的所有数据)就永远留在内存里,而且它的定时器还在跑、监听器还在响应。
这就是为什么第 4 章那个注册表的 contribute 一定要返回一个可弃置对象 —— 它把「卸载」从「遍历所有可能的角落去找残留」(一定会漏)变成了「把当初拿到的凭证挨个 dispose」(不可能漏)。
第 13 章会让你亲眼看到没做这件事的下场:来回启停 5 轮之后,宿主上残留 5 个监听器,而它们属于早就「停用」了的插件。
Obsidian:认真做了。Plugin 基类提供 registerEvent / registerDomEvent / registerInterval,用这些注册的东西在 onunload 时自动回收。它把正确写法做成了最省事的写法。
VS Code:同样思路,context.subscriptions.push(disposable)。插件停用时 VS Code 会把整个数组 dispose 一遍。
IntelliJ:这是它历史上最难的部分。早期插件必须重启 IDE 才能装卸;后来引入了「动态插件」,但对插件有一堆限制(不能有某些静态状态、EP 要支持动态增删)—— 可见「支持免重启卸载」是一个需要从头设计的能力,很难后加。
Mihon:扩展是独立 APK,卸载靠 Android 系统。但注意 —— DEX 加载进来的类,在应用重启前是回收不掉的。所以 Mihon 卸载扩展后,那部分内存要等下次启动才真的还回去。
Chrome:最干净的一个,因为 content script 和 service worker 本来就跑在独立的上下文里,整个上下文销毁掉就完事了 —— 这是运行时隔离白送的第二个好处(第一个是安全)。
一个常被忽略的选择:要不要支持「热重载」
插件作者最痛的开发体验是:改一行代码,要重启整个应用才能看到效果。
热重载(卸载旧的,加载新的)能极大提升插件开发体验,但它的代价是把「卸载必须干净」从「最好做到」升级成「必须做到」 —— 而且是在开发期反复做,任何一点残留都会累积成诡异的行为(「我明明改了,怎么还是老逻辑」「怎么触发了两次」)。
务实的建议:如果你的插件系统面向的是活跃的第三方开发者,热重载值得做,它对生态的价值超过大多数功能。如果插件主要由你自己写,别做 —— 重启一次的成本远低于把卸载做到完美。
装到自己身上
// ① 最简单:URLClassLoader 加载一个 jar
val loader = URLClassLoader(arrayOf(jar.toURI().toURL()), javaClass.classLoader)
val plugin = Class.forName(mainClass, false, loader)
.getDeclaredConstructor().newInstance() as NotePlugin
// ② 想卸载?必须让整个 ClassLoader 变成垃圾
// —— 类是由加载器持有的,只要加载器还活着,类就回收不掉
var loaderRef: URLClassLoader? = loader
plugin.deactivate()
loaderRef?.close() // 关掉它打开的 jar 文件句柄(Windows 上不关就删不掉文件)
loaderRef = null // 断掉引用,等 GC
// ⚠ 只要还有任何一个地方持有 plugin 对象、或它的任何一个类,整个加载器都回收不了
// ③ Android:加载一个外部 DEX/APK
val loader = DexClassLoader(
apkPath, // 要加载的 apk/dex
context.codeCacheDir.path, // 优化后的 dex 放哪
null, // native 库路径
context.classLoader // 父加载器
)
三个 JVM 特有的坑,按踩到的频率排序:
① 忘了 close()。URLClassLoader 持有 jar 文件句柄。不关的话,在 Windows 上你连删除那个 jar 都做不到 —— 这会让「更新插件」这个功能诡异地失败,而且只在 Windows 上。
② Class.forName 的第二个参数。写 true 会立刻执行类的静态初始化块 —— 那是插件代码,你还没准备好接住它抛的异常。写 false,把执行推迟到你真正 newInstance() 的那个 try 块里。这个细节 Mihon 的加载器里就是写的 false。
③ 线程的 context classloader。很多库(尤其用 ServiceLoader 的)会通过 Thread.currentThread().contextClassLoader 去找类。在插件里调用这些库时,那个加载器还是宿主的,找不到插件的资源。标准做法是临时换掉再换回来:
val prev = Thread.currentThread().contextClassLoader
try {
Thread.currentThread().contextClassLoader = pluginLoader
// …… 调那个库
} finally {
Thread.currentThread().contextClassLoader = prev
}
这一章的一句话
加载本身只是一个「给字节还对象」的调用,各平台都很简单;真正要设计的是它的三个后果 —— 它能看见什么(作用域)、它是谁(身份)、它能不能被赶走(卸载)。
下一章我们把「身份」这个后果推到底 —— 那句「Foo cannot be cast to Foo」到底是怎么冒出来的,以及为什么最聪明的插件系统明知有这个坑还是要往里跳。