使用 Reagent 构建单页应用(BUILDING SINGLE PAGE APPS WITH REAGENT)

By | 2018年7月12日

Background

I recently started working on a new project that
has a significant UI component. I decided that this was a good
opportunity to take a look at Angular and React for building the client
as a single page application.

After a bit of evaluation, I decided
that React was a better fit for the project. Specifically, I found the
idea of the virtual DOM very appealing and its component based approach
to be a good way to manage the application state.

Once I got a bit
deeper into using React I found it lacking in many areas. For example,
it doesn’t provide an adequate solution for complex data binding and
while there are a few libraries such as react-forms, I didn’t find them to be a good fit for my needs.

Having
heard lots of great things about Om, I decided that this might be a
good time to revisit ClojureScript. While I’ve done some projects in
ClojureScript previously, I always ended up going back to JavaScript in the end.

For
me, the benefits were not enough to outweigh the maturity of JavaScript
and the tooling available for it. One of the things I found to be
particularly painful was debugging generated JavaScript. This problem
has now been addressed by the addition of source maps.

背 景

最近我开始做一个与UI组件息息相关的新工程。我断定,这是一个绝佳的机会去学习Angular和React,从而打造一个简单页面应用的客户端。

经过再三评估后,我决定,React更加适合这个工程。尤其是,我发现虚拟DOM的主意非常具有吸引力,它的组件作为一个基础方法来管理应用状态。

我曾经更加深入地使用React,发现它在许多领域有缺陷。比如说,对于复杂数据的绑定。它没有提供合适的方案,当有一些例如react-forms库,我发现它们并没有满足我的需求。

据说Om有诸多巨大优势,我在思考,我应该重新审视ClojureScript。我之前做过一些有关ClojureScript的工程项目,我总是最终绕回到JavaScript。

对于我来说,(它的)便捷之处不足以超越JavaScript的成熟度与它的可用工具。我发现其中一点是,调试生成的JavaScript(代码)是非常痛苦的。通过添加源映射,这个问题已经得到解决了。

Trying Om

As I went through Om tutorials, I found that it exposes a lot of the incidental details to the user. Having to pass nil arguments, reify protocols, and manually convert to Js using #js hints are a but a few warts that I ran into. Although, it’s worth noting that the om-tools library from Prismatic address some of these issues.

Overall,
I feel that Om requires a significant time investment in order to
become productive. I found myself wanting a higher level of abstraction
for creating UI components and tracking state between them. This led me
to trying Reagent. This
library provides a very intuitive model for assembling UI components
and tracking their state, and you have to learn very few concepts to
start using it efficiently.

Differences between Om and Reagent

Om
and Reagent make different design decisions that result in different
tradeoffs, each with its own strength and weaknesses. Which of these
libraries is better primarily depends on the problem you’re solving.

The
biggest difference between Om and Reagent is that Om is highly
prescriptive in regards to state management in order to ensure that
components are reusable. It’s an anti-pattern for Om components to
manipulate the global state directly or by calling functions to do so.
Instead, components are expected to communicate using core.async
channels. This is done to ensure high modularity of the components.
Reagent leaves this part of the design up to you and allows using a
combination of global and local states as you see fit.

尝试Om

我翻遍Om教程,发现Om向用户公开了大量附带的细节。我还遇到了Om存在的少数几个不足,分别是必须传递nil参数、具体化的协议和使用#js提示符手动转换成js等。值得指出的是,尽管Prismatic中的 om-tools 

总的来看,我感觉要使Om的生产效率更高就需要大量的时间投资。我自己想要一种更高层次的抽象来创建UI部件并跟踪其状态。因此我尝试使用Reagent。该库提供了一种非常直观的模型,用于装配UI部件并跟踪其状态,并且你只需要学习很少的概念就可以开始高效地使用它。

Om和Reagent之间的区别

Om和Reagent各自有自己的优势和劣势,分别可以做出不同的设计决策,从而导致对它们有不同的权衡取舍。至于哪个库更好主要取决于你所要解决的问题。

