尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Choq:用QuickJS运行Cherry编译产物,打造嵌入式ClojureScript脚本层

Choq:用QuickJS运行Cherry编译产物,打造嵌入式ClojureScript脚本层 choq 是一个把 Cherry 编译产物运行在 QuickJS 上的项目。简单说它把 ClojureScript 的轻量编译方案和可嵌入的 JavaScript 运行时拼在同一条链路上开发者用类 ClojureScript 的语法写业务逻辑Cherry 负责把代码编译成体积小、依赖少的现代 JavaScript最终由 QuickJS 这个面向嵌入场景的 JS 引擎执行。这个组合的价值在于它绕开了在受限环境里跑完整 Node.js 的成本又给宿主应用提供了一层比手写 JS 更好维护的脚本能力。下面先讲清楚 Cherry、QuickJS 和 Choq 各自解决什么问题然后走一遍“安装工具、写 Cherry 源码、编译出 JS、用 qjs 运行、验证结果”的完整流程再拆解编译产物、QuickJS 模块系统和跨语言互操作最后给出常见问题排查表和生产环境落地建议。如果你正在做嵌入式脚本引擎选型或者想把 ClojureScript 风格的逻辑带进小体积运行环境这条链路值得完整走一遍。1. 先搞清楚三个名词Cherry、QuickJS 和 Choq1.1 Cherry 不是完整 ClojureScript它是一套轻量编译方案Cherry 是一套面向现代 JavaScript 的 ClojureScript 编译器。它的定位不是把完整的cljs.core全部搬过来而是实现一个有实用价值、同时又能保持编译产物很小很小的子集。用 Cherry 写代码时语法风格接近 ClojureScript使用命名空间、defn、let、集合字面量、宏和函数式操作但在编译之后你拿到的是体积可控、可读性不差的 ES Module 风格 JavaScript。这里要区分两个概念。ClojureScript 本身是一个成熟的编译器加运行时体系它提供了完整的cljs.core标准库、延迟序列、协议、reagent 等生态。优点是非常完整缺点是编译器体积和运行时不那么“轻”。如果你的目标环境是浏览器大应用或者 Node 服务ClojureScript 完全没问题。但如果目标环境是内存只有几十 MB 的嵌入式设备或者你想给宿主程序塞一个脚本引擎完整 ClojureScript 就偏重了。Cherry 走的是一条相反路线只保留语言核心能力把运行时压缩到很小的cherry.core把编译目标锁定在现代 JS 引擎能直接理解的形式上。它不做自举不追求覆盖全部cljs.core而是鼓励在边界处使用 JavaScript 互操作。因此 Cherry 的适用场景很明确需要 Lisp 风格表达力但对运行时体积和启动速度敏感的项目。容易误解的地方是Cherry 并不保证你的旧 ClojureScript 代码能直接编译通过。它只实现了常用子集很多高级宏、协议和多方法需要手工改造。选型前先拿真实代码测试一遍比看文档推测更可靠。1.2 QuickJS 是适合“塞进宿主程序”的 JavaScript 引擎QuickJS 是一个小巧、可嵌入的 JavaScript 引擎目标是提供完整的 ECMAScript 语言支持同时保持很小的内存占用和启动开销。它由 Fabrice Bellard 和 Charlie Gordon 开发最常被使用的形态是命令行解释器qjs以及通过 C API 嵌入到宿主程序里。QuickJS 的几个关键特征支持 ES2020 级别的语法和标准库日常的class、async/await、Map、Set、Promise都能用。自带qjs命令行程序方便在没有 Node.js 的环境里直接跑 JS 脚本。提供std、os等扩展模块覆盖文件读写、环境变量、命令行参数、进程调用等常见系统操作。可以通过 C API 在宿主进程中创建运行时和上下文Java、Rust、C 等语言都能包一层外壳。支持把引擎本身编译成 WebAssembly进一步扩大运行范围。QuickJS 对应的典型场景是嵌入式固件、游戏脚本、编辑器插件、移动端应用内的脚本层、边缘设备上的规则引擎。它和 Node.js 不是替代关系Node 有文件系统、网络、进程管理、包生态等完整能力QuickJS 则只有语言核心加少量扩展模块剩下的事情要宿主程序自己提供。1.3 Choq 解决的是“编译产物往哪运行”的问题从项目标题看Choq 做的是把 Cherry 的编译链路接到 QuickJS 运行时上。单独看两端其实都很清楚Cherry 负责把 .cljs 翻译成 JSQuickJS 负责执行 JS。但两者之间有一条缝隙Cherry 默认的编译产物要在 Node 或浏览器里跑而 QuickJS 的模块加载方式、内置 API、运行特性和 Node 并不完全一致。如果每次都在 Node 里测好、再把产物搬进小环境调试链路会非常别扭。Choq 的价值在于把这条链路收拢成一个可操作的开发闭环写代码时用 Cherry 的语法编译时产出 QuickJS 能直接加载的模块运行时用 qjs 验证最后还可以通过 C API 把这个能力嵌进自己的产品。它不重新设计语言也不重写引擎而是把两个成熟项目组合起来让“ClojureScript 风格语言 嵌入式 JavaScrip 运行时”这个组合变成可以实际落地的东西。2. 环境准备把编译器和运行时先对齐2.1 需要哪些工具要完整跑通 Choq 链路需要下面这几样东西工具作用验证命令Node.js运行 npm承载 Cherry 编译器node -vnpm / npx安装依赖、调用 Cherrynpm -vcherry-cljs把 .cljs 编译成 .mjsnpx cherry --helpQuickJSqjs执行编译后的 JSqjs --version代码编辑器编写 Cherry 源码无注意Node.js 在这里只是“编译期工具”。最终产物在 QuickJS 上运行不依赖 Node。如果你只想做最终验证甚至可以在一台没有 Node 的机器上提前装好 qjs只拷编译产物过去运行。2.2 安装 Cherry 编译器先创建一个空项目再安装 Cherry。mkdir choq-demo cd choq-demo npm init -y npm install -D cherry-cljs这里把cherry-cljs装进devDependencies因为它是构建期依赖不需要打包到运行时里。安装完后确认命令可用npx cherry --help如果提示命令不存在先检查node_modules/.bin/里是否有cherry可执行文件再检查依赖名是否和 npm Registry 上的实际包名一致。原始资料没有给出固定版本时落地前要以npm view cherry-cljs version拿到的稳定版本为准不要直接写死一个猜测版本。2.3 获取 QuickJS 运行时QuickJS 的获取方式有两种按具体环境选择。第一种是系统包管理器安装# Debian / Ubuntu 系 sudo apt install quickjs qjs --version这种方式最省事但发行版自带的 QuickJS 版本可能偏老如果 Cherry 编译产物用到了新版语法特性可能报语法错误。第二种是源码编译适合要固定版本、或者需要把引擎嵌进自己产品的场景# 从 QuickJS 官方站点下载源码包 tar xf quickjs-*.tar.xz cd quickjs-*/ make sudo make install qjs --version源码编译的优点是版本可控而且能拿到quickjs.h、quickjs-libc.h等头文件后续做 C 嵌入时用得上。缺点是第一次编译要花几分钟机器上需要准备好gcc、make等基础编译工具。建议在项目目录里单独保留一份 qjs 二进制避免系统升级导致运行时版本漂移。2.4 环境检查清单开始写代码前用下面这个清单确认环境能省掉后面大半的排障时间node -v和npm -v能正常输出版本号。npx cherry --help能输出 Cherry 的编译参数。qjs --version能输出 QuickJS 版本号。qjs -e console.log(ok)能打印ok确认引擎可执行。确认 qjs 支持--module参数后面加载 ES Module 文件会用到。注意不要只验证“工具能启动”还要验证“编译产物能在目标引擎里运行”。工具链各自正常不代表链路已经通。3. 最小案例写一个 Cherry 文件编译后用 QuickJS 运行3.1 项目目录结构下面这个结构足够承载一个最小可运行案例choq-demo/ ├── src/ │ └── demo/ │ └── main.cljs ├── out/ ├── package.json └── qjssrc/demo/main.cljs是 Cherry 源码out/存放编译后的 JSqjs是 QuickJS 可执行文件。把产物和运行时放在一起是为了后续用 shell 脚本或 npm script 一次性串联编译和运行。3.2 写一个能打印结果的 Cherry 入口创建src/demo/main.cljs内容如下(ns demo.main (:require [cherry.core :refer [str]])) (defn add [a b] ( a b)) (defn hello [name] (str Hello, name !)) (defn -main [] (let [sum (add 20 22) msg (hello Choq)] (js/console.log msg) (js/console.log sum: sum))) (-main)这里有几个关键点。ns声明了命名空间和依赖(:require [cherry.core :refer [str]])表示只引入str这个函数。Cherry 的实现哲学是按需引入str并不是凭空存在的需要显式从cherry.core引入。add和hello是两个普通函数用来演示函数定义和调用。let绑定局部变量js/console.log是 JavaScript 互操作调用在 Cherry 源码里直接调用console.log。文件末尾的(-main)是显式入口调用。Cherry 编译出的 JS 不会自动执行函数必须在源码里把入口函数调用出来。这一点和 ClojureScript 里的-main:main配置不同更接近普通脚本文件。3.3 用 Cherry 编译出 JS在项目根目录执行npx cherry src/demo/main.cljs -o out/main.mjs-o指定输出文件。这里刻意使用.mjs后缀明确表示输出内容包含 ES Module 语法比如import语句。执行后打开out/main.mjs能看到类似下面的代码结构实际输出以对应 Cherry 版本为准import { str } from cherry-cljs/core.mjs; const add (a, b) a b; const hello (name) str(Hello, , name, !); const main () { const sum add(20, 22); const msg hello(Choq); console.log(msg); console.log(sum:, sum); }; main();编译产物里函数被转成了普通 JavaScript 函数cherry.core被转成了模块导入。这说明 Cherry 的运行时依赖被控制在很小的范围最终执行时只需要core.mjs这一个库文件。3.4 用 qjs 运行并验证输出运行编译产物时关键是加--module参数qjs --module out/main.mjs正常输出Hello, Choq! sum: 42如果忘记加--moduleqjs 会按普通脚本解析文件而脚本里又有import语句通常会报类似下面的错误SyntaxError: import outside of module这个报错是链路里最常见的入门问题原因不是代码写错而是运行模式不对。QuickJS 区分普通脚本和 ES Module 两种加载方式包含import/export的文件必须用--module加载。3.5 把编译和运行固定成脚本每次都要手动执行两条命令容易漏参数。把步骤写进package.json{ name: choq-demo, version: 0.1.0, private: true, scripts: { compile: cherry src/demo/main.cljs -o out/main.mjs, run: qjs --module out/main.mjs, demo: npm run compile npm run run }, devDependencies: { cherry-cljs: latest } }之后执行npm run demo就能看到编译和运行的完整输出。这里把依赖版本写成latest只是演示写法实际项目建议锁定到具体版本号避免 Cherry 升级后编译产物行为变化。4. 关键代码拆解编译产物、模块系统和互操作4.1 Cherry 编译器的核心工作是什么Cherry 做的事情可以概括成三层。第一层是语法展开。Cherry 源码里的defn、let、if、str调用会被展开成对应的 JavaScript 结构。defn变成const fn (...) ...let变成const声明函数调用变成普通 JS 调用。第二层是宏处理。Cherry 支持宏宏在编译期执行不会进入产物。因为宏在编译期展开所以最终 JS 里看不到宏定义这也是 Cherry 产物体积能保持较小的原因之一。第三层是运行时依赖收敛。Cherry 有一个很小的core.mjs运行时库提供str、集合操作、谓词函数等常用能力。源码里:require [cherry.core :refer [str]]会转换成对core.mjs的模块导入。正是因为运行时库小Cherry 产物才能在其他 JS 引擎里顺利运行。4.2 QuickJS 的模块加载方式和 Node 的差异QuickJS 的qjs命令行工具支持两种脚本加载方式普通脚本模式按顶层代码顺序执行不支持import/export。模块模式使用--module参数支持 ES Module 语法。这一点和 Node.js 有明显差异。Node.js 会根据文件后缀.cjs、.mjs和package.json的type字段自动判断模块类型而 qjs 主要依赖命令行参数。所以在 Choq 链路里编译输出.mjs文件、运行使用--module是最不容易出错的组合。另外要注意qjs 不像 Node 那样自带一套庞大的内置模块。它提供了std和os两个扩展模块前者负责日志输出、文件读写、环境变量、命令行参数后者提供系统调用、进程执行等能力。这些扩展模块在 Node 里是不存在的反过来Node 内置的fs、path、http在普通 qjs 里也不存在。写互操作代码前先确认目标能力在 QuickJS 里有没有对应实现。4.3 ClojureScript 风格代码与 QuickJS 原生功能互操作Cherry 源码里可以调用 QuickJS 提供的扩展模块。例如读取环境变量(ns demo.qjs-ext) (defn show-home [] (let [std (js/require std)] (js/console.log HOME (.getenv std HOME)))) (show-home)这里js/require调用 QuickJS 提供的require能力std是 QuickJS 的扩展模块.getenv是方法调用。编译后这段代码会变成对应的 JSconst show_home () { const std require(std); console.log(HOME, std.getenv(HOME)); }; show_home();再比如调用 JavaScript 原生对象(ns demo.time) (defn now [] (js/console.log now(ms): (.getTime (js/Date.)))) (now)js/Date.是构造函数的互操作写法(.getTime d)是方法调用写法。Cherry 保留了 ClojureScript 的互操作语法因此在 Cherry 里写 JavaScript 原生调用时思路和写 ClojureScript 一致全局对象用js/前缀方法调用用(.method obj args)形式。4.4 通过 C API 嵌入 QuickJS 的路线qjs命令行只是 QuickJS 的一种使用形态。要在自己的产品里内嵌脚本能力通常走 C API。下面是示意骨架实际 API 要以 QuickJS 版本对应的头文件为准#include stdio.h #include string.h #include quickjs.h #include quickjs-libc.h int main(int argc, char **argv) { JSRuntime *rt JS_NewRuntime(); JSContext *ctx JS_NewContext(rt); js_std_add_helpers(ctx, argc, argv); js_std_init_handlers(rt); const char *code console.log(choq from QuickJS); JSValue result JS_Eval(ctx, code, strlen(code), choq, JS_EVAL_TYPE_MODULE); if (JS_IsException(result)) { js_std_dump_error(ctx); return 1; } JS_FreeValue(ctx, result); js_std_free_handlers(rt); JS_FreeContext(ctx); JS_FreeRuntime(rt); return 0; }这段代码创建了运行时和上下文注册了常用助手函数然后用JS_Eval执行一段模块代码。在真实项目里可以把之前编译好的out/main.mjs读成字符串传入或者用 QuickJS 提供的模块加载接口来加载文件。嵌入时还要考虑宿主进程和 JS 上下文之间的生命周期管理、异常处理、内存回收这些细节比命令行走一遍要多得多。5. 常见问题排查从编译到运行按这条链路查5.1 编译阶段的典型问题Cherry 编译失败时先看报错位置是不是在.cljs源码里。常见原因有用了 Cherry 没实现的cljs.core函数编译器直接报“未找到”。宏名写错或命名空间没引入。输出目录不存在-o写入失败。检查方式很简单先执行npx cherry --help确认参数再确认源码文件路径和输出目录是否存在。如果源码里用了不存在的函数把函数名换成 Cherry 支持的实现或者改用 JavaScript 互操作。5.2 运行阶段的模块问题编译成功后运行阶段的问题集中在模块加载上。最常见的是上面提到的import报错解决办法是加--module。另一个常见现象是ReferenceError: require is not defined这通常意味着代码里混用了 CommonJS 的require和 ES Module 的import。qjs 加载模块时import语句和require调用不能随意混用。建议统一使用 ES Module 写业务代码只有在调用 QuickJS 扩展模块时才通过js/require获取std、os等对象。5.3 QuickJS 与 Node 环境差异导致的问题有些代码在 Node 里能跑换到 qjs 就跑不了。根因一般是使用了 Node 内置 API。比如fs.readFileSync、path.join、Buffer等在普通 qjs 里都不存在。遇到这类问题先查目标环境的能力边界把 Node 专用 API 替换成 qjs 的std模块或者让宿主程序用 C API 把能力注入到 JS 上下文里。5.4 问题现象与处理方案速查表问题现象常见原因检查方式处理建议SyntaxError: import outside of module未使用模块模式看报错行号确认是 import 位置运行命令加--modulerequire is not definedESM 与 CJS 混用搜索源码里的 require统一用 import扩展模块用js/require获取cherry.core模块找不到依赖没装全或输出路径不对查看node_modules和 out 目录重新安装依赖调整编译输出路径编译报“函数不存在”使用了 Cherry 未实现的子集在 Cherry 文档查该函数改用互操作或手写 JS程序运行但无输出源码没调用入口函数检查 .cljs 末尾是否调用(main)在源码里显式调用入口同一段代码 Node 能跑 qjs 报错使用了 Node 内置 API检查fs、path、Buffer等引用替换成 qjs 的std/os或宿主注入能力qjs 版本太老导致语法不支持系统包版本偏旧执行qjs --version源码编译固定版本5.5 推荐的排查链路遇到问题按下面顺序排查不要跳跃先确认编译是否成功。编译失败优先查源码语法和 Cherry 支持的函数子集。再确认输出文件是否生成。没生成说明编译参数或目录有问题。然后确认加载模式。.mjs文件必须用--module加载。接着确认模块路径。cherry.core的导入路径是否能被 qjs 解析到。最后确认环境差异。同一个文件在 Node 里跑一遍对比差异定位是引擎 API 差异还是代码问题。注意出现运行错误时先把错误信息完整复制出来再顺着报错行号反查源码。很多 QuickJS 报错信息里带的文件位置是编译后的 JS 行号不是 .cljs 源码行号需要对应着看。6. 适合用 Choq 的场景以及不太适合的场景6.1 适合的场景Choq 这条链路最适合的场景是“需要一个可扩展的脚本层但没法承受完整 Node.js 运行时成本”的产品。嵌入式设备和边缘设备是典型场景。设备内存有限qjs 只有几 MB 级别的体量宿主程序通过 C API 内嵌引擎后可以动态执行规则脚本而规则脚本用 Cherry 编写比直接暴露 JS 给业务人员更好维护。桌面程序和编辑器插件也适合。宿主程序以 C API 方式内嵌 QuickJS用 Cherry 编写插件脚本既能享受 JavaScript 生态的灵活性又不需要每次更新脚本都做完整编译和打包。还有一类场景是批量任务的脚本执行器。CI 流程里有很多小脚本用 Node 跑会拉起来一个很大的运行时用 qjs 跑则快得多。如果团队熟悉 Lisp 风格语法用 Cherry 写这些脚本会有不错的体验。6.2 不太适合的场景反过来下面几种情况不建议选 Choq。高并发 Web 服务不适合。Node.js 的异步 I/O、HTTP 框架、进程管理、生态库都非常成熟QuickJS 在这条赛道上没有优势。需要完整 Node API 的业务不适合。fs、path、child_process、stream等模块在普通 qjs 里缺失硬要模拟会引入很大的兼容层得不偿失。团队不熟悉 Lisp 语法也不适合。Cherry 的收益前提是开发者能熟练读写法如果团队刚从 Java 或 Go 转过来学习成本反而会让脚本层变成维护负担。6.3 生产环境落地需要补的几件事从 demo 到生产还有一段距离。至少要做这些补充锁定版本。Cherry 编译器和 QuickJS 引擎都固定版本编译产物纳入构建产物管理。编译过程接入 CI。每次修改 .cljs 后自动编译并跑一段冒烟测试确认产物可运行。日志和异常处理。被嵌入的脚本引擎如果抛异常宿主程序要能捕获、记录、隔离不能让脚本错误拖垮主进程。资源限制。QuickJS 提供了内存和栈相关的配置项生产环境要限制脚本执行时间和内存占用防止恶意或异常脚本拖死宿主。安全隔离。脚本可能来自外部用户因此要明确脚本能访问哪些宿主能力不能把os.system之类的接口直接暴露给不信任的代码。回滚方案。脚本和宿主程序要分开部署脚本更新出问题时能快速切回旧版本。学习和开发环境可以把这几项放宽先保证链路能跑通生产环境则应该在第一天就把版本锁定和资源限制规划进去后面再补成本会高很多。7. 落地建议与练习路径7.1 推荐的工程做法把 Choq 链路放进真实项目时建议按阶段拆分编译阶段独立于运行阶段。.cljs源码编译成.mjs产物后产物才是真正交付给运行时的东西。产物目录和源码目录分开。src/放 Cherry 源码out/放编译产物避免混在一起误提交。用脚本串联编译和运行。npm script 或 Makefile 都可以目的是让“一条命令完成构建到验证”成为默认路径。对编译产物做版本控制。产物可以在构建时重新生成但如果产品要回滚脚本逻辑备份产物比重新编译更可靠。给 QuickJS 和 Cherry 各写一个版本探测命令。接入 CI 时先跑版本检查环境一变立刻暴露。7.2 给新手的练习路径如果你刚接触这条链路建议按下面顺序练跑通本文的最小 demo确认编译和运行都正常。改写main.cljs加一个读取环境变量的函数用 QuickJS 的std模块实现。再写一个读文件并统计行数的例子体会std模块和 Nodefs的差异。把编译产物嵌进一段 C 程序用 QuickJS C API 执行感受从命令行到嵌入的差别。在 Node 和 qjs 各跑一遍同一段代码记录差异建立对“目标引擎能力边界”的敏感度。做完这五步你对 Choq 这条链路的理解就不只是“多了一个工具”而是能判断“哪些场景该选 Node、哪些场景该选 qjs、Cherry 的编译边界在哪里”的程度。这也是这类跨层技术组合最值得掌握的部分不是记住某个命令而是能在不同运行时之间快速定位差异。
返回列表