July 22, 2019
By: Kevin

Reagent 深入学习第四部分

  1. 在第一部分中, 我们了解了如何定义组件并响应变化.
  2. 在第二部分中, 我们观察了组件的生命周期.
  3. 在第三部分中, 我们探讨了组件序列的细微差别.

最后, 我们构建一个涂鸦应用程序. 涂鸦应用程序允许用户使用鼠标创建线条绘图. 本章并不会介绍任何新特性.

在过程中我们会共同发现一些原则. 掌握这些原则, 会让我们对应用中可能遇到的问题有更好的准备.

绘制路径

生活就是一门没有橡皮擦的绘画艺术. -- 约翰·W·加德纳

为了创建一个涂鸦应用程序, 我们将处理鼠标笔画. 涂鸦将表示为一个包含路径的 SVG 元素. 在 HTML 中, 路径通过 d 属性定义, 指示绘制命令:

<svg width="100%" height="200">
  <path stroke="black" d="M 50 100 L 150 100"/>
</svg>

第一个命令是移动到(M), 后跟一个坐标对. M 50 100移动到(50,100).

下一个命令是绘制线到(L), 从当前位置绘制一条线到新位置.

50 100 L 150 100, 即从(50, 100)绘制线到(150,100). L 可以后跟一个或多个点(坐标对)以连接在一起.

可以在SVG参考中阅读更多关于路径的信息, 其中也描述了如何绘制曲线.

其中我们主要使用Path元素, 设置它的d属性.

d属性包含一系列命令和参数, 用于绘制路径. 常用的命令包括:

  • M(moveto): 移动到指定坐标.
  • L(lineto): 绘制直线到指定坐标.
  • C(curveto): 绘制三次贝塞尔曲线.
  • Q(quadratic Bézier curve): 绘制二次贝塞尔曲线.
  • Z(closepath): 闭合路径.

本文中,我们主要使用ML

要在 Reagent 中绘制带有横线线的 SVG, 我们可以写:

水平线路径

