在 ClojureScript 项目中处理异步代码时,开发者经常面临一个选择题:是否需要原生 ES 风格的 async/await 语法糖?截至 2026 年上半年,ClojureScript 官方并未将 async/await 纳入语言层面的第一等支持,而是将异步抽象交给 core-async 与 Promise 封装库处理。这一现状背后涉及编译器实现、CLJS 代码生成特性以及生态系统兼容性的多重考量。
异步抽象的历史分歧
Clojure 系列语言在异步编程上一直倾向于使用通道(Channel)模型。core-async 库引入的 <! 与 >! 符号提供了类似协程的阻塞式写法,编译后通过状态机实现非阻塞执行。这种设计在处理高并发 IO 密集型任务时表现出色,但也意味着与 JavaScript 原生 Promise 生态的互操作需要额外适配层。
与此同时,现代 JavaScript 生态已全面倒向 async/await 语法。无论是浏览器端调用 Fetch API,还是 Node.js 生态中的绝大多数异步库,默认接口均为返回 Promise 的 async 函数。当 ClojureScript 代码需要与这些第三方库交互时,频繁的 .then 链式调用或 Promise 手动包装会显著降低代码可读性。
实验性编译器扩展的尝试
社区开发者 Roman Lafantan 维护的 cljs-async-await 项目是目前最接近原生语法支持的实验方案。该扩展通过宏定义的方式在 ClojureScript 中实现了 async 块与 await 操作符,其核心机制如下:
(require '[async-await.core :refer [async await]])
(defn http-get [url]
(async
(let [response (await (js/fetch url))
json (await (.json response))]
(.log js/console json))))
编译阶段,这段代码会被转换为 ES2017+ 的 async 函数。值得注意的是,使用该扩展需要将编译器的 :language-in 选项设置为 :ecmascript-2017 或更高版本。如果目标运行环境不支持 async/await,Google Closure Compiler 会自动将其下转译为基于生成器与 Promise 的兼容实现。
然而,该方案存在一个关键限制:ClojureScript 的代码生成逻辑会将 do 表达式翻译为自调用函数(self-invoking function),这导致在某些上下文中 await 可能出现在非 async 函数内部,从而引发编译错误。例如:
(async
(let [x (do (inc 1) (await 2))]
x))
上述代码在编译后会生成如下无效 JavaScript:
(async function() {
var x = (function() {
1 + 1;
return await 2;
})();
return x;
})()
这种边界情况要求开发者在编写时避开复杂的嵌套表达式,或者手动重构代码结构。
库层面的替代方案
对于不愿引入编译器扩展的项目,Promesa 库提供了更为保守的路径。Promesa 本质上是 JavaScript Promise 的轻量包装,通过 p/then、p/catch 等函数提供组合式异步编程能力,并提供了类似 async/await 的 p/let 宏来模拟顺序执行效果:
(require '[promesa.core :as p])
(p/let [response (p/await (js/fetch url))
data (p/await (.json response))]
(.log js/console data))
这种写法的优势在于完全依赖库实现,不涉及编译器插件的兼容性问题,且与现有 CLJS 工具链(shadow-cljs、lumic 等)无缝集成。但它本质上仍是 Promise 链的语法包装,无法获得原生 async 函数提供的栈追踪优化与语法简洁度。
从工程选型角度,以下参数可供团队评估:
| 维度 | cljs-async-await | Promesa + p/let | core-async 通道 |
|---|---|---|---|
| 语法侵入性 | 编译器级别 | 库级别 | 库级别 |
| 目标环境 | ES2017+ | 任意 Promise 环境 | 任意 JS 环境 |
| 错误栈追踪 | 原生优化 | 依赖 Promise 实现 | 通道层面统一处理 |
| 生态互操作 | 直接调用 JS async | 需要包装 | 需要桥接层 |
| 维护状态 | 实验性,单人维护 | 活跃社区支持 | 官方维护 |
实践建议
对于新启动的 ClojureScript 项目,若目标浏览器与 Node.js 版本普遍支持 ES2017(主流浏览器自 2018 年起已全面支持),团队可以考虑将 cljs-async-await 纳入开发依赖。启用前应完成以下检查清单:
首先,确认项目构建工具的编译器配置支持传递自定义宏命名空间。shadow-cljs 用户需要在 shadow-cljs.edn 的 :compiler-options 中添加 :language-in :ecmascript-2017。其次,对现有代码进行静态扫描,标记所有包含 do 表达式且内部可能调用异步操作的代码位置,预估迁移成本。最后,建立端到端测试覆盖,确保异步错误处理路径在目标浏览器版本上行为一致。
如果项目对编译器扩展持保守态度,或需要同时支持较老的移动端浏览器,Promesa 组合 p/let 仍是目前最稳妥的选择。该方案在代码可读性与跨环境兼容性之间取得了良好平衡,且与 core-async 的互操作可通过 core.async/promise-chan 等桥接函数实现。
小结
ClojureScript 尚未在语言层面原生支持 async/await 语法糖,官方更倾向于通过 core-async 通道模型与 Promise 封装库提供异步抽象。社区驱动的实验性编译器扩展 cljs-async-await 提供了接近原生的写法,但受限于 CLJS 代码生成的边界情况。对于追求生产稳定性的团队,基于 Promesa 的库方案配合 p/let 宏能够在不修改编译器的前提下获得较好的异步编程体验。
参考资料
- Roman Lafantan, Experimental ClojureScript's compiler extension that enables JavaScript's async/await, GitHub, https://github.com/roman01la/cljs-async-await
- ClojureVerse, How to deal with js/async and js/await in ClojureScript?, https://clojureverse.org/t/how-to-deal-with-js-async-and-js-await-in-clojurescript/10234
内容声明:本文无广告投放、无付费植入。
如有事实性问题,欢迎发送勘误至 i@hotdrydog.com。