本文记录在 Rust 应用中嵌入 QuickJS(通过 rquickjs 绑定)实现 JS 插件引擎的完整技术方案与思路,包括异步运行时、宿主与脚本的双向桥接、CommonJS 兼容层、原生能力注入、线程模型与编码处理等关键设计。
为什么用 JS 做插件语言
一个长期维护的 Rust 应用,业务能力需要持续扩展,但不可能每个新能力都改 Rust 源码重新编译。插件化的常见方案有:
- 动态库(cdylib):性能好,但 ABI 不稳定、跨平台编译麻烦、版本兼容噩梦
- WASM:生态成熟,但插件作者需要懂 Rust/Go 等语言,学习曲线陡
- 内嵌脚本引擎(Lua/JS/Python):插件只是一份文本文件,改完即生效,无需编译
选择 JavaScript 的理由很直接:
- 语法亲和,前端开发者零成本上手
- JSON 原生支持,与 HTTP API 数据天然契合
- 社区有大量现成插件生态可参考,比如各大音乐聚合类应用的插件都是 JS 写的,接口约定成熟
- 不需要打包分发,插件就是一个
.js文件
脚本引擎里 QuickJS 是体积与性能平衡最好的选择:单个二进制几百 KB,无 JIT(天然规避了 iOS 审核等场景对 JIT 的限制),内存占用低,适合作为嵌入式运行时。
技术选型:rquickjs
Rust 侧绑定选 rquickjs,相比 other 的绑定库:
- 对 QuickJS 的封装完整,支持运行时快照、模块加载
- 原生支持 async/await(
AsyncRuntime/AsyncContext),这是关键 - 提供
bindgen宏(声明式导出 Rust 函数给 JS)与futuresfeature(Promise 与 Rust Future 互转)
Cargo 依赖:
| |
架构总览
整体分三层:
- 宿主侧(Rust):定义插件接口 trait,管理插件生命周期,调用脚本
- 桥接层(IIFE 模板 + 注入的原生函数):负责 JS 与 Rust 的互操作
- 脚本侧(JS):插件本体 + 内置 polyfill 包(sandbox.js)
| |
宿主调用协议:IIFE 模板
QuickJS 的 Ctx::eval 直接执行一段代码字符串。插件加载与调用都通过预定义的 IIFE 模板完成,把插件源码内联进模板再 eval。
加载插件时只读取元数据(平台名、版本、作者、支持的方法列表),不执行任何业务逻辑:
| |
调用具体方法时使用 async IIFE,支持插件内部 await 网络请求:
| |
这个设计的要点:
- 插件源码每次调用都会重新内联执行,因此天然无状态、无残留全局变量,不需要复杂的沙箱隔离
- CommonJS 兼容:手工模拟
module.exports/exports/require,插件按 CommonJS 风格编写即可 - 返回值统一走 JSON 字符串,Rust 侧反序列化,边界干净
- 插件抛错被捕获后包装成
__plugin_error字段,宿主侧统一转成错误枚举,不 panic
异步桥接:两个方向的 Promise
QuickJS 本身是同步引擎,rquickjs 的 futures feature 把 JS Promise 和 Rust Future 打通,这是整个引擎能支持 async/await 的基础。
Rust 原生函数注入 JS(Rust → JS)
用 Function::new 包一个 async 函数,注册到全局对象上。JS 侧调用它得到的是一个真正的 Promise:
| |
MD5、SHA256、HMAC、Base64 等同步函数同样注入,但不需要 Async 包装:
| |
JS Promise 返回值取回(JS → Rust)
ctx.eval 返回的 Promise 通过 into_future 转成 Rust Future 直接 await:
| |
这样插件内部可以自由地 await axios.get(...)、Promise.all([...]) 并发请求,宿主完全感知不到区别。
为什么用 JSON 字符串做参数边界
跨语言传参有两条路:直接传 JS 值(JsValue)或序列化成 JSON 字符串。项目选择了后者:
- 参数/返回值全部
serde_json::Value序列化,类型边界简单,宿主侧代码不用碰JsValue的借用来 - 插件数据天然是 JSON(网络接口返回的就是 JSON),零转换成本
- 出错时能拿到完整的字符串便于日志
线程模型:非 Send 的 QuickJS 与 tokio
QuickJS 运行时不是 Send,无法直接跨 tokio 任务移动。rquickjs 的 AsyncRuntime 内部用独立线程跑事件循环解决了一部分问题,但 Ctx 仍然不能跨线程。实际采用的模式是:
| |
要点:
spawn_blocking保证 QuickJS 每次调用独占一个线程,闭包捕获的数据要求Send + 'static- 闭包内部再建一个本地 tokio runtime,
block_on驱动 async 上下文,这样promise.into_future().await才能跑起来 - 每次调用都是新建 runtime + context,用完即弃。对低频插件调用(一次搜索/一次详情请求)完全够用,还顺带获得了天然隔离——插件状态不会泄漏到下一次调用
- 代价是每次调用都有上下文创建开销,不适合高频短调用场景。如果业务需要,可以改为线程池 + 常驻 context
沙箱与兼容层:一个文件内嵌的 npm 生态
插件生态里大量使用 axios、cheerio、crypto-js、qs、he、dayjs 这些包。QuickJS 没有 npm,方案是在 sandbox.js 里用原生 JS 手写这些包的迷你实现,注册到 require 能查到的包表里:
| |
- axios:完整实现
get/post/request/create、params 拼接、POST JSON 自动加 Content-Type、错误响应包装,底层转发到__native_http_async - cheerio:正则实现的轻量 HTML 解析,支持选择器、
find/text/attr/children等常用方法 - crypto-js:MD5/SHA256/HmacSHA256/Base64 直接转发 Rust 原生函数(
__native_md5等),AES 预留了原生转发位 - qs / he / dayjs:纯 JS 迷你实现
关键收益:插件作者按熟悉的 API 写代码,宿主侧用原生实现兜底性能敏感的加密哈希,两全其美。
编码处理:从 BOM 到 GBK 乱码
网络数据编码问题在中国区 API 上非常普遍,做了三层防御:
- 剥离 UTF-8 BOM:响应体前 3 字节是
EF BB BF时去掉 - UTF-8 解码失败回退 GBK:
encoding_rs::GBK兜底,覆盖大量老接口 - 歌词乱码修复:如果文本中 Latin-1 区字符(0x80-0xFF)占比超过阈值,说明是 GBK 字节被按 Latin-1 误读了,重新按 GBK 解码
| |
踩坑记录
- AsyncRuntime 不等于无脑跨线程:
Ctx依旧借用运行时,必须小心作用域,async_with闭包内完成所有操作再返回 Function::new的 async 闭包要求Send + 'static,捕获的 reqwest client 要用OnceLock静态化,避免每次调用重建连接池- 插件里
Object.assign、encodeURIComponent在 QuickJS 中可能缺失,sandbox.js 里补了 polyfill - eval 大字符串(插件源码几十 KB)性能可以接受,但注意
replace("__JS_CODE__", js)时如果插件源码本身含有模板占位符字符串会误替换,占位符要选足够独特的名字 - 每次调用新建 context 的隔离方案意味着插件不能用全局变量跨调用缓存 token,需要插件侧自己把状态放进返回数据里(业务上一般也足够)
总结
整套方案的最终形态:
- 插件 = 一个 JS 文件,按 CommonJS 风格写
module.exports,实现约定好的方法名即可 - 宿主侧用 trait 抽象插件能力,JS 引擎只作为其中一个实现
- 双向异步桥接靠 rquickjs 的
futuresfeature,参数边界统一 JSON 字符串 - 隔离靠"每次调用新建 context"而非沙箱,简单且可靠
- 生态兼容靠 sandbox.js 手写迷你包 + 原生函数转发
对于"Rust 应用需要快速扩展能力、又不想频繁发版"的场景,这套 QuickJS 插件方案值得参考。