Reagent Docs

Table of Contents

1. 使用 Hiccup 描述 HTML

Reagent 使用一种称为 Hiccup 的数据结构来表示 HTML.

Hiccup 形式是普通的 ClojureScript 数据结构 (通常是 vector), 可以轻松地组合和操作. Reagent 将 Hiccup 形式转换为 React 元素, React 再将其高效地渲染为 DOM.

例如, 这种 Hiccup 形式:

[:div [:p "Hello world"]]

会渲染成以下 HTML:

<div><p>Hello world</p></div>

1.1. Hiccup 格式

Hiccup 形式通常表示为如下 vector: [:type {:props...} children...]

  • type 是一个 keyword, 它指定 HTML 标签的类型 (例如 :div, :h1, :p 等), 或者是一个引用 React 组件的符号 (稍后详述). 类型名称可以使用 CSS 语法包含 idclass, 例如 :h1#title.foo.
  • props 是一个可选的 ClojureScript map, 它指定 HTML 属性 (如 id, class, style 等) 或传递给组件的属性. props map 中的属性名称通常是 keyword (但也可以是字符串).
  • children 代表元素的内容或子元素. 这可以是零个或多个项. 字符串会被渲染为文本节点. Vector 会被递归地解释为 Hiccup 形式. 其他集合类型 (list 或 seq) 会被展开, 其元素会被解释为子元素.

1.2. 示例

;; 一个简单的 h1 元素
[:h1 "Hello world!"]
;; => <h1>Hello world!</h1>

