ClojureScript(CLJS)把 Clojure 的函数式体验带到了浏览器与 Node.js。它并非「另一套 React 封装」——reagent 用不可变数据与 Hiccup 语法重新诠释了 React 组件,re-frame 则把应用状态管理归纳为一条清晰的单向数据流,而 shadow-cljs 解决了现代前端构建的配置复杂度。本文将从 reagent 组件、re-frame 事件循环,到与 Clojure 后端通信、shadow-cljs 构建,完整搭建一个全栈应用。
后端 Ring/reitit 部分可参考 Clojure 现代 Web 全栈开发,CLJS 编译原理可参考 Clojure 工具链演进。
1. ClojureScript 生态概览
1.1 编译目标与工具链
ClojureScript 编译为 JavaScript,核心编译器输出经过 Google Closure Compiler(不是 React 的 Closure!)优化:
| 构建工具 | 特点 | 适合 |
|---|---|---|
| shadow-cljs | 现代默认选择,npm 集成、热重载、多 target | 几乎所有 CLJS 项目 |
| Figwheel Main | 热重载体验极佳 | 纯 CLJS 项目 |
| Leiningen + lein-cljsbuild | 传统方案 | 已有 Lein 项目 |
| 官方 cljs.main | 命令行 REPL | 学习/调试 |
1.2 reagent:React 的函数式封装
reagent 把 React 组件简化为纯函数 + Hiccup 向量:
;; 一个组件就是一个返回 Hiccup 向量的函数
(defn greeting [name]
[:div
[:h1 (str "Hello, " name "!")]
[:p "Welcome to ClojureScript."]])
Hiccup 语法:[:tag {:attr val} child...],渲染时自动对应 React.createElement。组件函数只接受 props,返回数据描述——没有 this、没有类、没有生命周期样板。
;; 嵌套组件与事件
(defn counter [n]
[:button {:on-click #(js/alert (str "Clicked " n " times"))}
(str "Click me (" n ")")])
2. reagent 组件基础
2.1 响应式状态:r/atom
reagent 的 r/atom 是「reactive atom」:任何组件读取它,值变化时组件自动重渲染:
(require '[reagent.core :as r])
(defn counter-component []
(let [count (r/atom 0)]
(fn []
[:div
[:p "Current: " @count]
[:button {:on-click #(swap! count inc)} "+"]
[:button {:on-click #(swap! count dec)} "-"]])))
关键模式:外层函数返回内层函数。外层只执行一次(初始化 r/atom),内层函数在每次渲染时执行(读取最新值)。若 r/atom 定义在内层,每次渲染都会重置状态。
2.2 生命周期与 React 互操作
;; :component-did-mount 等生命周期可通过 meta 或 reactify-component 获得
(defn with-timer []
(let [now (r/atom (js/Date.))]
(r/create-class
{:component-did-mount
(fn [this]
(js/setInterval #(reset! now (js/Date.)) 1000))
:component-will-unmount
(fn [this] (js/clearInterval @interval))
:reagent-render
(fn [] [:p @now])})))
;; 嵌入真实 React 组件
(defn use-material-ui [props]
(r/create-class
{:display-name "MuiButton"
:reagent-render (fn [props]
(let [^js/ReactComponent el (.-default js/require "@mui/material/Button")]
(r/reactify-component el props)))}))
2.3 Form 输入与受控组件
(defn search-box []
(let [text (r/atom "")]
(fn []
[:input {:type "text"
:value @text
:placeholder "Search..."
:on-change (fn [e]
(reset! text (-> e .-target .-value)))}])))
3. re-frame:单向数据流架构
re-frame 把应用状态建模为 app-db(一个不可变 atom),所有状态变更必须经过事件(event)、订阅(subscription)与副作用(effect)三层。
3.1 事件循环六步
re-frame 的完整数据流:
1. Event Dispatch : 用户交互触发 (dispatch [:event-name args])
2. Event Handler : (reg-event-db :event-name f) 纯函数计算新状态
3. App-DB Update : 不可变 app-db 被替换
4. Subscription : (reg-sub :query-name f) 组件声明依赖
5. View Re-render : reagent 组件读取订阅,自动重渲染
6. Effects : 副作用(API 调用、路由跳转)通过 effect 声明
交互 → dispatch → handler(纯函数) → app-db → subscribe → 组件重渲染
│ ↑
└────────── effects(异步回调再 dispatch)──────────┘
3.2 单一数据源 app-db
(ns myapp.core
(:require [reagent.core :as r]
[re-frame.core :as rf]))
;; 初始状态:全部应用状态集中在一个 map
(rf/reg-event-db
::initialize-db
(fn [_ _]
{:current-user nil
:todos []
:loading? false
:error nil}))
3.3 事件处理器:reg-event-db / reg-event-fx
纯事件处理器(只有状态变化,无副作用)用 reg-event-db:
(rf/reg-event-db
::add-todo
(fn [db [_ text]]
(update db :todos conj {:id (random-uuid)
:text text
:done false})))
(rf/reg-event-db
::toggle-todo
(fn [db [_ id]]
(update db :todos
(fn [todos]
(mapv #(if (= (:id %) id)
(update % :done not)
%)
todos)))))
带副作用的处理器用 reg-event-fx,返回值是一个 effect map:
(rf/reg-event-fx
::load-todos
(fn [{:keys [db]} _]
{:db (assoc db :loading? true)
:fx [[:http/get {:url "/api/todos"
:on-success [::load-todos-success]
:on-failure [::load-todos-failure]}]]}))
3.4 订阅与派生数据:reg-sub
;; 基础订阅:直接取 db 子状态
(rf/reg-sub
::todos
(fn [db _] (:todos db)))
;; 带输入参数的订阅:筛选
(rf/reg-sub
::visible-todos
:<- [::todos] ; 依赖其它订阅
:<- [::filter]
(fn [[todos filter] _]
(case filter
:active (filterv (complement :done) todos)
:done (filterv :done todos)
:all todos)))
组件通过 (rf/subscribe [::visible-todos :active]) 获取反应式数据:
(defn todo-list []
(let [todos (rf/subscribe [::visible-todos :active])]
(fn []
[:ul
(for [todo @todos]
^{:key (:id todo)}
[:li {:on-click #(rf/dispatch [::toggle-todo (:id todo)])}
(:text todo)])])))
subscribe 返回一个 reagent reaction——@todos 读取即订阅,数据变化自动触发组件重渲染。
3.5 组件连接 app-db
(defn todo-app []
(let [todos (rf/subscribe [::visible-todos :all])
loading? (rf/subscribe [::loading?])]
(fn []
[:div
[:h1 "Todos"]
(when @loading? [:p "Loading..."])
(for [todo @todos] ...)
[:input {:on-key-down #(when (= (.-key %) "Enter")
(rf/dispatch [::add-todo (-> % .-target .-value)])
(set! (-> % .-target .-value) ""))}]])))
4. effects / coeffects:副作用建模
4.1 内置 effects
| Effect | 用途 |
|---|---|
:db | 更新 app-db(最常用) |
:dispatch | 派发另一个事件 |
:dispatch-later | 延迟派发 |
:dispatch-n | 派发多个事件 |
:fx | 组合 effect 向量 |
(rf/reg-event-fx
::login
(fn [{:keys [db]} [_ credentials]]
{:db (assoc db :loading? true)
:dispatch [:api/login credentials]
:dispatch-later [{:ms 5000 :dispatch [:session/expired]}]}))
4.2 自定义 effects
注册一个全局可复用的 :http/get effect:
(rf/reg-fx
:http/get
(fn [{:keys [url on-success on-failure]}]
(-> (js/fetch url)
(.then #(.json %))
(.then (fn [data]
(rf/dispatch (conj on-success data))))
(.catch (fn [err]
(rf/dispatch (conj on-failure (.-message err))))))))
4.3 coeffects:读取外部世界
coeffects(输入)与 effects(输出)相对。注册一个提供当前时间戳的 coeffect:
(rf/reg-cofx
:now
(fn [coeffects _]
(assoc coeffects :now (js/Date.now))))
(rf/reg-event-fx
::save-todo
(fn [{:keys [db now]} [_ text]] ; now 由 coeffect 注入
{:db (update db :todos conj {:text text :created-at now})}))
reg-event-fx 处理器的第一个参数就是 coeffect map,默认至少包含 :db。通过 inject-cofx 在事件中引入更多上下文(时间、随机数、localStorage),让处理器保持纯函数可测性。
5. 与后端 API 通信
5.1 HTTP 客户端选择
| 客户端 | 特点 |
|---|---|
原生 js/fetch | 零依赖,现代浏览器内置 |
cljs-http | Clojure 风格包装,XHR |
axios | npm 生态,拦截器丰富 |
aleph / http-kit(Node) | 服务端 CLJS 用 |
推荐 js/fetch 起步——不需要 npm 依赖,re-frame effect 里包一层即可。
5.2 异步事件流模式
re-frame 的标准异步模式:请求事件 → effect 发请求 → 成功/失败事件 → 更新 db:
(rf/reg-event-fx
::fetch-user
(fn [{:keys [db]} [_ user-id]]
{:fx [[:http/get
{:url (str "/api/users/" user-id)
:on-success [::fetch-user-success]
:on-failure [::fetch-user-failure]}]]}))
(rf/reg-event-db
::fetch-user-success
(fn [db [_ user]]
(assoc db :current-user user :loading? false)))
(rf/reg-event-db
::fetch-user-failure
(fn [db [_ error]]
(assoc db :error error :loading? false)))
5.3 与 Ring/reitit 后端对接
后端返回 JSON,前端解析为 Clojure 数据。注意 :body 解析与错误处理:
(rf/reg-fx
:api/get-json
(fn [{:keys [url on-success on-failure]}]
(-> (js/fetch url #js {:headers #js {"Accept" "application/json"}
:credentials "same-origin"})
(.then (fn [resp]
(if (.-ok resp)
(.json resp)
(throw (js/Error. (str "HTTP " (.-status resp)))))))
(.then (fn [data] (rf/dispatch (conj on-success (js->clj data :keywordize-keys true)))))
(.catch (fn [err] (rf/dispatch (conj on-failure (.-message err))))))))
;; 事件触发
(rf/dispatch [::fetch-user 42])
后端若同时支持 EDN(见 Clojure 现代 Web 全栈开发 的 muuntaja 内容协商),可以免去 js->clj 转换,直接拿 Clojure 数据:
;; 客户端 Accept: application/edn → 服务端返回 EDN
;; 用 cljs.reader/read-string 解析即可
5.4 防抖、取消与竞态
;; 简单防抖:dispatch-later 延迟,取消防抖事件
(rf/reg-event-fx
::search
(fn [{:keys [db]} [_ q]]
{:db (assoc db :search-q q)
:dispatch-later [{:ms 300 :dispatch [::perform-search q]}]}))
;; 竞态防护:请求带 request-id,只接受最新
(rf/reg-event-fx
::fetch-user
(fn [{:keys [db]} [_ user-id]]
{:db (assoc db :request-id user-id)
:fx [[:http/get {:url ...
:on-success [::fetch-user-success user-id]}]]}))
(rf/reg-event-db
::fetch-user-success
(fn [db [_ req-id user]]
;; 只有当 req-id 仍是最新请求时才应用结果
(if (= req-id (:request-id db))
(assoc db :current-user user :loading? false)
db)))
6. shadow-cljs 构建
6.1 项目配置
;; shadow-cljs.edn(项目根目录)
{:source-paths ["src" "test"]
:dependencies [[reagent "1.2.0"]
[re-frame "1.4.3"]]
:dev-http {8080 {:root "public"
:proxy-url "/api" "http://localhost:3000"}}
:builds
{:app {:target :browser
:modules {:app {:init-fn myapp.core/init}}
:compiler-options {:closure-defines {goog.DEBUG false}}}
:node-tests {:target :node-test
:autorun true}}}
;; deps.edn 只需指向 shadow-cljs
{:deps {thheller/shadow-cljs {:mvn/version "2.28.x"}}}
6.2 常用命令
# 开发模式(带热重载 watch)
npx shadow-cljs watch app
# 生产编译(压缩)
npx shadow-cljs release app
# REPL 连接(浏览器 eval)
npx shadow-cljs browser-repl
# 运行 Node 端测试
npx shadow-cljs test node-tests
dev-http 同时提供了静态文件服务与 /api 代理——前端开发时请求 /api/* 自动转发到本机后端,免去跨域配置。
6.3 多 target 与 npm 依赖
;; npm 依赖声明在 shadow-cljs.edn 的 :dependencies 同级 :npm-modules
{:dependencies [...]
:npm-modules {:axios "1.6.0"
:react "18.2.0"}
:builds {:app {:target :browser
:modules {:app {:init-fn myapp.core/init}}}}}
;; 在 CLJS 中引入 npm 模块
(ns myapp.api
(:require ["axios" :as axios]))
6.4 CLJC:前后端共享代码
.cljc 文件同时服务 CLJ 与 CLJS,用 reader conditionals 区分:
;; src/shared/validation.cljc
(ns shared.validation)
(defn valid-email? [email]
(boolean (re-matches #".+@.+\..+" email)))
;; 平台差异化实现
(defn now-ms []
#?(:clj (System/currentTimeMillis)
:cljs (js/Date.now)))
;; 前后端共用表单校验
(defn validate-user [{:keys [name email]}]
(cond-> []
(clojure.string/blank? name) (conj "Name required")
(not (valid-email? email)) (conj "Invalid email")))
;; deps.edn 需要把共享源码加入两个平台的 source-paths
{:paths ["src" "shared"]
:aliases {:cljs {:extra-paths ["shared"]}}}
这套共享机制让校验逻辑、领域规则、状态模型在前后端只写一次,是全栈 Clojure 的重要红利。
7. 全栈实战:完整 CRUD 示例
7.1 后端(Ring + reitit + next.jdbc)
;; src/myapp/server.clj
(ns myapp.server
(:require [reitit.ring :as ring]
[reitit.ring.middleware.muuntaja :as muuntaja]
[muuntaja.core :as m]
[next.jdbc :as jdbc]
[next.jdbc.sql :as sql]))
(def ds (jdbc/get-datasource {:dbtype "postgresql" :dbname "todos"}))
(def routes
["/api"
["/todos"
{:get {:handler (fn [_] {:status 200 :body (sql/query ds ["SELECT * FROM todos"])})}
:post {:handler (fn [req]
(let [{:keys [text]} (:body-params req)]
{:status 201
:body (sql/insert! ds :todos {:text text})}))}}]
["/todos/:id"
{:get {:handler (fn [{:keys [path-params]}]
(sql/get-by-id ds :todos (Long/parseLong (:id path-params))))}
:delete {:handler (fn [{:keys [path-params]}]
(sql/delete! ds :todos {:id (Long/parseLong (:id path-params))})
{:status 204})}}]])
(def app (ring/ring-handler (ring/router routes {:data {:muuntaja m/instance
:middleware [muuntaja/format-middleware]}})))
7.2 前端(re-frame + reagent)
;; src/myapp/core.cljs
(ns myapp.core
(:require [reagent.core :as r]
[re-frame.core :as rf]
[reagent.dom.client :as rdom]))
;; --- Events ---
(rf/reg-event-fx
::load-todos
(fn [{:keys [db]} _]
{:db (assoc db :loading? true)
:fx [[:http/get {:url "/api/todos"
:on-success [::load-todos-success]}]]}))
(rf/reg-event-db
::load-todos-success
(fn [db [_ todos]]
(assoc db :todos todos :loading? false)))
(rf/reg-event-fx
::add-todo
(fn [{:keys [db]} [_ text]]
{:db (assoc db :adding? true)
:fx [[:http/post {:url "/api/todos"
:body {:text text}
:on-success [::load-todos]}]]
:dispatch [::clear-input]}))
;; --- Subscriptions ---
(rf/reg-sub ::todos (fn [db _] (:todos db)))
(rf/reg-sub ::loading? (fn [db _] (:loading? db)))
;; --- Views ---
(defn todo-item [todo]
[:li {:key (:id todo)}
(:text todo)
[:button {:on-click #(rf/dispatch [::delete-todo (:id todo)])} "✕"]])
(defn app []
(let [todos (rf/subscribe [::todos])
loading? (rf/subscribe [::loading?])]
(fn []
[:div
[:h1 "Clojure Fullstack Todos"]
(when @loading? [:p "Loading..."])
[:ul (map todo-item @todos)]
[:input {:id "todo-input"}]]
[:button {:on-click #(rf/dispatch
[::add-todo (-> (js/document.getElementById "todo-input") .-value)])}
"Add"]])))
(defonce root (rdom/create-root (.getElementById js/document "app")))
(defn init []
(rf/dispatch-sync [::initialize-db])
(rf/dispatch [::load-todos])
(rdom/render [app] root))
7.3 启动
# 终端 1:后端
clojure -M:backend -m myapp.server
# 终端 2:前端
npx shadow-cljs watch app
# 浏览器打开 http://localhost:8080
8. 常见陷阱与最佳实践
8.1 陷阱清单
| 陷阱 | 症状 | 解决方案 |
|---|---|---|
r/atom 定义在内层函数 | 状态每次渲染被重置 | 外层函数初始化,内层函数读取 |
| 在事件处理器做副作用 | 难测试、状态混乱 | 副作用全部走 effects |
直接 swap! app-db | 绕过 re-frame 流程 | 一律 dispatch 事件 |
| 订阅在渲染外调用 | 组件不响应更新 | subscribe 必须在内层渲染函数调用 |
忘记 :key | React 警告、渲染错位 | for 循环加 ^{:key (:id item)} |
| npm 依赖未声明 | 编译报找不到模块 | 在 shadow-cljs.edn 声明 :npm-modules |
8.2 最佳实践
- 事件是动作,订阅是查询:事件命名用动词(
::add-todo),订阅命名用名词(::todos)。 - 处理器保持纯函数:所有外部读取用 coeffects,写入用 effects,处理器天然可单测。
- db 形状扁平化:避免深嵌套,用
assoc-in/update-in维护。 - 订阅组合派生:用
:<-依赖组合出视图需要的精确数据,避免组件各自派生。 - 共享
.cljc:校验、领域常量、日期格式化放共享目录,前后端单一事实源。 - 错误路径必测:为每个
on-failure事件编写 UI 兜底(错误提示、重试)。
9. 总结
| 层次 | 技术 | 职责 |
|---|---|---|
| 组件层 | reagent + Hiccup | 纯函数组件、响应式渲染 |
| 状态层 | re-frame | 事件、订阅、app-db 单向数据流 |
| 副作用层 | effects/coeffects | API 调用、定时器、外部依赖隔离 |
| 通信层 | js/fetch + re-frame fx | 与后端 REST/EDN API 交互 |
| 构建层 | shadow-cljs | 编译、热重载、npm 集成 |
| 共享层 | .cljc + reader conditionals | 前后端复用业务逻辑 |
re-frame 的事件循环把「用户交互 → 状态变化 → UI 更新」收敛为一条清晰、可测、可调试的单向数据流,这是它相比裸 React 的核心优势。配合 shadow-cljs 的现代构建与 .cljc 代码共享,Clojure 全栈不再是「两个项目两套语言」,而是一个语言、一套数据模型贯穿前后端。后端与部署细节可进一步参考 Clojure 现代 Web 全栈开发。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。