TL;DR:插件系统要同时解决安全隔离、开发体验、热重载和调用开销。选型看三根轴——隔离强度 × 语言生态 × 性能:可信、重 DX → 同进程 TS/JS 或 Lua;热路径 → 嵌入式引擎;要崩溃隔离 → 独立进程 / Worker;不可信或多语言安全边界 → WASM 能力模型;跑任意/LLM 生成代码 → 微虚机。落地顺序永远是:先定契约与隔离边界,再选引擎,最后写加载器。Pi 式同进程热重载与 Magpie 式分层(进程外重任务 + 进程内热路径)是近年很典型的实用组合(后两者为社区/产品实践叙述)。
插件系统在解决什么#
宿主需要把一部分行为交给外部代码:编辑器要命令与语言服务,网关要中间件,Agent 要工具。硬编码进主仓库会卡发版;随意 dlopen / require 又会把崩溃和越权带进主进程。
所以现代方案通常同时追四件事,而这四件事彼此冲突:
- 安全隔离:默认拿不到盘、网、密钥;崩溃尽量不拖垮 UI。
- 可扩展契约:约定怎么发现、怎么启用、能调什么。
- 开发体验:熟悉语言、完整工具链,最好能热重载。
- 可接受的开销:热路径微秒级调用,和冷路径 RPC,不能混为一谈。
设计工作不是找「万能引擎」,而是按威胁模型和性能预算分层:热路径用嵌入,不可信扩展用 WASM 或独立进程,管理面用同进程 TS 做编排。
整体架构:不变的几块积木#
不论引擎是什么,成熟系统大多由同一组角色组成:
- 发现与清单:扫描目录或注册表,读 id、版本、入口、激活条件、权限。激活条件未到就不启动运行时(VS Code、Neovim remote 都强调惰性)。
- 宿主 API 面:插件能调的能力必须显式给出。同进程是注入对象;独立进程是 RPC;WASM 是接口里写死的 import。原则一致——默认无能力,按需授予,版本可演进。
- 运行时与隔离边界:代码跑在同进程、独立堆、独立进程,还是微虚机。边界决定崩溃隔离、序列化成本和语言生态。
- 生命周期:load → activate → deactivate → unload。热重载 = 不停宿主走完 unload 再 load;内存态多数直接丢,只留落盘配置。
- 通信:热路径倾向同步函数调用;UI 与长任务倾向异步通知,避免插件卡住界面。
坐标系:五类主流方案#
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)。
- 只有 Wasm、没有下面两层时:实例化后插件只能做纯计算,没有读文件、没有
showWarning的入口——Wasm 标准本身不定义操作系统访问,也不定义你的业务函数。 - WASI 是标准:规定读文件、网络、时钟等通用系统能力的接口形态。运行时实现它。你在实例化时只授予「读
/notes/draft.md」;插件按 WASI 去读文件,越权路径会被拒绝。showWarning不在 WASI 里,那是业务 API。 - WIT 是接口描述格式:用
.wit文本写出本产品的导入/导出清单,例如宿主提供show-warning、插件导出check。工具读这份文件生成两侧绑定代码,两边按同一份定义链接。 - Component Model 是组件如何按这类接口链接、组合的规范;WIT 负责把接口写下来,Component Model 负责这套链接规则。许多 WASI 能力本身也用 WIT 描述。
少 WASI:业务 import 仍可手写,但系统能力没有统一标准。少 WIT:check / showWarning 没有标准清单,绑定易不一致。裸 Wasm:算得动,读不了稿,也调不了 UI。
Component Model 与 WIT 格式是公共规范,各项目不同的是 WIT 里写哪些业务接口,不是另搞一套规则。落地时分两段,不要混成「运行时读配置」:
- 构建期:写
.wit→wit-bindgen/cargo component等生成两侧绑定 → 插件编成 component 形态的.wasm。WIT 源文件给人与构建工具用。 - 运行期:宿主(如 Wasmtime)加载该
.wasm,按组件里已编码的 import/export 链接你允许的实现(含按需打开的 WASI 能力),再调 export。运行时负责链接与执行,不负责读.wit生成代码。
适合:不可信插件、要多语言又要干净沙箱。代价是工具链和调试仍在成熟中;热路径若每请求跨边界,仍要量调用成本。
5. 原生库与微虚机(两极)#
原生 .so 调用最快,但 ABI 脆、崩即宿主崩、几乎无安全边界——现代项目很少当主力插件模型。Firecracker / gVisor / E2B 一类把隔离拉到微虚机,适合跑不可信或 LLM 生成的任意代码;成本高,不是每个命令面板插件的默认选项。
一次生命周期(阶段比实现细节重要)#
用「加载 → 使用 → 热重载 → 禁用」串起来,换引擎只是边界不同,阶段不变:
- 发现:读清单,校验版本与权限;失败就跳过,不要半加载。
- 注册但不一定激活:命令/菜单先挂上,入口可先不跑。
- 激活:触发条件到了,才建运行时、注入已声明的 API、调用 activate。
- 调用:热路径同步、冷路径异步;设超时与取消,避免拖死 UI。
- 热重载:逆序 deactivate → 清掉事件/定时器等可拆除资源 →(进程模型则杀 Worker)→ 再加载。默认丢内存态;「零闪断保留所有连接」第一版通常做不到。
- 卸载:同热重载后半段,但不装回来。最高发事故是:全局回调没登记拆除,禁用后还在跑。
依赖关系要按图处理:先停依赖方;环形依赖在校验阶段拒绝。
设计时真正要想清楚的事#
不必先写加载器。先把下面几件事想明白,引擎以后可以换。
契约:清单里至少有 id、版本、入口、最低宿主版本、权限、激活条件。生命周期钩子对齐 load / activate / deactivate / unload。权限即使同进程「暂时都能做」,也建议先写上——日后迁 Worker/WASM 时,作者心智不用推倒重来。
API 面:窄、稳、可版本化。不要把宿主内部对象树整棵丢给插件,否则宿主永远拆不动。稳定面有兼容承诺,实验面单独通道。
隔离与权限:在一张图上画出谁能碰盘、谁能出网。不可信与 LLM 生成代码默认无能力。可信也不等于永不犯错——死循环和误删文件仍然存在,开发期要能一键禁用,高危 API 可单独权限位。
热重载:核心不是「技巧」,而是可拆除资源必须登记;连续 reload 不泄漏,才算合格。进行中的请求要有拒绝或取消策略,否则新旧实例会交错。
性能:热路径不要「一切皆 RPC」;冷路径不要为了省事牺牲隔离。分层往往比单引擎更务实。
选型:四个问题#
- 可信吗? 不可信 → WASM / 独立进程 / 微虚机,不要用同进程 TS 当安全边界。可信 → 往下看。
- 必须多语言吗? 要干净边界 → WASM;可接受 RPC → 独立进程;团队主力 TS 且可信 → 同进程或 Worker;要极致轻量 → Lua。
- 在热路径吗? 是 → 嵌入或同进程函数。否 → 优先 DX 与隔离。
- 要护 UI / 崩溃隔离吗? 是 → Extension Host / Worker。否且可信 → 同进程热重载最省事。
经验组合:编辑器常是 Lua 同进程 + 可选 remote/LSP 进程;Agent/工具可信扩展常是 同进程 TS + 热重载;网关类常是 分层(重任务出进程,热路径留进程内);从第一天就要第三方不可信插件,直接上 能力模型(WASI/WIT),往往比「先同进程再补沙箱」便宜。
常见坑#
- 原生
.so当面向第三方的主力模型。 - 不可信插件同进程且默认全权限。
- 热重载不拆事件/定时器,悬挂回调。
- 热路径强行跨进程 RPC,用架构洁癖换掉延迟预算。
- 把 Isolate 当成与微虚机同级的安全终点。
- 无清单、无权限声明就先写加载器。
- 第一版承诺热重载保留全部连接与内存态,又没有显式状态迁移协议。
- 把 LLM 生成代码当成「内部插件」而走可信路径。
小结#
插件系统设计不是追最热门的运行时,而是把契约、隔离、性能、DX拆开,按可信度和调用频率分层安放。近年务实趋势:可信侧继续吃满 TS/JS 或 Lua;不可信与多语言侧转向 WASM 能力模型;最强隔离留给真正的任意代码执行。清单、生命周期、权限和可拆除资源做对了,引擎可以换;反过来,引擎再新,契约混乱也只是更快地制造悬挂回调与越权。
评论
欢迎交流,使用 GitHub 账号登录后即可留言。
在 GitHub Discussions 中查看讨论 ↗