TL;DR:插件系统要同时解决安全隔离、开发体验、热重载和调用开销。选型看三根轴——隔离强度 × 语言生态 × 性能:可信、重 DX → 同进程 TS/JS 或 Lua;热路径 → 嵌入式引擎;要崩溃隔离 → 独立进程 / Worker;不可信或多语言安全边界 → WASM 能力模型;跑任意/LLM 生成代码 → 微虚机。落地顺序永远是:先定契约与隔离边界,再选引擎,最后写加载器。Pi 式同进程热重载与 Magpie 式分层(进程外重任务 + 进程内热路径)是近年很典型的实用组合(后两者为社区/产品实践叙述)。


插件系统在解决什么#

宿主需要把一部分行为交给外部代码:编辑器要命令与语言服务,网关要中间件,Agent 要工具。硬编码进主仓库会卡发版;随意 dlopen / require 又会把崩溃和越权带进主进程。

所以现代方案通常同时追四件事,而这四件事彼此冲突:

  1. 安全隔离:默认拿不到盘、网、密钥;崩溃尽量不拖垮 UI。
  2. 可扩展契约:约定怎么发现、怎么启用、能调什么。
  3. 开发体验:熟悉语言、完整工具链,最好能热重载。
  4. 可接受的开销:热路径微秒级调用,和冷路径 RPC,不能混为一谈。

设计工作不是找「万能引擎」,而是按威胁模型和性能预算分层:热路径用嵌入,不可信扩展用 WASM 或独立进程,管理面用同进程 TS 做编排。

整体架构:不变的几块积木#

不论引擎是什么,成熟系统大多由同一组角色组成:

坐标系:五类主流方案#

1. 嵌入式脚本(轻、可控)#

宿主进程里嵌 Lua/LuaJIT、QuickJS 一类引擎。体积小、调用接近普通函数,沙箱靠裁剪标准库和白名单 API。代表:Neovim / WezTerm 的 Lua、Redis 的 Lua 脚本、大量游戏引擎;轻量 JS 沙箱常见于嵌入式工具和部分设计工具(如 Figma 把碰文档的逻辑放进无浏览器 API 的沙箱,UI/网络另放 iframe)。

适合:要极致控制和热路径,语言生态可以收窄。

2. 同进程高级语言(DX 优先)#

TS/JS 直接跑在宿主里(jiti / Bun / Node / Electron)。写起来最舒服,完整 npm 生态,热重载通常是「卸掉旧运行时 → 再加载」。代表:Obsidian;社区实践中的 Pi(jiti + /reload)。

代价:几乎没有隔离,插件与宿主共享权限。只适合可信作者或可信社区,不适合当安全边界。

3. 独立进程 / Worker(崩溃隔离)#

插件放到另一进程或 Worker,用 JSON-RPC、MessagePack、postMessage 通信。代表:VS Code Extension Host(扩展拖不垮 UI)、Neovim remote plugin(任意语言 + 惰性启动)、Bun/Node Worker 或子进程。V8 Isolate(如 isolated-vm)是同进程独立堆:适合跑小段用户脚本,但不是内核级隔离,不能当安全终点。

适合:要护 UI、要完整 Node 生态、可接受毫秒级通信开销。

4. WASM 能力模型(当前最被看好的安全方向)#

插件编成 .wasm,宿主用 Wasmtime 一类运行时加载。用一个具体场景分清定位:

笔记软件要加载 spellcheck.wasm。插件需要读当前打开的那一个文件,并调用宿主的 showWarning;宿主需要调用插件的 check(text)。

少 WASI:业务 import 仍可手写,但系统能力没有统一标准。少 WIT:check / showWarning 没有标准清单,绑定易不一致。裸 Wasm:算得动,读不了稿,也调不了 UI。

Component Model 与 WIT 格式是公共规范,各项目不同的是 WIT 里写哪些业务接口,不是另搞一套规则。落地时分两段,不要混成「运行时读配置」:

  1. 构建期:写 .wit → wit-bindgen / cargo component 等生成两侧绑定 → 插件编成 component 形态的 .wasm。WIT 源文件给人与构建工具用。
  2. 运行期:宿主(如 Wasmtime)加载该 .wasm,按组件里已编码的 import/export 链接你允许的实现(含按需打开的 WASI 能力),再调 export。运行时负责链接与执行,不负责读 .wit 生成代码。

