卷 IV · 墙CH 18深度 18/24

动手 ③:为什么 VS Code 不给插件 DOM

上一章之前,插件和宿主还在同一个运行时里。这一章我们把它推过边界 —— 亲手搭一座 RPC 桥,让插件跑在 Worker 里。桥搭完那一刻,三件事会自动发生,而其中一件,就是「不给 DOM」这个看似武断的决定背后的全部真相。

动手RPC序列化chatty API声明式 UI

先看消息真的在流动

下面这个 demo 里,插件跑在一个真的 Worker 里。它做两件极其普通的事:读一篇笔记,然后把字数写到状态栏。

右边是边界上真实流过的每一条消息,带时间戳:

两次调用,四条消息。原本在同进程里是这样的:

const t = ctx.notes.read('n1');       // 同步,纳秒级
ctx.ui.status(t.length + ' 字');      // 同步

跨边界之后只能是这样:

const t = await rpc('notes.read', 'n1');
await rpc('ui.status', t.length + ' 字');

桥的完整实现

RPC 桥的核心逻辑其实很短。插件那一侧(跑在 Worker 里):

let seq = 0;
const waiting = new Map();

function rpc(method, ...args) {
  return new Promise((resolve, reject) => {
    const id = ++seq;
    waiting.set(id, { resolve, reject });
    postMessage({ t: 'call', id, method, args });
  });
}

onmessage = (e) => {
  const m = e.data;
  if (m.t === 'ret') {
    waiting.get(m.id)?.resolve(m.value);
    waiting.delete(m.id);
  } else if (m.t === 'err') {
    waiting.get(m.id)?.reject(new Error(m.msg));    // ← 错误也要能过桥
    waiting.delete(m.id);
  } else if (m.t === 'activate') {
    loadPluginAndActivate();
  }
};

宿主那一侧:

worker.onmessage = async (e) => {
  const m = e.data;
  if (m.t !== 'call') return;

  const handler = API_TABLE[m.method];          // ← 白名单:只有表里有的才能被调
  if (!handler) {
    worker.postMessage({ t: 'err', id: m.id, msg: `未知方法 ${m.method}` });
    return;
  }
  try {
    const value = await handler(...m.args);
    worker.postMessage({ t: 'ret', id: m.id, value });
  } catch (err) {
    // Error 对象不能直接 postMessage,要拆成普通数据
    worker.postMessage({ t: 'err', id: m.id, msg: err.message, name: err.name });
  }
};

那张 API_TABLE 值得注意:跨边界之后,插件能调的东西天然变成了一张显式的白名单 —— 它没法「顺着对象摸到别的东西」,因为它手里根本没有对象,只有方法名字符串。

这是运行时隔离白送的第四个红利(前三个是:安全、崩溃隔离、干净卸载):API 表面变成了一个你能一眼看完的列表。

桥搭完之后,三件事自动发生

后果一:一切变成异步

这个最直观。消息要过边界,就不可能同步返回。

这就是 VS Code 插件 API 里几乎所有东西都返回 Thenable 的原因 —— 不是设计者偏好异步,是边界逼的。

影响比想象中大:

// 同进程时,你可以设计这样的钩子
ctx.ui.addTransform('加粗首行', (text) => text.toUpperCase());
//                              ↑ 渲染流程会同步调用它,拿到结果立刻用

// 跨边界后,这个设计不可能存在
// 渲染是同步的,它不能 await 一个插件的回复
// 除非…… 整个渲染流程也变成异步的(性能和复杂度都要付账)

某些扩展点在跨边界之后根本没法存在,你必须重新设计它们的形状(比如改成「插件主动推送更新」而不是「渲染时拉取」)。

后果二:只能传能序列化的东西

这条是「不给 DOM」的技术原因。

想想一个 DOM 节点是什么:

  • 它有几十个属性和上百个方法(appendChildquerySelector…… 函数是不能序列化的);
  • 它有 parentNode,父节点又有 childNodes 指回来 —— 循环引用
  • 它是活的:你拿到它之后,页面变了它也跟着变。而序列化出来的是一个死快照;
  • 整棵树可能有几万个节点。

你没法把它 postMessage 过去。不是「不方便」,是原理上做不到。

那有没有办法?有 —— 给每个 DOM 节点分配一个 id,插件那边持有 id,每次操作都发消息回来:

// 假想的「远程 DOM」API
const id = await rpc('querySelector', 'h1');
await rpc('setTextContent', id, '新标题');
const childIds = await rpc('getChildren', id);
for (const c of childIds) {
  const tag = await rpc('getTagName', c);     // 每读一个属性一次往返!
}

