Reagent 源码解析:ClojureScript 如何优雅地封装 React
Reagent 是什么
Reagent 是 ClojureScript 对 React 的精简封装。不引入新概念,不造新范式,就做三件事:
- Hiccup 语法写 UI(用 vector 代替 JSX)
- Clojure 函数即组件(不用 class、不用 hooks)
- 响应式 atom(
deref自动触发重渲染)
核心代码 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 函数前后设置了追踪:
- 执行前:开启”谁在 deref”的追踪
- render 函数执行时,遇到
@count→ deref → 记录”组件 C 依赖了 atom A” - 执行后:关闭追踪
- 当
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 基本没变过。
稳定的原因:
- 薄——就是把 Hiccup 翻译成
React.createElement,把 Clojure 函数包装成 React 组件,翻译层越薄越不容易坏 - 不造轮子——状态管理交给 ratom(Clojure 思路),Context/Refs/Suspense 直接透传 React,不自己搞一套
- 语言特性替它干了活——Clojure 的不可变数据让
=比较天然高效,闭包天然保存状态(替代 hooks),宏天然生成样板代码。它不是在 React 之上建框架,而是在 React 之上铺了薄薄的翻译层。翻译层不需要新功能,因为 Clojure 和 React 各自的演进不会打破它。