适合:不可信插件、要多语言又要干净沙箱。代价是工具链和调试仍在成熟中;热路径若每请求跨边界,仍要量调用成本。

5. 原生库与微虚机(两极)#

原生 .so 调用最快,但 ABI 脆、崩即宿主崩、几乎无安全边界——现代项目很少当主力插件模型。Firecracker / gVisor / E2B 一类把隔离拉到微虚机,适合跑不可信或 LLM 生成的任意代码;成本高,不是每个命令面板插件的默认选项。

一次生命周期(阶段比实现细节重要)#

用「加载 → 使用 → 热重载 → 禁用」串起来,换引擎只是边界不同,阶段不变:

  1. 发现:读清单,校验版本与权限;失败就跳过,不要半加载。
  2. 注册但不一定激活:命令/菜单先挂上,入口可先不跑。
  3. 激活:触发条件到了,才建运行时、注入已声明的 API、调用 activate。
  4. 调用:热路径同步、冷路径异步;设超时与取消,避免拖死 UI。
  5. 热重载:逆序 deactivate → 清掉事件/定时器等可拆除资源 →(进程模型则杀 Worker)→ 再加载。默认丢内存态;「零闪断保留所有连接」第一版通常做不到。
  6. 卸载:同热重载后半段,但不装回来。最高发事故是:全局回调没登记拆除,禁用后还在跑。

依赖关系要按图处理:先停依赖方;环形依赖在校验阶段拒绝。

设计时真正要想清楚的事#

不必先写加载器。先把下面几件事想明白,引擎以后可以换。

契约:清单里至少有 id、版本、入口、最低宿主版本、权限、激活条件。生命周期钩子对齐 load / activate / deactivate / unload。权限即使同进程「暂时都能做」,也建议先写上——日后迁 Worker/WASM 时,作者心智不用推倒重来。

API 面:窄、稳、可版本化。不要把宿主内部对象树整棵丢给插件,否则宿主永远拆不动。稳定面有兼容承诺,实验面单独通道。

隔离与权限:在一张图上画出谁能碰盘、谁能出网。不可信与 LLM 生成代码默认无能力。可信也不等于永不犯错——死循环和误删文件仍然存在,开发期要能一键禁用,高危 API 可单独权限位。

热重载:核心不是「技巧」,而是可拆除资源必须登记;连续 reload 不泄漏,才算合格。进行中的请求要有拒绝或取消策略,否则新旧实例会交错。

性能:热路径不要「一切皆 RPC」;冷路径不要为了省事牺牲隔离。分层往往比单引擎更务实。

选型:四个问题#

  1. 可信吗? 不可信 → WASM / 独立进程 / 微虚机,不要用同进程 TS 当安全边界。可信 → 往下看。
  2. 必须多语言吗? 要干净边界 → WASM;可接受 RPC → 独立进程;团队主力 TS 且可信 → 同进程或 Worker;要极致轻量 → Lua。
  3. 在热路径吗? 是 → 嵌入或同进程函数。否 → 优先 DX 与隔离。
  4. 要护 UI / 崩溃隔离吗? 是 → Extension Host / Worker。否且可信 → 同进程热重载最省事。

经验组合:编辑器常是 Lua 同进程 + 可选 remote/LSP 进程;Agent/工具可信扩展常是 同进程 TS + 热重载;网关类常是 分层(重任务出进程,热路径留进程内);从第一天就要第三方不可信插件,直接上 能力模型(WASI/WIT),往往比「先同进程再补沙箱」便宜。

常见坑#


小结#

插件系统设计不是追最热门的运行时,而是把契约、隔离、性能、DX拆开,按可信度和调用频率分层安放。近年务实趋势:可信侧继续吃满 TS/JS 或 Lua;不可信与多语言侧转向 WASM 能力模型;最强隔离留给真正的任意代码执行。清单、生命周期、权限和可拆除资源做对了,引擎可以换;反过来,引擎再新,契约混乱也只是更快地制造悬挂回调与越权。