Reagent 深入学习第四部分
- 在第一部分中, 我们了解了如何定义组件并响应变化.
- 在第二部分中, 我们观察了组件的生命周期.
- 在第三部分中, 我们探讨了组件序列的细微差别.
最后, 我们构建一个涂鸦应用程序. 涂鸦应用程序允许用户使用鼠标创建线条绘图. 本章并不会介绍任何新特性.
在过程中我们会共同发现一些原则. 掌握这些原则, 会让我们对应用中可能遇到的问题有更好的准备.
绘制路径
生活就是一门没有橡皮擦的绘画艺术. -- 约翰·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): 闭合路径.
本文中,我们主要使用M和L
要在 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 容器. 有两个解决方案:
- 我们可以捕获 SVG DOM 节点并相对于它计算坐标.
- 我们可以阻止路径触发事件.
原则 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-mount或ref函数直接调用元素上的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. -- 本文作者