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 的方法: simple 和 malli.
让我们从 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 是一个双参数函数. 如果只传递一个回调函数, 它的行为就像没有依赖项一样, 所以回调将在每个组件生命周期中执行一次(类似于 didMount 和 willUnmount).
如果想传递依赖项, 有两种方式:
- 将一个 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-callback 是 React.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/defc 或 m/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 文件).