Rumext 用户指南

Table of Contents

Rumext 是一个用于在 ClojureScript 中构建 Web UI 的工具.

是对 React >= 18 的一层薄封装, 专注于性能并提供 Clojure-idiomatic (符合 Clojure 习惯) 的接口.

它使用 Clojure 宏来实现与 JSX 格式相同的目标, 而无需使用任何非原生 Clojure 的语法. HTML 以一种受 hiccup 库启发的格式表示, 但有其自己的实现.

HTML 代码表示为嵌套数组, 其中使用关键字作为标签和属性. 例如:

[:div {:class "foobar"
       :style {:background-color "red"}
:on-click some-on-click-fn}
"Hello World"]

宏非常智能, 能够将属性名称从 lisp-case (短横线命名法) 转换为 camelCase (驼峰命名法), 并将 :class 重命名为 className.

因此, 此片段编译后的 JavaScript 代码可能如下所示:

React.createElement("div",
                    {className: "foobar",
                     style: {"backgroundColor": "red"},
                     onClick: someOnClickFn},
                    "Hello World");

当应用在浏览器中加载时, 将渲染以下内容:

<div class="foobar"
     style="background-color: red"
     onClick=someOnClickFn>
  Hello World
</div>

警告: 此工具主要是为了在 Penpot 中使用而实现的, 并为了方便而作为独立项目发布, 不对向后兼容性做出承诺.

1. 安装

添加到 deps.edn:

funcool/rumext
{:git/tag "v2.20"
 :git/sha "7f5e1cd"
 :git/url "[https://github.com/funcool/rumext.git](https://github.com/funcool/rumext.git)"}

2. 实例化元素和自定义组件

2.1. 传递 props

如上所示, 当使用类似 Hiccup 的语法时, 可以用一个关键字(如 :div, :span:p)创建一个 HTML 元素.

你还可以指定一个属性 map, 这些属性在编译时会转换为一个 JavaScript 对象.

重要提示: JavaScript 普通对象与 Clojure 普通 map 是不同的. 在 ClojureScript 中, 你可以使用特定的 API 处理可变的 JS 对象 , 并与 Clojure map 之间进行相互转换. 你可以在 ClojureScript Unraveled 一书中了解更多信息.

Rumext 宏具有一些特性, 可以更方便地, 以符合 Clojure 习惯的方式传递属性. 例如, 当使用 [:div {...}] 语法时, 不需要添加 #js 前缀, 它会自动添加.

还有一些属性名称的自动转换:

  • lisp-case 格式的名称会转换为 camelCase.
  • 保留名称如 class 会被转换为 React 的约定, 如 className.
  • 已经是 camelCase 的名称会直接传递, 不进行转换.
  • data-aria- 开头的属性也会直接传递.
  • 转换仅应用于 :keyword 属性. 也可以发送字符串属性, 这些属性不会被处理.

需要注意的是, 这些转换是在 编译时 执行的, 对运行时性能没有影响.

2.2. 动态元素名称和属性

有时, 我们需要在运行时动态选择或构建元素名称; 或者动态构建 props; 或者从用户定义的组件创建元素.

为此, Rumext 提供了一个特殊的宏: :>, 这是一个通用的处理器, 用于将动态定义的 props 传递给 DOM 原生元素或从用户定义的组件创建元素.

要动态定义元素, 只需将一个带有名称的变量作为 :> 的第一个参数传递.

(let [element (if something "div" "span")]
  [:> element {:class "foobar"
               :style {:background-color "red"}
               :on-click some-on-click-fn}
   "Hello World"])

要提供一个动态的属性 map, 也可以将一个变量作为第二个参数传递:

(let [props #js {:className "fooBar"
                 :style #js {:backgroundColor "red"}
                 :onClick some-on-click}]
  [:> "div" props
   "Hello World"])

重要提示 如果在 :> 宏之外动态定义属性, 则不会有自动转换. 因此, 需要使用 #js 前缀或其他方式将 map 定义为纯 JavaScript 对象. 还需要使用 camelCase 名称, 并记住使用 className 而不是 class.

有几个工具可以更方便地管理动态属性.