Om和Reagent之间的最大不同在于Om对状态管理具有高度说明性以确保部件的可重用性。使用Om部件直接操作全局状态或通过函数调用来操作是一种反模式。相反,我们希望部件之间使用 core.async通道进行通信。这样做的目的是确保部件的高度模块化。Reagent把设计决策留给用户并允许用户根据需要使用全局和本地状态的组合。

Om takes a
data centric view of the world by being agnostic about how the data is
rendered. It treats the React DOM and Om components as implementation
details. This decision often results in code that’s verbose and exposes
incidental details to the user. These can obviously be abstracted, but
Om does not aim to provide such an abstraction and you’d have to write
your own helpers as seen with Prismatic and om-tools.

On the other hand, Reagent provides a standard way to define UI components using Hiccup
style syntax for DOM representation. Each UI component is a data
structure that represents a particular DOM element. By taking a DOM
centric view of the UI, Reagent makes writing composable UI components
simple and intuitive. The resulting code is extremely succinct and
highly readable. It’s worth noting that nothing in the design prevents
you from swapping in custom components. The only constraint is that the
component must return something that is renderable.

Using Reagent

The
rest of this post will walk through building a trivial Reagent app
where I hope to illustrate what makes Reagent such an excellent library.
Different variations of CRUD apps are probably the most common types of
web applications nowadays. Let’s take a look at creating a simple form
with some fields that we’ll want to collect and send to the server.

Om以世界中心为论点,探讨数据时如何被渲染的。它把React DOM与Om组件作为实施细节。这个决定经常导致冗长的细节暴露给用户。这些明显是抽象的,但并不是旨在提供这样一个抽象(对象),你必须编写自己的提供者,作为棱柱和光学工具参考对象。

另一方面,响应式提供一个标准方式来定义UI组件,使用DOM表示的打嗝式语法。每一个UI组件就是一个数据结构,代表一个特定的DOM元素。以DOM为中心的UI视图,响应式可使用组合的简单、直观的UI组件。由此产生的代码是非常简洁的、可读性强的。值得注意的是,没有什么可以阻止您在自定义组件中交换。唯一的限制是,该组件返回的东西是可用的。

采用响应式

这篇文章的剩余部分将会构建一个琐碎的响应式应用程序,我希望解析明白为什么响应式是如此优秀的一个库。不同种类的CRUD 应用程序可能是时下最常见的web应用程序。让我们来看下,如何使用一些模块来创建一个简单表单,并且我们将会手机与发送给服务器。

I won’t go into details of setting up a ClojureScript project in this post, but you can use the reagent-example project to follow along. The project requires Leiningen build tool and you will need to have it installed before continuing.

Once you check out the project, you will need to start the ClojureScript compiler by running lein cljsbuild auto and run the server using lein ring server.

The
app consists of UI components that are tied to a model. Whenever the
user changes a value of a component, the change is reflected in our
model. When the user clicks the submit button then the current state is
sent to the server.

The ClojureScript code is found in the main.core under the src-cljs
source directory. Let’s delete its contents and start writing our
application from scratch. As the first step, we’ll need to reference reagent in our namespace definition.

(ns main.core (:require [reagent.core :as reagent :refer [atom]]))

Next, let’s create a Reagent component to represent the container for our page.

(defn home []
  [:div
    [:div.page-header [:h1 "Reagent Form"]]])

We can now render this component on the page by calling the render-component function.

(reagent/render-component [home]
  (.getElementById js/document "app"))

As I mentioned above, the components can be nested
inside one another. To add a text field to our form we’ll write a
function to represent it and add it to our home component.

(defn text-input [label]
  [:div.row
    [:div.col-md-2
      [:span label]]
    [:div.col-md-3
      [:input {:type "text" :class "form-control"}]]])(defn home []
  [:div
    [:div.page-header [:h1 "Reagent Form"]]
    [text-input "First name"]])

