
最近翻 Hacker News 的时候看到一条很有意思的条目Show HN: Choq: Cherry (ClojureScript) on QuickJS。如果你是做 ClojureScript 工具链、嵌入式 JavaScript 引擎或者对“编译器如何被重定位运行环境”这件事感兴趣的人这个项目值得停下来看两眼。先说一个容易被忽略的判断Choq并不是又一个 ClojureScript 框架也不是要替代shadow-cljs或者官方cljs.main。它的本质是把 Cherry 这个 ClojureScript 编译器跑到了一个非常轻量的 JavaScript 引擎 QuickJS 里面。换句话说你不再需要为了一次编译任务拉起 Node.js 或者 JVM而是可以在 QuickJS 这个几十MB都不到、启动极快的环境里完成 Clojure 代码到 JavaScript 的编译和运行。这篇文章我会从项目定位、基础概念、运行原理、环境搭建、验证思路、常见问题和工程建议几个层面展开。读完你可以得到一个清晰判断什么场景下 Choq 有意义什么场景下它就是玩具级尝试以及如果你想去跑通这个项目最需要关注哪些技术坑。1. 这篇文章真正要解决的问题先说痛点。大多数 ClojureScript 开发者对编译器的印象是运行在 JVM 上的cljs.main或者跑在 Node.js 上的shadow-cljs。这两条路线都很成熟但都有一个共同特点——编译环境很重。JVM 启动热机慢Node.js 环境虽然比 JVM 轻但如果只为了在 CI 里做一次“把几个 CLJS 文件编译成 JS”的任务为它安装 Node 运行时、维护依赖缓存、处理 npm 包版本多少有点杀鸡用牛刀。更极端的场景在嵌入式环境。比如一个软路由、一块树莓派、一个微服务容器甚至一个需要在启动前动态编译小段业务逻辑的边缘网关。这时候我们希望的是有一个足够小的 JavaScript 引擎能塞进受限环境同时还能执行“编译器”这种复杂程序。Choq 的切入点就藏在这个矛盾里。它把 Cherry 编译器放到了 QuickJS 上QuickJS 本身是一个可嵌入、资源占用极小的 JS 引擎Cherry 又是一个被编译成 JavaScript 的 ClojureScript 编译器。两者组合起来后你拿到的是一条更轻的编译链路不需要 JVM。不需要完整 Node.js API。只需要带 QuickJS 的环境就能运行 ClojureScript 编译器。所以本文真正要解决的问题是Choq 到底解决了开发链路中的哪一环痛它适合谁不适合谁以及如果要自己接入 QuickJS ClojureScript 编译器应该怎么理解架构和踩坑。2. 基础概念与核心原理要读懂 Choq至少要理清三个相关概念ClojureScript、Cherry 和 QuickJS。2.1 ClojureScript 不是“另一个 JavaScript”ClojureScript 是 Clojure 语言在 JavaScript 平台上的编译器实现。它把 Clojure 语法编译成可运行的 JavaScript 代码。Clojure 是一门 Lisp 方言以不可变数据结构、宏、REPL 驱动开发闻名。ClojureScript 保留了这些特性同时利用 JavaScript 生态进行分发。传统上ClojureScript 代码会通过编译器生成 JavaScript然后放到浏览器、Node.js 或 Deno 中运行。这个编译过程本身很重因为编译器需要分析命名空间、处理宏、做优化、生成输出。这也是为什么官方编译链路的运行成本远高于普通构建工具。2.2 Cherry一个跑在 JS 里的 ClojureScript 编译器Cherry 是 Clojure 社区里一个比较新的编译器项目由 Souenzzo 主导开发。它的定位和官方 ClojureScript 编译器有区别核心特点可以概括为编译器本身用 ClojureScript 编写再编译成 JavaScript。目标是让 Clojure 代码能更方便地被嵌入 JavaScript 工具链。相比官方编译器Cherry 更关注轻量、可嵌入和子集语义。通俗讲Cherry 用“Clojure 语法 → JavaScript 代码”的编译方式把 Lisp 开发体验带入纯 JS 环境。因为它是编译成 JS 的所以理论上可以在任何 JS 引擎里运行只要这个引擎支持的 ECMAScript 特性足够新。2.3 QuickJSFabrice Bellard 的轻量级 JavaScript 引擎QuickJS 是一个非常小的、可嵌入的 JavaScript 引擎作者是 FFmpeg 和 QEMU 的作者 Fabrice Bellard。它和 V8、JavaScriptCore 最大的区别在于体积小代码库精简。启动速度非常快。内存占用低。支持 ES2020 的大部分特性。可以直接嵌入 C/C 程序也可以作为独立命令行工具运行。如果我们把 V8 比作一个功能齐全的重型卡车那 QuickJS 更像是一辆越野摩托——油箱不大但能去很多大车不方便去的地方。2.4 三者如何咬合把三个概念放在一起看Clojure 源码 → Cherry 编译器CLJS 写的编译成了 JS ↓ QuickJS 引擎承载编译器运行 ↓ 最终生成 JavaScript 代码Choq 的本质就是在第三层做了一次“运行环境替换”。过去 Cherry 编译器虽然已经是 JS但通常跑在 Node.js 上。Choq 把运行底座换成 QuickJS验证一条更轻的路径是否可行。3. 这个项目到底“新”在哪里如果只看功能层面Choqu 做的事情看起来很简单把一个 JS 编译器放到另一个 JS 引擎里跑。但把它放到更大的背景里看这里有几个值得展开的点。3.1 对编译器“可嵌入性”的重新审视传统编译器更像一个独立的命令行工具。你输入源码它输出目标代码中间过程不对外暴露。Choq 体现的则是编译器的另一种形态可嵌入组件。这意味着你可以在自己的 C 程序里直接内嵌 QuickJS然后调用 Cherry 编译器编译一段 Clojure 代码。你可以在微服务里按需动态编译一段配置文件或业务规则。你可以在边缘设备上把 Clojure 写成 DSL然后现场编译成 JS 执行。过去这些场景很难实现因为环境太大启动太重。Choq 的意义在于证明了嵌入纯 JS 环境中的 CLJS 编译资源开销是可以被压低到可用级别的。3.2 编译器本身也是 JS 程序这带来循环优化的可能一个有意思的点是Cherry 编译器是用 ClojureScript 写的最终被编译成了 JavaScript。这意味着它和目标语言共享同一个运行时平台。这种“自举”特性让编译器本身也能享受 JS 生态的红利。比如它可以被 npm 打包。它可以被各种 JS 引擎加载。它可以嵌入任何支持标准 ECMAScript 的环境。Choq 看起来只是做了一次适配但这次适配选择的目标引擎非常极端——极小的 QuickJS。这相当于给整个“CLJS 工具链轻量化”方向做了一个压力测试。3.3 和 Cherry Studio 这类应用完全不是一回事如果你在搜索引擎里搜Cherry大概率会搜到Cherry Studio这类 AI 聊天客户端而它和本文讨论的 Cherry 编译器完全不是同一个东西。这里有必要做一次明确区分名称类型领域Cherry本文ClojureScript 子集编译器编程语言工具链Cherry StudioAI 对话客户端大模型交互应用CherryPick代码片段管理工具开发者效率工具搜索时看到的结果里Cherry Studio、FreeCAD MCP 服务器、CC Switch这些热词都跟 Choq 无关别被带偏。4. 架构拆解Cherry 编译器在 QuickJS 上如何工作虽然我无法拿到 Choq 项目每一行源码的细节但从项目标题和技术栈可以推导出清晰的架构图景。4.1 整体运行流程一个基于 Choq 思路的运行链路会分成下面几个阶段启动 QuickJS 引擎。在引擎中加载 Cherry 编译器以 JS 文件形式。将 Clojure 源码字符串传给 Cherry 编译器。Cherry 解析并分析代码。生成 JavaScript 代码字符串。在同一个引擎上下文里执行生成的 JavaScript如果需要的话。第 6 步非常关键。因为 QuickJS 既能编译也能执行 JS所以在同一引擎内完成“编译 运行”是可行的。传统方案里编译阶段和执行阶段往往由两个运行时承担而 Choq 的思路把它们统一了。4.2 为什么 QuickJS 能承载编译器很多人会质疑编译器不是需要很多内存和复杂特性吗QuickJS 这么小能撑得住吗从技术角度这是说得通的编译器本质上是纯函数式数据处理流程。Cherry 被设计为子集编译器规避了官方 CLJS 编译器中的大量复杂逻辑。QuickJS 虽然小但支持现代 ES 特性可以运行非平凡的程序。编译器不再需要文件系统、网络等能力只负责“字符串进字符串出”QuickJS 的嵌入边界反而很合适。和 Node.js 大而全的 API 比起来QuickJS 提供的标准库非常有限但 Cherry 编译过程不一定需要太多外部 API。4.3 架构上的限制当然架构上也有明显天花板Cherry 支持的 Clojure 语法子集有限不是所有 CLJS 项目都能直接编译。QuickJS 没有 Node.js 的完整模块系统依赖 commonjs 的编译器加载方式可能需要适配。自动加载宏在 QuickJS 环境里天然受限因为宏展开依赖编译器的动态求值能力。这些限制决定了 Choq 不是一个“通用 ClojureScript 构建工具”更像是“面向特定场景的嵌入方案”。5. 环境准备与最小演示下面进入实操思路部分。由于 Choq 项目本身还在快速迭代中下面给出的命令和代码更多是演示“如何从 QuickJS 角度理解这个项目”具体命令以项目最新 README 为准。5.1 准备 QuickJSQuickJS 可以从官方仓库源码编译也可以在一些包管理器中直接安装。常见方式git clone https://github.com/bellard/quickjs.git cd quickjs make编译完成后你会得到qjs命令。可以用下面的命令验证./qjs --version如果输出版本号说明 QuickJS 已经可用。5.2 在 QuickJS 中运行 JavaScript 程序写一个最简单的测试文件hello.js// 文件路径hello.js function greet(name) { return Hello, name !; } console.log(greet(QuickJS));运行./qjs hello.js预期输出Hello, QuickJS!这个小实验说明QuickJS 已经可以运行包含函数定义、字符串拼接和标准输出的 JS 程序。5.3 模拟“编译器嵌入宿主环境”的模式编译器嵌入的关键是宿主程序可以调用编译器的“字符串进字符串出”接口。下面用一个最小示例演示这种模式// 文件路径minicompiler.js // 这是一个“伪编译器”用于演示宿主如何调用编译函数 const pseudoCompiler (source) { // 在实际项目中这里会调用 Cherry 编译器 const result (function(){ return ${source}; }); return result; }; const source 1 2; const generated pseudoCompiler(source); console.log(Generated code:, generated); // 执行生成的代码 const fn eval(generated); console.log(Execution result:, fn());运行./qjs minicompiler.js预期输出Generated code: (function(){ return 1 2; }) Execution result: 3这个例子说明了一个核心设计把编译器当作纯函数调用输入端是源码字符串输出端是 JS 代码然后再由同一引擎执行。Choq 的思路本质上就是这样。5.4 从项目中获取 Choq 并尝试接入如果 Choq 官方提供了可执行文件或 CLI大致的使用方式可以参照下面的思路# 示意命令以项目 README 为准 ./qjs choq.bundle.js --compile input.cljs -o output.js如果项目的 API 是库形式加载方式大致是# 示意把 choq 作为 JS 模块加载 ./qjs -m app.js其中app.js内部负责require或importChoq 入口文件。由于项目还在早期阶段接口细节大概率会变动。跑通项目前第一件事永远是阅读项目的 README 和 examples 目录。6. 运行结果与效果验证6.1 判断成功标准用 QuickJS 跑通一个 ClojureScript 编译器成功的标准有两个编译器进程无报错退出。输入一段 Clojure 代码能输出对应 JavaScript 代码。比如输入(defn add [a b] ( a b))输出可能类似于function add(a, b) { return a b; }如果 QuickJS 环境下能完成这个变换说明整个链路已经打通。6.2 如果失败按什么顺序排查如果运行失败建议按下面顺序排查优先级检查项原因1QuickJS 版本旧版本可能不支持所需 ES 特性2Cherry 编译器加载路径模块加载失败会导致编译函数未定义3Clojure 输入语法Cherry 只支持子集高级语法会被拒绝4宏相关代码QuickJS 环境里动态加载宏非常困难5编译产物引用标准库Cherry 生成的代码可能依赖部分运行时库排查时使用 QuickJS 自带的报错信息定位问题最后再去看编译器的解析错误。6.3 对比 Node.js 环境的资源差异如果你的机器同时有 Node.js可以做一个小对比time node -e console.log(hello) time ./qjs -e console.log(hello)在绝大多数机器上QuickJS 的启动速度都会明显优于 Node.js。这种差异在 CI 里可能只是毫秒级但在嵌入式环境、边缘设备或者高频冷启动场景就是质的区别。这也正好解释了 Choq 的价值判断它不做 Node.js 能做的事情而是做 Node.js 做不好的事情。7. 常见问题与排查思路虽然 Choq 还比较早期但基于 ClojureScript 编译器和 QuickJS 生态的已有经验可以提前避开一批高频问题。7.1 问题Clojure 代码无法编译提示语法不支持问题现象可能原因排查方式解决方案解析错误Cherry 只支持 Clojure 子集查看文档支持列表改写为标准子集语法宏无法展开QuickJS 环境无法加载宏数据检查宏文件是否被正确打包避免使用依赖宏的库7.2 问题QuickJS 报错找不到模块问题现象可能原因排查方式解决方案Cannot find module模块加载路径未配置检查入口文件引用的路径把依赖打成单一 bundle兼容性问题某些 npm 包依赖 Node API查看包是否在浏览器环境可运行替换为不依赖 Node API 的包7.3 问题编译过程非常慢问题现象可能原因排查方式解决方案编译耗时大编译器需要解析大量源码分析是输入规模还是引擎瓶颈减少单次编译量冷热分离内存持续增长编译器内缓存未清理检查是否反复创建上下文复用引擎上下文控制生命周期7.4 问题Clojure 代码能用但生成的 JS 运行不正确问题现象可能原因排查方式解决方案输出结果与 JVM 不一致数字类型语义差异检查 JS 数字精度对整数边界情况做显式处理副作用顺序异常CLJS 和 JS 副作用模型不同对照编译产物检查流程在源码中避免依赖隐藏副作用8. 最佳实践与工程建议Choq 这个项目到目前为止实验性质还是比较明显的。但它的思路对工程实践有很强的借鉴意义。如果你想在自己的项目里借鉴这种“轻量编译器嵌入”模式下面是几条具体建议。8.1 明确“编译”和“执行”两个阶段很多人在做编译器嵌入时会把编译和执行混在一个上下文里来回切换状态导致内存和逻辑都很难维护。更好的做法是明确分离阶段一源码字符串 → 编译器 → 目标 JS 字符串 阶段二目标 JS 字符串 → 执行引擎 → 运行结果阶段一尽量保持无副作用便于缓存和测试。阶段二再引入宿主环境能力。8.2 复用 QuickJS 运行时而不是每次冷启动QuickJS 启动快但也不是零成本。如果需要处理大量文件更好的办法是启动一个长期运行的 QuickJS 进程。通过标准输入或 IPC 接收编译请求。编译完成后返回结果继续等待下一条。这样把启动成本摊到多任务里才能发挥 QuickJS “嵌入式”的优势。8.3 为编译产物设计显式的运行环境依赖Cherry 生成的 JS 代码可能依赖少量运行时辅助函数。如果这些辅助函数来自 Node.js那么 QuickJS 环境就跑不了。因此在工程实践里要做到统一打包运行时依赖。尽量让编译产物是纯 ECMAScript。避免隐式依赖process、Buffer、require。8.4 对编译能力做严格测试子集编译器的风险在于它编译成功不代表语义和完整 CLJS 一致。因此在项目里接入 Cherry 或 Choq 时必须有回归测试。简单做法是维护一组测试用例纯函数计算。字符串处理。数据结构操作。宏展开场景如果支持。推荐把这些用例做成 CI 的一部分每次升级编译器版本时自动跑一遍。8.5 不要一开始就全量迁移如果你有一个较大的 ClojureScript 项目最好的策略不是把它整体切换到 Choq而是在一个独立模块中验证选择一段业务逻辑简单、无复杂宏的代码。用 Choq 编译并运行。对照原项目行为。验证通过后再逐步扩大范围。9. 总结与后续学习方向Choq 这个项目最值得关注的不是它能替代什么工具而是它验证了一个方向ClojureScript 编译器可以作为轻量组件嵌入到 QuickJS 这样的微型运行时中。这意味着嵌入式环境里跑 CLJS 编译是可能的。编译器不再是“重量级工具链”的代名词。未来可以出现更多基于“微型引擎 子集编译器”的工具形态。下一步你可以从这几个方向继续深入阅读 Cherry 编译器的源码理解它的语法子集和分析流程。梳理 QuickJS 的嵌入 API尝试在自己的 C 程序里调用 QuickJS。参与 Choq 项目或其他类似项目体验编译器适配引擎的过程。研究 ClojureScript 官方编译器与 Cherry 之间在宏、命名空间、优化策略上的差异。如果你正准备做一个边缘计算或嵌入式开发环境Choq 和它的技术路线值得收藏起来当成一份“轻量级编译器嵌入”的参考样本。