Reagent 源码解析:ClojureScript 如何优雅地封装 React

2026/4/5
ClojureScriptReact函数式编程源码分析Reagent

Reagent 是什么

Reagent 是 ClojureScript 对 React 的精简封装。不引入新概念,不造新范式,就做三件事:

  1. Hiccup 语法写 UI(用 vector 代替 JSX)
  2. Clojure 函数即组件(不用 class、不用 hooks)
  3. 响应式 atomderef 自动触发重渲染)

核心代码 2700 行,没有黑魔法。

本文基于 Reagent master 分支源码分析。项目已 clone 到本地 ~/work/reagent


整体架构

src/reagent/
├── core.cljs              ← 公开 API(atom, cursor, track, render)
├── ratom.cljs (643行)     ← 响应式原子(核心魔法)
├── hooks.cljs (209行)     ← React Hooks 适配
├── impl/
│   ├── template.cljs (353行) ← Hiccup → React Element 转换
│   ├── component.cljs (513行) ← Clojure 函数 → React 组件桥接
│   ├── batching.cljs (138行) ← 批量渲染调度
│   ├── util.cljs (250行)     ← 工具函数
│   └── input.cljs (166行)    ← input 受控组件处理
└── dom.cljs (80行)           ← render 函数(挂载到 DOM)

数据流:

用户写的 Hiccup (vector)
    ↓ as-element
    ↓ vec-to-elem → 解析 tag

┌───────────────────────────────────────┐
│ HTML tag?     → native-element        │
│   → parse-tag (正则提取 id/class)     │
│   → convert-props (Clojure map→JS)    │
│   → React.createElement               │
│                                       │
│ Clojure fn?  → reag-element           │
│   → create-class (包装成 React class) │
│   → 或 functional-render-fn (hooks)   │
│                                       │
│ React组件?   → adapt-react-class      │
│   → NativeWrapper 直接透传            │
└───────────────────────────────────────┘

React Element

下面逐个拆解。


1. Hiccup → React Element(template.cljs)

Reagent 让你这样写 UI:

[:div {:class "foo"} 
  [:h1 "Hello"]
  [:p "World"]]

而不是:

React.createElement('div', {className: "foo"},
  React.createElement('h1', null, "Hello"),
  React.createElement('p', null, "World"))

入口:as-element

(defn as-element [this x fn-to-element]
  (cond (util/js-val? x) x                ;; JS 原始值直接返回
        (vector? x) (vec-to-elem x this fn-to-element)  ;; ← 核心:Hiccup vector
        (seq? x) (expand-seq x this)       ;; 列表展开为数组
        (named? x) (name x)                ;; keyword → 字符串
        :else x))                          ;; 其他原样返回

这就是整个转换——一个递归分发器。vector 走 Hiccup 解析,seq 展开为数组,其他直接返回。

Tag 解析:parse-tag

:div#my-id.class1.class2 提取 tag、id、class:

(def re-tag #"([^\s\.#]+)(?:#([^\s\.#]+))?(?:\.([^\s#]+))?")

;; :div#main.container.active
;; → {:tag "div" :id "main" :class "container active"}

解析结果缓存到 tag-name-cache,避免每次渲染都跑正则。

Props 转换:convert-props

Clojure map → JS object,自动映射属性名:

(def prop-name-cache #js{:class "className"
                         :for "htmlFor"
                         :charset "charSet"})

;; 转换规则:
;; :class       → "className"
;; :on-click    → "onClick"    (dash → camelCase)
;; :data-value  → "data-value" (data- 开头不变)

值的转换:

(defn convert-prop-value [x]
  (cond (util/js-val? x) x          ;; number/string/boolean 不转换
        (named? x) (name x)         ;; keyword → 字符串
        (map? x) (reduce-kv kv-conv #js{} x)  ;; 嵌套 map 递归
        (coll? x) (clj->js x)       ;; 集合 → JS 数组
        (ifn? x) (fn [& args] (apply x args))  ;; Clojure fn → JS fn
        :else (clj->js x)))         ;; 兜底

自定义元素(含 - 的 tag 如 my-component)不走 className 映射,用独立的 custom-prop-name-cache

特殊 Hiccup 语法

;; :> 直接使用 React 组件
[:> ReactComponent {:prop "val"} "child"]

;; :<> Fragment
[:<> [:div "a"] [:div "b"]]

;; :f> 强制函数组件
[:f> my-fn {:arg 1}]

;; :r> 原始元素(跳过 props 转换)
[:r> some-comp {} "child"]

make-element 的优化

(defn make-element [this argv component jsprops first-child]
  (case (- (count argv) first-child)
    0 (react/createElement component jsprops)           ;; 无子元素
    1 (react/createElement component jsprops            ;; 单子元素优化
          (p/as-element this (nth argv first-child nil)))
    ;; 多个子元素用 reduce-kv + apply
    (.apply react/createElement nil ...)))

0 或 1 个子元素走了快速路径,避免数组分配。这种 micro-optimization 在 Reagent 里随处可见。


2. Clojure 函数 → React 组件(component.cljs)

Reagent 支持三种组件形式:

Form-1:函数直接返回 Hiccup

(defn greeting [name]
  [:div "Hello " name])

Form-2:函数返回闭包

(defn timer []
  (let [seconds (r/atom 0)]      ;; 外层:初始化(只跑一次)
    (fn []                        ;; 内层:render 函数
      [:div "Seconds: " @seconds])))

Reagent 怎么区分?看 render 返回值:

(defn wrap-render [c compiler]
  (let [res (调用 render 函数)]
    (cond
      (vector? res) (p/as-element compiler res)   ;; Form-1: 返回 Hiccup
      (ifn? res) (do                               ;; Form-2: 返回函数
                   (set! (.-reagentRender c) res)  ;; 替换 render 函数
                   (recur c compiler))              ;; 递归再调
      :else res)))                                  ;; 其他直接返回

巧妙:Form-2 的外层函数只在首次渲染时调用一次,返回的内层函数被存下来作为真正的 render。后续渲染直接用内层函数。这用 Clojure 的闭包自然而然地实现了 React hooks 的 state 保存效果——而且比 hooks 更直观。

Form-3:create-class

(r/create-class
  {:component-did-mount (fn [this] ...)
   :reagent-render (fn [name] [:div name])})

create-class 手搓了一个继承 React.Component 的 JS 类:

(let [cmp (fn [props context updater]
             (this-as this
               (.call react/Component this props context updater)  ;; super()
               ;; 初始化 Reagent 内部状态
               (set! (.-cljsMountOrder this) (batch/next-mount-count))
               this))]
  ;; 原型链继承
  (gobj/extend (.-prototype cmp) (.-prototype react/Component) methods)
  ;; 静态方法(getDerivedStateFromProps 等)
  (gobj/extend cmp react/Component static-methods))

不用 extends,不用 Babel,直接用 gobj/extend 拼原型链。纯 ClojureScript interop。

函数组件的 Hooks 桥接

现代 Reagent 也支持用 React 函数组件的方式工作(functional-render-fn):

(let [[_ force-update] (react/useReducer inc 0)   ;; 手动触发重渲染
      state-ref (react/useRef)]                     ;; 存储 Reagent 内部状态

  (react/useEffect
    (fn mount [] (cancel-cleanup state-ref)
     (fn unmount [] (queue-cleanup state-ref)))
    #js [])                                         ;; 空依赖 = mount/unmount

  ;; 核心:用 run-in-reaction 连接 ratom 和 React 渲染
  (if (nil? rat)
    (ratom/run-in-reaction render-fn state-ref "cljsRatom" batch/queue-render opts)
    (._run rat false)))

然后用 React.memo 包一层做 props 比较:

(defn functional-render-memo-fn [prev-props next-props]
  (= (.-argv prev-props) (.-argv next-props)))  ;; 直接比较 Hiccup vector

(react/memo f functional-render-memo-fn)

关键洞察:Clojure 的不可变数据结构让 = 比较非常高效(结构共享,先比 hash 再递归)。所以 Reagent 的 shouldComponentUpdate 本质上就是 Clojure 的 =——不需要 shouldComponentUpdate 手写、不需要 PureComponent、不需要 React.memo 的浅比较。

shouldComponentUpdate 的默认行为

:shouldComponentUpdate
(fn [nextprops nextstate]
  (let [old-argv (.. c -props -argv)
        new-argv (.-argv nextprops)]
    (not= old-argv new-argv)))  ;; Hiccup vector 的值比较

如果父组件传了同样的参数(Clojure 的 = 为 true),直接跳过渲染。这比 React 的浅比较更精确——深层嵌套的 map/vector 也能正确比较。


3. 响应式原子 ratom(ratom.cljs)

Reagent 的 atom 比 Clojure 原生的 atom 多了一个能力:追踪谁在 deref 我

(def count (r/atom 0))

(defn counter []
  [:div 
    [:p "Count: " @count]        ;; @count = deref,自动建立依赖
    [:button {:on-click #(swap! count inc)} "+"]])

swap! 修改 count 时,counter 组件自动重渲染。不需要 useState、不需要 setState、不需要任何订阅代码。

原理

渲染时,Reagent 用 run-in-reaction 包装 render 函数:

(ratom/run-in-reaction
  #(do-render c compiler)      ;; render 函数
  c "cljsRatom"                ;; 存储位置
  batch/queue-render           ;; 值变化时的回调
  rat-opts)

run-in-reaction 在执行 render 函数前后设置了追踪:

  1. 执行前:开启”谁在 deref”的追踪
  2. render 函数执行时,遇到 @count → deref → 记录”组件 C 依赖了 atom A”
  3. 执行后:关闭追踪
  4. swap! count inc 时 → 通知 C → queue-render → React 重渲染

辅助工具

cursor——atom 的镜头,聚焦到某个路径:

(let [c (r/cursor app-state [:user :name])]
  @c                              ;; 等价于 (get-in @app-state [:user :name])
  (reset! c "新名字")             ;; 等价于 (swap! app-state assoc-in [:user :name] "新名字")
  )

track——衍生计算值,自动缓存:

(defn full-name [db]
  (str (:first @db) " " (:last @db)))

;; @(track full-name db) 只在 first 或 last 变化时重算

wrap——把 value + callback 包装成类 atom 的东西,传给子组件:

(r/wrap (:foo @state) swap! state assoc :foo)
;; 子组件 @ 它拿值,reset! 它触发 swap!

4. 批量渲染调度(batching.cljs)

Reagent 不直接调 forceUpdate,而是攒一批一起渲染:

atom 变化 → queue-render → requestAnimationFrame → flush → 批量渲染

这避免了同一个 tick 内多次 atom 变化导致的多次渲染。


为什么不需要 hooks

React Hooks 解决的核心问题是”在函数组件中保持状态”。但 Reagent 的 Form-2 模式已经用 Clojure 闭包解决了这个问题:

(defn counter []
  (let [count (r/atom 0)]        ;; ← 通过闭包捕获,只执行一次
    (fn []                        ;; ← 真正的 render 函数
      [:div 
        [:p "Count: " @count]
        [:button {:on-click #(swap! count inc)} "+"]])))

原理:外层函数只在首次渲染时调用(相当于 constructor),内层函数被存为 render 函数。这个闭包天然就是组件的局部状态——不需要 useState,不需要 useRef

;; component.cljs 的 wrap-render 判断逻辑:
(defn wrap-render [c compiler]
  (let [res (调用 render 函数)]
    (cond
      (vector? res) (as-element res)     ;; Form-1: 直接返回 Hiccup
      (ifn? res) (do
                   (set! (.-reagentRender c) res)   ;; Form-2: 存下内层函数
                   (recur c compiler))              ;; 用内层函数重新渲染
      :else res)))

| React Hooks 解决的问题 | Reagent ratom + 闭包 解决的问题 | |---|---|---| | 在函数组件间保持状态 | 外层函数闭包 + ratom | | 副作用清理 | ratom 的 dispose 机制 | | 引用稳定性 | Clojure 不可变数据 + 闭包 | | deps 数组 | Clojure = 值比较,自动追踪 |

所以 Reagent 的哲学就是:用 Clojure 的语言特性解决问题,不发明新概念。React 只是一个渲染引擎。状态、响应式、组件模型都是 Clojure 的。

这也意味着:当你自己封装 React 时,不需要实现 hooks 适配层。ClojureScript 本身就有足够的能力处理状态管理。你只需要 ratom(响应式追踪)就够了。

不需要 useState。

如果你需要和 React 生态互操作(比如接入第三方库),Reagent 也提供了完整的 hooks 封装(hooks.cljs)。但核心的 Form-1/2/3 模型加上 ratom 已经覆盖了绝大多数场景。hooks 是给”不得不用 React 原生 API”的场景准备的。


为什么 Reagent 这么稳定

2700 行,薄薄一层翻译,该有的都有,不该加的没加。10 年了 API 基本没变过。

稳定的原因:

  1. ——就是把 Hiccup 翻译成 React.createElement,把 Clojure 函数包装成 React 组件,翻译层越薄越不容易坏
  2. 不造轮子——状态管理交给 ratom(Clojure 思路),Context/Refs/Suspense 直接透传 React,不自己搞一套
  3. 语言特性替它干了活——Clojure 的不可变数据让 = 比较天然高效,闭包天然保存状态(替代 hooks),宏天然生成样板代码。它不是在 React 之上建框架,而是在 React 之上铺了薄薄的翻译层。翻译层不需要新功能,因为 Clojure 和 React 各自的演进不会打破它。

参考来源

📝 文章反馈