Reagent文档:创建组件
在Reagent中, 基本的构建单元是组件.
Reagent应用程序通常会有许多组件--比如说, 多于5个但少于100个--而一个Reagent应用的整体用户界面就是所有这些组件拼接在一起的输出, 每个组件都贡献一部分整体的HTML, 以层次结构排列.
因此, 它们很重要, 本文档描述了如何创建它们.
非绝对入门
本文尽可能保持在基础的范围, 但写作的目的并非作为入门教程. 假设读者已经理解基本用法后, 再阅读这份文档.
这份文档的阐明了基础原理, 代表了额外的学习, 可能会帮你避免一些恼人的小问题.
本文包含谎言和歪曲
因为作者更关心提供一个有用的心智模型, 而不是陷入完整真相的泥潭. 接下来会有一些善意的谎言和歪曲.
组件的核心
任何组件的核心都是一个渲染函数.
渲染函数是组件的必要部分, 事实上, 组件有时候仅仅是一个渲染函数.
渲染函数将数据转换为HTML. 数据通过函数参数提供, HTML是返回值.
数据进来, HTML出去.
大多数时间, 渲染函数会是一个纯函数. 如果你将相同的数据传递给渲染函数, 那么它将返回相同的HTML, 并且不会产生副作用.
注意: 最终, 周围的Reagent/React框架将因返回的HTML被插入到DOM中(改变全局状态! )而导致非纯的副作用, 但在这里, 我们只关心渲染函数本身的纯度)
创建组件的三种方式
有三种方法可以创建组件.
按复杂性递增排序, 它们是:
- 通过一个简单的渲染函数--数据作为参数传入, 返回HTML.
- 通过一个返回渲染函数的函数--返回的函数就是渲染函数.
- 通过一个函数映射, 其中一个是渲染函数, 其他函数是React生命周期方法, 允许进行一些更高级的干预.
在所有这三种情况中, 都提供了一个渲染函数--这是核心. 这三种创建方法只在它们提供的额外内容上有所不同.
形式1: 一个简单的函数
在最简单的情况下, 一个组件只简化为一个渲染函数. 不需要提供其他任何东西.
虽然这是一个简单的方法, 但根据我的经验, 大概会使用形式1的组件大约40%的时间, 也许更多. 简单且有用.
只需编写一个常规的ClojureScript函数, 该函数接受数据作为参数并生成HTML.
(defn greet
[name] ;; 数据输入是一个字符串
[:div "Hello " name]) ;; 返回Hiccup(HTML)
到目前为止, 我谈到的渲染函数返回HTML. 当然, 这并不严格准确, 正如你在官方介绍中看到的那样. 相反, 渲染器始终返回指定HTML的ClojureScript数据结构, 使用Hiccup格式.
Hiccup使用向量来表示HTML元素, 使用映射来表示元素的属性.
因此, 这个ClojureScript数据结构:
[:div {:style {:background "blue"}} "hello " "there"]
实际上只是一个包含关键字, 映射和两个字符串的ClojureScript vector. 但当作为Hiccup处理时, 这个数据结构将产生HTML:
<div style="background:blue;">hello there</div>
要了解更多关于Hiccup的信息, 请参阅Hiccpu的wiki.
新手错误
在某个时刻, 你可能会尝试在一个普通的cljs向量中返回同级HTML元素:
(defn wrong-component
[name]
[[:div "Hello"] [:div name]]) ;; 一个包含2个[:div]的向量
这不是有效的Hiccup, 会得到一个令人困惑的错误. 必须通过将两个同级元素包装在一个父[:div]中来纠正这个错误:
(defn right-component
[name]
[:div
[:div "Hello"]
[:div name]]) ;; [:div]包含两个嵌套的[:div]
或者, 可以返回一个React Fragment. 在reagent中, React Fragment是使用:<> Hiccup形式创建的.
(defn right-component
[name]
[:<>
[:div "Hello"]
[:div name]])
根据React文档中的示例, Columns组件可以在reagent中定义为:
(defn columns
[]
[:<>
[:td "Hello"]
[:td "World"]]
形式2: 返回函数的函数
现在, 让我们在复杂性上再向上迈一步. 有时, 一个组件需要:
- 一些初始值.
- 一些局部状态.
- 一个渲染器.
前两个是可选的, 最后一个则不是.
形式2的组件编写为一个外部函数, 返回一个内部渲染.
这个示例取自教程:
(defn timer-component []
(let [seconds-elapsed (reagent/atom 0)] ;; 设置和局部状态
(fn [] ;; 返回的内部, 渲染函数
(js/setTimeout #(swap! seconds-elapsed inc) 1000)
[:div "Seconds Elapsed: " @seconds-elapsed])))
这里timer-component是外部函数, 它返回一个内部的匿名渲染函数, 该函数关闭了初始化的局部状态seconds-elapsed.
像之前一样, 渲染函数的任务是将数据转换为HTML. 这是核心. 只是形式2允许你的渲染器关闭由外部创建并初始化的一些状态.
根据我的经验, 一半的组件将是form2组件.
让我们清楚地了解这里发生了什么:
- timer-component每个组件实例调用一次(并为该实例创建状态)
- 它返回的渲染函数将可能被调用很多次. 事实上, 每当Reagent检测到该组件输入可能有差异时, 就会调用它.
新手错误
刚开始时, 每个人都会在Form-2构造中犯这个错误: 他们忘记在内部的匿名渲染函数中重复参数.
(defn outer
[a b c] ;; <--- 参数
;; ....
(fn [a b c] ;; <--- 忘记重复它们, 是一个新手错误
[:div
(str a b c)]))
所以新手错误是忘记在内部渲染函数上放置[a b c]参数.
记住, outer函数每个组件实例调用一次. 参数将持有初始参数值. 另一方面, 渲染器(匿名函数fn)将被Reagent多次调用, 每次都可能具有不一样的参数值, 但除非你在渲染器上写名输入形式参数, 否则它会始终是outer中的初始值.
因此, 组件渲染器将始终只渲染原始参数值, 而不是更新的参数值, 这会非常令人困惑.
形式3: 具有生命周期方法的类
现在, 进入复杂性的最后一步.
根据我的经验, 形式3的组件不到1%, 也许只有在想使用像D3这样的js库或引入一些手工优化时. 虽然大多数时间会忽略形式3的组件, 但确实需要它们时, 你非常需要它们.
虽然组件的关键部分是其渲染函数, 有时我们需要在组件生命周期的各个关键时刻执行操作, 比如当它首次创建时, 或当它即将被销毁(从DOM中移除)时, 或当它即将被更新时等.
使用形式3的组件, 可以指定生命周期方法. reagent在React自己的生命周期方法之上提供了非常薄的一层. 所以, 在继续之前, 请阅读关于React生命周期方法的内容.
因为React的生命周期方法是面向对象的, 它们假设可以访问this来获取组件的当前状态. 因此, 相应的Reagent生命周期方法的签名都需要将对reagent组件的引用作为第一个参数.
这个引用可以与r/props, r/children和r/argv一起使用, 以获取当前的props/参数. 下面描述了这些函数的一些意外细节. 你也可能会发现r/dom-node有用, 因为形式3组件的一个常见用途是绘制到画布元素中, 而你将需要访问底层的DOM元素才能做到这一点.
一个形式3的组件定义看起来是这样的:
(defn my-component
[x y z]
(let [some (local but shared state) ;; <-- 被生命周期函数包裹
can (go here)]
(reagent/create-class ;; <-- 期望一个函数映射
{:display-name "my-component" ;; 提供更有帮助的警告和错误
:component-did-mount ;; 生命周期函数的名称
(fn [this]
(println "component-did-mount")) ;; 你的实现
:component-did-update ;; 生命周期函数的名称
(fn [this old-argv] ;; reagent提供整个" argv" , 不仅仅是" props"
(let [new-argv (rest (reagent/argv this))]
(do-something new-argv old-argv)))
;; 其他生命周期函数可以放在这里
:reagent-render ;; 注意: 不是:render
(fn [x y z] ;; 记得重复参数
[:div (str x " " y " " z)])})))
(reagent/render
[my-component 1 2 3] ;; 传入x y z
(.-body js/document))
;; 或作为更大的Reagent组件的子组件
(defn homepage []
[:div
[:h1 "Welcome"]
[my-component 1 2 3]]) ;; 确保将Reagent类放在方括号中以强制其渲染!
注意上面component-did-update签名中的old-argv. 许多这些Reagent生命周期方法类比都需要prev-argv或old-argv(参见reagent/create-class的docstring以获取完整列表). 这些argv参数包括组件构造函数作为第一个参数, 通常应该被忽略. 这与(reagent/argv this)返回的格式相同.
另外, 你可以使用(reagent/props this)和(reagent/children this), 但从概念上讲, 这些不像argv概念那样清晰. 具体来说, 传递给你的渲染函数的参数实际上作为子代(而不是props)传递给底层的React组件, 除非第一个参数是一个映射. 如果第一个参数是一个映射, 那么这个映射将作为props传递, 其余参数作为子代传递. 使用props和children可能读起来更清晰, 但你确实需要注意你是否传递了一个props映射.
最后, 请注意, 一些React生命周期方法接受prevState和nextState. 因为Reagent提供了自己的状态管理系统, 所以在生命周期方法中无法访问这些参数.
使用with-meta可以创建形式3的组件. 然而, with-meta有点笨拙, 没有任何优势, 但请注意, 存在另一种方法可以达到相同的结果.
新手错误
在上面的代码示例中, 请注意, 渲染函数通过给reagent/create-class提供的映射中的一个奇怪关键字来标识. 它被称为:reagent-render, 而不是更短, 更明显的:render.
使用:render是一个陷阱, 因为你不会收到任何错误, 除了你提供的函数只会被调用一次, 并且它不会是你期望的那个参数. 这里有一些细节.
新手错误
虽然你可以重写component-should-update以实现一些性能改进, 但你可能不应该这样做, 除非你真的很了解自己在做什么. 抵制这种冲动. 你现在的性能已经很好了. :-)
新手错误
遗漏:display-name条目. 如果你省略了它, Reagent和React将无法知道是哪个组件引起了问题. 结果, 它们生成的警告和错误将不那么信息丰富.
最后说明
以上使用了Form-1, Form-2和Form-3这些术语, 但实际上只有一种组件. 只是有3种不同的方式来创建组件.
归根结底, 无论组件是如何创建的, 最终都会有一个渲染函数和一些生命周期方法. 通过Form-1创建的组件与通过Form-3创建的组件在底层都只是React组件.
附录A - 稍微揭开盖子
这里有一些关于Reagent机制的进一步说明:
- 当在hiccup向量的第一个元素中提供一个函数[my-func 1 2 3]时, Reagent会想" 嘿, 给我了一个渲染函数". 那个函数可能是Form-1或Form-2, 但它在那个时候并不知道. 它只看到一个函数.
- 一个渲染函数本身不足以成为一个React组件. 因此, Reagent将这个渲染函数与默认的生命周期函数"合并" 以形成一个React组件. (当然, Form-3允许我们提供自己的生命周期函数)
- 稍后, 当Reagent第一次想要渲染这个组件时, 它会调用我们提供的渲染函数. 它将传入由渲染父代提供的"props" (参数)(1 2 3在上面的代码片段中).
- 如果这次对渲染函数的调用
- 返回hiccup(一个vector), Reagent将会渲染这个Hiccpu组件, 这是Form-1函数的情况.
- 如果这个渲染函数返回另一个函数, 即它是一个Form-2的外部函数返回的内部函数, 那么Reagent知道要用新返回的内部函数替换组件的渲染函数. 外部将被调用一次, 但从那时起, 内部函数将用于所有进一步的渲染. 事实上, Reagent会在外部返回内部函数后立即调用内部函数, 因为Reagent需要组件的第一次渲染(hiccup).
- 所以, 在Form-2的情况下, 外部函数被调用一次(带有初始的props/参数), 而内部则至少被调用一次(带有初始的props/参数), 但可能会随着时间的推移被调用很多次. 两者都将以相同的props/参数布局被调用--尽管内部渲染函数将在这些props/参数中看到不同的值.
附录B - with-let宏
with-let看起来就像let--但绑定只执行一次, 并且它包含一个可选的finally子句, 当组件不再渲染时运行. 这可能特别有用, 可以避免很多form-2组件的使用(例如在组件中创建一个局部reagent原子).
例如: 这里有一个设置鼠标移动事件监听器的组件, 并在组件被移除时停止监听.
(defn mouse-pos-comp []
(r/with-let [pointer (r/atom nil)
handler #(swap! pointer assoc
:x (.-pageX %)
:y (.-pageY %))
_ (.addEventListener js/document "mousemove" handler)]
[:div
"Pointer moved to: "
(str @pointer)]
(finally
(.removeEventListener js/document "mousemove" handler))))
当然, 同样的事情可以通过React生命周期方法来实现, 但那会更加冗长.
with-let还可以与track(和其他Reactive上下文)结合使用. 例如, 上面的组件可以写为:
(defn mouse-pos []
(r/with-let [pointer (r/atom nil)
handler #(swap! pointer assoc
:x (.-pageX %)
:y (.-pageY %))
_ (.addEventListener js/document "mousemove" handler)]
@pointer
(finally
(.removeEventListener js/document "mousemove" handler))))
(defn tracked-pos []
[:div
"Pointer moved to: "
(str @(r/track mouse-pos))])
finally子句将在mouse-pos不再被任何地方跟踪时运行, 即在这种情况下, 当tracked-pos被卸载时.
参考
翻译自官方文档中创建组件的章节