这就是「chatty API」问题:一个在本地几乎免费的操作(遍历子节点读属性),跨边界之后变成了几十次往返。渲染一个稍微复杂的 UI 可能要上千条消息。

◆ 跨边界 API 设计的第一原则

粒度要粗。一次往返做完一件完整的事,而不是暴露一堆细粒度的原子操作。

对比一下:

// ✗ chatty:5 次往返
const doc = await rpc('openDocument', uri);
const text = await rpc('getText', doc);
const sel = await rpc('getSelection');
await rpc('replaceRange', doc, sel, newText);
await rpc('save', doc);

// ✓ 一次往返做完
await rpc('editDocument', { uri, edits: [{ range: sel, newText }], save: true });

这就是为什么 VS Code 的 API 长成那个样子 —— WorkspaceEdit 是一个「一次性描述一批修改」的对象,而不是让你一个字符一个字符地改。那不是设计者喜欢批处理,是边界的经济学逼出来的。

顺带一提:这也是为什么同进程系统的 API 迁移到跨进程会很痛。原本细粒度的 API 在新架构下性能崩溃,而你已经不能改它了(第 1 章:门开了就关不上)。又一个「第一天就该想清楚」的决定。

后果三:需要一套声明式的 UI 贡献机制

前两个后果加起来,把 VS Code 逼到了一个位置:插件不可能高效地画界面。

于是它做了那个决定:插件不画界面,只声明它要贡献什么。

{
  "contributes": {
    "commands":  [{ "command": "myExt.run", "title": "运行" }],
    "menus":     { "editor/context": [{ "command": "myExt.run", "when": "editorHasSelection" }] },
    "views":     { "explorer": [{ "id": "myExt.tree", "name": "我的视图" }] },
    "keybindings": [{ "command": "myExt.run", "key": "ctrl+alt+r" }]
  }
}

真正的像素由 VS Code 渲染,插件只提供数据(比如树视图的节点列表)和响应(用户点了某项时的回调)。

而这个被逼出来的设计,回头看竟然比「给你 DOM 随便画」更好

因为不给 DOM,所以……白得的好处
插件描述不了具体像素界面风格天然统一,不会有插件搞出一个格格不入的面板
插件不依赖 DOM 结构VS Code 能随意重构界面,不会弄死插件
贡献点全在清单里不跑代码就能显示菜单(懒激活,第 21 章)
UI 由宿主渲染无障碍、主题、高对比度、本地化统一生效,插件作者什么都不用做
插件不需要本地 DOM远程开发、Web 版直接能跑
◆ 这是全书我最想让你记住的设计案例

一个好的约束,衡量标准不是「它少给了什么」,而是「因为有了它,你不用再操心什么」。

「不给 DOM」听起来是纯粹的损失。但它一次性消灭了五类问题:界面风格失控、宿主不敢重构、无法懒加载、无障碍和主题要每个插件各自实现、以及远程场景做不了。

而这五件事,如果给了 DOM,你就永远解决不了了 —— 因为一万个插件已经依赖了具体的 DOM 结构。

下次你在设计插件 API 时纠结「要不要给插件这个能力」,可以问:如果不给,我能因此保住什么自由?那个答案往往比「给了他们能做什么」更重要。

那插件真要画个自定义界面怎么办

VS Code 留了一个口子:Webview。插件可以创建一个 webview 面板,往里塞 HTML。

但注意这个口子的形状 —— 它是一个受严格限制的 iframe(第 16 章讲过这个方案):

  • webview 里的代码和插件的代码也是隔离的,之间还要 postMessage(插件自己都要过一次桥);
  • 有独立的 CSP,默认不能加载远程资源;
  • 本地资源要通过特殊的 URI scheme 转换;
  • 它拿不到 VS Code 自己的 DOM,只有它自己那个盒子。

这个设计的逻辑很清楚:给你一块画布,但那块画布在墙外面。你想怎么涂随你,但你碰不到我的房子。

Chrome 的做法在结构上完全一致(第 12 章说过这个「巧合」):扩展想在页面上画东西,用 content script 操作页面 DOM —— 但那是网页的 DOM,不是浏览器 UI 的 DOM。浏览器自己的界面,扩展只能通过声明式的 actionmenus 去贡献。

两家都划了同一条线:宿主自己的界面神圣不可侵犯,但可以给你一块隔离的画布。

桥的工程细节(真做的时候会遇到的)

上面那个 40 行的桥能跑,但离能用还差几件事:

① 超时