;; 带有 id 和 class 的 div
[:div#foo.bar [:p "Baz"]]
;; => <div id="foo" class="bar"><p>Baz</p></div>

;; 带有属性的链接
[:a {:href "http://example.com"} "Example"]
;; => <a href="http://example.com">Example</a>

;; 带有事件处理程序的 input
[:input {:type "text" :value "foo" :on-change #(prn "Changed!")}]
;; => <input type="text" value="foo"> ...

1.3. CSS 类

除了使用 :class 属性外, 还可以使用 CSS 语法在类型 keyword 中指定类:

[:div#foo.bar.baz "Foo"]

等同于:

[:div {:id "foo" :class "bar baz"} "Foo"]

如果同时使用 :class 属性和 CSS 语法, 类名会被合并:

[:div.foo {:class "bar"} "Foo"]
;; => <div class="foo bar">Foo</div>

1.4. Style 属性

style 属性可以是一个 map, Reagent 会将其转换为 CSS 字符串:

[:div {:style {:color "red"
               :font-size "14px"
               :border "1px solid black"}}]
;; => <div style="color:red; font-size:14px; border:1px solid black;"></div>

Style map 中的 key 可以是驼峰式 (camelCase) keyword 或字符串, 也可以是普通的小写 keyword 或字符串.

1.5. 与 Hiccup 的差异

Reagent 的 Hiccup 风格语法受到流行的 [Hiccup 库](https://github.com/weavejester/hiccup) 的启发, 但有一些重要的区别:

  • Reagent 的 Hiccup 是为了生成 React 元素, 而不是 HTML 字符串.
  • Reagent 支持将 React 组件用作类型.
  • 属性 map 是可选的. 如果第二个元素是 map, 则将其视为属性. 否则, 所有第一个元素之后的元素都将被视为子元素.
  • Reagent 需要一个 key 属性来唯一标识动态集合中的子元素. 这对于 React 高效地更新 DOM 至关重要.
  • 事件处理程序 (例如 :on-click, :on-change) 是函数, 而不是字符串.
  • Reagent 会对某些属性 (如 :class, :style, :value, :checked, :selected) 进行特殊处理, 以适应 React 的行为.

2. 创建 Reagent 组件

Reagent 组件只是返回 Hiccup (描述 HTML 或其他组件) 的普通 ClojureScript 函数.

2.1. 一个简单的组件

这里有一个简单的 Reagent 组件:

(defn simple-component []
  [:div
   [:p "I am a component!"]
   [:p "Here is a random number: " (rand-int 100)]])

要使用这个组件, 只需在你的 Hiccup 标记中像 HTML 标签一样引用它:

[:div
 [simple-component]
 [simple-component]]

2.2. 注意方括号

请注意, simple-component 被方括号括起来. 这很重要!

如果不加括号, 如下所示:

[:div
 (simple-component) ;; 错误: 这里没有括号!
 (simple-component)]

…(simple-component) 将被 调用, 其返回值 (一个 Hiccup vector) 将被插入到外部 :div vector 中. 这有时被称为 "内联" 组件. Reagent 将无法将其识别为对 simple-component 的调用.

通过将组件调用包装在方括号中, Reagent 可以识别出你正在调用一个特定的组件函数. 这使得 Reagent 能够管理组件的状态和生命周期, 从而实现更高效的渲染和其他功能.

2.3. 带参数的组件

组件可以接受参数. 这些参数在 Hiccup vector 中作为属性 map 的一部分或直接传递给组件函数.

(defn greeting [message]
  [:p "Hello, " message "!"])

;; 使用组件
[:div
 [greeting "world"]
 [greeting "Reagent"]]

这会渲染成:

<div>
  <p>Hello, world!</p>
  <p>Hello, Reagent!</p>
</div>

参数可以是任何 ClojureScript 值, 包括函数和数据结构.

2.4. 组件的子元素

组件也可以像 HTML 标签一样接受子元素. 这些子元素作为最后一个参数传递给组件函数.

(defn container [& children]
  (into [:div.container] children))

;; 使用组件
[container
 [:h1 "Container Title"]
 [:p "This is the content inside the container."]]

这会渲染成:

<div class="container">
  <h1>Container Title</h1>
  <p>This is the content inside the container.</p>
</div>

2.5. Props Map 和子元素

如果一个组件调用包含一个 map 作为第二个元素, 它就被视为 "props map". map 中的键值对和任何剩余的参数都会被传递给组件函数.

(defn user-profile [{:keys [name age]} & children]
  [:div.user-profile
   [:h2 name]
   [:p "Age: " age]
   (into [:div.details] children)])

;; 使用组件
[user-profile {:name "Alice" :age 30}
 [:p "Bio: Loves ClojureScript."]
 [:a {:href "/users/alice"} "Profile Link"]]

在这个例子中:

  • 第一个参数传递给 user-profile 的是 map {:name "Alice" :age 30}.
  • 剩余的参数 ([:p "Bio: ..."][:a ...]) 被收集到 children 中.

2.6. 组件的类型

Reagent 组件有三种主要形式:

  1. Form-1: 普通函数: 如上所示, 这是最简单的形式. 它是一个返回 Hiccup 的普通 ClojureScript 函数. 这些组件没有内部状态或生命周期方法. 每次父组件重新渲染时, 它们都会重新计算.
  2. Form-2: 返回函数的函数: 这种形式允许组件拥有一个设置阶段和在多次渲染之间保持局部状态的能力. 它返回一个渲染函数.
  3. Form-3: 带有生命周期方法的 map: 这是最强大的形式, 允许组件拥有局部状态、生命周期方法 (如 component-did-mount, component-did-update) 等等. 它返回一个包含 render 方法和其他可选生命周期方法的 map.

这些更高级的形式将在关于 管理状态React 特性 的文档中详细介绍.

2.7. 何时使用哪种形式?

  1. Form-1 (普通函数): 适用于简单、无状态的表示型组件. 这是最常见的形式.
  2. Form-2 (返回函数的函数): 当你需要进行一次性设置或在渲染之间保持一些局部状态 (使用闭包) 但不需要完整的 React 生命周期方法时使用.
  3. Form-3 (带有生命周期方法的 map): 当你需要细粒度地控制组件的生命周期、管理局部状态、与 JavaScript 库交互或执行性能优化时使用.

从最简单的形式 (Form-1) 开始, 只有在需要更高级功能时才升级到 Form-2 或 Form-3.

3. 使用方括号代替圆括号

Reagent 的一个常见混淆点是何时使用方括号 [] 而不是圆括号 () 来调用组件.

简而言之: 始终使用方括号 [] 来调用 Reagent 组件.

3.1. 解释

考虑这两个组件:

(defn child-component [message]
  [:p "Child says: " message])

(defn parent-component []
  [:div
   [:h1 "Parent Component"]
   [child-component "Hello from parent!"]]) ;; 注意方括号

当 Reagent 遇到 [:div ...] 时, 它会处理它的子元素. 当它看到 [child-component "Hello from parent!"] 时:

  1. 识别组件: 因为它是一个 vector (方括号), Reagent 知道第一个元素 (child-component) 是一个组件函数或类.
  2. 传递参数: 它将 vector 中剩余的项 ("Hello from parent!") 作为参数传递给 child-component.
  3. 渲染组件: Reagent 然后管理 child-component 的渲染过程, 将其转换为 React 元素.

3.2. 如果使用圆括号会发生什么?

(defn parent-component-wrong []
  [:div
   [:h1 "Parent Component"]
   (child-component "Hello from parent!")]) ;; 错误: 使用圆括号

在这个 (parent-component-wrong) 版本中:

  1. 函数调用: ClojureScript 在处理 [:div ...] vector 之前首先计算 (child-component "Hello from parent!"). 这是一个普通的函数调用.
  2. 返回 Hiccup: child-component 函数执行并返回它的 Hiccup 结果: [:p "Child says: Hello from parent!"].
  3. 内联: 这个返回的 Hiccup vector 被直接插入到父 :div vector 中. 最终的 Hiccup 结构看起来像这样:

    [:div
     [:h1 "Parent Component"]
     [:p "Child says: Hello from parent!"]]
    
  4. 丢失身份: Reagent 只看到一个 :div 包含一个 :h1 和一个 :p. 它不知道 :p 元素是由 child-component 生成的. 它将 child-component 视为已被"内联".

3.3. 为什么方括号很重要?

使用方括号允许 Reagent:

  1. 识别组件实例: Reagent 可以区分 child-component 的不同调用.
  2. 管理状态和生命周期: 对于 Form-2 和 Form-3 组件 (具有内部状态或生命周期方法的组件), Reagent 需要知道哪个组件实例正在被渲染, 以便正确地管理其状态和调用其生命周期方法. 如果你使用圆括号内联组件, Reagent 就无法做到这一点.
  3. 优化渲染: React 和 Reagent 可以通过识别组件实例来优化渲染过程 (例如, 避免不必要的重新渲染).

3.4. 结论

当想要在你的 UI 中包含一个 Reagent 组件时, 总是 将对该组件的调用放在方括号 [] 中. 将其视为 Hiccup 标记的一部分, 而不是一个普通的函数调用.

圆括号 () 用于 ClojureScript 中的常规函数调用, 通常在组件 内部 用于辅助函数或逻辑, 或者在 Hiccup 结构的属性或子元素位置计算值时使用.

4. 组件何时更新?

理解 Reagent 何时以及为何重新渲染你的组件对于构建高效的应用程序至关重要. Reagent 利用 React 的更新机制, 并添加了一些自己的优化.

4.1. 基本原则: 参数变更

最基本的规则是: 当传递给组件的参数发生变化时, Reagent 会重新渲染该组件.

Reagent 使用简单的 ClojureScript 值比较 = 来检查参数是否已更改.

(defn display-data [data]
  [:div
   [:pre (pr-str data)]])

;; 初始渲染
[display-data {:a 1 :b 2}]

;; 如果再次使用相同的数据渲染...
[display-data {:a 1 :b 2}] ;; ...display-data 不会重新计算/渲染, 因为数据没有改变.

;; 如果使用不同的数据渲染...
[display-data {:a 1 :b 3}] ;; ...display-data 将会重新计算/渲染, 因为数据已改变.

4.2. 重要提示: Reagent 比较的是参数的 . 如果你传递一个新的但值相等的 map 或 vector, Reagent 会认为它们是相同的, 并且 不会 重新渲染.

(let [data1 {:a 1 :b 2}
      data2 {:a 1 :b 2}]
  (println (= data1 data2)) ;; => true
  ;; 渲染 data1
  [display-data data1]
  ;; 稍后用 data2 渲染
  [display-data data2] ;; 不会重新渲染, 因为 (= data1 data2) 为 true
  )

4.3. Reagent Atoms 的角色

当组件在其渲染函数中解引用 (dereference) Reagent atom 时, 它会建立对此 atom 的依赖关系.

(def app-state (reagent/atom {:count 0}))

(defn counter []
  [:div
   "Count: " @app-state
   [:button {:on-click #(swap! app-state update :count inc)} "Increment"]])
  1. 依赖:counter 组件首次渲染时, 它解引用 @app-state. Reagent 记录下 counter 依赖于 app-state.
  2. Atom 更改: 当按钮被点击时, swap! app-state 会修改 atom 的值.
  3. 通知: Reagent atom 会通知 Reagent 它的值已经改变.
  4. 重新渲染: Reagent 检查哪些组件依赖于 app-state (在这个例子中是 counter), 并安排它们重新渲染.

组件 在其解引用的 atom 发生变化时才会因 atom 的变化而重新渲染. 如果一个组件在其渲染函数中没有解引用某个特定的 atom, 那么即使该 atom 改变了, 也不会触发该组件的重新渲染.

4.4. 父组件重新渲染

如果一个父组件重新渲染, 它通常也会导致其子组件重新渲染, 除非 Reagent 能够确定子组件的参数没有改变.

(defn parent []
  (let [rand-val (rand-int 1000)] ;; 父组件每次渲染都有不同的值
    [:div
     [:p "Parent thinks the number is: " rand-val]
     [child "static message"] ;; 子组件参数不变
     [another-child rand-val]])) ;; 子组件参数改变

在这个例子中:

  • 每次 parent 重新渲染时, rand-val 都会改变.
  • [child "static message"] 的参数 ("static message") 不变, 所以 child 不会 因为父组件的重新渲染而被重新渲染 (假设 child 是一个纯函数或 Form-2/3 组件).
  • [another-child rand-val] 的参数 (rand-val) 每次都会改变, 所以 another-child 随着父组件一起重新渲染.

4.5. Form-1, Form-2, Form-3 组件的行为

  • Form-1 (普通函数): 这些组件没有内部状态或生命周期. 它们本质上是其父组件渲染过程的一部分. 如果父组件重新渲染, Form-1 子组件 总是 会被重新调用 (它们的函数体会被执行). 然而, React 仍然可能通过其虚拟 DOM (Virtual DOM) 比对来优化实际的 DOM 更新, 如果 Form-1 函数返回与上次相同的 Hiccup 结构.
  • Form-2 (返回函数的函数) 和 Form-3 (带 map 的组件): Reagent 对这些组件应用了更智能的更新逻辑. 如果传递给它们的参数 (使用 ~ 比较) 没有改变, Reagent 会阻止它们的渲染函数运行, 从而跳过整个子树的渲染过程. 这就是为什么 Form-2 和 Form-3 组件在性能优化方面更有用.

4.6. 强制更新: force-update

在极少数情况下, 你可能需要强制一个组件重新渲染, 即使它的参数或解引用的 atom 没有改变. 你可以通过 reagent.core/force-update 来实现. 通常不建议这样做, 应优先考虑通过改变参数或 atom 来驱动更新.

4.7. 性能考虑: shouldComponentUpdateReact.memo

对于 Form-3 组件, 你可以提供一个 :component-should-update 生命周期方法来自定义更新逻辑. 这类似于 React 的 shouldComponentUpdate.

对于 Form-1 和 Form-2 组件, Reagent 自动像使用了 React.memo 一样进行优化: 如果参数不变, 它会跳过重新渲染. 你可以通过元数据 ^{:memo true} (默认) 或 ^{:memo false} 来控制这种行为, 但通常不需要改变默认设置.

4.8. 总结

一个 Reagent 组件会在以下情况下重新渲染:

  1. 传递给它的参数发生了变化 (根据 ~ 比较).
  2. 它在其渲染函数中解引用的 Reagent atom 的值发生了变化.
  3. 它的父组件重新渲染, 并且 (对于 Form-2/3) 它自身的参数发生了变化, 或者 (对于 Form-1) 它总是会被重新调用 (尽管实际的 DOM 更新可能被 React 跳过).
  4. 手动调用了 reagent.core/force-update.

理解这些规则有助于你构建响应迅速且高效的 Reagent 应用程序.

5. 管理状态

在 Reagent (以及一般的 React 应用程序) 中管理状态是一个核心概念. 状态是指随时间变化并影响 UI 渲染的数据. Reagent 提供了几种管理状态的机制.

5.1. Reagent Atoms

管理全局或共享应用程序状态的最常见和推荐的方法是使用 Reagent atoms. Reagent atoms 类似于 Clojure 的标准 atoms, 但有一个关键的区别: 当它们的值发生变化时, 它们会自动触发依赖于它们的 Reagent 组件进行重新渲染.

(ns myapp.core
  (:require [reagent.core :as r]))

;; 定义一个全局的 application state atom
(defonce app-state (r/atom {:message "Hello"
                           :counter 0}))

;; 一个显示状态的组件
(defn display-state []
  [:div
   [:h1 (:message @app-state)]
   [:p "Counter: " (:counter @app-state)]])

;; 一个更新状态的组件
(defn controls []
  [:div
   [:button {:on-click #(swap! app-state update :counter inc)}
    "Increment Counter"]
   [:input {:type "text"
            :value (:message @app-state)
            :on-change #(swap! app-state assoc :message (-> % .-target .-value))}]])

;; 根组件
(defn app []
  [:div
   [display-state]
   [controls]])

;; 渲染根组件
(r/render [app] (js/document.getElementById "app"))

在这个例子中:

  1. app-state 是一个 reagent.core/atom.
  2. display-state 组件通过解引用 @app-state 来读取状态. 这会使 Reagent 知道 display-state 依赖于 app-state.
  3. controls 组件使用 swap! 来修改 app-state.
  4. app-state 的值通过 swap! 改变时, Reagent 会自动重新渲染 display-state (以及任何其他解引用了 app-state 的组件).

5.2. 优点:

  • 简单易用.
  • 自动 UI 更新.
  • 适用于全局或跨多个组件共享的状态.

5.3. 何时使用:

当数据需要在应用程序的不同部分之间共享时, Reagent atoms 是一个很好的选择.

5.4. 局部状态 (Form-2 和 Form-3 组件)

有时, 状态只与单个组件实例相关, 不需要与其他组件共享. 对于这种情况, 你可以使用 Reagent 组件的 Form-2 或 Form-3 形式来创建局部状态.

5.4.1. Form-2: 使用闭包

Form-2 组件是一个返回渲染函数的函数. 外部函数只运行一次 (每个组件实例), 允许你初始化局部状态 (例如, 使用标准的 Clojure atom 或 volatile). 内部渲染函数形成一个闭包, 可以访问这个局部状态.

(defn timer-component []
  (let [seconds-elapsed (r/atom 0) ; 局部状态
        timer-id (atom nil)]    ; 用于存储 interval ID

    ;; 一次性设置: 组件挂载时启动计时器
    (js/console.log "Timer component Setup")
    (reset! timer-id
            (js/setInterval #(swap! seconds-elapsed inc) 1000))

    ;; 返回渲染函数 (闭包)
    (fn []
      (js/console.log "Timer component Rendering")
      [:div
       "Seconds Elapsed: " @seconds-elapsed]))

    ;; 注意: 这种简单的 Form-2 没有清理计时器的机制.
    ;; Form-3 更适合需要清理的场景.
    ))

;; 使用组件
[timer-component]

在这个例子中, seconds-elapsed 是组件实例的局部状态. 它是一个 Reagent atom, 所以当它的值改变时, 内部渲染函数会被重新调用. 外部函数只在组件首次挂载时运行一次.

5.4.2. Form-3: 使用 :component-did-mount:reagent-render

Form-3 组件是创建具有局部状态和生命周期管理的组件的最灵活方式. 你返回一个 map, 其中包含 :reagent-render 方法以及可选的生命周期方法. 你通常会将局部状态存储在闭包中, 或者对于更复杂的场景, 存储在组件实例的元数据中 (虽然不太常见).

(defn timer-with-cleanup []
  (let [seconds-elapsed (r/atom 0)
        timer-id (atom nil)]
    {:component-did-mount ; 生命周期方法: 挂载后启动计时器
     (fn [this]
       (js/console.log "Timer Mounted")
       (reset! timer-id
               (js/setInterval #(swap! seconds-elapsed inc) 1000)))

     :component-will-unmount ; 生命周期方法: 卸载前清理计时器
     (fn [this]
       (js/console.log "Timer Unmounting")
       (js/clearInterval @timer-id)
       (reset! timer-id nil)) ; 清理 ID

     :reagent-render ; 渲染函数
     (fn []
       [:div "Seconds Elapsed (Form-3): " @seconds-elapsed])}))

;; 使用组件
[timer-with-cleanup]

5.5. 何时使用局部状态: 当状态本质上属于单个组件实例并且不需要在其他地方访问时 (例如, UI 元素是否处于活动状态, 输入字段的临时值, 动画状态), 使用 Form-2 或 Form-3 的局部状态. Form-3 对于需要生命周期方法 (如设置和清理资源) 的情况特别有用.

5.6. RAtoms (可反应的 Atoms)

RAtom 是 Reagent atoms 的一种更高级形式 (reagent.ratom/atom). 它们允许你创建依赖于其他 RAtoms 的 "计算" atom (类似于电子表格中的公式). 当依赖的 atom 发生变化时, 计算 RAtom 会自动重新计算其值, 并触发依赖于它的组件进行更新.

(ns myapp.reactions
  (:require [reagent.core :as r]
            [reagent.ratom :as ratom]))

(def first-name (r/atom "John"))
(def last-name (r/atom "Doe"))

;; 一个依赖于 first-name 和 last-name 的计算 RAtom
(def full-name (ratom/reaction (str @first-name " " @last-name)))

(defn name-display []
  [:div
   [:p "First: " @first-name]
   [:p "Last: " @last-name]
   [:h3 "Full: " @full-name]]) ;; 解引用 reaction

(defn name-inputs []
  [:div
   [:input {:type "text" :value @first-name
            :on-change #(reset! first-name (-> % .-target .-value))}]
   [:input {:type "text" :value @last-name
            :on-change #(reset! last-name (-> % .-target .-value))}]])

(defn name-app []
  [:div
   [name-display]
   [name-inputs]])

在这个例子中, full-name 是一个 reaction. 当 first-namelast-name 改变时, full-name 会自动更新, 并且由于 name-display 解引用了 @full-name, 它也会被重新渲染.

5.7. 何时使用 RAtoms:** 当你有派生自其他状态的状态时, RAtoms 非常有用. 它们有助于保持状态逻辑清晰, 并确保派生数据始终是最新的, 而无需手动同步.

5.8. 总结

  • reagent.core/atom: 用于全局或共享状态. 当 atom 改变时, 自动触发组件更新.
  • Form-2/Form-3 局部状态: 用于特定于单个组件实例的状态. 使用闭包 (通常配合 r/atom 或 Clojure atom) 来存储状态. Form-3 提供生命周期方法用于设置和清理.
  • reagent.ratom/reaction* 用于创建依赖于其他 RAtoms 的派生或计算状态.

选择哪种状态管理机制取决于状态的作用域和性质. 通常, 一个应用程序会混合使用 Reagent atoms (用于共享状态) 和局部状态 (用于特定组件的内部逻辑).

6. 批处理和时序

Reagent/React 中的更新如何以及何时发生可能有点微妙. Reagent (很大程度上继承自 React) 会尝试将多个状态更新批处理 (batch) 到单个重新渲染周期中, 以提高性能.

6.1. 默认行为: 事件处理程序中的批处理

在 React 事件处理程序 (例如 :on-click, :on-change) 中触发的状态更新通常会被批处理.

(def counter (r/atom 0))
(def status (r/atom "Idle"))

(defn batching-example []
  (js/console.log "Rendering batching-example...")
  [:div
   [:p "Count: " @counter]
   [:p "Status: " @status]
   [:button {:on-click (fn []
                         (js/console.log "Button Clicked!")
                         (swap! counter inc) ; 更新 1
                         (reset! status "Processing...") ; 更新 2
                         ;; 尽管有两个状态更新, 但 Reagent/React
                         ;; 通常只会触发一次重新渲染.
                         (js/console.log "State updates done in handler."))}
    "Update Both"]])

当你点击按钮时:

  1. "Button Clicked!" 被打印.
  2. swap! counter inc 执行.
  3. reset! status "Processing..." 执行.
  4. "State updates done in handler." 被打印.
  5. 事件处理程序结束.
  6. Reagent/React 处理被批处理的状态更新, 并安排一次 batching-example 组件的重新渲染.
  7. "Rendering batching-example…" 被打印 (只有一次, 在事件处理程序完成后).

6.2. 异步操作中的更新 (例如 setTimeout, Promises)

在 React 事件处理程序 之外 发生的状态更新 (例如在 js/setTimeout, js/setInterval 的回调中, 或在 Promise 的 .then/catch 中) 传统上不会 自动批处理 (在 React 17 及更早版本中). 每个状态更新都可能触发一次单独的重新渲染.

(def async-counter (r/atom 0))
(def async-status (r/atom "Idle"))

(defn async-example []
  (js/console.log "Rendering async-example...")
  [:div
   [:p "Async Count: " @async-counter]
   [:p "Async Status: " @async-status]
   [:button {:on-click (fn []
                         (js/console.log "Async Button Clicked!")
                         (js/setTimeout
                          (fn []
                            (js/console.log "Inside setTimeout 1")
                            (swap! async-counter inc) ; 可能触发渲染 1
                            (js/console.log "Inside setTimeout 2")
                            (reset! async-status "Updated Async") ; 可能触发渲染 2
                            (js/console.log "State updates done in setTimeout."))
                          50))} ; 延迟 50ms
    "Update Async"]])

在 React 17 及更早版本中, 点击 "Update Async" 按钮通常会导致:

  • "Async Button Clicked!"
  • (50ms 后) "Inside setTimeout 1"
  • swap! async-counter inc 执行. Reagent/React 安排重新渲染.
  • "Rendering async-example…" (第一次渲染)
  • "Inside setTimeout 2"
  • reset! async-status "Updated Async" 执行. Reagent/React 安排另一次重新渲染.
  • "Rendering async-example…" (第二次渲染)
  • "State updates done in setTimeout."

6.3. React 18 及更高版本: 自动批处理

React 18 引入了跨异步操作的 自动批处理. 这意味着即使在 setTimeout, Promises 或原生事件处理程序中, React 现在也会尝试将多个状态更新批处理到一次重新渲染中.

如果你用的是 React 18 (Reagent 通常与最新的稳定版 React 配合使用), 上面的 async-example 的行为将更像 batching-example, 通常只会触发一次重新渲染. 这简化了行为并提高了性能.

6.4. Reagent 的 flushforce-update

  • reagent.core/flush: Reagent 维护一个内部队列来处理 atom 更改的重新渲染. 当一个 Reagent atom 改变时, 它不会立即触发重新渲染, 而是将依赖于它的组件添加到这个队列中. Reagent 使用 requestAnimationFrame (或类似的机制) 来在浏览器准备好绘制下一帧时处理这个队列并执行实际的渲染. reagent.core/flush 强制 Reagent 立即处理这个挂起的渲染队列. 你很少需要手动调用它, 但在与非 React 代码集成或进行某些测试时可能有用.
  • reagent.core/force-update: 这个函数强制一个特定的组件实例及其子组件重新渲染, 绕过常规的 shouldComponentUpdate / 参数检查逻辑. 它应该非常谨慎地使用, 因为它破坏了正常的渲染优化. 通常最好是通过改变状态或 props 来触发更新.

6.5. reagent.dom/renderreagent.dom.client/render

当你调用 render 将根组件挂载到 DOM 时, 初始渲染会同步发生 (在 React 17 中) 或异步发生 (在 React 18 的并发模式中). 随后的更新 (由 atom 更改或父组件更新触发) 则通过 Reagent/React 的批处理和调度机制进行管理. Reagent 0.11.0+ 使用 reagent.dom.client 命名空间和 React 18 的 createRoot API, 这使得所有渲染本质上都是并发和可中断的.

6.6. 时序考虑

  • 状态更新不是立即生效的: 当你调用 reset!swap! 时, atom 的值会立即改变, 但依赖于该 atom 的组件 不会 立即重新渲染. 渲染被调度在稍后发生 (通常在当前事件处理程序完成后, 或在下一动画帧).
  • 不要依赖同步渲染: 避免编写依赖于在状态更新后 UI 立即 反映变化的代码. 如果你需要对 DOM 更新做出反应, 使用生命周期方法 (如 :component-did-update for Form-3) 或 useEffect hook (参见 React 特性).
  • 理解批处理: 知道多个状态更新 (尤其是在 React 18 中) 可能会合并为一次渲染, 这对于理解渲染次数和性能至关重要.

总的来说, Reagent 和 React 的目标是抽象掉精确的渲染时序细节, 提供一个高效且一致的更新模型. 了解批处理和异步更新行为有助于调试和优化你的应用程序.

7. 与 React 的互操作

Reagent 旨在与 React 生态系统良好协作. 你可以在 Reagent 应用程序中使用现有的 React 组件, 也可以将 Reagent 组件包装起来以便在纯 React 项目中使用.

7.1. 在 Reagent 中使用 React 组件

你可以像使用普通 HTML 标签或 Reagent 组件一样, 在 Hiccup 标记中使用 React 组件 (无论是 JavaScript 编写的类组件还是函数组件).

  1. 获取 React 组件:
    1. 如果组件来自 npm 包, 请确保你的 ClojureScript 构建 (例如 shadow-cljs 或 Figwheel) 配置为可以访问它. 你可能需要 :npm-deps 配置.
    2. 如果组件是全局可用的 (例如通过 <script> 标签加载), 你可以通过 js/ReactMyComponent 访问它.
    3. 通常, 你会通过 require 导入它:

      (ns myapp.core
        (:require ["react-select" :as Select] ;; 从 npm 导入
                  ["my-js-components" :refer [MyJsChart]] ;; 从本地 JS 文件导入
                  [reagent.core :as r]))
      
  2. 在 Hiccup 中使用: 将导入的组件直接放在 Hiccup vector 的类型位置. Props 在 map 中传递, 子元素紧随其后.

    (def options [{:value "chocolate" :label "Chocolate"}
                  {:value "strawberry" :label "Strawberry"}
                  {:value "vanilla" :label "Vanilla"}])
    
    (defn my-form []
      [:div
       [:h2 "Using React Select"]
       ;; 将 JS React 组件用作类型
       [Select {:options options
                :is-multi true
                :onChange #(js/console.log "Selected: " (map .-value %))}]
    
       [:h2 "Using My JS Chart"]
       [MyJsChart {:data [10 40 20 80]
                   :color "blue"}]
    
       ;; 传递子元素给 React 组件 (如果它支持的话)
       [:some-react-wrapper
        [:h3 "This is a child"]
        [:p "Rendered inside the wrapper."]]
       ])
    

7.2. 关键点:

  • Props: ClojureScript map 中的关键字 props (:likeThis) 会被转换为 JavaScript 对象中的驼峰式 (camelCase) 键 ("likeThis") 传递给 React 组件. 你也可以在 map 中使用字符串键.
  • Callbacks: 传递给 React 组件的回调函数 (如 :onChange) 应该是普通的 ClojureScript 函数. 它们会在 JavaScript 端被调用. 你可能需要处理 JavaScript 事件对象 (例如 (-> % .-target .-value)).
  • Children: Hiccup 子元素会被转换为 React 的 children prop.
  • Keys: 当在集合中渲染 React 组件列表时, 像在 Reagent 中一样, 你需要提供一个唯一的 :key prop.

7.3. 将 Reagent 组件转换为 React 组件

如果你想在 JavaScript React 项目中使用 Reagent 组件, 或者需要将 Reagent 组件传递给需要 React 组件的 JavaScript 库, 你可以使用 reagent.core/adapt-react-class.

(ns myapp.reagent-components)

;; 一个简单的 Reagent 组件
(defn my-reagent-counter [initial-value]
  (let [count-atom (r/atom (or initial-value 0))]
    (fn [initial-value] ;; 确保渲染函数也接受参数
      [:div
       "Reagent Count: " @count-atom
       [:button {:on-click #(swap! count-atom inc)} "+"]])))

;; 将其适配为 React 类组件
(def MyReactCounter (r/adapt-react-class my-reagent-counter))

;; 如果你想从 JS 调用它, 你需要导出它
;; (使用 :export 元数据或构建工具配置)
(goog-define MY_REACT_COUNTER MyReactCounter) ; 示例: 使用 goog-define

现在, MyReactCounter 是一个标准的 React 类组件, 可以在 JavaScript 中使用:

// Assuming MyReactCounter is available globally or imported
import React from 'react';
import ReactDOM from 'react-dom';

// const MyReactCounter = window.MY_REACT_COUNTER; // Example if globally defined

function App() {
  return (
    <div>
      <h1>Using Reagent component in React</h1>
      <MyReactCounter initialValue={5} />
      <MyReactCounter /> {/* Starts at 0 */}
    </div>
  );
}

ReactDOM.render(<App />, document.getElementById('root'));

adapt-react-class 处理了 props 的传递和 Reagent 组件的渲染生命周期.

7.4. as-element

有时, 你可能只需要将 Hiccup 形式转换为单个 React 元素, 而不是创建一个可重用的组件类. 例如, 将一些 Hiccup 作为 prop 传递给 React 组件. 这时可以使用 reagent.core/as-element.

(ns myapp.core
  (:require ["some-react-modal" :as Modal]
            [reagent.core :as r]))

(defn show-modal-button []
  (let [modal-visible (r/atom false)]
    (fn []
      [:div
       [:button {:on-click #(reset! modal-visible true)} "Show Modal"]
       (when @modal-visible
         ;; 使用 React 组件 Modal
         [Modal {:is-open true
                 :on-close #(reset! modal-visible false)
                 ;; 将一些 Hiccup 转换为 React 元素并作为 prop 传递
                 :header (r/as-element [:h2 "My Modal Title"])
                 :footer (r/as-element [:div [:button {:on-click #(reset! modal-visible false)} "Close"]])}
          ;; Modal 的子元素也是 Hiccup
          [:p "This is the content of the modal."]])])))

as-element 获取一个 Hiccup vector 并返回一个 React 元素实例.

7.5. 总结

Reagent 提供了无缝的方式在两个方向上与 React 进行互操作:

  • 在 Reagent 中使用 React: 直接在 Hiccup 中将 React 组件作为类型引用, 将 props 作为 map 传递. Reagent 处理 props 键的转换.
  • 在 React 中使用 Reagent: 使用 reagent.core/adapt-react-class 将 Reagent 组件 (任何形式) 包装成标准的 React 类组件.
  • 传递 Hiccup 作为元素:* 使用 reagent.core/as-element 将 Hiccup vector 转换为单个 React 元素, 用于传递给 React 组件的 props.

这种互操作性使你能够利用庞大的 React 生态系统, 同时享受 Reagent 带来的 ClojureScript 的简洁性和强大功能.

8. React 特性

Reagent 构建在 React 之上, 允许你利用许多核心 React 特性, 尽管有时会通过 Reagent 特定的方式来访问它们.

8.1. Hooks (例如 useState, useEffect)

React Hooks 可以在 Reagent 的 函数组件 中使用. 要创建一个可以使用 Hooks 的 Reagent 函数组件, 只需定义一个普通的 ClojureScript 函数 (不需要 Form-2 或 Form-3).

(ns myapp.hooks
  (:require [reagent.core :as r]
            ["react" :as React])) ; 必须导入 React 才能使用 hooks

(defn hook-counter [initial-value]
  (let [[count setCount] (React/useState (or initial-value 0)) ; useState hook
        label (str "Count (" initial-value "): ")]

    ;; useEffect hook - 类似于 componentDidMount 和 componentDidUpdate
    (React/useEffect
     (fn []
       (js/console.log "Hook counter effect ran. Count is:" count)
       ;; 返回一个清理函数 (可选), 类似于 componentWillUnmount
       #(js/console.log "Hook counter cleanup.")))
     #js [count]) ; 依赖数组: effect 只在 count 改变时运行

    [:div
     label count
     [:button {:on-click #(setCount (inc count))} "+ (Hook)"]]))

;; 使用带 hook 的组件
(defn hook-app []
  [:div
   [hook-counter 0]
   [hook-counter 10]])

8.2. 关键点:

  • 导入 React: 你需要 (:require ["react" :as React]) 来访问 React/useState, React/useEffect 等 hooks.
  • 普通函数: 使用 Hooks 的组件是普通的 ClojureScript 函数 (Form-1 风格). Reagent 会识别出何时在这些函数中调用 Hooks.
  • Hook 规则: 必须遵守所有 React Hooks 的规则 (例如, 只在顶层调用 Hooks, 不在循环或条件语句中调用).
  • 状态: useState 返回一个包含当前状态和更新函数的 JavaScript 数组. 你可以使用解构赋值 [value setValue] 来获取它们. 更新函数 (例如 setCount) 是一个 JavaScript 函数, 你直接调用它来更新状态.
  • Effects: useEffect 接受一个函数 (effect) 和一个可选的依赖数组 (JavaScript 数组 #js [...]). effect 函数可以返回一个清理函数.
  • 与 Reagent Atoms 的比较: Hooks (特别是 useState) 提供了组件局部状态的另一种方式, 类似于 Form-2/3 组件的闭包状态或 Reagent atoms. 对于局部状态, useState 是 React 中的惯用方式. Reagent atoms 仍然是管理 共享 或全局状态的首选方式.

8.3. Context

React Context 提供了一种将数据深入传递到组件树的方法, 而无需手动在每个层级传递 props. 你可以在 Reagent 中使用 Context.

  1. 创建 Context (通常在 CLJS 或 JS 中):

    (ns myapp.context
      (:require ["react" :as React]))
    
    (def ThemeContext (React/createContext "light")) ; 默认值 'light'
    
  2. 提供 Context 值: 使用 Context Provider 组件 (ThemeContext.Provider) 将值提供给组件树的一部分. Provider 组件的 value prop 设置当前 context 值.

    (ns myapp.core
      (:require [myapp.context :refer [ThemeContext]]
                [reagent.core :as r]))
    
    (defn themed-button []
      ;; 3. 消费 Context 值 (见下文)
      (r/with-let [theme (React/useContext ThemeContext)] ; 使用 useContext hook
        [:button {:style (if (= theme "dark")
                           {:background "black" :color "white"}
                           {:background "white" :color "black"})}
         (str "Current theme: " theme)]))
    
    (defn app []
      ;; 提供 'dark' 主题给下面的子树
      [:> ThemeContext.Provider {:value "dark"}
       [:div
        [:h1 "App with Themed Button"]
        [themed-button]]]
    
      ;; 不提供, 将使用默认值 'light'
      ;; [themed-button]
      )
    

    注意: Reagent Hiccup 编译器有一个特殊的 [:> comp ...] 语法糖, 用于处理像 ProviderConsumer 这样作为 React 组件对象的属性存在的组件. 你也可以直接引用它们, 如果你的 JS 导入设置正确的话.

  3. 消费 Context 值:
    • 使用 useContext Hook (推荐): 在函数组件中, 使用 React/useContext hook 来读取 context 值. 这是最简单的方式. (如上 themed-button 所示).
    • 使用 Context Consumer: 你也可以使用 Consumer 组件 (ThemeContext.Consumer). 它需要一个 "render prop" 函数作为其子元素, 该函数接收 context 值作为参数.

      (defn themed-button-consumer []
        [:> ThemeContext.Consumer {}
         (fn [theme] ; 函数作为子元素
           [:button {:style (if (= theme "dark")
                              {:background "black" :color "white"}
                              {:background "white" :color "black"})}
            (str "Theme (Consumer): " theme)])])
      

8.4. Refs

Refs 提供了一种访问 DOM 节点或在渲染之间持久保存可变值的方法, 而不会触发重新渲染.

  • 访问 DOM:

    (defn focus-input-button []
      (let [input-ref (React/useRef nil)] ; 1. 创建 ref
        (React/useEffect
         (fn []
           ;; 3. 在 effect 中访问 DOM 节点
           (.focus (.-current input-ref)))
         #js []) ; 空依赖数组, effect 只运行一次 (挂载后)
    
        [:div
         ;; 2. 将 ref 附加到 DOM 元素
         [:input {:ref input-ref :type "text"}]
         [:button {:on-click #(.focus (.-current input-ref))}
          "Focus the input"]]))
    
    • React/useRef 创建一个 ref 对象, 其 .current 属性被初始化为你传递的参数 (通常是 nil).
    • 将 ref 对象传递给 Hiccup 元素的 :ref prop.
    • 在 effect (或回调) 中通过访问 ref 对象的 .current 属性来访问底层的 DOM 节点.
  • 保存可变值: 你可以使用 ref 来存储不需要触发重新渲染的可变值.

    (defn interval-timer-ref []
      (let [interval-ref (React/useRef nil)
            [seconds setCount] (React/useState 0)]
        (React/useEffect
         (fn []
           ;; 存储 interval ID 到 ref 中
           (set! (.-current interval-ref)
                 (js/setInterval #(setCount (fn [c] (inc c))) 1000)) ; 使用函数式更新
           ;; 清理函数
           #(js/clearInterval (.-current interval-ref)))
         #js []) ; Mount/unmount effect
    
        [:div "Seconds (Ref Timer): " seconds]))
    

8.5. Portals

Portals 允许你将子元素渲染到父组件 DOM 层级之外的 DOM 节点中.

(ns myapp.portals
  (:require ["react-dom" :as ReactDOM]
            [reagent.core :as r]))

(defn modal-content []
  [:div {:style {:background "lightblue" :padding "20px"}}
   "I am in a modal, rendered elsewhere in the DOM!"])

(defn portal-example []
  (let [show-modal (r/atom false)
        ;; 确保这个 DOM 节点存在于你的 index.html 中
        modal-root (js/document.getElementById "modal-root")]
    (fn []
      [:div
       [:button {:on-click #(reset! show-modal (not @show-modal))} "Toggle Modal"]
       (when @show-modal
         ;; 使用 ReactDOM.createPortal
         (ReactDOM/createPortal (r/as-element [modal-content]) ; 将 Hiccup 转为元素
                                modal-root))])))

8.6. 其他 React 特性

  • Fragments: 用于返回多个元素而无需添加额外的 DOM 包装器. 在 Reagent 中, 你可以使用 :<> keyword 或仅使用一个 vector (如果它在可以接受子元素序列的上下文中).

    [:<> ;; React Fragment
     [:p "Element 1"]
     [:p "Element 2"]]
    
    ;; 或者通常直接这样写 (如果父元素允许多个子元素):
    [:div
     [:p "Element 1"]
     [:p "Element 2"]]
    
  • Error Boundaries: 是 React 类组件, 用于捕获其子组件树中的 JavaScript 错误. 你可以创建 Reagent Form-3 组件并实现 :component-did-catch 生命周期方法来创建错误边界.
  • Suspense (用于代码分割和数据获取): Reagent 通过与 React 本身的 Suspense 功能集成来支持它. 你可以使用 React/lazy:fallback prop 与 Suspense 组件 ([:> React.Suspense ...]).

8.7. 结论

Reagent 让你能够利用现代 React 的大部分功能, 包括 Hooks, Context, Refs, Portals 等. 对于 Hooks, 你需要在 Reagent 函数组件中使用它们, 并确保导入了 react. 对于 Context 和 Portals, 你可能需要导入 reactreact-dom. Reagent 的 Form-3 组件可以通过实现相应的生命周期方法来模拟 React 类组件的许多功能 (如错误边界). 这使得 Reagent 既能保持其基于 Hiccup 的简洁性, 又能充分利用 React 生态系统的强大能力.

9. Reagent 编译器

Reagent 不仅仅是一个运行时库; 它还包含一个 ClojureScript 编译器 扩展, 负责将 Hiccup 形式转换为 React API 调用. 这个编译器在 ClojureScript 编译期间运行.

9.1. 主要职责

  1. Hiccup 转换: 将形如 [:div {:class "foo"} "bar"] 的 Hiccup vector 转换为对 React.createElement (或类似的内部函数) 的调用.
  2. 组件识别: 识别 Hiccup vector 中的组件引用 (例如 [my-component arg1 arg2]) 并生成适当的代码来渲染该 Reagent 组件.
  3. Props 处理: 处理 props map, 将 ClojureScript keywords (如 :onClick:on-click) 转换为 React 期望的 JavaScript 属性名 (如 "onClick"), 并处理像 :class:style 这样的特殊属性.
  4. 优化: 应用优化, 例如静态 Hiccup 形式的提升 (hoisting), 以减少运行时的创建开销.
  5. Hooks 支持: 检测函数组件中对 React Hooks 的使用, 并确保它们被正确处理.
  6. 语法糖: 实现像 :<> (Fragments) 和 :> (访问组件属性, 如 Context Provider/Consumer) 这样的语法糖.

9.2. 如何工作 (高层次)

当编写 ClojureScript 代码时:

(defn my-comp []
  [:div#main.container {:style {:color "blue"}}
   [:h1 "Hello"]
   [another-comp 1 2]])

在 ClojureScript 编译期间:

  1. 宏扩展: ClojureScript 编译器遇到 Hiccup vector.
  2. Reagent 编译器介入: Reagent 编译器分析这个 vector.
    1. 它解析 :div#main.container, 提取标签名 (div), ID (main), 和类 (container).
    2. 它看到 props map {:style {:color "blue"}}. 它知道 :style 需要特殊处理 (将其转换为 JS 对象或字符串).
    3. 它看到子元素 :h1 和组件调用 [another-comp 1 2].
  3. 代码生成: Reagent 编译器将这个 Hiccup 结构转换为 JavaScript 代码, 大致类似于 (概念上):

    // (伪代码, 实际输出更复杂)
    React.createElement("div",
      {id: "main", className: "container", style: {color: "blue"}},
      React.createElement("h1", null, "Hello"),
      reagent.render_component(another_comp, [1, 2]) // 调用 Reagent 渲染逻辑
    );
    

这个编译步骤意味着在运行时, Reagent 不需要花费大量时间来解析和解释 Hiccup vector. 大部分转换工作已经在编译时完成了, 使得运行时性能更好.

9.3. 配置和选项

通常, 不需要直接与 Reagent 编译器交互. 它作为 Reagent 库的一部分自动运行. 然而, 有一些可以通过编译器选项 (通常在 project.cljdeps.edn:compiler 选项中设置) 来影响其行为的情况, 尽管这比较少见.

一个例子是 :foreign-libs 中 Reagent 的配置, 它可能会影响某些高级设置, 但对于大多数用户来说, 默认设置就足够了.

9.4. 对开发者的影响

  • 性能: 编译时转换提高了运行时性能.
  • 宏: 因为它在编译时运行, 所以 Reagent 能够利用 Clojure/ClojureScript 宏系统 (尽管 Reagent 内部更多地使用编译器分析而不是公共宏).
  • 错误: 有时, Hiccup 格式的错误会在编译时被捕获, 而不是在运行时.
  • 限制: 编译时性质意味着 Hiccup 结构本身必须在编译时是静态已知的. 不能动态地 构建 Hiccup vector 的 类型 (例如, [(keyword dynamic-tag-name) ...]). 可以动态地计算 属性子元素, 但 vector 的基本结构和类型需要是静态的.

    ;; 可以: 动态计算子元素和属性
    (defn dynamic-content [tag-name show-title? title]
      [tag-name {:class (if show-title? "with-title" "no-title")}
       (when show-title?
         [:h2 title])
       [:p "Content..."]])
    
    ;; 不可以 (通常): 动态计算标签类型 keyword
    ;; (let [tag :div] [tag ...]) ;; 这通常不会被 Reagent 编译器正确处理
    ;; 需要写成 [:div ...] 或使用上面 dynamic-content 的方式
    

    更新: 最近的 Reagent 版本在处理动态标签方面有所改进, 但直接使用 keyword 或符号作为标签类型仍然是最可靠和最高效的方式.

9.5. 总结

Reagent 编译器是 Reagent 的一个关键部分, 它在 ClojureScript 编译期间将声明式的 Hiccup UI 描述转换为高效的 JavaScript React 调用. 它使得 Reagent 既具有声明式 UI 的简洁性, 又能获得接近原生 React 的运行时性能. 对大多数开发者来说, 这个编译器在幕后透明地工作.

10. 受控输入 (Controlled Inputs)

在 React 和 Reagent 中, 处理表单输入 (如 <input>, <textarea>, <select>) 通常涉及一个称为 "受控组件" (Controlled Components) 的模式. 这意味着 React/Reagent 组件的状态成为输入元素值的 "单一事实来源 (single source of truth)".

10.1. 概念

对于一个 HTML 输入元素, 它的值通常由 DOM 自己管理. 用户输入时, DOM 中的输入值会更新.

在一个 受控输入 中:

  1. 输入元素的值 (例如 <input value="...">) 被设置为来自你的组件状态 (例如, Reagent atom 或 useState hook) 的某个值.
  2. 当用户尝试更改输入时 (例如, 输入文本), 会触发一个事件 (例如 onChange).
  3. 你的事件处理函数 (例如 :on-change) 被调用.
  4. 在事件处理函数中, 你更新你的组件状态以反映新的值.
  5. 由于状态已更新, 组件会重新渲染.
  6. 重新渲染时, 输入元素的值再次从更新后的状态中设置.

这样, 输入的值始终由你的组件状态 控制.

10.2. 使用 Reagent Atoms 实现受控输入

这是在 Reagent 中最常见的方式, 特别是当输入值需要被应用程序的其他部分访问或影响时.

(ns myapp.forms
  (:require [reagent.core :as r]))

(def form-state (r/atom {:name ""
                       :message ""
                       :selected-option "B"
                       :is-checked false}))

(defn controlled-form []
  [:form {:on-submit (fn [e]
                       (.preventDefault e) ; 阻止表单默认提交行为
                       (js/alert (str "Submitting: " (pr-str @form-state))))}

   ;; 文本输入 (Input type="text")
   [:div
    [:label "Name: "]
    [:input {:type "text"
             :value (:name @form-state) ; 1. 值来自 atom
             :on-change (fn [e]
                          (let [new-value (-> e .-target .-value)] ; 2. 获取新值
                            (swap! form-state assoc :name new-value)))}]] ; 3. 更新 atom

   ;; 文本区域 (Textarea)
   [:div
    [:label "Message: "]
    [:textarea {:value (:message @form-state) ; 1. 值来自 atom
                :on-change (fn [e]
                             (swap! form-state assoc :message (-> e .-target .-value)))}]] ; 3. 更新 atom

   ;; 选择框 (Select)
   [:div
    [:label "Option: "]
    [:select {:value (:selected-option @form-state) ; 1. 值来自 atom
              :on-change (fn [e]
                           (swap! form-state assoc :selected-option (-> e .-target .-value)))} ; 3. 更新 atom
     [:option {:value "A"} "Option A"]
     [:option {:value "B"} "Option B"]
     [:option {:value "C"} "Option C"]]]

   ;; 复选框 (Checkbox)
   [:div
    [:label " Agree? "]
    [:input {:type "checkbox"
             :checked (:is-checked @form-state) ; 1. 使用 :checked, 值来自 atom
             :on-change (fn [e]
                          (swap! form-state assoc :is-checked (-> e .-target .-checked)))}]] ; 3. 更新 atom (注意是 .-checked)

   [:div [:button {:type "submit"} "Submit"]]])

10.3. 关键点:

  • :value (或 :checked) Prop: 输入元素的 :value (对于 <input type="text">, <textarea>, <select>) 或 :checked (对于 <input type="checkbox">, <input type="radio">) 属性直接绑定到你的 Reagent atom 中的相应状态.
  • :on-change Handler: 这是至关重要的. 当用户与输入交互时, 此处理程序被触发.
  • 获取新值::on-change 处理程序中, 你需要从事件对象 (e) 中提取新的值.
    • 对于文本输入和选择框, 通常是 (-> e .-target .-value).
    • 对于复选框, 是 (-> e .-target .-checked).
  • 更新 Atom: 使用 swap!reset! 来更新你的 Reagent atom. 这将触发组件的重新渲染, 反过来用新状态更新输入元素的 :value:checked.
  • 单向数据流: 这个模式强制执行了单向数据流: 状态 -> UI -> 事件 -> 更新状态 -> 重新渲染 UI.

10.4. 使用 Hooks (useState) 实现受控输入

如果你正在使用 Reagent 函数组件并且状态是局部的, 你可以使用 useState hook.

(ns myapp.hooks-form
  (:require [reagent.core :as r]
            ["react" :as React]))

(defn controlled-input-hook []
  (let [[name setName] (React/useState "")
        [message setMessage] (React/useState "")
        [option setOption] (React/useState "B")]

    [:div
     [:h3 "Hook Controlled Inputs"]
     [:div
      [:label "Name: "]
      [:input {:type "text"
               :value name ; 1. 值来自 hook state
               :on-change (fn [e]
                            (setName (-> e .-target .-value)))}]] ; 3. 调用 setter 更新 state

     [:div
      [:label "Message: "]
      [:textarea {:value message
                  :on-change #(setMessage (-> % .-target .-value))}]]

     [:div
      [:label "Option: "]
      [:select {:value option
                :on-change #(setOption (-> % .-target .-value))}
       [:option {:value "A"} "A"]
       [:option {:value "B"} "B"]
       [:option {:value "C"} "C"]]]

     [:p "Current Name: " name]
     [:p "Current Message: " message]
     [:p "Current Option: " option]]))

这个版本的工作方式非常相似, 只是状态管理从 Reagent atom 换成了 React/useState hook.

10.5. 为什么使用受控输入?

  • 单一事实来源: 组件状态始终包含输入的当前值, 使其易于访问和推理.
  • 验证和格式化: 你可以在 :on-change 处理程序中轻松实现输入验证或实时格式化, 然后再更新状态.
  • 动态行为: 可以根据其他状态或 props 禁用输入、更改其行为或有条件地渲染它.
  • 易于提交: 当需要提交表单时, 所有当前值都已在你的状态中可用, 无需查询 DOM.

10.6. 非受控组件 (Uncontrolled Components)

React 也支持 "非受控组件", 其中表单数据由 DOM 本身处理. 你可以使用 ref 来在需要时从 DOM 中提取值. 在 Reagent 中, 这不太常见, 因为 Reagent atoms 或 Hooks 通常提供了一种更惯用和更容易管理状态的方式.

然而, 在某些情况下 (例如与需要直接 DOM 访问的第三方库集成, 或非常简单的表单), 它们可能有用.

10.7. 结论

受控输入是 React 和 Reagent 中处理表单的标准且推荐的模式. 通过将输入的值或选中状态绑定到组件状态 (使用 Reagent atoms 或 useState), 并通过 :on-change 事件处理程序更新该状态, 你可以创建可预测、易于管理且功能强大的表单交互.

11. FAQ

11.1. FAQ: 组件不重新渲染

当你的组件没有像预期那样重新渲染时, 通常有以下几个原因:

  1. 没有解引用 (dereference) atom

    Reagent 组件只有在它们 解引用 的 Reagent atom 发生变化时才会自动重新渲染.

    (defonce app-state (reagent/atom {:text "Initial text"}))
    
    ;; 错误: 这个组件永远不会因为 atom 改变而重新渲染
    (defn buggy-component []
      (let [current-text (:text @app-state)] ; Atom 在 let 内部被解引用
        [:div
         "The text is: " current-text])) ; 渲染函数本身没有解引用 atom
    
    ;; 正确: 这个组件会在 atom 改变时重新渲染
    (defn working-component []
      [:div
       "The text is: " (:text @app-state)]) ; Atom 在渲染函数返回的 hiccup 中被解引用
    
    ;; 另一个正确的方式 (Form-2):
    (defn another-working-component []
      (let [state @app-state] ; 在 setup/外部函数中解引用
        (fn [] ;; 返回的渲染函数形成闭包
          [:div
           "The text is: " (:text state)]))) ;; 访问闭包中的值
    ;; 注意: 上面 Form-2 的例子只有在 app-state 改变导致父组件重新渲染并
    ;; 重新调用 another-working-component 时才会更新. 它不会仅因为 atom 改变而更新.
    ;; 更常见的模式是直接在渲染函数中解引用, 或者使用 RAtom/Reaction.
    

    关键: Reagent 需要知道渲染过程 依赖于 哪个 atom. 只有在渲染函数返回的 Hiccup 结构中直接 (或通过 Ratom/reaction 间接) 解引用 atom, Reagent 才能建立这种依赖关系.

  2. 参数没有改变 (对于 Form-2 和 Form-3 组件)

    Form-2 (返回函数的函数) 和 Form-3 (基于 map 的组件) 组件具有内置的优化: 如果传递给它们的参数与上次渲染时相比没有改变 (使用 ClojureScript 的 = 比较), Reagent 会跳过调用它们的渲染函数.

    (defn my-form2-component [data]
      (let [initial-data data] ; 在 setup 阶段捕获
        (fn [data] ;; 渲染函数
          [:pre (pr-str data)])))
    
    (let [state (atom {:a 1})]
      ;; 第一次渲染
      [my-form2-component @state]
    
      ;; 修改 atom, 但值不变
      (reset! state {:a 1})
    
      ;; 再次渲染
      [my-form2-component @state]
      ;; => my-form2-component 的渲染函数不会再次执行,
      ;;    因为传递的参数 {:a 1} 等于 (=) 上次的参数.
      )
    

    如果期望组件在参数值相同时也重新渲染 (这通常是不必要的), 可以:

    • 强制更新: 使用 reagent.core/force-update. (不推荐)
    • 改变参数: 确保每次渲染时传递一个 不同 的值 (即使内容相似). 例如, 添加一个递增的 :key 或随机数, 但这通常表明你的状态管理或组件结构可能需要重新思考.
    • 使用 Form-1 组件: Form-1 组件 (普通函数) 没有这个优化, 每次父组件渲染时它们都会被调用. 然而, React 仍然可能基于 VDOM diffing 跳过实际的 DOM 更新.
  3. 父组件没有重新渲染

    如果一个组件依赖于 Reagent atom, 并且该 atom 改变了, Reagent 会安排该组件重新渲染. 但是, 如果一个组件的更新依赖于从父组件接收到的 参数, 那么只有当父组件重新渲染 并且 传递了新的参数时, 子组件才会更新.

    确保触发更新的事件或状态变化最终导致了相关父组件的重新渲染.

  4. 修改了参数或 props

    React 的核心原则是 props 是不可变的. 不要尝试在组件内部修改传递给它的参数 (props). 如果你需要基于 props 初始化状态, 请在 let (Form-1/2) 或生命周期方法 (Form-3) 或 useState/useEffect (Hooks) 中进行, 并将可变状态存储在 atom 或 hook state 中.

    ;; 错误: 直接修改参数
    (defn bad-component [data]
      (set! (.-value data) "new value") ;; 非常糟糕的做法!
      [:div (:value data)])
    
    ;; 正确: 基于 prop 初始化局部状态
    (defn good-component [initial-data]
      (let [local-state (r/atom initial-data)]
        (fn [initial-data] ; props 仍然可以传入, 但不直接修改
          [:div
           [:p "Initial: " (pr-str initial-data)]
           [:p "Local: " (:value @local-state)]
           [:button {:on-click #(swap! local-state assoc :value "changed")}
            "Change Local State"]])))
    
  5. Hiccup 格式错误

    确保你的 Hiccup 格式正确. 一个常见的错误是忘记 props map 是可选的.

    ;; 错误: {:foo "bar"} 被当作子元素, 而不是 props
    [:div [:span "hello"] {:foo "bar"}]
    
    ;; 正确: props map 是第二个元素
    [:div {:foo "bar"} [:span "hello"]]
    

    通过仔细检查这些常见问题, 你通常可以诊断出组件未按预期重新渲染的原因.

11.2. FAQ: 使用 Refs

Refs 提供了一种访问 DOM 节点或在渲染函数中创建的 React 组件实例的方法. 它们在需要命令式地修改子节点 (例如管理焦点、触发动画、集成第三方 DOM 库) 时非常有用.

何时不使用 Refs

首先, 考虑是否有更声明式的方法来完成你的任务. 你通常应该优先尝试通过 props 和 state 来控制组件的行为. 例如, 不要使用 ref 来在模态框组件上调用 show() 或 hide() 方法, 而是向其传递一个 isOpen prop.

11.2.1. Reagent 中使用 Refs

Reagent 支持 React 的两种主要 ref 机制: Callback Refs 和通过 Hooks (useRef) 创建的对象 Refs. (它不支持旧版的 String Refs).

  1. Callback Refs (适用于所有组件类型) 这是在 Reagent 中使用 ref 的最灵活和推荐的方式 (尤其是在 Form-2/3 组件中). 你将一个函数传递给 Hiccup 元素的 :ref 属性. 当相应的 DOM 节点被挂载时, React 会调用这个函数并将 DOM 节点 (或组件实例) 作为参数传递给它. 当节点卸载时, React 会再次调用该函数并传递 nil.

    你通常会将节点存储在一个 atom 中, 以便在其他地方 (如事件处理程序或生命周期方法) 访问它.

    (ns myapp.refs
      (:require [reagent.core :as r]
                ["react-dom" :as rdom])) ; For findDOMNode if needed (less common now)
    
    (defn focus-input-example []
      (let [input-node (r/atom nil)] ; Atom 来存储 DOM 节点
        (fn []
          [:div
           "Input with Callback Ref:"
           [:input {:type "text"
                    ;; Callback ref: 当 input 挂载/卸载时调用
                    :ref (fn [element]
                           (js/console.log "Ref callback called with:" element)
                           (reset! input-node element))}] ; 存储节点到 atom
           [:button {:on-click (fn []
                                 (when-let [node @input-node]
                                   (.focus node)))} ; 从 atom 读取节点并调用 .focus()
            "Focus Input"]])))
    
    ;; --- 对于 Form-3 组件 ---
    (defn form3-ref-example []
      (let [node-atom (atom nil)]
        {:component-did-mount
         (fn [this] ; 'this' 是 Reagent 组件实例
           ;; 在挂载后访问 ref
           (when-let [node @node-atom]
             (js/console.log "Form-3 Mounted, input node:", node)
             ;; 你可以在这里聚焦或做其他事情
             ))
    
         :reagent-render
         (fn []
           [:div
            "Form-3 Input:"
            [:input {:type "text"
                     :ref #(reset! node-atom %)}]
            [:button {:on-click #(.focus @node-atom)} "Focus (Form-3)"]])}))
    

    要点:

    • :ref 的值是一个函数.
    • 该函数接收 DOM 元素或组件实例作为其参数 (如果 ref 附加到 React 组件而不是 DOM 元素).
    • 在节点卸载时, 该函数会以 nil 参数被调用, 给你一个清理的机会 (虽然在这个例子中 reset! 会自动处理).
    • 使用 atom (通常是 Clojure atom, 因为它不需要触发重新渲染) 来存储节点引用.
  2. Hook Refs (useRef) (仅限函数组件)

如果你正在编写 Reagent 函数组件 (Form-1 风格), 你可以使用 React/useRef hook. 这被认为是现代 React 中使用 ref 的标准方式.

useRef 返回一个可变的 ref 对象, 其 .current 属性被初始化为你传递的参数 (通常是 nil). 你可以将这个 ref 对象直接传递给元素的 :ref prop. React 会在挂载时将 DOM 节点或组件实例赋给 ref 对象的 .current 属性.

(ns myapp.hook-refs
  (:require ["react" :as React]
            [reagent.core :as r]))

(defn focus-input-hook []
  (let [input-ref (React/useRef nil)] ; 1. 创建 ref 对象

    (React/useEffect ; 效果钩子, 用于在渲染后执行副作用
     (fn []
       ;; 在挂载后聚焦
       (when-let [node (.-current input-ref)] ; 3. 通过 .-current 访问节点
         (.focus node))
       ;; 清理函数 (可选)
       #())
     #js []) ; 空依赖数组, effect 只在挂载和卸载时运行一次

    [:div
     "Input with Hook Ref:"
     [:input {:type "text"
              :ref input-ref}] ; 2. 将 ref 对象传递给 :ref prop
     [:button {:on-click (fn []
                           (when-let [node (.-current input-ref)]
                             (.focus node))) } ; 访问 .-current
      "Focus Input (Hook)"]]))

要点:

导入 ["react" :as React]. 调用 React/useRef(initialValue) 来创建 ref 对象. 将 ref 对象传递给 :ref prop. 通过 ref 对象的 .-current 属性访问节点或实例. 访问 .current 通常在 useEffect 或事件处理程序中进行, 以确保节点已经被渲染.

11.2.2. 访问 Reagent 组件实例 (不常见)

你也可以将 ref 附加到 Reagent 组件上 (而不是 DOM 元素), 但这通常不太需要. Callback ref 或 useRef 的 .current 将持有 Reagent 组件实例 (对于 Form-2/3) 或 nil (对于 Form-1). 你可以使用 reagent.core/dom-node 来获取 Reagent 组件实例对应的根 DOM 节点.

(defn my-reagent-comp [] [:div "I am a component"])

(defn parent-getting-ref []
  (let [comp-ref (r/atom nil)]
    (fn []
      [:div
       [my-reagent-comp {:ref #(reset! comp-ref %)}] ; Ref 附加到组件
       [:button {:on-click (fn []
                             (when-let [comp-instance @comp-ref]
                               (let [dom-node (r/dom-node comp-instance)]
                                 (js/console.log "Component Instance:" comp-instance)
                                 (js/console.log "Component DOM node:" dom-node))))}
        "Log Component Ref"]])))

总结

  • 优先使用声明式方法 (props/state).
  • 当需要命令式访问 DOM 或组件实例时, 使用 refs.
  • Callback Refs: 适用于所有 Reagent 组件类型, 通过将函数传递给 :ref prop 工作. 使用 atom 存储节点.
  • useRef Hook: 现代 React/Reagent 函数组件的标准方式. 返回一个 ref 对象, 通过 .current 访问节点.
  • 避免使用旧版的 String Refs.

11.3. FAQ: 使用 "实体" (Entity)

有时, 来自面向对象背景的开发者会问如何在 Reagent 中获取组件的 "实体" 或 "对象实例".

11.3.1. Reagent 组件不是传统意义上的对象

理解 Reagent 组件 (尤其是最常见的 Form-1 和 Form-2) 不是 类或对象实例至关重要.

  • Form-1: 只是一个返回 Hiccup (描述 UI 的数据结构) 的普通函数. 它没有自己的实例或生命周期. 每次父组件渲染时, 它都会被调用.
  • Form-2: 是一个返回 渲染函数 的函数. 外部函数只运行一次 (用于设置, 创建闭包状态), 内部的渲染函数在每次需要渲染时被调用. 你可以认为闭包环境有点像实例状态, 但它仍然不是一个典型的对象.
  • Form-3: 是一个返回描述组件行为的 map (包含 :reagent-render 和生命周期方法). 这是最接近类组件的形式. Reagent/React 在内部管理这些组件的实例, 你可以通过生命周期方法中的 this 参数访问到这个实例.

11.3.2. 你真正想做的是什么?

当你想要获取 "实体" 时, 通常是为了以下目的之一:

管理状态: 你想存储和更新只与该组件相关的数据. 解决方案: Reagent Atoms: 如果状态需要在组件的多次渲染之间保持, 或者需要被事件处理程序修改, 使用 reagent.core/atom (或 Clojure atom 如果不需要自动重绘). 对于 Form-2/3, 将 atom 定义在 let 绑定或闭包中. 对于需要跨组件共享的状态, 使用全局或传递的 atom. useState Hook: 对于函数组件 (Form-1 风格), 使用 React/useState 来管理局部状态. 调用方法/行为: 你想在组件上调用一个方法 (例如, reset 一个表单, focus 一个输入). 解决方案: 声明式 Props: 首选方法是传递 props 来控制组件的行为. 例如, 传递一个 :should-reset prop 而不是调用 .reset() 方法. Refs: 如果必须进行命令式操作 (如聚焦输入), 使用 Refs (Callback Refs 或 useRef) 来获取底层的 DOM 节点或组件实例 (对于 Form-3), 然后直接调用 DOM API (如 .focus()) 或在 Form-3 实例上定义的辅助函数 (不太常见). 提升状态/回调: 让父组件管理状态和逻辑, 并将回调函数传递给子组件. 子组件调用回调来通知父组件执行操作. 访问生命周期: 你需要在特定时间点执行代码 (例如, 组件挂载后获取数据, 卸载前清理资源). 解决方案: Form-3 生命周期方法: 使用 :component-did-mount, :component-will-unmount, :component-did-update 等. this 参数在这些方法中指向 Reagent 组件实例. useEffect Hook: 对于函数组件, 使用 React/useEffect 来处理副作用, 它涵盖了挂载、更新和卸载的场景.

11.3.3. Form-3 和 this

在 Form-3 组件的生命周期方法中, 你会收到一个 this 参数. 这个 this 是 Reagent 管理的组件实例. 你可以使用它来:

访问 props: (reagent/props this) 访问 state (如果你使用 Reagent 的内置状态管理, 但通常推荐 atom): (reagent/state this) 获取 DOM 节点: (reagent/dom-node this) 调用 force-update (不推荐): (r/force-update this)

(defn form3-example [initial-prop]
  {:component-did-mount
   (fn [this]
     (js/console.log "Mounted Form-3. Prop:" (:prop (r/props this)))
     (js/console.log "DOM Node:", (r/dom-node this)))

   :component-did-update
   (fn [this old-argv] ; old-argv 包含旧的 props 和参数
     (let [old-props (r/argv->props (vec old-argv)) ; 辅助函数提取 props
           current-props (r/props this)]
       (when (not= (:prop old-props) (:prop current-props))
         (js/console.log "Prop changed from" (:prop old-props) "to" (:prop current-props)))))

   :reagent-render
   (fn [prop] ; 参数直接传递给 render
     [:div "Prop is: " prop])})

;; 使用:
[form3-example "hello"]

总结

停止寻找 "实体" 或 "对象实例". 转而思考你的目标: 是管理状态, 调用行为, 还是与生命周期交互? 然后使用 Reagent/React 提供的机制:

状态: Reagent atoms, useState. 行为: 声明式 props, refs (用于命令式操作), 提升状态/回调. 生命周期: Form-3 方法, useEffect. 理解 Reagent 的函数式和声明式方法将帮助你更有效地构建应用程序.

11.4. FAQ: 我的属性 (Attributes) 丢失了

一个常见的 Hiccup 陷阱是忘记属性 map (props map) 必须 是 vector 中的第二个元素 (紧跟在类型 keyword/symbol 之后). 如果第二个元素不是 map, Reagent 会将其视为第一个子元素.

错误示例

;; 错误: {:class "foo" :id "bar"} 在子元素之后
[:div
 [:span "Some content"]
 {:class "foo" :id "bar"}]

在这个错误的示例中, Reagent 会将 [:span "Some content"] 和 {:class "foo" :id "bar"} 都视为 :div 的子元素. 最终渲染的 HTML 会是:

<div> <span>Some content</span> <!– ClojureScript map 通常会渲染为空白或 [object Object] –> </div>

class 和 id 属性没有被应用到 <div> 上.

正确示例

属性 map 必须紧跟在类型 keyword 之后:

;; 正确: {:class "foo" :id "bar"} 是第二个元素
[:div {:class "foo" :id "bar"}
 [:span "Some content"]]

这将正确地渲染为:

<div class="foo" id="bar">
  <span>Some content</span>
</div>

只有类型和属性, 没有子元素

如果一个元素只有属性而没有子元素, 那么 props map 仍然是第二个元素:

[:input {:type "text" :value "hello" :class "input-field"}]

只有类型和子元素, 没有属性

如果一个元素没有属性, 你可以省略 props map. 第一个非类型元素会被视为子元素:

[:ul
 [:li "Item 1"]
 [:li "Item 2"]]

动态属性和子元素

当你动态地构建 Hiccup 时, 这个问题尤其容易出现.

(defn my-dynamic-div [show-details? details]
  [:div
   (when show-details?
     {:data-details-visible "true"}) ; 错误: 这个 map 可能不是第二个元素
   [:h1 "Title"]
   (when show-details?
     [:p details])])

;; 调用 (my-dynamic-div true "Extra Info") 可能产生:
;; [:div {:data-details-visible "true"} [:h1 "Title"] [:p "Extra Info"]] -- 正确

;; 调用 (my-dynamic-div false "Extra Info") 可能产生:
;; [:div nil [:h1 "Title"] nil] -- 这里的 nil 是 when 返回的, [:h1 "Title"] 是第二个元素, 不是 map
;; 导致 <h1> 之后的元素被忽略或处理不当.

;; --- 更好的动态构建方式 ---

(defn my-better-dynamic-div [show-details? details]
  [:div (when show-details? {:data-details-visible "true"}) ; Props map (或 nil) 始终是第二个元素
   [:h1 "Title"]
   (when show-details?
     [:p details])])
;; 调用 (my-better-dynamic-div false "Extra Info") 产生:
;; [:div nil [:h1 "Title"] [:p details]]
;; Reagent 会忽略 nil 的 props map, 正确处理子元素.

;; --- 或者, 在外部构建 props map ---
(defn my-other-dynamic-div [show-details? details]
  (let [props (if show-details?
                {:class "details-shown" :data-foo "bar"}
                {:class "details-hidden"})]
    [:div props ; 将 props map 作为第二个参数传递
     [:h1 "Title"]
     (when show-details?
      [:p details])]))

总结

始终记住 Hiccup 的结构: [:type {:optional-props-map} children...]. 如果你想提供属性, 确保属性 map 是 vector 中的 第二个 元素. 如果没有属性, 则省略 map, 第二个元素将被视为第一个子元素.

11.5. FAQ: dangerouslySetInnerHTML

有时你需要直接在 Reagent 组件中渲染原始 HTML 字符串, 而不是通过 Hiccup 创建 DOM 节点. React (以及 Reagent) 提供了一个特殊的 prop dangerouslySetInnerHTML 来实现这个功能, 但你需要非常小心地使用它.

为什么它 "危险"?

直接从代码设置 HTML 内容可能使你的应用程序面临跨站脚本 (Cross-Site Scripting, XSS) 攻击的风险. 如果你渲染的 HTML 字符串来自用户输入或不受信任的来源, 攻击者可能会注入恶意的 <script> 标签或其他代码, 这些代码将在用户的浏览器中执行.

因此, 这个 prop 的名字故意带有警示意味. 你应该只在你完全信任要插入的 HTML 内容时 (例如, 它来自安全的、经过处理的源, 或者完全是静态的), 或者你已经对其进行了充分的净化 (sanitized) 处理时才使用它.

如何在 Reagent 中使用

dangerouslySetInnerHTML prop 接受一个 map 作为其值, 这个 map 必须 有一个键 _html, 其对应的值就是你想插入的原始 HTML 字符串.

(ns myapp.dangerous
  (:require [reagent.core :as r]))

(def my-raw-html-string "<p>This is <strong>bold</strong> and <em>italic</em> text.</p><script>alert('潜在的 XSS!');</script>")
(def safe-html-string "<p>This is from a trusted source.</p>")

(defn dangerous-component []
  [:div
   [:h2 "Rendering Raw HTML"]

   [:p "Using dangerouslySetInnerHTML:"]
   [:div {:dangerouslySetInnerHTML {:__html safe-html-string}}] ; 正确用法

   [:hr]

   [:p "Example with potentially unsafe HTML (Use with extreme caution!):"]
   ;; 警告: 仅用于演示目的. 不要直接渲染不受信任的 HTML!
   ;; 下面的代码如果执行, 且浏览器允许, 会显示一个 alert.
   #_[:div {:dangerouslySetInnerHTML {:__html my-raw-html-string}}]
   [:p [:em "(Potentially unsafe example commented out)"]]
   ])

关键点:

  • Prop 名称: 使用 :dangerouslySetInnerHTML.
  • 值是一个 Map: 该 prop 的值必须是一个 map.
  • _html 键: 这个 map 必须包含一个键 :_html.
  • 值是 HTML 字符串: :_html 的值是你想要渲染的原始 HTML 字符串.
  • 安全性: 再次强调, 绝对不要 将未经处理或不受信任的用户输入直接传递给 :_html. 在渲染之前, 必须对其进行净化处理 (例如, 使用像 goog.string.html.sanitize 这样的库).

替代方案

在许多情况下, 你可以通过标准的 Hiccup 标记达到同样的效果, 这样更安全:

;; 不使用 dangerouslySetInnerHTML, 而是使用 Hiccup:

[:div
 [:p "This is " [:strong "bold"] " and " [:em "italic"] " text."]]

仅在你确实需要渲染无法轻易用 Hiccup 表示的、来自可信来源的复杂 HTML 片段时, 才应考虑使用 dangerouslySetInnerHTML.

11.6. FAQ: cljsjs/react 相关问题

如果你在使用旧的 cljsjs/* 包来管理你的 React 依赖 (例如 cljsjs/react, cljsjs/react-dom), 你可能会遇到一些问题, 特别是随着 React 和 Reagent 版本的更新.

常见问题

版本不匹配: cljsjs/react, cljsjs/react-dom, 和 reagent 包之间可能存在版本不兼容. Reagent 的特定版本通常期望特定范围的 React 版本. 如果 cljsjs 包提供的版本与 Reagent 不兼容, 可能导致运行时错误或意外行为. 重复的 React 实例: 如果你的项目依赖或你使用的某些库也引入了它们自己的 React 版本 (可能来自 npm), 你最终可能会在运行时加载多个 React 实例. 这几乎总会导致问题, 因为 React 期望只有一个实例来管理组件和状态. 错误可能表现为 "Invalid hook call", state 丢失, 或其他奇怪的渲染问题. 与 npm 生态系统的冲突: 现代 ClojureScript 开发越来越多地直接利用 npm 包. 同时使用 cljsjs 和 npm 来管理 React 依赖会增加冲突的风险. 更新滞后: cljsjs 包通常是社区维护的对原始 JavaScript 库的包装. 它们可能不会像 npm 上的官方包那样快速更新到最新的 React 版本.

推荐的解决方案: 使用 npm 依赖

强烈建议直接使用 npm 来管理 React (和 React DOM) 的依赖, 而不是 cljsjs 包. 这确保了:

  • 你可以精确控制所使用的 React 版本.
  • 与依赖 npm 生态系统的其他 JavaScript 库的兼容性更好.
  • 减少了版本冲突和重复实例的风险.
  • 可以及时获取最新的 React 特性和修复.

如何切换到 npm 依赖 (以 shadow-cljs 为例)

移除 cljsjs 依赖: 从你的 project.clj 或 deps.edn 中移除 cljsjs/react, cljsjs/react-dom, cljsjs/react-dom-server 等依赖. 添加 npm 依赖: 如果你使用 shadow-cljs, 在你的 shadow-cljs.edn 文件中添加:

:dependencies
[[reagent "1.2.0"] ; Reagent 依赖
 ;; 添加 npm 依赖
 ["react" "18.2.0"]
 ["react-dom" "18.2.0"]]

或者, 更常见的是, 使用 npm 或 yarn 在项目根目录下管理 package.json:

npm install react@^18.2.0 react-dom@^18.2.0
# 
yarn add react@^18.2.0 react-dom@^18.2.0

然后在 shadow-cljs.edn 中只需要 Reagent 依赖. Shadow-cljs 会自动从 nodemodules 解析 React. 如果你使用 Figwheel Main 或 cljs.main, 在 deps.edn 中配置 :npm-deps:

:deps {org.clojure/clojurescript {:mvn/version "..."}
       reagent/reagent {:mvn/version "1.2.0"}}
:npm-deps {react "18.2.0"
           react-dom "18.2.0"}
;; 可能需要配置 :foreign-libs 或 :bundle target

更新 Requires: 在你的 ClojureScript 代码中, 你可能需要更新 require 语句. 如果你之前没有显式 require React, 你现在可能需要:

(ns myapp.core
  (:require [reagent.core :as r]
            ;; 如果你需要直接调用 React API (如 Hooks, Context)
            ["react" :as React]
            ["react-dom/client" :as rdom-client])) ; React 18

清理和重建: 删除旧的编译输出 (例如 target 目录), 运行 npm install 或 yarn, 然后重新构建你的 ClojureScript 项目.

结论

避免使用 cljsjs/react 和 cljsjs/react-dom. 直接通过 npm (并由你的 ClojureScript 构建工具如 shadow-cljs 或 Figwheel 管理) 依赖 React 和 React DOM. 这将提供更可靠、更兼容且更易于维护的开发体验.

11.7. FAQ: 强制组件重新创建 (销毁和重建)

通常, 当 React/Reagent 重新渲染一个组件时, 如果该组件在父组件的渲染输出中保持在相同的位置且类型相同, React 会尝试 更新 现有的组件实例, 而不是创建一个新的实例. 它会调用更新生命周期方法 (如 :component-did-update 或 useEffect) 并重用 DOM 节点 (如果可能).

然而, 在某些情况下, 你可能希望完全销毁 (unmount) 一个组件实例并创建一个全新的实例来替换它, 即使它在 UI 中的位置没有改变. 这通常是为了:

完全重置组件的内部状态: 当某些外部因素 (如用户导航到不同的页面或选择了一个完全不同的数据集) 发生变化时, 你希望组件的所有内部状态 (无论是来自 Reagent atoms、闭包还是 useState) 都恢复到其初始状态, 而不是尝试手动重置每个状态片段. 重新运行设置逻辑: 如果组件的 Form-2 设置代码或 Form-3 的 :component-did-mount 或 useEffect (带有空依赖数组) 包含了依赖于初始 props 的一次性设置逻辑, 你可能需要通过重新创建组件来重新运行这个逻辑.

如何强制重新创建: 使用 :key Prop

React 提供了一个特殊的 prop :key. 当你在一个元素或组件上使用 :key 时, React 使用这个 key 来识别该元素/组件的身份.

如果一个组件的 :key 从一次渲染到下一次渲染发生了变化, React 会认为这是一个 完全不同 的组件. 它会:

卸载 (Unmount): 销毁旧 key 对应的组件实例 (调用 :component-will-unmount 或 useEffect 的清理函数). 挂载 (Mount): 创建一个具有新 key 的全新组件实例 (运行 Form-2 设置代码, 调用 :component-did-mount 或 useEffect 的设置函数).

(ns myapp.keys
  (:require [reagent.core :as r]
            ["react" :as React]))

;; 一个带有内部状态的计数器组件 (使用 hook)
(defn stateful-counter [id]
  (let [[count setCount] (React/useState 0)]
    (React/useEffect
     (fn []
       (js/console.log (str "Counter [" id "] Mounted/Reset! Initial count: 0"))
       #()) ; No cleanup needed
     #js []) ; Effect runs only on mount

    [:div {:style {:border "1px solid blue" :margin "5px" :padding "5px"}}
     [:h4 "Counter ID: " id]
     "Count: " count
     [:button {:on-click #(setCount (inc count))} " + "]]))

(defn key-switcher []
  (let [current-id (r/atom "A")] ; Atom 来控制 key
    (fn []
      [:div
       [:h3 "Key Switcher Example"]
       [:button {:on-click #(reset! current-id (if (= @current-id "A") "B" "A"))}
        (str "Switch to Counter " (if (= @current-id "A") "B" "A"))]

       ;; 使用 atom 的值作为 key
       ;; 当 current-id 从 "A" 变为 "B" 或反之,
       ;; React 会销毁旧的 stateful-counter 并创建一个新的.
       [stateful-counter {:key @current-id} @current-id]

       ])))

在这个例子中:

  • stateful-counter 有自己的内部状态 (useState).
  • key-switcher 渲染 stateful-counter, 并将 @current-id (即 "A" 或 "B") 同时用作 prop id 和特殊的 :key prop.
  • 当你点击按钮时, @current-id 的值在 "A" 和 "B" 之间切换.
  • 因为 :key 改变了 (:key "A" 变为 :key "B"), React 不会尝试更新现有的 stateful-counter 实例. 相反, 它会卸载带有旧 key 的实例, 并挂载一个带有新 key 的全新实例. 你会看到控制台打印 "Counter [B] Mounted/Reset!" (或 A), 并且计数器会重置为 0.

何时使用这种技术?

当一个组件代表一个特定的实体 (例如, 由 ID 标识的用户资料页面、一个文档编辑器), 并且当这个实体改变时, 你希望组件从一个干净的状态开始. 当组件的内部状态很复杂, 手动重置所有状态比通过改变 :key 来重新创建组件更麻烦或更容易出错时.

注意事项:

性能: 销毁和创建组件比更新现有组件的开销更大. 不要过度使用 :key 来强制重新创建. 仅在确实需要完全重置时使用. 选择合适的 Key: Key 应该是能够唯一标识组件实例身份的字符串或数字. 它应该在组件的生命周期内保持稳定, 除非你 故意 想让它重新创建. 通常, 它是来自数据的唯一 ID. 不要 使用像 (rand) 或 (random-uuid) 这样在每次渲染时都会改变的值作为 key, 除非你明确希望组件在每次父组件渲染时都重新创建 (这很少见且通常效率低下). 通过策略性地使用 :key prop, 你可以有效地控制 React 组件的生命周期, 并在必要时强制它们完全重新创建.

11.8. FAQ: HTML 实体

如何在 Reagent/Hiccup 中渲染像 &nbsp; (不换行空格), &copy; (版权符号), &lt; (小于号) 这样的 HTML 实体?

推荐方法: 直接使用 Unicode 字符

最简单、最直接的方法是在你的 ClojureScript 字符串中直接使用相应的 Unicode 字符. ClojureScript (和现代浏览器) 对 Unicode 有很好的支持.

(defn entities-example []
  [:div
   [:p "This has extra" "\u00A0" "spacing."] ; \u00A0 是不换行空格 (nbsp)
   [:p "Copyright" "\u00A9" " 2023 Me."]     ; \u00A9 是版权符号 (copy)
   [:p "5 is" "\u003C" " 10"]                ; \u003C 是小于号 (lt)
   [:p "10 is" "\u003E" " 5"]                ; \u003E 是大于号 (gt)
   [:p "Use" "\u0026" " wisely."]           ; \u0026 是和号 (amp)

   ;; 你也可以直接输入字符 (如果你的编辑器和文件编码支持)
   [:p "Directly: This has extra spacing."] ; 直接输入的不换行空格
   [:p "Directly: Copyright© 2023 Me."]
   [:p "Directly: 5 < 10"]
   [:p "Directly: 10 > 5"]
   [:p "Directly: Use & wisely."]
  ])

React (以及 Reagent) 在将字符串渲染到 DOM 时, 会自动正确地转义 <、> 和 & 等字符, 以防止 XSS 攻击. 因此, 当你直接在字符串中使用这些字符时, 它们在 HTML 源代码中会被转义 (例如, < 变成 &lt;), 但在浏览器中会正确显示为字符本身.

对于像不换行空格 (&nbsp;) 或版权符号 (&copy;) 这样的字符, 使用它们的 Unicode 表示 (\uXXXX) 或直接输入字符通常是最好的方法.

不推荐的方法: 使用 HTML 实体字符串

不要 尝试在 Hiccup 字符串中直接写 HTML 实体名称, 如 "&nbsp;".

;; 错误: 这将按字面意思渲染 " " 字符串
[:p "This will show   literally."]

因为 React 会对字符串进行转义, "&nbsp;" 会被渲染到 DOM 中成为 &amp;nbsp;, 浏览器会将其显示为文本 " " 而不是一个不换行空格.

特殊情况: dangerouslySetInnerHTML

如果你 绝对 需要渲染包含未转义 HTML 实体的原始 HTML 字符串 (并且你完全信任该 HTML 的来源), 你可以使用 dangerouslySetInnerHTML.

(def raw_html_with_entities "<p>Raw HTML with   & < entities.</p>")

(defn dangerous-entities []
  [:div {:dangerouslySetInnerHTML {:__html raw_html_with_entities}}])
;; 这将正确渲染 HTML, 包括实体代表的字符.
;; 但请记住 dangerouslySetInnerHTML 的安全风险!

总结

  • 首选: 在 ClojureScript 字符串中直接使用 Unicode 字符 (\uXXXX 或直接输入字符). React 会为你处理必要的 HTML 转义, 以确保安全并正确显示.
  • 避免: 在字符串中写入 HTML 实体名称 (如 "&nbsp;").
  • 最后手段: 仅在处理可信的原始 HTML 时使用 dangerouslySetInnerHTML.

对于绝大多数情况, 直接使用 Unicode 字符是最简单、最安全、最符合 Reagent/React 理念的方法.

在生命周期钩子中使用 Props (来自外部博客文章) [基于 Nils Blum-Oeste 的文章 "ClojureScript’s Reagent: Using props in lifecycle hooks" 的核心概念]

在 Reagent 的 Form-3 组件 (返回 map 的组件) 中, 你经常需要在生命周期方法 (如 :component-did-mount, :component-did-update) 中访问传递给组件的 props (参数).

访问当前 Props

Form-3 生命周期方法接收 this 作为它们的第一个参数, 它代表 Reagent 组件实例. 你可以使用 reagent.core/props 函数来从这个实例中提取当前的 props map.

(ns myapp.lifecycle-props
  (:require [reagent.core :as r]))

(defn lifecycle-props-example [message] ; message 是 prop
  {:component-did-mount
   (fn [this]
     ;; 从 'this' 实例获取 props map
     (let [current-props (r/props this)]
       (js/console.log "Component Mounted. Message prop:" (:message current-props))))

   :component-will-unmount
   (fn [this]
     (js/console.log "Component Will Unmount. Message was:" (:message (r/props this))))

   :reagent-render
   (fn [message] ; prop 也直接传递给 render
     [:div
      [:h3 "Lifecycle Props Demo"]
      [:p "Current Message: " message]])})

;; 使用:
[:div
 [lifecycle-props-example "Hello Initial"]
 ;; 当这个组件因为外部原因被移除时, unmount 会被调用.
]

访问之前的 Props (:component-did-update)

:component-did-update 生命周期方法稍微特殊一些. 它在组件更新发生 之后 被调用, 并且它接收 之前的 props 和参数作为其第二个参数 (通常命名为 old-argv 或 prev-argv).

old-argv 是一个包含旧 props 和参数的 vector. Reagent 提供了一个辅助函数 reagent.core/argv->props 来方便地从中提取旧的 props map. 当前的 props 仍然可以通过 (r/props this) 获取.

(defn update-props-example [data]
  {:component-did-update
   (fn [this old-argv] ; old-argv 是包含旧 props/参数的 vector
     (let [;; 从 this 获取当前 props
           current-props (r/props this)
           ;; 从 old-argv 提取旧 props
           old-props (r/argv->props (vec old-argv))] ; argv->props 需要 vector

       (js/console.log "Component Updated.")
       (when (not= (:value old-props) (:value current-props))
         (js/console.log "Data prop 'value' changed from:" (:value old-props)
                         "to:" (:value current-props)))))

   :reagent-render
   (fn [data]
     [:div
      [:h3 "Update Props Demo"]
      [:pre (pr-str data)]])})


;; 父组件控制 props
(defn parent-controller []
  (let [data-atom (r/atom {:value 10 :other "info"})]
    (fn []
      [:div
       [:button {:on-click #(swap! data-atom update :value inc)} "Increment Value"]
       [update-props-example @data-atom] ; 将 atom 的值作为 prop 传递
       ])))

在这个例子中:

当父组件中的 data-atom 改变时, update-props-example 会接收到新的 data prop 并重新渲染. :component-did-update 会被触发. old-argv 将包含上一次渲染时的 props/参数 vector (例如 [{:value 10 :other "info"}]). (r/argv->props (vec old-argv)) 会提取出旧的 props map {:value 10 :other "info"}. (r/props this) 会得到当前的 props map (例如 {:value 11 :other "info"}). 然后代码比较新旧 props 的 :value 来检测变化.

总结

在 Form-3 生命周期方法中, this 参数是 Reagent 组件实例. 使用 (reagent.core/props this) 获取当前传递给组件的 props map. 在 :component-did-update 中, 第二个参数 old-argv 是包含旧 props/参数的 vector. 使用 (reagent.core/argv->props (vec old-argv)) 来获取旧的 props map. 这允许你在组件生命周期的关键点比较当前和之前的 props, 以执行条件逻辑或副作用.

Author: 青岛红创翻译

Created: 2025-06-20 Fri 16:16