June 1, 2021
By: Kevin

Reagent文档:创建组件

  1. 组件的核心
  2. 创建组件的三种方式
  3. 形式1: 一个简单的函数
  4. 形式2: 返回函数的函数
  5. 形式3: 具有生命周期方法的类
  6. 最后说明
  7. 附录A - 稍微揭开盖子
  8. 附录B - with-let宏
  9. 参考

在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机制的进一步说明:

  1. 当在hiccup向量的第一个元素中提供一个函数[my-func 1 2 3]时, Reagent会想" 嘿, 给我了一个渲染函数". 那个函数可能是Form-1或Form-2, 但它在那个时候并不知道. 它只看到一个函数.
  2. 一个渲染函数本身不足以成为一个React组件. 因此, Reagent将这个渲染函数与默认的生命周期函数"合并" 以形成一个React组件. (当然, Form-3允许我们提供自己的生命周期函数)
  3. 稍后, 当Reagent第一次想要渲染这个组件时, 它会调用我们提供的渲染函数. 它将传入由渲染父代提供的"props" (参数)(1 2 3在上面的代码片段中).
  4. 如果这次对渲染函数的调用
    • 返回hiccup(一个vector), Reagent将会渲染这个Hiccpu组件, 这是Form-1函数的情况.
    • 如果这个渲染函数返回另一个函数, 即它是一个Form-2的外部函数返回的内部函数, 那么Reagent知道要用新返回的内部函数替换组件的渲染函数. 外部将被调用一次, 但从那时起, 内部函数将用于所有进一步的渲染. 事实上, Reagent会在外部返回内部函数后立即调用内部函数, 因为Reagent需要组件的第一次渲染(hiccup).
  5. 所以, 在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被卸载时.

参考

翻译自官方文档中创建组件的章节

Tags: Reagent react