ClojureScript 全栈开发:reagent、re-frame 与 shadow-cljs 实战

深入 ClojureScript 全栈开发:reagent 的函数式 React 封装与 Hiccup 语法、re-frame 单向数据流事件循环、effects/coeffects 副作用建模、reg-event-fx/reg-sub 订阅体系、与 Ring/reitit 后端的 API 通信,以及 shadow-cljs 构建配置与 CLJC 代码共享,附完整可运行的全栈 CRUD 示例。

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-httpClojure 风格包装,XHR
axiosnpm 生态,拦截器丰富
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 必须在内层渲染函数调用
忘记 :keyReact 警告、渲染错位for 循环加 ^{:key (:id item)}
npm 依赖未声明编译报找不到模块在 shadow-cljs.edn 声明 :npm-modules

8.2 最佳实践

  1. 事件是动作,订阅是查询:事件命名用动词(::add-todo),订阅命名用名词(::todos)。
  2. 处理器保持纯函数:所有外部读取用 coeffects,写入用 effects,处理器天然可单测。
  3. db 形状扁平化:避免深嵌套,用 assoc-in/update-in 维护。
  4. 订阅组合派生:用 :<- 依赖组合出视图需要的精确数据,避免组件各自派生。
  5. 共享 .cljc:校验、领域常量、日期格式化放共享目录,前后端单一事实源。
  6. 错误路径必测:为每个 on-failure 事件编写 UI 兜底(错误提示、重试)。

9. 总结

层次技术职责
组件层reagent + Hiccup纯函数组件、响应式渲染
状态层re-frame事件、订阅、app-db 单向数据流
副作用层effects/coeffectsAPI 调用、定时器、外部依赖隔离
通信层js/fetch + re-frame fx与后端 REST/EDN API 交互
构建层shadow-cljs编译、热重载、npm 集成
共享层.cljc + reader conditionals前后端复用业务逻辑

re-frame 的事件循环把「用户交互 → 状态变化 → UI 更新」收敛为一条清晰、可测、可调试的单向数据流,这是它相比裸 React 的核心优势。配合 shadow-cljs 的现代构建与 .cljc 代码共享,Clojure 全栈不再是「两个项目两套语言」,而是一个语言、一套数据模型贯穿前后端。后端与部署细节可进一步参考 Clojure 现代 Web 全栈开发。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「clojure」更多文章

  1. Clojure 领域建模与事件溯源:DDD、CQRS 与函数式实现
  2. Clojure 性能优化与 GraalVM 原生编译
  3. Clojure 生成式测试实战:test.check、收缩与 spec 集成