2.3. mf/spread-props

mf/spread-props宏, 使用 JS 的扩展运算符({...props1, ...props2})在两个 props 数据结构之间进行合并. 如果你将一个字面量 map 作为第二个参数传递, 此宏还会执行名称转换.

通常用法如下:

(mf/defc my-label*
  [{:keys [name class on-click] :rest props}]
  (let [class (or class "my-label")
        props (mf/spread-props props {:class class})]
    [:span {:on-click on-click}
     [:> :label props name]]))

2.4. mf/props

辅助宏, 用于从 Clojure map 创建 JavaScript props 对象, 并应用名称转换.

以下是一个如何使用它并与 mf/spread-props 结合的示例:

(mf/defc my-label*
  [{:keys [name class on-click] :rest props}]
  (let [class (or class "my-label")
        new-props (mf/props {:class class})
        all-props (mf/spread-props props new-props)]
    [:span {:on-click on-click}
     [:> :label props name]]))

2.5. mf/map->props

在某些情况下, 需要从一个动态的 Clojure 对象创建 props. 你可以使用 mf/map->props 函数, 但请注意, 它在 运行时 进行到 JavaScript 的转换和名称转换 , 因此每次渲染都会增加一些开销. 如果性能很重要, 请考虑这一点.

(let [clj-props {:class "my-label"}
      props (mf/map->props clj-props)]
  [:> :label props name])

2.6. 实例化自定义组件

你可以将自定义组件(见 下文)的名称传递给 :> 宏来创建它的实例:

(mf/defc my-label*
  [{:keys [name class on-click] :rest props}]
  [:span {:on-click on-click}
   [:> :label props name]])

(mf/defc other-component*
  []
  [:> my-label* {:name "foobar" :on-click some-fn}])

3. 创建 React 自定义组件

defc 宏是 Rumext UI 的基本构建块. 用于生成 React 的 函数组件 (function component), 并添加了一些适配, 使其在 ClojureScript 代码中更方便使用 , 例如前面解释过的 camelCase 转换和保留名称更改.

例如, 下面定义了一个 React 组件:

(require '[rumext.v2 :as mf])

(mf/defc title*
  [{:keys [label-text] :as props}]
  [:div {:class "title"} label-text])

此代码块编译后的 JavaScript 将类似于由此 JSX 代码块获得的内容:

function title({labelText}) {
  return (
      <div className="title">
      {labelText}
    </div>
  );
}

注意: 组件名称中的 * 是一个强制性约定, 用于在视觉上正确区分 React 组件和 Clojure 函数. 它还启用了当前处理 props 的默认方式. 如果不使用 * 后缀, 组件将以旧版模式运行(参见下面的 *FAQ).

这样创建的组件可以挂载到 DOM 上:

(ns myname.space
  (:require
   [goog.dom :as dom]
   [rumext.v2 :as mf]))

(def root (mf/create-root (dom/getElement "app")))
(mf/render! root (mf/html [:> title* {:label-text "hello world"}]))

或者可以使用 mf/element, 但在这种情况下, 需要以原始 JavaScript 形式提供属性, 因为这个宏没有自动转换功能:

(ns myname.space
  (:require
   [goog.dom :as dom]
   [rumext.v2 :as mf]))

(def root (mf/create-root (dom/getElement "app")))
(mf/render! root (mf/element title* #js {:labelText "hello world"}))

3.1. 读取组件 props 和解构

当 React 实例化一个函数组件时, 它会传递一个 props 参数, 这是一个包含调用点定义的属性名称和值的 JS map.

通常, JavaScript 对象是不能被解构的. 但 defc 宏实现了解构功能, 这与 Clojure map 类似, 但有一些细微的差别和方便的增强功能 , 使得处理 React props 和习惯用法变得容易, 比如前面解释过的 camelCase 转换.

(mf/defc title*
  [{:keys [title-name] :as props}]
  (assert (object? props) "expected object")
  (assert (string? title-name) "expected string")
  [:label {:class "label"} title-name])

如果组件是通过 [:> 宏(如 上文 所述)调用的, 将会有两次编译时转换, 一次在调用时, 另一次在解构时.

在 Clojure 代码中, 所有名称都将是 lisp-case, 但如果你检查生成的 JavaScript 代码, 你会看到 camelCase 格式的名称.

3.2. 默认值

也像通常的解构一样, 你可以使用 :or 结构为属性提供默认值:

(mf/defc color-input*
  [{:keys [value select-on-focus] :or {select-on-focus true} :as props}]
  ...)

3.3. 剩余 props

一个额外的习惯用法(特定于 Rumext 组件宏, 在标准 Clojure 解构中不可用)是使用 :rest 结构获取一个包含所有未解构 props 的对象.

这允许提取组件控制的 props, 并将剩余部分留在一个对象中, 可以原样传递给下一个元素.

(mf/defc title*
  [{:keys [name] :rest props}]
  (assert (object? props) "expected object")
  (assert (nil? (unchecked-get props "name")) "no name in props")

  ;; See below for the meaning of `:>`
  [:> :label props name])

3.4. 不使用解构读取 props

解构是可选的. 可以接收完整的 props 参数, 稍后再读取属性. 但在这种情况下没有自动转换:

(mf/defc color-input*
  [props]
  (let [value            (unchecked-get props "value")
        on-change        (unchecked-get props "onChange")
        on-blur          (unchecked-get props "onBlur")
        on-focus         (unchecked-get props "onFocus")
        select-on-focus? (or (unchecked-get props "selectOnFocus") true)
        class            (or (unchecked-get props "className") "color-input")

推荐的读取 props JavaScript 对象的方式是使用 ClojureScript 核心函数 unchecked-get. 这会直接转换为 JavaScript 的 props["propName"].

由于 Rumext 注重性能, 这是在一般情况下读取 props 最有效的方式. 其他方法, 如 Google Closure Library 中的 obj/get, 会增加额外的安全检查 , 但在这种情况下是不必要的, 因为 React 保证 props 属性有值, 尽管它可能是一个空对象.

3.5. 转发引用 (Forwarding references)

在 React 中, 有一种机制可以为渲染的 DOM 元素设置引用, 以便后续操作. 组件也可以接收这个引用并将其传递给内部元素. 这被称为"引用转发" , 要在 Rumext 中实现它, 需要添加 forward-ref 元数据. 然后, 引用将作为第二个参数传递给 defc 宏:

(mf/defc wrapped-input*
  {::mf/forward-ref true}
  [props ref]
  (let [...]
    [:input {:style {...}
             :ref ref
             ...}]))

在 React 19 中, 这将不再是必需的, 因为你将能够直接在 props 内部传递 ref. 但 Rumext 目前只支持 React 18.

4. Props 检查

Rumext 库提供了两种检查 props 的方法: simplemalli.

让我们从 simple 开始, 它包括简单的存在性检查或纯粹的谓词检查. 为此, 我们有 mf/expect 宏, 它接收一个 Clojure set , 如果 set 中的任何 props 没有被提供给组件, 它就会抛出异常:

(mf/defc button*
  {::mf/expect #{:name :on-click}}
  [{:keys [name on-click]}]
  [:button {:on-click on-click} name])

prop 名称遵循与解构相同的规则, 所以你应该使用相同的名称.

有时简单的存在性检查是不够的; 对于这些情况, 你可以给 mf/expect 一个 map, 其中键是 props, 值是谓词:

(mf/defc button*
  {::mf/expect {:name string?
                :on-click fn?}}
  [{:keys [name on-click]}]
  [:button {:on-click on-click} name])

如果这还不够, 你可以使用 mf/schema 宏, 它支持 malli schemas作为 props 的验证机制:

(def ^:private schema:props
  [:map {:title "button:props"}
   [:name string?]
   [:class {:optional true} string?]
   [:on-click fn?]])

(mf/defc button*
  {::mf/schema schema:props}
  [{:keys [name on-click]}]
  [:button {:on-click on-click} name])

重要提示: props 检查遵循 :elide-asserts 编译器选项, 默认情况下, 如果在生产构建中没有明确更改配置值, 它们将被移除.

5. Hooks

可以直接使用 React hooks, 因为它们被 Rumext 作为 mf/xxx 包装函数暴露出来. 此外, Rumext 提供了几个特定的 hooks , 它们对 React 的 hooks 进行了适配, 以提供更符合 Clojure 习惯的接口.

React hooks 以其在 React 中的原样暴露, 函数名为 camelCase, 而 Rumext hooks 使用 lisp-case 语法, 喜欢哪种就用哪种.

这里只记录了可用 hooks 的一个子集; 参阅 React API 参考文档 以获取有关可用 hooks 的详细信息.

5.1. use-state

类似于 React.useState, 提供相同的功能, 但使用 ClojureScript 的 atom 接口.

调用 mf/use-state 返回一个类似 atom 的对象, 解引用(deref)它将得到当前值, 你可以对它调用 swap!reset! 来修改其状态. 返回的对象始终具有稳定的引用(在重新渲染之间不会改变).

任何修改都会安排组件重新渲染.

(require '[rumext.v2 as mf])

(mf/defc local-state*
  [props]
  (let [clicks (mf/use-state 0)]
    [:div {:on-click #(swap! clicks inc)}
     [:span "Clicks: " @clicks]]))

这在功能上等同于直接使用 React hook:

(mf/defc local-state*
  [props]
  (let [[counter update-counter] (mf/useState 0)]
    [:div {:on-click (partial update-counter #(inc %))}
     [:span "Clicks: " counter]]))

5.2. use-var

use-state 一样返回一个类似 atom 的对象. 唯一的区别是更新 ref 的值 不会 安排组件重新渲染. 在底层, 它使用 useRef hook.

5.3. use-effect

类似于 React.useEffect hook, 但调用约定有微小变化(参数顺序相反).

这是一个原语, 允许将可能有副作用的代码合并到函数组件中:

(mf/defc local-timer*
  [props]
  (let [local (mf/use-state 0)]
    (mf/use-effect
     (fn []
       (let [sem (js/setInterval #(swap! local inc) 1000)]
         #(js/clearInterval sem))))
    [:div "Counter: " @local]))

use-effect 是一个双参数函数. 如果只传递一个回调函数, 它的行为就像没有依赖项一样, 所以回调将在每个组件生命周期中执行一次(类似于 didMountwillUnmount).

如果想传递依赖项, 有两种方式:

  • 将一个 JS 数组作为第一个参数传递(就像在 React 中一样, 但顺序相反).
  • 使用 rumext.v2/deps 辅助函数:

    (mf/use-effect
     (mf/deps x y)
     (fn [] (do-stuff x y)))
    

最后, 如果想在每次渲染时都执行它, 将 nil 作为依赖项传递(与原始 useEffect 的工作方式非常相似).

为了方便, 有一个 mf/with-effect 宏, 可以减少一级缩进:

(mf/defc local-timer*
  [props]
  (let [local (mf/use-state 0)]
    (mf/with-effect []
      (let [sem (js/setInterval #(swap! local inc) 1000)]
        #(js/clearInterval sem)))
    [:div "Counter: " @local]))

在这里, 依赖项必须作为向量(第一个参数)内的元素传递.

显然, 也可以通过 mf/useEffect 直接使用 React hook.

5.4. use-memo

use-effect 类似, 这个 hook 类似于 React 的 useMemo hook, 但参数顺序相反.

这个 hook 的目的是返回一个 memoized (记忆化) 的值.

示例:

(mf/defc sample-component*
  [{:keys [x]}]
  (let [v (mf/use-memo (mf/deps x) #(pow x 10))]
    [:span "Value is: " v]))

在每次渲染时, 只要 x 的值相同, v 将只计算一次.

这也可以用 rumext.v2/with-memo 宏来表达, 它减少了一级缩进:

(mf/defc sample-component*
  [{:keys [x]}]
  (let [v (mf/with-memo [x]
            (pow x 10))]
    [:span "Value is: " v]))

5.5. use-fn

use-memo 的一个特例, 其中 memoized 的值是一个函数定义.

它是 use-callback 的别名, 而 use-callbackReact.useCallback 的包装.

5.6. deref

一个 Rumext 自定义 hook, 它为组件添加了对 atom 变化的反应性. 调用 mf/deref 返回与 Clojure 的 deref 相同的值, 但当值改变时也会触发组件重新渲染.

示例:

(def clock (atom (.getTime (js/Date.))))
(js/setInterval #(reset! clock (.getTime (js/Date.))) 160)

(mf/defc timer*
  [props]
  (let [ts (mf/deref clock)]
    [:div "Timer (deref): "
     [:span ts]]))

在内部, 它使用了 react.useSyncExternalStore API 以及 atom 的 watch 能力.

6. 高阶组件 (Higher-Order Components)

React 允许创建一个组件来适配或包装另一个组件, 以扩展它并添加额外功能. Rumext 包含一个方便的机制来实现这一点: ::mf/wrap 元数据.

目前 Rumext 提供了一个这样的组件:

  • mf/memo: 类似于 React.memo, 基于 props 比较为组件添加 memoization. 这允许在 props 没有改变的情况下完全避免执行组件函数.

    (mf/defc title*
      {::mf/wrap [mf/memo]}
      [{:keys [name]}]
      [:div {:class "label"} name])
    

默认情况下, 使用 identical? 谓词来比较 props; 你可以传递一个自定义的比较函数作为第二个参数:

(mf/defc title*
  {::mf/wrap [#(mf/memo % =)]}
  [{:keys [name]}]
  [:div {:class "label"} name])

为了更方便, Rumext 有一个特殊的元数据 ::mf/memo, 它简化了组件 props memoization 的一般情况.

如果传递 true, 它的行为将与 ::mf/wrap [mf/memo]React.memo(Component) 相同. 你也可以传递一个字段集合;

在这种情况下, 它将创建一个特定的函数来测试该 props 集合的相等性.

如果你想创建自己的高阶组件, 可以使用 mf/fnc 宏:

(defn some-factory
  [component param]
  (mf/fnc my-high-order-component*
          [props]
          [:section
           [:> component props]]))

7. FAQ

7.1. 与 RUM 的区别

这个项目最初是作为 rum 的一个友好分支, 供个人使用, 但后来演变成一个完全独立的库, 现在不依赖于它, 并且可能不再保留任何原始代码.

无论如何, 非常感谢 Tonksy 创建了 rum.

以下是主要区别的列表:

  • 使用基于函数的组件而不是基于类的组件.
  • 为 React Hooks 提供了 ClojureScript 友好的抽象.
  • 组件主体是静态编译的( благодаря hicada, 从不在运行时解释).
  • 专注于性能, 目标是在 React 之上提供几乎 0 的运行时开销.

7.2. 为什么示例中的导入别名是 mf?

导入 RUM 项目的通常约定是使用 rum/defcm/defc. 对于 Rumext, 最直接的缩写是 mx/defc. 但是那个前缀已经被其他东西使用了.

所以我们最终选择了 mf/defc. 但这不是强制性的, 只是我们在本手册和 Penpot 中遵循的一个约定.

7.3. 什么是旧版模式 (legacy mode)?

在早期版本的 Rumext 中, 组件的默认行为是自动将来自 React 的 props JavaScript 对象转换为 Clojure 对象, 以便可以通过正常的解构或任何其他读取对象的方式来读取它.

此外, 你可以使用 :& 处理器代替 :> 来提供一个 Clojure 对象, 该对象会被转换为 JavaScript 以传递给 React.

但这两种转换都是在 运行时 完成的, 从而为组件的每次渲染都增加了转换开销. 由于 Rumext 针对性能进行了优化, 这种行为现在已被弃用. 通过上面解释的宏解构和其他工具, 你可以几乎同样方便地进行参数传递, 但所有的更改都在编译时完成.

目前, 名称不使用 * 作为后缀的组件会以旧版模式运行. 你可以通过添加 ::mf/props :obj 元数据来激活新行为, 但所有这些现在都被认为是已弃用的. 所有新组件都应该在名称中使用 *.

8. 许可证

根据 MPL-2.0 许可(详见仓库根目录的 LICENSE 文件).

Author: 青岛红创翻译

Created: 2025-11-01 Sat 20:36