clojure GUI编程-3

clojure GUI编程-3

clojure GUI编程-3

1 简介

这部分主要是使用re-frame构建一个SPA程序,完成okex行情信息的显示。

关于re-frame的设计理念和使用方法,参考官方文档

2 实现过程

2.1 创建项目

使用re-frame-template创建项目:

lein new re-frame okex-web +10x +re-com +cider

+cider配合emacs使用, +re-com使用现成的web gui组件, +10x 用于re-frame的调试。

在emacs下使用cider-jack-in-cljs后,执行下面的代码转到cljs repl:

(use 'figwheel-sidecar.repl-api)
(start-figwheel!)
(cljs-repl)

发现cljs不能正确输入,会出现一个stdin的minibuffer,解决方法参考 https://clojureverse.org/t/emacs-figwheel-main-why-stdin-in-the-minibuffer/3955/8, 修改figwheel-sidecar的版本号为"0.5.18",cider/piggieback的版本号为"0.4.1",主要是为了兼容nrepl 0.6。

由于要使用ajax请求API,需要添加http-fx依赖,最后的project.clj如下:

 1: (defproject okex-web "0.1.0-SNAPSHOT"
 2:   :dependencies [[org.clojure/clojure "1.10.0"]
 3:                  [org.clojure/clojurescript "1.10.520"]
 4:                  [reagent "0.8.1"]
 5:                  [re-frame "0.10.6"]
 6:                  [re-com "2.4.0"]
 7:                  [day8.re-frame/http-fx "0.1.6"]
 8:                  [camel-snake-kebab "0.4.0"] ;; 命名转换
 9:                  [com.rpl/specter "1.1.2"] ;; data selector
10:                  ]
11: 
12:   :plugins [[lein-cljsbuild "1.1.7"]]
13: 
14:   :min-lein-version "2.5.3"
15: 
16:   :source-paths ["src/clj" "src/cljs"]
17: 
18:   :clean-targets ^{:protect false} ["resources/public/js/compiled" "target"]
19: 
20:   :figwheel {:css-dirs ["resources/public/css"]}
21: 
22:   :profiles
23:   {:dev
24:    {:dependencies [[binaryage/devtools "0.9.10"]
25:                    [day8.re-frame/re-frame-10x "0.3.7-react16"]
26:                    [day8.re-frame/tracing "0.5.1"]
27:                    [figwheel-sidecar "0.5.18"]
28:                    [cider/piggieback "0.4.1"]]
29:     :repl-options {:nrepl-middleware [cider.piggieback/wrap-cljs-repl]}
30:     :plugins      [[lein-figwheel "0.5.18"]]}
31: 
32:    :prod { :dependencies [[day8.re-frame/tracing-stubs "0.5.1"]]}
33:    }
34: 
35:   :cljsbuild
36:   {:builds
37:    [{:id           "dev"
38:      :source-paths ["src/cljs"]
39:      :figwheel     {:on-jsload "okex-web.core/mount-root"}
40:      :compiler     {:main                 okex-web.core
41:                     :output-to            "resources/public/js/compiled/app.js"
42:                     :output-dir           "resources/public/js/compiled/out"
43:                     :asset-path           "js/compiled/out"
44:                     :source-map-timestamp true
45:                     :preloads             [devtools.preload
46:                                            day8.re-frame-10x.preload]
47:                     :closure-defines      {"re_frame.trace.trace_enabled_QMARK_" true
48:                                            "day8.re_frame.tracing.trace_enabled_QMARK_" true}
49:                     :external-config      {:devtools/config {:features-to-install :all}}
50:                     }}
51: 
52:     {:id           "min"
53:      :source-paths ["src/cljs"]
54:      :compiler     {:main            okex-web.core
55:                     :output-to       "resources/public/js/compiled/app.js"
56:                     :optimizations   :advanced
57:                     :closure-defines {goog.DEBUG false}
58:                     :pretty-print    false}}
59: 
60: 
61:     ]}
62:   )

2.2 绕过CORS

因为要跨域使用API,需要绕过浏览器的跨域限制,具体方法参考Bypass CORS Errors When Testing APIs Locally

对于chrome,使用下面的命令行启动:

chromium --disable-web-security --user-data-dir ./chromeuser

后来发现Allow CORS插件比较好用,支持主流浏览器,建议使用。

2.3 re-frame的核心思想

re-frame内部使用一个ratom作为db层进行数据存储1

修改db的事件使用reg-event-db注册,然后其它地方(其它事件中,或者view中)就可以通过dispatch这个事件发布消息(相当于发布者)。

通过reg-sub注册对db的访问,在view中通过subscribe订阅注册的sub(订阅者),当sub指向的数据更改,view就会自动刷新。

2.4 注册事件

主要是进行数据修改的事件,如:保存币对信息,设置当前选择的基准货币和交易货币信息,保存深度数据和异步请求API等。 具体参考events.cljs:

  1: (ns okex-web.events
  2:   (:require
  3:    [re-frame.core :as re-frame]
  4:    [okex-web.db :as db]
  5:    [okex-web.utils :refer [evt-db2]]
  6:    [ajax.core :as ajax]
  7:    [goog.string :as gstring]
  8:    [goog.string.format]
  9:    [camel-snake-kebab.core :as csk]
 10:    [com.rpl.specter :as s :refer-macros [select select-one transform]]
 11:    [day8.re-frame.tracing :refer-macros [fn-traced defn-traced]]
 12:    ))
 13: 
 14: ;;;;;;;;;;;;;;;;;;;;;;; helper functions
 15: (defn format-map-keys
 16:   "把map的keyword转换为clojure格式"
 17:   [m]
 18:   (s/transform [s/ALL s/MAP-KEYS] csk/->kebab-case-keyword m))
 19: 
 20: (defn format-depth-data
 21:   "格式化深度数据"
 22:   [data]
 23:   (transform [(s/multi-path :asks :bids) s/INDEXED-VALS]
 24:              (fn [[idx [price amount order-count]]]
 25:                [idx {:pos idx
 26:                      :price price
 27:                      :amount amount
 28:                      :order-count order-count}])
 29:              data))
 30: 
 31: (defn get-instrument-id
 32:   "获得当前币对名称"
 33:   [db]
 34:   (let [base-coin (:base-coin db)
 35:         quote-coin (:quote-coin db)]
 36:     (s/select-one [s/ALL
 37:                    #(and (= (:base-currency %) base-coin)
 38:                          (= (:quote-currency %) quote-coin))
 39:                    :instrument-id]
 40:                   (:instruments db))))
 41: 
 42: (defn get-quote-coins
 43:   [db base-coin]
 44:   (->> (:instruments db)
 45:        (select [s/ALL #(= (:base-currency %) base-coin) :quote-currency])
 46:        set
 47:        sort))
 48: 
 49: ;;;;;;;;;;;;;;;;;;;;;;;;; timer event
 50: 
 51: (defn dispatch-timer-event
 52:   []
 53:   (let [now (js/Date.)]
 54:     (re-frame/dispatch [:timer now])))  ;; <-- dispatch used
 55: 
 56: ;; 200毫秒刷新1次
 57: (defonce do-timer (js/setInterval dispatch-timer-event 200))
 58: 
 59: ;;;;;;;;;;;;;;;;;;;;;;; event db
 60: (re-frame/reg-event-db
 61:  ::initialize-db
 62:  (fn-traced [_ _]
 63:    db/default-db))
 64: 
 65: ;; 设置标题
 66: (evt-db2 :set-name [:name])
 67: 
 68: ;; 保存所有币对信息
 69: (re-frame/reg-event-db
 70:  :set-instruments
 71:  (fn-traced [db [_ data]]
 72:             (->> (format-map-keys data)
 73:                  (assoc db :instruments))))
 74: 
 75: (evt-db2 :set-quote-coins [:quote-coins])
 76: 
 77: (evt-db2 :set-quote-coin [:quote-coin])
 78: 
 79: (re-frame/reg-event-db
 80:  :set-depth-data
 81:  (fn-traced [db [_ data]]
 82:             (->> (format-depth-data data)
 83:                  (assoc db :depth-data))))
 84: 
 85: (re-frame/reg-event-db
 86:  :set-base-coin
 87:  (fn-traced [db [_ base-coin]]
 88:             (re-frame/dispatch [:set-quote-coins (get-quote-coins db base-coin)])
 89:             (assoc db :base-coin base-coin)))
 90: 
 91: ;; 保存错误信息
 92: (re-frame/reg-event-db
 93:  :set-error
 94:  (fn-traced [db [_ path error]]
 95:             (assoc db :error {:path path
 96:                               :msg error})))
 97: 
 98: ;; 清除错误信息
 99: (re-frame/reg-event-db
100:  :clear-error
101:  (fn-traced [db _]
102:             (assoc db :error nil)))
103: 
104: ;;; ================ api 请求
105: (re-frame/reg-event-fx
106:  ::fetch-instruments
107:  (fn-traced [_ _]
108:             {:dispatch [:clear-error]
109:              :http-xhrio {:method :get
110:                           :uri "https://www.okex.com/api/spot/v3/instruments"
111:                           :timeout 8000
112:                           :response-format (ajax/json-response-format {:keywords? true})
113:                           :on-success [:set-instruments]
114:                           :on-failure [:set-error :fetch-instruments]}}))
115: 
116: (re-frame/reg-event-fx
117:  ::fetch-depth-data
118:  (fn-traced [_ [_ instrument-id]]
119:             {:dispatch [:clear-error]
120:              :http-xhrio {:method :get
121:                           :uri (gstring/format "https://www.okex.com/api/spot/v3/instruments/%s/book" instrument-id)
122:                           :timeout 8000
123:                           :response-format (ajax/json-response-format {:keywords? true})
124:                           :on-success [:set-depth-data]
125:                           :on-failure [:set-error :fetch-depth-data]}}))
126: 
127: ;;; =================== fx event
128: (re-frame/reg-event-fx
129:  :timer
130:  (fn [{:keys [db]} _]
131:    (when-let [instrument-id (get-instrument-id db)]
132:      {:dispatch [::fetch-depth-data instrument-id]})))

注意reg-event-fx和reg-event-db传递的函数参数是不同的,reg-event-db的第一个参数是db,reg-event-fx的第一个参数是coeffects2

2.5 注册订阅

用于访问db层的数据,具体参考subs.cljs:

 1: (ns okex-web.subs
 2:   (:require
 3:    [re-frame.core :as re-frame]
 4:    [com.rpl.specter :as s :refer-macros [select transform]]
 5:    ))
 6: 
 7: ;; 标题,懒得改名字了
 8: (re-frame/reg-sub
 9:  ::name
10:  (fn [db]
11:    (:name db)))
12: 
13: ;; 币对信息
14: (re-frame/reg-sub
15:  ::instruments
16:  (fn [db]
17:    (:instruments db)))
18: 
19: ;; 深度数据
20: (re-frame/reg-sub
21:  ::depth-data
22:  (fn [db]
23:    (:depth-data db)))
24: 
25: ;; 注意base-coins是基于instruments更新的,不能通过直接访问db的方式获取base-coins,
26: ;; 否则instruments刷新,base-coins的订阅不会自动刷新。
27: (re-frame/reg-sub
28:  ::base-coins
29:  :<- [::instruments]
30:  (fn [instruments]
31:    (-> (select [s/ALL :base-currency] instruments)
32:        set
33:        sort)))
34: 
35: (re-frame/reg-sub
36:  ::quote-coins
37:  (fn [db]
38:    (:quote-coins db)))
39: 
40: (re-frame/reg-sub
41:  ::base-coin
42:  (fn [db]
43:    (:base-coin db)))
44: 
45: (re-frame/reg-sub
46:  ::quote-coin
47:  (fn [db]
48:    (:quote-coin db)))
49: 
50: ;; 错误信息
51: (re-frame/reg-sub
52:  ::error
53:  (fn [db]
54:    (:error db)))

2.6 界面代码

订阅subs,显示界面,具体参考views.cljs:

 1: (ns okex-web.views
 2:   (:require
 3:    [re-frame.core :as re-frame]
 4:    [re-com.core :as re-com]
 5:    [reagent.core :refer [atom]]
 6:    [okex-web.utils :refer [>evt <sub]]
 7:    [com.rpl.specter :as s]
 8:    [okex-web.subs :as subs]
 9:    ))
10: 
11: (defn depth-table
12:   [title data]
13:   [:div.container
14:    [:h4.text-center title]
15:    [:table.table.table-bordered
16:     [:thead
17:      [:tr
18:       [:th "价位"]
19:       [:th "价格"]
20:       [:th "数量"]
21:       [:th "订单数"]]]
22:     [:tbody
23:      (for [row data]
24:        ^{:key (str title (:pos row))}
25:        [:tr
26:         [:td (:pos row)]
27:         [:td (:price row)]
28:         [:td (:amount row)]
29:         [:td (:order-count row)]])]]])
30: 
31: (defn vec->dropdown-choices
32:   ([v] (vec->dropdown-choices v nil))
33:   ([v group]
34:    (map #(hash-map :id % :label % :group group) v)))
35: 
36: (defn depth-view []
37:   (let [base-coins (re-frame/subscribe [::subs/base-coins])
38:         quote-coins (re-frame/subscribe [::subs/quote-coins])
39:         base-coin (re-frame/subscribe [::subs/base-coin])
40:         quote-coin (re-frame/subscribe [::subs/quote-coin])
41:         depth-data (re-frame/subscribe [::subs/depth-data])]
42:     [re-com/v-box
43:      :gap "10px"
44:      :children [[re-com/h-box
45:                  :gap "10px"
46:                  :align :center
47:                  :children [[re-com/single-dropdown
48:                              :choices (vec->dropdown-choices @base-coins)
49:                              :model @base-coin
50:                              :placeholder "选择基准币种"
51:                              :filter-box? true
52:                              :on-change #(>evt [:set-base-coin %])]
53:                             [re-com/gap :size "10px"]
54:                             [re-com/single-dropdown
55:                              :choices (vec->dropdown-choices @quote-coins @base-coin)
56:                              :model @quote-coin
57:                              :placeholder "选择计价币种"
58:                              :on-change #(>evt [:set-quote-coin %])
59:                              ]
60:                             ]]
61:                 [re-com/h-split
62:                  :panel-1 [depth-table "买入信息" (:bids @depth-data)]
63:                  :panel-2 [depth-table "卖出信息" (:asks @depth-data)]]
64:                 ]]))
65: 
66: 
67: (defn title []
68:   [re-com/title
69:    :label (<sub [::subs/name])
70:    :class "center-block"
71:    :level :level1])
72: 
73: (defn error
74:   "显示错误"
75:   []
76:   (let [error (re-frame/subscribe [::subs/error])]
77:     (when @error
78:       [re-com/alert-box
79:        :alert-type :danger
80:        :heading (str "错误!!!   " (:path @error))
81:        :body [:span (str (:msg @error))]])))
82: 
83: (defn main-panel []
84:   [:div.container
85:    [re-com/v-box
86:     :height "100%"
87:     :children [[title]
88:                [error]
89:                [depth-view]
90:                ]]])

2.7 发布

使用以下命令编译生成js文件到resources/public文件夹:

lein do clean, cljsbuild once min

可以看到release发布只有一个app.js,文件大小不到900K。 在浏览打开index.html就可以使用了。注意必须关掉浏览器的CORS限制。

https://img2018.cnblogs.com/blog/1545892/201905/1545892-20190531200434625-1037160174.jpg

图1  网页运行界面截图

3 总结

re-frame写SPA程序非常强大,整体架构比较清晰,值得学习。示例项目完整代码

脚注:

1

关于ApplicationState的官方文档

2

关于coeffects的官方文档

作者: ntestoc

Created: 2019-05-31 五 20:04

原文地址:https://www.cnblogs.com/ntestoc/p/10955523.html