给 20 年 JS/TS 工程师的 ClojureScript 指南
你为什么在这里
你写了 20 年 JavaScript。你经历过 callback hell、Promise 革命、async/await、ES6 模块化、TypeScript 的类型系统。你对原型链、闭包、事件循环的理解已经刻进肌肉记忆了。
但你开始觉得表达力不够了:
- 想写一个宏来消除重复代码?TypeScript 的类型体操能做一部分,但运行时的代码生成你得靠 eval 或代码字符串拼接
- 想做模式匹配?switch/case 太弱了,得靠一堆 if-else 或外部库
- 想让数据不可变?得手动
Object.freeze、immer、或readonly,到处都是心智负担 - 想做多重分派?没有语言级支持,得自己搞 visitor 模式或策略模式
- 想让数据既有 map 的灵活性又有类型的行为?class 和 object 二选一,没法两全
- 想给已有类型追加方法?修改 prototype 不安全,给别人的 class 加接口做不到
- 想把代码当数据操作?得用 AST 工具链(babel、estree),重到不想碰
ClojureScript 解决这些问题的方式不是”加个库”,而是语言本身就能表达。
这篇指南不讲”什么是函数”、“什么是数组”。讲的是:你已有的 JS 心智模型,在 ClojureScript 里怎么对应,以及哪些东西 JS 表达不了,ClojureScript 可以。
心智模型映射
数据类型
JS/TS ClojureScript
─────────────────────────────────────────────
null / undefined nil(只有一个)
number number(不分 int/float)
string string
boolean boolean
Array vector:[1 2 3]
Object map:{:name "foo" :age 30}
Set set:#{1 2 3}
Promise Promise(直接用 JS 的)
最大的区别:ClojureScript 的集合类型默认不可变。
// JS:可变是默认的,不可变要靠工具
const arr = [1, 2, 3];
arr.push(4); // 原地修改
const newArr = [...arr, 4]; // 手动复制
;; ClojureScript:不可变是默认的,没有原地修改
(def arr [1 2 3])
(conj arr 4) ;; 返回 [1 2 3 4],arr 不变
;; 没有 push、splice、sort 原地修改
;; 不需要 immer、不需要 readonly
这不是”习惯就好”的差异。不可变数据 + 持久化数据结构意味着:
- 永远不用担心对象被谁改了(JS 里最深奥的 bug 来源之一)
- 比较两个值是否相等变得可靠(
=是值比较,不是引用比较) - undo/redo 天然免费(持有旧引用就持有旧状态)
函数
// JS
const add = (a, b) => a + b;
const result = add(1, 2);
;; ClojureScript
(defn add [a b] (+ a b))
(def result (add 1 2))
函数是一等公民、高阶函数、闭包——这些你熟。差异在于语法习惯:前缀表示法 (+ 1 2) 而不是中缀 1 + 2。这不是倒退,而是为了一个更大的好处——宏(后面讲)。
解构
// JS
const { name, age } = person;
const [first, ...rest] = arr;
;; ClojureScript
(let [{:keys [name age]} person]
;; name 和 age 绑定好了
)
(let [[first & rest] arr]
;; first 和 rest 绑定好了
)
ClojureScript 的解构更强大——支持嵌套、默认值、as 别名、map 解构的 :or 默认值等,而且是语言内建的,不依赖工具链。
JS 表达不了的东西
1. 宏(Macro System)
这是 ClojureScript 表达力的核心。也是 JS/TS 真正做不到的事情。
JS 的困境:代码重复。你写过这种东西吗?
// 处理表单验证的重复代码
function validateUser(data) {
const errors = {};
if (!data.name) errors.name = 'required';
if (!data.email) errors.email = 'required';
if (!data.email?.includes('@')) errors.email = 'invalid';
if (!data.age || data.age < 0) errors.age = 'invalid';
return errors;
}
function validateProduct(data) {
const errors = {};
if (!data.name) errors.name = 'required';
if (!data.price || data.price < 0) errors.price = 'invalid';
return errors;
}
// 重复的 errors 模式,每个验证函数都写一遍
你可以用高阶函数或装饰器减少一些重复,但结构性的重复很难消除——因为 JS 的抽象能力只到”函数”这一层。
ClojureScript 的宏:代码即数据,宏是操作代码的函数。
;; 定义一个宏,生成验证代码
(defmacro defvalidator [name & field-specs]
`(defn ~name [data#]
(let [errors# {}]
~(reduce (fn [acc [field check msg]]
`(if ~check ~acc (assoc ~acc ~field ~msg)))
'errors#
(partition 3 field-specs)))))
;; 使用:一行定义一个验证器
(defvalidator validate-user
:name (nil? (:name data#)) "required"
:email (nil? (:email data#)) "required"
:age (neg? (:age data#)) "invalid")
(defvalidator validate-product
:name (nil? (:name data#)) "required"
:price (neg? (:price data#)) "invalid")
宏在编译时展开,生成你手写会嫌麻烦的代码。运行时没有额外开销。
关键区别:
| JS/TS | ClojureScript | |
|---|---|---|
| 抽象层次 | 函数、类、泛型 | 函数、宏 |
| 代码生成 | 运行时 eval(不安全、慢) | 编译时宏展开(安全、零开销) |
| 元编程 | 需要 Babel/AST 工具链 | 内建的 defmacro |
| 模板字符串 | `${x}`(字符串拼接) | 准引用 `(~x)(代码拼接) |
TypeScript 的类型系统很强,但它操作的是类型,不是代码。宏操作的是代码本身——你可以用宏发明新的控制流、新的语法糖、新的领域语言,而且都是零开销的编译时转换。
2. 代码即数据(Homoiconicity)
ClojureScript 的代码就是它的数据结构。一个列表(list),第一个元素是函数名,后面是参数。
;; 这行代码
(+ 1 2)
;; 就是一个 list
(list '+ 1 2) ;; => (+ 1 2)
;; 代码可以被当作数据操作
(first '(+ 1 2)) ;; => +
(rest '(+ 1 2)) ;; => (1 2)
JS 里 const x = a + b 是一段文本(字符串),不是结构化的数据。要操作它你得 parse 成 AST(用 babel、acorn 等),操作完再 generate 回去。ClojureScript 里代码本身就是 AST——不需要 parse 和 generate 这两步。
这就是宏的基础:因为代码是数据,所以函数可以接收代码、变换代码、输出代码。
3. 多重分派(Multimethods)
JS 的分派靠 if-else 或 switch:
function handleEvent(event) {
switch (event.type) {
case 'click': return handleClick(event);
case 'hover': return handleHover(event);
case 'submit': return handleSubmit(event);
default: return handleDefault(event);
}
}
问题:只能按一个维度分派(event.type)。如果你想按 (event.type, event.target.tagName) 两个维度分派,就得嵌套 switch。
// 多维度分派变得很丑
function handleEvent(event) {
const key = `${event.type}:${event.target.tagName}`;
switch (key) {
case 'click:BUTTON': ...
case 'click:A': ...
case 'hover:DIV': ...
}
}
ClojureScript 的 defmulti:
;; 定义分派函数,按两个维度分派
(defmulti handle-event
(fn [event] [(:type event) (-> event :target :tag)]))
;; 为每种组合实现处理逻辑,可以散落在不同文件
(defmethod handle-event [:click :button] [event]
;; 处理按钮点击
)
(defmethod handle-event [:click :a] [event]
;; 处理链接点击
)
(defmethod handle-event [:hover :div] [event]
;; 处理 div 悬停
)
;; 默认处理
(defmethod handle-event :default [event]
;; fallback
)
特点:
- 任意维度分派:分派函数可以返回任何值(类型、向量、计算结果),不受限制
- 开放扩展:新的
defmethod可以加在任何地方、任何文件,不需要改原来的代码。不像 switch 那样必须改同一个函数 - 层次结构:分派值可以有继承关系,支持
derive定义层级
这比 visitor 模式、策略模式都更直接——语言直接支持,不需要设计模式的样板代码。
5. 命名空间和模块系统
// JS:模块是文件级的
// utils.js
export function add(a, b) { return a + b; }
// app.js
import { add } from './utils.js';
;; ClojureScript:命名空间是声明的,不依赖文件路径
(ns my-app.utils)
(defn add [a b] (+ a b))
;; 另一个文件
(ns my-app.core
(:require [my-app.utils :as u]))
(u/add 1 2)
JS 的模块系统和文件路径强绑定(或 bundler 的 resolve 配置)。ClojureScript 的命名空间是逻辑名——文件路径只是惯例(my-app.utils → my_app/utils.cljs),但不强制。你可以重组文件结构而不改命名空间。
更重要的是,命名空间比模块更强大:
(ns my-app.core
(:require [my-app.utils :as u] ;; 别名
[my-app.config :refer [db]] ;; 直接引入
[reagent.core :as r]))
require 不仅仅引入值,还能引入宏:
(ns my-app.core
(:require-macros [my-app.macros :refer [defvalidator]]))
4. Protocol、Record、reify — 运行时抽象
JS/TS 的 interface 编译后就没了。Clojure 的 defprotocol 运行时存在——可以做运行时分派和检查。
defprotocol:定义接口
(defprotocol Drawable
(draw [this])
(area [this]))
defrecord:数据+行为合一
(defrecord Circle [radius]
Drawable
(draw [this] (str "Circle r=" radius))
(area [this] (* Math/PI radius radius)))
(defrecord Rect [width height]
Drawable
(draw [this] (str "Rect " width "x" height))
(area [this] (* width height)))
(draw (->Circle 5)) ;; => "Circle r=5"
(area (->Rect 3 4)) ;; => 12
;; record 同时是 map,支持所有 map 操作
(:radius (->Circle 5)) ;; => 5
(assoc (->Circle 5) :color :red) ;; => {:radius 5, :color :red}
JS 里你得在 class 和 plain object 之间选一个。Clojure 的 record 既是 map 又有行为——数据和面向对象不冲突。
reify:匿名实现(就地创建)
(defn make-handler [on-click]
(reify
Drawable
(draw [this] "custom drawable")
Serializable
(serialize [this] {:type "handler" :click on-click})))
(draw (make-handler nil)) ;; => "custom drawable"
类似 JS 的对象字面量,但有运行时类型检查:
(satisfies? Drawable (make-handler nil)) ;; => true
(satisfies? Drawable "just a string") ;; => false
JS 没有这个能力——handler instanceof Drawable 不存在。
extend-type:事后扩展已有类型
;; 给字符串追加 Drawable 实现,不需要改原始定义
(extend-type string
Drawable
(draw [this] (str "Text: " this))
(area [this] (count this)))
(draw "hello") ;; => "Text: hello"
(area "hello") ;; => 5
JS 里给 String.prototype 加方法会污染全局,给别人的 class 加接口做不到。Clojure 的 extend-type 安全地做这件事。
deftype:底层实现(日常较少用)
;; 比 defrecord 更底层,不自动实现 map 接口
;; 可以手动控制可变性
(deftype MutableCounter [^:mutable count]
Object
(inc [this] (set! count (clojure.core/inc count)))
(get [this] count))
Reagent 的 RAtom 就是用 deftype 实现的——需要可变性或性能优化时用。日常用 defrecord 更多。
抽象能力对比:
| JS/TS | ClojureScript | |
|---|---|---|
| 接口 | interface(编译时擦除) | defprotocol(运行时存在) |
| 数据+行为 | class 或 object,二选一 | defrecord(map + 行为合一) |
| 匿名实现 | 对象字面量(鸭子类型) | reify(运行时可检查) |
| 事后扩展 | 修改 prototype(不安全) | extend-type(安全) |
| 多实现 | class extends(单继承) | 任意组合多个 protocol |
你会喜欢的部分
线程宏(Threading Macros)
这是从 JS 过来最容易感受到”啊这就对了”的特性。
// JS:嵌套调用,从内向外读
const result = format(
filter(
map(data, x => x.value),
x => x > 10
),
'json'
);
;; ClojureScript:thread-first,从上向下读
(->> data
(map :value)
(filter #(> % 10))
(format :json))
->>(thread-last)把每一步的结果作为最后一个参数传给下一步。->(thread-first)传给第一个参数。这让数据管道的阅读顺序和执行顺序一致——从上到下,而不是从内到外。
JS 的 Promise 链解决了异步的嵌套问题,但同步操作的嵌套问题一直存在。线程宏解决了它。
条件线程
;; 只在条件满足时才塞进管道
(->> data
(some-pred? then-process)
;; 如果 some-pred? 返回 nil,整个值为 nil,后续步骤不执行
)
;; cond->:条件性地变换
(cond-> request
logged-in? (assoc :user current-user)
admin? (assoc :role :admin)
trial? (assoc :plan :trial))
JS 里你得写一堆 if:
let req = { ...request };
if (loggedIn) req = { ...req, user: currentUser };
if (admin) req = { ...req, role: 'admin' };
if (trial) req = { ...req, plan: 'trial' };
解构无处不在
不只是 let,函数参数、for 循环、doseq、if-let 到处都能解构:
;; 函数参数直接解构
(defn create-user [{:keys [name email] :or {email "none"}}]
{:name name :email email})
;; if-let:绑定 + 判断
(if-let [result (try-parse input)]
(handle result)
(handle-error))
JS 的解构只能在特定语法位置用(声明、参数、for-of)。ClojureScript 的解构在任何绑定位置都能用。
Spec:声明式校验
(require '[clojure.spec.alpha :as s])
(s/def ::email (s/and string? #(re-matches? #".+@.+\..+" %)))
(s/def ::user (s/keys :req [::name ::email] :opt [::age]))
;; 自动校验
(s/valid? ::user {::name "foo" ::email "foo@bar.com"}) ;; => true
(s/valid? ::user {::name "foo"}) ;; => false,缺 email
;; 自动生成测试数据
(s/gen ::user) ;; => {::name "G2h3k" ::email "a8@b9.co"}
TypeScript 的类型在运行时不存在(编译后擦除)。ClojureScript 的 Spec 是运行时的——可以校验数据、生成测试数据、解释校验失败原因。它不是类型系统的替代品,而是数据契约系统。
你需要适应的部分
前缀表示法
(+ 1 2) ;; 不是 1 + 2
(= a b) ;; 不是 a === b
(and x y z) ;; 不是 x && y && z
(.length str) ;; 不是 str.length
刚开始会觉得别扭。但前缀表示法有一个巨大的好处:没有运算符优先级。
// JS 的优先级陷阱
1 + 2 * 3 // 7,不是 9
a && b || c // 是 (a && b) || c 还是 a && (b || c)?
;; ClojureScript:没有优先级问题,永远从左到右
(+ 1 (* 2 3)) ;; 7
(* (+ 1 2) 3) ;; 9
;; 嵌套关系一目了然
括号多
Lisp 的括号是出了名的。但注意——括号是唯一你需要关心的语法。没有花括号、没有分号、没有逗号、没有关键字块的 end。所有结构都用括号表达。
;; 一个完整的函数
(defn greet [name]
(println (str "Hello, " name)))
;; 结构: (defn name [params] body)
;; 就这么简单,没有其他语法需要记
vs
// JS
function greet(name) {
console.log(`Hello, ${name}`);
}
// 关键字 function、花括号 {}、圆括号 ()、分号 ;、模板字符串 ``
空值只有 nil
;; 没有 null vs undefined 的困惑
;; nil 就是 nil,没有第二个"空"值
(if nil "truthy" "falsy") ;; => "falsy"
(if false "truthy" "falsy") ;; => "falsy"
(if 0 "truthy" "falsy") ;; => "truthy"!0 是 truthy
(if "" "truthy" "falsy") ;; => "truthy"!空字符串是 truthy
(if [] "truthy" "falsy") ;; => "truthy"!空数组是 truthy
ClojureScript 里只有 nil 和 false 是 falsy。0、""、[] 都是 truthy。这避免了 JS 里 if (x) 的一整类 bug。
和 JS 生态的互操作
ClojureScript 编译到 JS。可以直接用任何 JS 库。
;; 直接用 React
(ns my-app.core
(:require [react :as react]
[react-dom/client :as rdom]))
(defn app []
(let [root (rdom/createRoot (.querySelector js/document "#app"))]
(rdom/render root (react/createElement "div" nil "Hello"))))
不用 FFI、不用 binding、不用 wrapper。JS 的值在 ClojureScript 里就是普通值。
值不值得学
值得,如果你:
- 觉得 JS/TS 的表达力开始不够了
- 想要宏、多重分派、代码即数据这些能力
- 写了大量 React/JS 但觉得状态管理、不可变数据到处都是心智负担
- 对函数式编程有兴趣但 Haskell/ML 太学术了
可以先等等,如果你:
- 团队全是 JS/TS 开发者,没有 Clojure 生态需求
- 需要快速出活,没有时间学新语言
- 只做前端,ClojureScript 的工具链(shadow-cljs)配置比 Vite/Webpack 重
建议的路径:
- 跟着这篇指南把基础语法过一遍
- 读 Clojure for the Brave and True(免费在线)
- clone Reagent,看它的源码——2700 行的 ClojureScript 实战教材
- 用 ClojureScript 写一个小项目(比如一个 CLI 工具或简单的 web 应用)
- 感受宏的威力后,再决定要不要深入
参考资源
- Clojure for the Brave and True — 最友好的入门书
- ClojureScript Docs — 官方文档
- shadow-cljs — 构建工具(替代 webpack)
- ClojureScript Cheatsheet — 快速参考