function rpc(method, ...args) {
  return new Promise((resolve, reject) => {
    const id = ++seq;
    const timer = setTimeout(() => {
      waiting.delete(id);
      reject(new Error(`调用 ${method} 超时`));
    }, 10_000);
    waiting.set(id, {
      resolve: (v) => { clearTimeout(timer); resolve(v); },
      reject:  (e) => { clearTimeout(timer); reject(e); },
    });
    postMessage({ t: 'call', id, method, args });
  });
}

没有超时的 RPC 会静默地泄漏:宿主那边挂了或者忘了回复,插件这边的 Promise 永远 pending,waiting 这个 Map 越来越大。

② Worker 挂了怎么办

worker.onerror = (e) => {
  log(`插件宿主崩溃:${e.message}`);
  // 所有等待中的调用要全部 reject,否则它们永远挂着
  for (const [id, w] of waiting) w.reject(new Error('插件宿主已崩溃'));
  waiting.clear();
  // 然后决定:重建一个?还是标记这个插件为失败?
};

VS Code 在这里做了产品级的处理:extension host 崩溃时它会提示用户「扩展宿主意外终止」并提供重启选项,而你的文档一个字都没丢

③ 错误要能过桥

Error 对象不能直接 postMessage(结构化克隆算法对它的支持有限,且自定义属性会丢)。要手动拆开再组装:

// 宿主侧发送
catch (err) {
  worker.postMessage({ t: 'err', id, name: err.name, msg: err.message,
                       code: err.code });     // 自定义字段单独带上
}

// 插件侧还原
const e = new Error(m.msg);
e.name = m.name;
e.code = m.code;
reject(e);

这一步经常被跳过,结果是插件作者在跨边界之后拿到的所有错误都变成了一个信息全无的 Error: undefined,调试体验断崖式下降。

④ 双向调用

上面只演示了「插件调宿主」。真实系统还需要反向:宿主要通知插件(事件回调、命令被触发)。协议要对称,两边各持一份 waiting 表和一张方法表。

装到自己身上

◆ 一个能省下大量重写的技巧:先假装有边界

第 12 章提过这条建议,这里把它具体化。哪怕你现在是同进程加载,也按跨边界的规矩来设计 API:

// ① 所有 API 返回 Promise
const api = {
  async readNote(id) { return host.find(id)?.body ?? null; },
  async applyEdits(edits) { /* 一次做完一批 */ },
};

// ② 只传纯数据。绝不把宿主对象交出去
// ✗ return host.find(id);              ← 交出了活对象
// ✓ return { id: n.id, title: n.title, body: n.body };   ← 纯数据快照

// ③ API 表是显式的一张表,不是「一个对象上挂满方法」
const API_TABLE = {
  'notes.read':  (id) => /* … */,
  'notes.write': (id, text) => /* … */,
  'ui.status':   (t) => /* … */,
};

// ④ 粒度粗一点:宁可一个参数多的方法,不要五个方法连着调

做到这四条,将来要迁到 Worker / 独立进程时,插件代码可能一个字都不用改 —— 你只需要把 API_TABLE 接到消息分发上。

而如果没做,那一天你要让所有插件作者重写代码,而他们中的大部分不会重写,你的生态就死了一半。这不是假设 —— Chrome 的 MV2→MV3 就是这个故事,下一章会详细算这笔账。

◆ Android:同样的桥,用 AIDL
// ① 定义接口(AIDL 会生成跨进程的桩代码)
interface IPluginHost {
    // 粒度要粗:一次拿完,不要一个字段一次
    fun readNote(id: String): NoteData?
    fun applyEdits(edits: List<Edit>): Boolean
}

// ② 插件跑在单独进程的 Service 里
<service android:name=".PluginService" android:process=":plugin" />

// ③ 掐死它 —— 这在同进程里做不到(第 15 章)
Process.killProcess(pluginPid)

Binder 特有的两个坑:

① 事务大小限制约 1MB(而且是整个进程共享的缓冲区)。传大数据要用 ParcelFileDescriptor 或共享内存,直接塞进 Parcel 会抛 TransactionTooLargeException —— 而且这个异常经常在生产环境才出现,因为测试数据都很小。

② 同步 Binder 调用会阻塞调用方线程。如果宿主在主线程上同步调插件进程,插件卡住 = 宿主 ANR —— 你辛苦分进程的隔离效果就没了。要用 oneway 方法或者在 IO 线程上调。

这一章的一句话

桥一搭好,三件事自动发生:一切变异步、只能传纯数据、UI 必须改成声明式 —— 而「不给 DOM」正是第二条的直接后果;这个看似严厉的约束,反过来买到了风格统一、重构自由、懒加载和远程开发。

卷 IV 结束。下一卷讲的是那些第一天不存在、第三年要命的问题 —— 宿主要升级,插件要互相依赖,启动越来越慢,还有人往里塞恶意代码。