Notice that even though text-input is a
function we’re not calling it, but instead we’re putting it in a vector.
The reason for this is that we’re specifying the component hierarchy.
The components will be run by Reagent when they need to be rendered.

We can also easily extract the row into a separate component. Once again, we won’t need to call the row function directly, but can treat the component as data and leave it up to Reagent when it should be evaluated.

(defn row [label & body]
  [:div.row
   [:div.col-md-2 [:span label]]
   [:div.col-md-3 body]])(defn text-input [label]
  [row label [:input {:type "text" :class "form-control"}]])

We now have an input field that we can display. Next, we
need to create a model and bind our component to it. Reagent allows us
to do this using its atom abstraction over the React state.
The Reagent atoms behave just like standard Clojure atoms. The main
difference is that a change in the value of the atom causes any
components that dereference it to be repainted.

本文中我不会去详细介绍如何去设置一个ClojureScript工程,不过你还是可以使用 reagent-example 项目来了解这个过程。这个工程需要有 Leiningen 构建工具,而你将需要在继续之前先将其安装好。

当你检出了这个工程之后,就需要通过运行 lein cljsbuild auto 命令来启动 ClojureScript 编译器,然后使用 lein ring server 来启动服务器。

应用包含了一些被绑定到模型的UI组件。无论用户在何时改变了一个组件的值,修改都会被反映到我们的模型中。当用户点击了提交按钮,那么当前的状态就会被发送到服务端。

ClojureScript 可以在 src-cljs源代码目录下的main.core中的被找到。让我们删除它的内容并且从头开始编写我们的应用程序。作为第一个步骤,我们将需要在我们的命名空间定义中对 reagent 进行引用。

(ns main.core (:require [reagent.core :as reagent :refer [atom]]))

接下来,让我们创建一个 Reagent 组件来呈现我们页面的容器。

(defn home []
  [:div
    [:div.page-header [:h1 "Reagent Form"]]])

现在我们就能够通过调用 render-component函数来在页面上渲染出这个组件。

(reagent/render-component [home]
  (.getElementById js/document "app"))

如我前面所提到的,组件可以嵌套到另外一个组件里面。为了向我们的表单中添加一个文本域,我们将编写一个函数来呈现它并且将它添加到我们的home组件中。

(defn text-input [label]
  [:div.row
    [:div.col-md-2
      [:span label]]
    [:div.col-md-3
      [:input {:type "text" :class "form-control"}]]])(defn home []
  [:div
    [:div.page-header [:h1 "Reagent Form"]]
    [text-input "First name"]])

注意尽管text-input是一个我们并没有调用到的函数,不过我们会把它放到一个向量中。这样做的原因是我们在指定组件的层级。组件在它们需要被渲染时将会由Reagent来运行。

我们可以轻松地将行提取到一个单独的组件中去。这里我们还是不会需要直接调用到 row 函数,不过是将组件看做是数据,并且在当它需要被计算时,把它留给 Reagent 来处理。

(defn row [label & body]
  [:div.row
   [:div.col-md-2 [:span label]]
   [:div.col-md-3 body]])(defn text-input [label]
  [row label [:input {:type "text" :class "form-control"}]])

现在我们有了一个输入域来进行显示。接下来我们需要创建一个模型,并且将我们将我们的组件绑定到它上面。 Reagent 允许我们使用它的在React状态之上的原子抽象来做这件事情。Reagent 的原子行为就像标准的 Clojure原子一样。主要的不同之处在于,原子的值里面的一个改变会导致任何对其进行了解引用的组件被重绘。

Any time we wish
to create a local or global state we create an atom to hold it. This
allows for a simple model where we can create variables for the state
and observe them as they change over time. Let’s add an atom to hold the
state for our application and a couple of handler functions for
accessing and updating it.

(def state (atom {:doc {} :saved? false}))(defn set-value! [id value]
  (swap! state assoc :saved? false)
  (swap! state assoc-in [:doc id] value))(defn get-value [id]
  (get-in @state [:doc id]))