(require '[reagent.core :as r]
         '[reagent.dom :as rdom]
         '[clojure.string :as string])
[:svg {:width "100%" :height 200}
 [:path {:stroke "black" :d "M 50 100 L 150 100"}]]

要创建一个涂鸦应用程序, 我们需要显示代表用户绘制线条的许多路径. 让我们首先定义绘图的模型; 绘图应为线条的vector, 其中线条是连接坐标的vector.

基本绘图模型, Vector的Vector

(require '[reagent.core :as r]
         '[reagent.dom :as rdom]
         '[clojure.string :as string])

(def my-drawing
  (r/atom []))

(swap! my-drawing conj [50 100 150 100])

要向绘图中添加新线条, 我们将一个新的坐标向量添加到绘图向量中.

原则 1: 从数据开始.

上文中我们阐明了数据结构.

接下来, 基于这个模型, 我们完成渲染功能. 只需将每个向量转换为一个路径元素, 并将它们插入到一个 SVG 父元素中.

渲染基本绘图模型的基本绘图视图

(defn 涂鸦1 [drawing]
  (into
   [:svg
    {:width "100%"
     :height 200
     :stroke "black"
     :fill "none"}]
   (for [[x y & more-points] @drawing]
     [:path {:d (str "M " x " " y " L " (clojure.string/join " " more-points))}])))

[涂鸦1 my-drawing]

绘图而言, 机制我们已经熟悉, 但是上面的例子中增加了参数. 参数是一个很重要的概念.

参数把状态(数据)进行了区隔.

原则 2: 传递状态, 而不是放诸全局.

只依赖参数的函数是纯粹的, 更容以测试, 而且更容易重用.

练习: 重构 涂鸦1 通过参数改变SVG的背景色.

预先知道组件的所有输入是不可能的.

便开发边添加, 可能会搞出一个有一大长串参数的组件.

在这种情况下, 把参数作都放到一个map而不是散开挨个传递是个很好的习惯.

背景色修改涉及嵌套在属性的style, 简单的合并不起作用.

merge-with merge 可以完成这项工作.

控制背景色

(defn 涂鸦2 [attrs drawing]
  (into
   [:svg
    (merge-with merge attrs
                {:width "100%"
                 :height 200
                 :stroke "black"
                 :stroke-width 4
                 :fill "none"
                 :style {:border "1px solid"
                         :box-sizing "border-box"
                         :cursor "crosshair"}})]
   (for [[x y & more-points] @drawing]
     [:path {:d (str "M " x " " y " L " (string/join " " more-points))}])))

(def my-drawing2 (r/atom [[50 100 75 150 100 100 125 150 150 100]]))

[涂鸦2 {:style {:background-color "lightgreen"}} my-drawing2]

练习: 画个Z, 熟悉线条.

并非我们定义的每个组件都需要遵循传递属性的模式. 接受属性是一种在某些情况下效果良好的模式, 但在所有地方这样做会很繁琐. 我的经验法则是留意过多的自定义参数, 它们可能表明有机会传递属性.

原则 3: 留意传递自定义属性的机会.

我们有一个模型和一个视图. 现在, 如果我们有一种方法可以使用鼠标绘制线条就好了!

处理鼠标事件

要进行涂鸦, 我们需要监听mousedown以检测路径的开始, mousemove以检测路径的继续, 以及mouseup以检测路径的完成.

不太明显的是mouseleave, 它也应该完成路径, 因为它表示鼠标光标已离开 SVG 区域, 无法绘制合理的路径.

这些事件的所有处理程序都需要知道鼠标光标相对于SVG图像的位置.

我们的第一个任务是计算鼠标事件的位置:

检测鼠标点击的坐标

(defn xy  "从鼠标事件当中拿到坐标`[x, y]`" [e]
  (let [rect (.getBoundingClientRect (.-target e))]
    [(- (.-clientX e) (.-left rect))
     (- (.-clientY e) (.-top rect))]))

(def my-drawing3 (r/atom []))

[:div
 [涂鸦2
  {:on-click
   (fn [e]
     (js/alert (pr-str (xy e))))}
  (r/atom [])]
 [:h3 "点击空的 SVG! "]]

点击现在会弹出点击事件的坐标.

xy 函数接受一个事件, 并使用getBoundingClientRect获得该事件的坐标, 目标是事件的目标. 在这种情况下, 事件的目标是我们的 SVG 元素.

因为 涂鸦2 函数可以接受属性, 我们可以轻松地提供一个 on-click 处理程序, 而无需修改组件.

我喜欢这种方法, 它允许我们分离视图和控制行为. 这只是常规的函数和数据组合.

现在让我们定义模型如何处理鼠标事件. 我们将使用一个名为 pen-down?r/atom 来记录我们当前是否正在绘制路径.

当路径开始时, 我们将插入两个坐标对, 因为 SVG 路径需要一个起始位置来 移动到 和一个或多个位置来 绘制线到那里. 它们是相同的点是没问题的.

要继续绘制线条, 我们将一个坐标对追加到路径中.

要完成线条, 我们将重置 pen-down? 状态, 表示我们不再绘制路径.

更新绘图模型的事件处理

(defn mouse-handlers [drawing]
  (let [pen-down? (r/atom false)
        start-path
        (fn start-path [e]
          (when (not= (.-buttons e) 0)
            (reset! pen-down? true)
            (let [[x y] (xy e)]
              (swap! drawing conj [x y x y]))))
        continue-path
        (fn continue-path [e]
          (when @pen-down?
            (let [[x y] (xy e)]
              (swap! drawing (fn [lines]
                               (let [last-line-idx (dec (count lines))]
                                 (update lines last-line-idx conj x y)))))))
        end-path
        (fn end-path [e]
          (when @pen-down?
            (continue-path e)
            (reset! pen-down? false)))]
    {:on-mouse-down start-path
     :on-mouse-over start-path
     :on-mouse-move continue-path
     :on-mouse-up end-path
     :on-mouse-out end-path}))

(def my-drawing3 (r/atom []))

[:div
 [涂鸦2 (mouse-handlers my-drawing3) my-drawing3]
 [:h3 "在我上面涂鸦! "]]

以上代码中, 事件处理与组件渲染是分离的.

实际情况可能会更复杂些, 比如有许多子组件都需要修改数据.

更比如说单个属性输入不能够区分更加多样的映射条件.

然而, 此类扩展并不需要太多的想象力.

一个命名处理程序的映射, 或者一个调度函数来接受和路由事件都可以解决问题.

核心在于复杂的组件需要一些特定的结构来规范化对模型的更改. 而且熟悉re-frame之后, 订阅/事件派发/副作用处理/更多副作用注册会对这你现在尚且感觉模糊的想法系统化.

此外, 独立的业务领域对应单独的命名空间, 用来处理此域转换和操作.

即使模型很简单(事实上应该是这样), 将数据操作逻辑放在一起也是一个巨大的好处.

最终的结果是组件本身会得到简化, 组件代码只需渲染传递给它的数据.

原则 4: 分离组件, 模型和事件处理代码.

练习: 涂鸦有重叠的线条, 解决这个bug

在现有线条上绘制会导致的跳跃, 在 SVG 的左上角留下奇怪的虚线.

问题在于我们的xy数是相对于鼠标事件的目标计算位置的, 但当鼠标位于路径上时, 路径是事件的目标, 而不是 SVG 容器. 有两个解决方案:

  1. 我们可以捕获 SVG DOM 节点并相对于它计算坐标.
  2. 我们可以阻止路径触发事件.

原则 5: 倾向于 HTML 解决方案而非 DOM 解决方案.

选项(1)让人想起 jQuery 方法; 找到你想要处理的元素并按需操作. 小规模下没有问题, 但随着新需求的出现, 可能会变得复杂.

在这种情况下, 存在选项(2), 即在 HTML 中表达我们的意图, 因此我们无需保留 SVG 元素的引用来查找坐标.

让我们稍微绕道一下, 看看另一个呈现类似选择的示例; 一个按钮激活的输入字段, 在显示时应获取焦点.

如果我们要为我们的涂鸦添加一个可选标题, 这可能会很有用.

显示时获取焦点的输入字段

(defn optional-title []
  (let [show? (r/atom false)]
    (fn []
      [:div
       [:button
        {:on-click
         (fn [e]
           (swap! show? not))}
        (if @show? "隐藏" "添加标题")]
       (when @show?
         [:input {:auto-focus true}])])))

练习: 点击按钮目前会将光标放入输入框中. 移除输入框中的 auto-focus true 属性.

如果没有auto-focus, 要实现自动聚焦的功能, 就需要使用component-did-mountref函数直接调用元素上的focus来实现这一行为.

但由于 HTML 中内置了这个功能, 因此避免调用代码, 而添加一个属性就足够了, 这样会更干净.

所以回到路径触发事件的问题, 让我们选择选项(2). 利用pointer-events "none"样式, 可以防止我们的路径触发事件.

防止路径触发事件

(defn paths [drawing]
  (into
   [:g
    {:style {:pointer-events "none"}
     :fill "none"
     :stroke "black"
     :stroke-width 4}]
   (for [[x y & more-points] @drawing]
     [:path {:d (str "M " x " " y "L " (string/join " " more-points))}])))

(defn scribble3 [attrs drawing]
  [:svg
   (merge-with merge attrs
               {:width "100%"
                :height 400
                :style {:border "1px solid"
                        :box-sizing "border-box"
                        :cursor "crosshair"}})
   [paths drawing]])

(def my-drawing4 (r/atom []))

[scribble3 (mouse-handlers my-drawing4) my-drawing4]

我们稍微修改了视图的定义. 不再将 pointer-events "none" 样式附加到每个单独的路径上, 而是选择使用g标签来分组路径, 并将样式一次性应用于整个组.

练习: 修改示例 AF 以使用不同颜色的涂鸦. 练习: 为示例 AF 添加一个" 清除" 按钮, 将 my-drawing4 重置为空向量.

组合, 组合, 再组合

除非他在画画前在脑海中携带他的画面, 并确信他的方法和构图, 否则没有人是艺术家. -- 克劳德·莫奈

我们将把我们的涂鸦放入我们在第二部分中定义的捕鼠器中. 点击捕获按钮将创建新的涂鸦.

一个自包含的小部件

(defn scribble-widget []
  (let [a-drawing (r/atom [])
        handlers (mouse-handlers a-drawing)]
    [scribble3 handlers a-drawing]))

(defn a-better-mouse-trap [mouse]
  (let [mice (r/atom 1)]
    (fn render-mouse-trap [mouse]
      (into
       [:div
        [:button
         {:on-click
          (fn [e]
            (swap! mice (fn [m] (inc (mod m 4)))))}
         "Catch!"]]
       (repeat @mice mouse)))))

[a-better-mouse-trap [:div [scribble-widget]]]

Reagent 组件组合配合无间, 因为它们实际上是函数和数据.

将一个函数传递给另一个函数在Clojure中看起来很自然. Reagent将Clojure函数和数据组合的超能力转移到了 HTML 世界.

原则 6: 使用函数.

这一系列文章的核心主题是组合. 以Reagent方式表示组件确实将焦点放在函数和数据上, 这反过来又使得组合既核心又无缝.

我们不必事先做出所有决策. 由于组件是函数, 组件的内容易于进一步拆分和提取为函数, 因此适应和重组变得容易.

虽然你永远不会拥有太多函数, 但你可能会有太多的atoms.

原则 7: 避免模型碎片化.

在这一系列文章中, 我们花了很多时间观察 r/atom 用于切换组件的显示状态的情况. 我想在这里区分一下, 局部界面目标(例如切换文本框)的状态与表示逻辑模型的状态之间的区别.

通常, 拥有一个中心模型是个好主意. 如果我发现自己在搞清楚当前组件状态上花了太多时间, 通常这是一个信号, 表明我需要退一步, 更清晰地定义基础模型.

** 结论

我物理教授教给我最重要的东西不是公式, 而是如何思考. -- 卡林·迈尔

Reagent 提供了一种一致的模型, 忠实于Clojure的精神. 函数和数据的核心抽象在组件构建和组合中发挥了良好的作用. 我们能够利用强大的抽象来表示数据和转换数据.

从本质上讲, Reagent非常简单. 一个组件是一个返回UI元素的函数. 组件可以订阅到数据模型变化.

行文至此, 我们涵盖了这个简单理念应用中的各种细微差别.

Reagent 教会我最重要的事情不是如何制作 Web应用程序, 而是如何组合 HTML. -- 本文作者

Tags: Reagent react clojurescript