JS 派:三种把代码放进来的姿势
Obsidian、VS Code、Chrome 扩展全都是 JavaScript,跑在 V8 上,插件也都是 js 文件。但它们把代码放进来的方式,隔离强度差了整整两个数量级。这一章把三种姿势并排拆开 —— 你会发现差别完全不在技术能力,而在于每一家愿意为隔离付多少钱。
姿势一:直接 require(Obsidian)
最简单的一种。Obsidian 是 Electron 应用,插件的 main.js 被直接在渲染进程里加载:
// 宿主那侧(示意,Obsidian 内部细节未公开,但机制就是这个)
const pluginModule = require(`${vaultPath}/.obsidian/plugins/${id}/main.js`);
const PluginClass = pluginModule.default ?? pluginModule;
const instance = new PluginClass(app, manifest);
await instance.onload();
插件和宿主共享一切:同一个 V8 隔离区、同一个全局对象、同一个事件循环、同一份 Node 集成能力。
这意味着插件能做的事,和 Obsidian 自己能做的事完全相同:
// 插件里这些全都可以,没有任何东西拦着
require('fs').readFileSync('/etc/passwd');
require('child_process').execSync('任何命令');
document.body.innerHTML = ''; // 想清空整个界面也行
app.vault.adapter // 宿主的内部对象,随便拿
Object.prototype.toString = () => '哈'; // 甚至可以污染原型链
| 得到 | 失去 |
|---|---|
| 零开销 —— 调用就是普通函数调用 | 零隔离 —— 插件卡死=应用卡死 |
| API 全同步,写起来最舒服 | 插件能做任何事,包括恶意的 |
| 插件能做官方 API 没提供的一切 | 宿主不敢重构内部结构(插件在依赖它) |
| 实现成本几乎为零 | 没有任何技术手段限制权限 |
这是一个自洽的选择(第 2 章讲过),但要清楚它把安全责任整个转移给了用户和审核流程。
姿势二:独立进程(VS Code)
VS Code 把插件请出了 UI 进程。它的进程结构大致是:
# VS Code 的进程们 主进程(Electron main) ├─ 渲染进程(窗口 / UI / 编辑器) ← DOM 在这里 ├─ extension host(Node 进程) ← 插件全部在这里,没有 DOM ├─ 语言服务器们(插件自己拉起的子进程) └─ 其它辅助进程(搜索、文件监视…)
插件的加载就是在 extension host 那个 Node 进程里 require。加载动作本身和 Obsidian 一样简单,差别在于它发生在哪个进程里。
而一旦跨了进程,所有交互都必须变成消息:
// 插件写的是这样(看起来像普通调用)
const doc = await vscode.workspace.openTextDocument(uri);
await vscode.window.showTextDocument(doc);
// 底层实际发生的(简化)
// extension host → 序列化成 {rpcId, method:'openTextDocument', args:[...]}
// → IPC 管道
// → 渲染进程反序列化,真的去打开文档
// → 结果序列化回来
// → Promise resolve
VS Code 在 extension host 启动时会为每个 UI 服务建立代理对象:你调一个方法,它把调用序列化成消息发过去,等回复。
① 插件卡死不影响编辑器。extension host 忙成死循环,UI 进程照样流畅 —— 甚至能弹出「扩展宿主无响应,要重启吗」。
② 插件崩溃不影响编辑器。进程挂了就重启一个,你的未保存文档还在。
③ 拿不到 DOM,于是 VS Code 能自由重构界面。没有任何插件能依赖某个 CSS 类名或 DOM 结构,因为它们根本看不到。
④ 白送了远程开发。既然插件本来就通过消息和 UI 通信,那把 extension host 挪到另一台机器上也一样能跑 —— 这就是 Remote SSH、Dev Containers、Codespaces 的技术基础。这个红利大到几乎是意外之喜:一个为了稳定性做的决定,十年后变成了产品的核心竞争力之一。
代价:几乎所有 API 变成异步(第 18 章会让你亲手体验),以及跨进程传数据要序列化(大对象会有开销,DOM 那种带循环引用的活对象树根本传不过去 —— 这也是「不给 DOM」的技术原因之一,不只是政策)。
姿势三:独立世界(Chrome)
Chrome 的做法最特别,因为它面对一个前两家都没有的难题:插件代码必须和一个完全不可信的网页共处一室。
content script 需要读写页面的 DOM(这是它存在的意义),但又不能和页面的 JS 混在一起。Chrome 的解法叫独立世界:
// 页面自己的脚本
window.secret = '页面的数据';
window.jQuery = jQuery_v1;
Array.prototype.map = function () { return '我劫持了 map'; }; // 页面在搞破坏
// 你的 content script —— 同一个页面,不同的世界
console.log(window.secret); // undefined ← 看不见页面的变量
window.jQuery = jQuery_v3; // 不影响页面的 jQuery
[1,2].map(x => x); // 正常 ← 页面污染的原型链影响不到你
document.querySelector('h1'); // 正常 ← 但 DOM 是共享的
切得极其精准:DOM 共享,JS 变量空间隔离。
为什么这一刀是对的?因为:
- 扩展要干的活(改页面内容)全都需要 DOM;
- 扩展和页面互相搞坏对方的常见方式(覆盖全局变量、污染原型链、抢同名函数)全都发生在 JS 变量空间。
顺带还解决了「多个扩展在同一个页面上互不干扰」—— 各在各的世界里。
而真正有权力的那部分代码(能调 chrome.tabs、chrome.cookies 的),被放在另一个地方:service worker。content script 只能通过消息请它办事:
// content.js —— 能碰 DOM,但没什么权力
chrome.runtime.sendMessage({ type: 'fetch', url }, (resp) => {
document.querySelector('#box').textContent = resp.data;
});
// sw.js —— 有权力,但碰不到页面 DOM
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (msg.type === 'fetch') {
fetch(msg.url).then(r => r.text()).then(data => sendResponse({ data }));
return true; // 表示会异步回复
}
});
把「能碰界面的部分」和「有权力的部分」拆成两半,中间用消息连接,谁也不能同时拥有两样。
Chrome 是 content script(碰 DOM,没权力)+ service worker(有权力,碰不到 DOM)。
VS Code 是渲染进程(画界面,插件碰不到)+ extension host(插件有能力,碰不到界面)。
两个团队独立地演化出了同一个结构,这通常意味着它是这个问题的自然解。如果你的插件系统要认真做隔离,这个形状值得直接抄。
三种姿势并排
| Obsidian 同进程 require | VS Code 独立进程 | Chrome 独立世界 + SW | |
|---|---|---|---|
| 插件能拿到 DOM 吗 | 整个应用的 | 没有 | 页面的(不是浏览器 UI 的) |
| 能拿到宿主内部对象吗 | 全部 | 不能(跨进程) | 不能(跨世界) |
| 调用开销 | 零 | IPC + 序列化 | 消息 + 序列化 |
| API 同步/异步 | 大多同步 | 几乎全异步 | 几乎全异步 |
| 死循环的后果 | 应用卡死 | 只有插件那侧卡 | 只有那个世界卡 |
| 能限制权限吗 | 不能 | 能(API 表面就是边界) | 能,而且逐项授权 |
| 实现成本 | 几乎为零 | 要建整套 RPC | 要改浏览器内核 |
最后一行解释了一切。Chrome 的方案需要在 V8 里实现「同一个 document 挂多个 JS 上下文」—— 那是浏览器厂商才做得到的事。VS Code 的方案需要几万行 RPC 基础设施。Obsidian 的方案需要一行 require。
所以这不是「谁更用心」的问题,是谁有多少预算、面对多大风险的问题。
还有第四种:换一个 JS 运行时
上面三种之外,还有一类做法:不用宿主的 JS 引擎,而是嵌一个独立的。
| 手段 | 隔离强度 | 代价 | 典型用途 |
|---|---|---|---|
| Web Worker | 好 —— 独立全局,没有 DOM | 全异步、传数据要序列化 | 浏览器里最轻的真隔离(第 16 章会真跑) |
| iframe(sandbox 属性) | 好 —— 独立文档 + 可配的能力 | 较重、通信要 postMessage | 需要插件自己画 UI 的场景 |
| QuickJS / Hermes 嵌入 | 很好 —— 你决定给它什么全局 | 要自己搭桥、生态受限 | 移动端、需要跨平台脚本 |
| WASM | 最好 —— 默认什么都碰不到 | 要定义整套宿主函数、数据传递麻烦 | 需要强隔离 + 多语言支持 |
WASM 那一行值得多说一句,因为它的默认状态和其它所有方案都相反:一个 WASM 模块天生什么都做不了 —— 没有文件、没有网络、没有系统调用,甚至没有时钟。它能做的每一件事,都必须由宿主显式地作为导入函数交给它。
这正是插件系统梦寐以求的默认值:不是「拿走它不该有的」,而是「只给它该有的」。代价是数据传递麻烦(只能传数字,字符串和对象要自己在线性内存里编解码)和生态还年轻。但如果你今天从零设计一个需要强隔离的插件系统,WASM 是最该认真评估的选项。
很多人以为 Node 的 vm 模块能安全地跑不可信代码。Node 官方文档明确说了它不是安全机制,不要用它运行不可信代码。
为什么?因为 vm 创建的只是一个新的全局对象,不是新的隔离区。跑在里面的代码依然在同一个 V8 隔离区里,能通过各种途径拿到外面的东西 —— 最经典的一条:
const vm = require('vm');
// 看起来什么都没给它
const result = vm.runInNewContext(`
this.constructor.constructor('return process')().mainModule.require('fs')
`);
// 拿到了 fs 模块 —— 逃逸成功
原理和第 14 章那个逃逸一模一样:只要能摸到任何一个函数或对象,就能顺着原型链回到构造器,再从构造器拿到外面的世界。
社区有 vm2 这类试图加固的库,但它反复出过逃逸漏洞并已停止维护 —— 这本身就是最好的证据:在同一个运行时里造沙箱,是一场你必须次次全对、攻击者只要对一次的比赛。
要在 Node 里真隔离,正确答案是:子进程(配合系统级限制)、worker_threads(较弱但有用)、或者独立的 WASM/QuickJS 运行时。
装到自己身上
如果你在给一个 JS/TS 应用设计插件系统,按这个顺序问:
① 插件作者是不是可信的(你自己、你团队、经过严格审核的)?
是 → 直接 require / import()。别过度设计,把 try/catch 和 disposable 做好就够了。
② 插件需要画自己的界面吗?
不需要 → 走 Worker + 声明式贡献点(VS Code 模式)。这是性价比最高的方案:真隔离,而且逼你设计出一套清晰的贡献点。
需要 → sandbox iframe,通过 postMessage 通信。(VS Code 的 Webview 就是这么做的 —— 它给插件开的那个「可以画界面」的口子,本身是一个受限的 iframe。)
③ 插件来自完全不可信的来源,且你能承受较大工程量?
→ WASM,或者独立进程 + 系统级限制(容器 / seccomp / 用户权限降级)。
④ 任何情况下都不要做的事:用 vm、vm2、或者「我自己用 Proxy 和 Object.freeze 写一个沙箱」来跑不可信代码。第 14 章会让你亲眼看到这条路为什么走不通。
好消息是这几种姿势之间可以渐进迁移,只要你一开始就把 API 设计成异步的:
// 第一版:同进程,但 API 就写成 async
const api = {
async readNote(id) { return host.find(id)?.body ?? null; },
async writeNote(id, text) { /* … */ },
};
// 半年后要上 Worker 隔离 —— 插件代码一个字都不用改
const api = {
async readNote(id) { return rpc('readNote', id); },
async writeNote(id, text) { return rpc('writeNote', id, text); },
};
这是本章最实用的一条建议:哪怕你现在是同进程加载,也把插件 API 全部设计成返回 Promise。成本几乎为零(同进程时 async 函数就是立刻 resolve),但它保住了你未来加隔离的可能性 —— 否则那一天到来时,你要让所有插件作者重写代码。
这也解释了为什么 VS Code 的 API 全是异步的:不是因为异步好,是因为边界逼的。而你可以在还没有边界的时候,就先把账付了。
这一章的一句话
同样是 JavaScript,三家的隔离强度差了两个数量级 —— 差别不在技术能力,在于每一家愿意为隔离付多少钱;而不管你现在选哪种,把 API 设计成异步,就是给未来的自己留了一扇门。
下一章处理加载的第三个后果,也是最难的那个:怎么让一个已经进来的插件,干净地消失。你会亲眼看到「没做干净」长什么样。