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
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:
63: (ns okex-web.events 64: (:require 65: [re-frame.core :as re-frame] 66: [okex-web.db :as db] 67: [okex-web.utils :refer [evt-db2]] 68: [ajax.core :as ajax] 69: [goog.string :as gstring] 70: [goog.string.format] 71: [camel-snake-kebab.core :as csk] 72: [com.rpl.specter :as s :refer-macros [select select-one transform]] 73: [day8.re-frame.tracing :refer-macros [fn-traced defn-traced]] 74: )) 75: 76: ;;;;;;;;;;;;;;;;;;;;;;; helper functions 77: (defn format-map-keys 78: "把map的keyword转换为clojure格式" 79: [m] 80: (s/transform [s/ALL s/MAP-KEYS] csk/->kebab-case-keyword m)) 81: 82: (defn format-depth-data 83: "格式化深度数据" 84: [data] 85: (transform [(s/multi-path :asks :bids) s/INDEXED-VALS] 86: (fn [[idx [price amount order-count]]] 87: [idx {:pos idx 88: :price price 89: :amount amount 90: :order-count order-count}]) 91: data)) 92: 93: (defn get-instrument-id 94: "获得当前币对名称" 95: [db] 96: (let [base-coin (:base-coin db) 97: quote-coin (:quote-coin db)] 98: (s/select-one [s/ALL 99: #(and (= (:base-currency %) base-coin) 100: (= (:quote-currency %) quote-coin)) 101: :instrument-id] 102: (:instruments db)))) 103: 104: (defn get-quote-coins 105: [db base-coin] 106: (->> (:instruments db) 107: (select [s/ALL #(= (:base-currency %) base-coin) :quote-currency]) 108: set 109: sort)) 110: 111: ;;;;;;;;;;;;;;;;;;;;;;;;; timer event 112: 113: (defn dispatch-timer-event 114: [] 115: (let [now (js/Date.)] 116: (re-frame/dispatch [:timer now]))) ;; <-- dispatch used 117: 118: ;; 200毫秒刷新1次 119: (defonce do-timer (js/setInterval dispatch-timer-event 200)) 120: 121: ;;;;;;;;;;;;;;;;;;;;;;; event db 122: (re-frame/reg-event-db 123: ::initialize-db 124: (fn-traced [_ _] 125: db/default-db)) 126: 127: ;; 设置标题 128: (evt-db2 :set-name [:name]) 129: 130: ;; 保存所有币对信息 131: (re-frame/reg-event-db 132: :set-instruments 133: (fn-traced [db [_ data]] 134: (->> (format-map-keys data) 135: (assoc db :instruments)))) 136: 137: (evt-db2 :set-quote-coins [:quote-coins]) 138: 139: (evt-db2 :set-quote-coin [:quote-coin]) 140: 141: (re-frame/reg-event-db 142: :set-depth-data 143: (fn-traced [db [_ data]] 144: (->> (format-depth-data data) 145: (assoc db :depth-data)))) 146: 147: (re-frame/reg-event-db 148: :set-base-coin 149: (fn-traced [db [_ base-coin]] 150: (re-frame/dispatch [:set-quote-coins (get-quote-coins db base-coin)]) 151: (assoc db :base-coin base-coin))) 152: 153: ;; 保存错误信息 154: (re-frame/reg-event-db 155: :set-error 156: (fn-traced [db [_ path error]] 157: (assoc db :error {:path path 158: :msg error}))) 159: 160: ;; 清除错误信息 161: (re-frame/reg-event-db 162: :clear-error 163: (fn-traced [db _] 164: (assoc db :error nil))) 165: 166: ;;; ================ api 请求 167: (re-frame/reg-event-fx 168: ::fetch-instruments 169: (fn-traced [_ _] 170: {:dispatch [:clear-error] 171: :http-xhrio {:method :get 172: :uri "https://www.okex.com/api/spot/v3/instruments" 173: :timeout 8000 174: :response-format (ajax/json-response-format {:keywords? true}) 175: :on-success [:set-instruments] 176: :on-failure [:set-error :fetch-instruments]}})) 177: 178: (re-frame/reg-event-fx 179: ::fetch-depth-data 180: (fn-traced [_ [_ instrument-id]] 181: {:dispatch [:clear-error] 182: :http-xhrio {:method :get 183: :uri (gstring/format "https://www.okex.com/api/spot/v3/instruments/%s/book" instrument-id) 184: :timeout 8000 185: :response-format (ajax/json-response-format {:keywords? true}) 186: :on-success [:set-depth-data] 187: :on-failure [:set-error :fetch-depth-data]}})) 188: 189: ;;; =================== fx event 190: (re-frame/reg-event-fx 191: :timer 192: (fn [{:keys [db]} _] 193: (when-let [instrument-id (get-instrument-id db)] 194: {: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:
195: (ns okex-web.subs 196: (:require 197: [re-frame.core :as re-frame] 198: [com.rpl.specter :as s :refer-macros [select transform]] 199: )) 200: 201: ;; 标题,懒得改名字了 202: (re-frame/reg-sub 203: ::name 204: (fn [db] 205: (:name db))) 206: 207: ;; 币对信息 208: (re-frame/reg-sub 209: ::instruments 210: (fn [db] 211: (:instruments db))) 212: 213: ;; 深度数据 214: (re-frame/reg-sub 215: ::depth-data 216: (fn [db] 217: (:depth-data db))) 218: 219: ;; 注意base-coins是基于instruments更新的,不能通过直接访问db的方式获取base-coins, 220: ;; 否则instruments刷新,base-coins的订阅不会自动刷新。 221: (re-frame/reg-sub 222: ::base-coins 223: :<- [::instruments] 224: (fn [instruments] 225: (-> (select [s/ALL :base-currency] instruments) 226: set 227: sort))) 228: 229: (re-frame/reg-sub 230: ::quote-coins 231: (fn [db] 232: (:quote-coins db))) 233: 234: (re-frame/reg-sub 235: ::base-coin 236: (fn [db] 237: (:base-coin db))) 238: 239: (re-frame/reg-sub 240: ::quote-coin 241: (fn [db] 242: (:quote-coin db))) 243: 244: ;; 错误信息 245: (re-frame/reg-sub 246: ::error 247: (fn [db] 248: (:error db)))
2.6 界面代码
订阅subs,显示界面,具体参考views.cljs:
249: (ns okex-web.views 250: (:require 251: [re-frame.core :as re-frame] 252: [re-com.core :as re-com] 253: [reagent.core :refer [atom]] 254: [okex-web.utils :refer [>evt <sub]] 255: [com.rpl.specter :as s] 256: [okex-web.subs :as subs] 257: )) 258: 259: (defn depth-table 260: [title data] 261: [:div.container 262: [:h4.text-center title] 263: [:table.table.table-bordered 264: [:thead 265: [:tr 266: [:th "价位"] 267: [:th "价格"] 268: [:th "数量"] 269: [:th "订单数"]]] 270: [:tbody 271: (for [row data] 272: ^{:key (str title (:pos row))} 273: [:tr 274: [:td (:pos row)] 275: [:td (:price row)] 276: [:td (:amount row)] 277: [:td (:order-count row)]])]]]) 278: 279: (defn vec->dropdown-choices 280: ([v] (vec->dropdown-choices v nil)) 281: ([v group] 282: (map #(hash-map :id % :label % :group group) v))) 283: 284: (defn depth-view [] 285: (let [base-coins (re-frame/subscribe [::subs/base-coins]) 286: quote-coins (re-frame/subscribe [::subs/quote-coins]) 287: base-coin (re-frame/subscribe [::subs/base-coin]) 288: quote-coin (re-frame/subscribe [::subs/quote-coin]) 289: depth-data (re-frame/subscribe [::subs/depth-data])] 290: [re-com/v-box 291: :gap "10px" 292: :children [[re-com/h-box 293: :gap "10px" 294: :align :center 295: :children [[re-com/single-dropdown 296: :choices (vec->dropdown-choices @base-coins) 297: :model @base-coin 298: :placeholder "选择基准币种" 299: :filter-box? true 300: :on-change #(>evt [:set-base-coin %])] 301: [re-com/gap :size "10px"] 302: [re-com/single-dropdown 303: :choices (vec->dropdown-choices @quote-coins @base-coin) 304: :model @quote-coin 305: :placeholder "选择计价币种" 306: :on-change #(>evt [:set-quote-coin %]) 307: ] 308: ]] 309: [re-com/h-split 310: :panel-1 [depth-table "买入信息" (:bids @depth-data)] 311: :panel-2 [depth-table "卖出信息" (:asks @depth-data)]] 312: ]])) 313: 314: 315: (defn title [] 316: [re-com/title 317: :label (<sub [::subs/name]) 318: :class "center-block" 319: :level :level1]) 320: 321: (defn error 322: "显示错误" 323: [] 324: (let [error (re-frame/subscribe [::subs/error])] 325: (when @error 326: [re-com/alert-box 327: :alert-type :danger 328: :heading (str "错误!!! " (:path @error)) 329: :body [:span (str (:msg @error))]]))) 330: 331: (defn main-panel [] 332: [:div.container 333: [re-com/v-box 334: :height "100%" 335: :children [[title] 336: [error] 337: [depth-view] 338: ]]])
2.7 发布
使用以下命令编译生成js文件到resources/public文件夹:
lein do clean, cljsbuild once min
可以看到release发布只有一个app.js,文件大小不到900K。 在浏览打开index.html就可以使用了。注意必须关掉浏览器的CORS限制。
3 总结
re-frame写SPA程序非常强大,整体架构比较清晰,值得学习。项目完整代码。