卷 III · 入CH 12深度 12/24

JS 派:三种把代码放进来的姿势

Obsidian、VS Code、Chrome 扩展全都是 JavaScript,跑在 V8 上,插件也都是 js 文件。但它们把代码放进来的方式,隔离强度差了整整两个数量级。这一章把三种姿势并排拆开 —— 你会发现差别完全不在技术能力,而在于每一家愿意为隔离付多少钱。

requireextension host独立世界vm 模块的真相

姿势一:直接 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.tabschrome.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 模块能安全地跑不可信代码。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 / 用户权限降级)。

④ 任何情况下都不要做的事:vmvm2、或者「我自己用 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 设计成异步,就是给未来的自己留了一扇门。

下一章处理加载的第三个后果,也是最难的那个:怎么让一个已经进来的插件,干净地消失。你会亲眼看到「没做干净」长什么样。