卷 III · 入CH 09深度 09/24

加载:把死代码变成活对象

一段插件源码,在被加载之前只是一串字符 —— 和一篇日记、一段乱码没有区别。让它变成进程里一个能被调用的对象,这一步各家用的原语不同,但都极其简单。难的是加载之后跟着来的三个后果:它能看见什么、它是谁、以及它能不能被赶走。

evalnew Function作用域三个后果

各家的加载原语

先把牌摊开。「把静态数据变成可调用对象」这件事,各个平台的原语是:

平台原语输入输出
浏览器 JSnew Function(...)import(url)源码字符串 / URL函数 / 模块命名空间对象
Noderequire()vm.Scriptimport()文件路径 / 源码module.exports
JVMClassLoader.loadClass() + 反射 newInstance()字节码(jar / 目录)对象实例
AndroidDexClassLoader / PathClassLoaderDEX(apk / dex 文件)对象实例
原生(C/C++)dlopen + dlsym / LoadLibrary动态库 .so / .dll函数指针
WASMWebAssembly.instantiate()wasm 字节导出函数表
.NETAssemblyLoadContext程序集对象实例

形状完全一样:给它一坨字节,还你一个能调用的东西。不同的只是那坨字节的格式,和还给你的东西叫什么。

所以「怎么加载」不值得写一章。值得写一章的是加载方式带来的三个后果。

后果一 · 作用域

它能看见什么

这是选择加载原语时最直接的差别。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 模块,都在一个这样的函数里。那些「凭空出现」的 requiremodule__dirname,就是这么被塞进来的 —— 它们不是全局变量,是参数。

⚠ 但请看 demo 的第二行

三种方式都能看见 globalThis

作用域隔离只解决了「插件不能摸到宿主的局部变量」,它完全没有解决「插件不能摸到全局的一切」。在浏览器里那意味着 documentfetchlocalStorage;在 Node 里那意味着 processrequire('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 上,这个问题被抬到了语言规则的高度:

◆ 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 本来就跑在独立的上下文里,整个上下文销毁掉就完事了 —— 这是运行时隔离白送的第二个好处(第一个是安全)。

一个常被忽略的选择:要不要支持「热重载」

插件作者最痛的开发体验是:改一行代码,要重启整个应用才能看到效果。

热重载(卸载旧的,加载新的)能极大提升插件开发体验,但它的代价是把「卸载必须干净」从「最好做到」升级成「必须做到」 —— 而且是在开发期反复做,任何一点残留都会累积成诡异的行为(「我明明改了,怎么还是老逻辑」「怎么触发了两次」)。

务实的建议:如果你的插件系统面向的是活跃的第三方开发者,热重载值得做,它对生态的价值超过大多数功能。如果插件主要由你自己写,别做 —— 重启一次的成本远低于把卸载做到完美。

装到自己身上

◆ Kotlin / JVM:三种加载法的对应物
// ① 最简单: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」到底是怎么冒出来的,以及为什么最聪明的插件系统明知有这个坑还是要往里跳。