给 20 年 JS/TS 工程师的 ClojureScript 指南

2026/4/5
ClojureScriptJavaScriptTypeScript函数式编程语言设计

你为什么在这里

你写了 20 年 JavaScript。你经历过 callback hell、Promise 革命、async/await、ES6 模块化、TypeScript 的类型系统。你对原型链、闭包、事件循环的理解已经刻进肌肉记忆了。

但你开始觉得表达力不够了

  • 想写一个宏来消除重复代码?TypeScript 的类型体操能做一部分,但运行时的代码生成你得靠 eval 或代码字符串拼接
  • 想做模式匹配?switch/case 太弱了,得靠一堆 if-else 或外部库
  • 想让数据不可变?得手动 Object.freezeimmer、或 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/TSClojureScript
抽象层次函数、类、泛型函数、宏
代码生成运行时 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
  )

特点

  1. 任意维度分派:分派函数可以返回任何值(类型、向量、计算结果),不受限制
  2. 开放扩展:新的 defmethod 可以加在任何地方、任何文件,不需要改原来的代码。不像 switch 那样必须改同一个函数
  3. 层次结构:分派值可以有继承关系,支持 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.utilsmy_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/TSClojureScript
接口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 循环、doseqif-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 里只有 nilfalse 是 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 重

建议的路径

  1. 跟着这篇指南把基础语法过一遍
  2. Clojure for the Brave and True(免费在线)
  3. clone Reagent,看它的源码——2700 行的 ClojureScript 实战教材
  4. 用 ClojureScript 写一个小项目(比如一个 CLI 工具或简单的 web 应用)
  5. 感受宏的威力后,再决定要不要深入

参考资源

📝 文章反馈