We can now update our text-input component to set the state when the onChange event is called and display the current state as its value.

(defn text-input [id label]
  [row label   [:input
     {:type "text"
       :class "form-control"
       :value (get-value id)
       :on-change #(set-value! id (-> % .-target .-value))}]])(defn home []
  [:div
    [:div.page-header [:h1 "Reagent Form"]]
    [text-input :first-name "First name"]])

Let’s add a save button to our form so that we can
persist the state. For now, we’ll simply log the current state to the
console.

(defn home []
  [:div
    [:div.page-header [:h1 "Reagent Form"]]
    [text-input :first-name "First name"]    
    [:button {:type "submit"
              :class "btn btn-default"
              :on-click #(.log js/console (clj->js @state))}
     "Submit"]])

If we open the console, then we should see the current value of the :first-name
key populated in our document whenever we click submit. We can now
easily add a second component for the last name and see that it gets
bound to our model in exactly the same way.

(defn home []
  [:div
    [:div.page-header [:h1 "Reagent Form"]]

    [text-input :first-name "First name"]
    [text-input :last-name "First name"]

    [:button {:type "submit"
              :class "btn btn-default"
              :onClick #(.log js/console (clj->js @state))}
     "Submit"]])

So far we’ve been using a global variable to hold all
our state, while it’s convenient for small applications this approach
doesn’t scale well. Fortunately, Reagent allows us to have localized
states in our components. Let’s take a look at implementing a
multi-select component to see how this works.

When the user clicks
on an item in the list, we’d like to mark it as selected. Obviously,
this is something that’s only relevant to the list component and
shouldn’t be tracked globally. All we have to do to create a local state
is to initialize it in a closure.

We’ll implement the
multi-select by creating a component to represent the list and another
to represent each selection item. The list component will accept an id
and a label followed by the selection items.

Each item will be represented by a vector containing the id and the value of the item, eg: [:beer "Beer"]. The value of the list will be represented by a collection of the ids of the currently selected items.

We will use a let binding to initialize an atom with a map keyed on the item ids to represent the state of each item.

(defn selection-list [id label & items]
  (let [selections (->> items (map (fn [[k]] [k false])) (into {}) atom)]    
    (fn []
      [:div.row
       [:div.col-md-2 [:span label]]
       [:div.col-md-5
        [:div.row
         (for [[k v] items]
          [list-item id k v selections])]]])))

The item component will be responsible for updating its
state when clicked and persisting the new value of the list in the
document.

(defn list-item [id k v selections]
  (letfn [(handle-click! []
            (swap! selections update-in [k] not)
            (set-value! id (->> @selections                                (filter second)
                                (map first))))]
    [:li {:class (str "list-group-item"
                      (if (k @selections) " active"))
          :on-click handle-click!}
      v]))

Let’s add an instance of the selection-list component to our form and see how it looks.

(defn home []
  [:div
    [:div.page-header [:h1 "Reagent Form"]]

    [text-input :first-name "First name"]
    [text-input :last-name "First name"]

    [selection-list :favorite-drinks "Favorite drinks"
     [:coffee "Coffee"]
     [:beer "Beer"]
     [:crab-juice "Crab juice"]]

    [:button {:type "submit"
              :class "btn btn-default"
              :onClick #(.log js/console (clj->js @state))}
     "Submit"]])

每当我们希望创建一个本地或者全局状态的时候,就创建一个原子来持有它。这样就拥有了一个简单的模型,我们可以在其中创建状态的变量并且观察到他们随着时间的变化。让我们添加一个原子来持有应用程序的状态,以及一对可以对其进行访问和更新处理函数。

(def state (atom {:doc {} :saved? false}))(defn set-value! [id value]
  (swap! state assoc :saved? false)
  (swap! state assoc-in [:doc id] value))(defn get-value [id]
  (get-in @state [:doc id]))

现在当 onChangeevent被调用时,我们就可以通过更新 text-input组件来设置状态并且显示当前状态的value了。

(defn text-input [id label]
  [row label   [:input
     {:type "text"
       :class "form-control"
       :value (get-value id)
       :on-change #(set-value! id (-> % .-target .-value))}]])(defn home []
  [:div
    [:div.page-header [:h1 "Reagent Form"]]
    [text-input :first-name "First name"]])

让我们在表单中添加一个保存按钮,那样我们就可以将状态进行持久化。目前,我们会简单地将当前状态在控制台上进行日志记录。

(defn home []
  [:div
    [:div.page-header [:h1 "Reagent Form"]]
    [text-input :first-name "First name"]    
    [:button {:type "submit"
              :class "btn btn-default"
              :on-click #(.log js/console (clj->js @state))}
     "Submit"]])

如果我们打开控制台,那么我们应该就会看到当点击提交按钮是,:first-name键的当前值被填充到文档中。现在我们能轻松地为姓氏添加第二个组件,并且看到它已经被使用完全一样的方式绑定到模型上了。

(defn home []
  [:div
    [:div.page-header [:h1 "Reagent Form"]]

    [text-input :first-name "First name"]
    [text-input :last-name "First name"]

    [:button {:type "submit"
              :class "btn btn-default"
              :onClick #(.log js/console (clj->js @state))}
     "Submit"]])

目前为止我们已经使用了一个全局变量来持有所有的状态,而对于小型的应用程序而言比较方便的方法并不能进行很好的扩展。幸运的是,Reagent 允许我们的组件中拥有本地化的状态。让我们来看看如何实现一个多选组件并且观察其运行。

当用户点击列表中的一项时,我们会想要将其标识为已选。很明显,这只是跟列表组件相关的意见事情而并不应该被全局地进行跟踪。我们要做的就是创建一个本地状态并且一个闭包中进行初始化。

我们实现的多选,将会创建一个组件来呈现列表,以及另外一个组件来呈现每一个被选中的项。这个列表组件将会接收一个id以及一个跟在选项后面的标识。

每一项都会由一个包含了选项的id和值向量来呈现,例如:[:beer “Beer”]。列表的值则有当前已选项的id集合来呈现。

我们将会使用一个let绑定来用一个id到每一项当前状态的映射对一个原子进行初始化。

(defn selection-list [id label & items]
  (let [selections (->> items (map (fn [[k]] [k false])) (into {}) atom)]    
    (fn []
      [:div.row
       [:div.col-md-2 [:span label]]
       [:div.col-md-5
        [:div.row
         (for [[k v] items]
          [list-item id k v selections])]]])))

单项组件将会负责当有点击并且将列表的新值持久化到文档中时更新其状态。

(defn list-item [id k v selections]
  (letfn [(handle-click! []
            (swap! selections update-in [k] not)
            (set-value! id (->> @selections                                (filter second)
                                (map first))))]
    [:li {:class (str "list-group-item"
                      (if (k @selections) " active"))
          :on-click handle-click!}
      v]))

让我们向表单中添加选择列表的一个实例,并对其进行观察。

(defn home []
  [:div
    [:div.page-header [:h1 "Reagent Form"]]

    [text-input :first-name "First name"]
    [text-input :last-name "First name"]

    [selection-list :favorite-drinks "Favorite drinks"
     [:coffee "Coffee"]
     [:beer "Beer"]
     [:crab-juice "Crab juice"]]

    [:button {:type "submit"
              :class "btn btn-default"
              :onClick #(.log js/console (clj->js @state))}
     "Submit"]])

Finally, let’s update our submit button to actually send the data to the server. We’ll use the cljs-ajax library to handle our Ajax calls. Let’s add the following dependency [cljs-ajax "0.2.6"] to our project.clj and update our namespace to reference it.

(ns main.core (:require [reagent.core :as reagent :refer [atom]]
           [ajax.core :refer [POST]]))

With that in place we can write a save-doc function that will send the current state of the document to the server and set the state to saved on success.

(defn save-doc []
  (POST (str js/context "/save")
        {:params (:doc @state)
         :handler (fn [_] (swap! state assoc :saved? true))}))

We can now update our form to either display a message
indicating that the document has been saved or the submit button based
on the value of the :saved? key in our state atom.

(defn home []
  [:div
    [:div.page-header [:h1 "Reagent Form"]]

    [text-input :first-name "First name"]
    [text-input :last-name "Last name"]
    [selection-list :favorite-drinks "Favorite drinks"
     [:coffee "Coffee"]
     [:beer "Beer"]
     [:crab-juice "Crab juice"]]

   (if (:saved? @state)
     [:p "Saved"]
     [:button {:type "submit"
              :class "btn btn-default"
              :onClick save-doc}
     "Submit"])])

On the server side we’ll simply log the value submitted by the client and return “ok”.

(ns reagent-example.routes.services  (:use compojure.core)
  (:require [reagent-example.layout :as layout]
            [noir.response :refer [edn]]
            [clojure.pprint :refer [pprint]]))(defn save-document [doc]
  (pprint doc)
  {:status "ok"})(defroutes service-routes  (POST "/save" {:keys [body-params]}
        (edn (save-document body-params))))

With the route hooked up in our handler we should see something like the following whenever we submit a message from our client:

{:first-name "Jasper", :last-name "Beardly", :favorite-drinks (:coffee :beer)}

As you can see, getting started with Reagent is
extremely easy and it requires very little code to create a working
application. You could say that single page Reagent apps actually fit on
a single page. 🙂 In the next installment we’ll take a look at using
the secretary library to add client side routing to the application.

最后,让我们对提交按钮进行一下更新,使其能实际向服务器发送数据。我们将使用 cljs-ajax 库莱处理Ajax调用。让我们向 project.clj 添加如下的依赖 [cljs-ajax “0.2.6”]并且对我们的命名空间进行更新来引用到它。

(ns main.core (:require [reagent.core :as reagent :refer [atom]]
           [ajax.core :refer [POST]]))

这件事情做好之后我们就能编写一个 sava-doc函数,它将会将文档的当前状态发送给服务端,并且将状态设置为成功。

(defn save-doc []
  (POST (str js/context "/save")
        {:params (:doc @state)
         :handler (fn [_] (swap! state assoc :saved? true))}))

现在我们就能通过更新表单来显示一条能提示说文档已经被保存的消息或者是基于我们的状态原子中:saved?键的值的提交按钮。

(defn home []
  [:div
    [:div.page-header [:h1 "Reagent Form"]]

    [text-input :first-name "First name"]
    [text-input :last-name "Last name"]
    [selection-list :favorite-drinks "Favorite drinks"
     [:coffee "Coffee"]
     [:beer "Beer"]
     [:crab-juice "Crab juice"]]

   (if (:saved? @state)
     [:p "Saved"]
     [:button {:type "submit"
              :class "btn btn-default"
              :onClick save-doc}
     "Submit"])])

在服务端我们会简单地对由客户端提交的值进行记录并且返回“ok”。

(ns reagent-example.routes.services  (:use compojure.core)
  (:require [reagent-example.layout :as layout]
            [noir.response :refer [edn]]
            [clojure.pprint :refer [pprint]]))(defn save-document [doc]
  (pprint doc)
  {:status "ok"})(defroutes service-routes  (POST "/save" {:keys [body-params]}
        (edn (save-document body-params))))

当路由在我们的处理器中挂好以后,我们应该就可以看到当我们从客户端提交一条消息时会法神什么了:

{:first-name "Jasper", :last-name "Beardly", :favorite-drinks (:coffee :beer)}

如你所见,上手 Reagent 极其容易,只需要非常少的代码来创建一个工作应用程序。你会说单页面 Reagent应用实际上只适合一个单独的页面。 🙂 接下来我们就会看看如何使用 secretary 库来向应用程序中添加客户端路由。


via:oschina

发表评论

电子邮件地址不会被公开。 必填项已用*标注