clojure GUI編程-3
目錄
1 簡介
這部分主要是使用re-frame構建一個SPA程序,完成okex行情信息的顯示。 css
關於re-frame的設計理念和使用方法,參考官方文檔。 html
2 實現過程
2.1 建立項目
使用re-frame-template建立項目: java
lein new re-frame okex-web +10x +re-com +cider
+cider配合emacs使用, +re-com使用現成的web gui組件, +10x 用於re-frame的調試。 python
在emacs下使用cider-jack-in-cljs後,執行下面的代碼轉到cljs repl: react
(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。 git
因爲要使用ajax請求API,須要添加http-fx依賴,最後的project.clj以下: github
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。 web
對於chrome,使用下面的命令行啓動: ajax
chromium --disable-web-security --user-data-dir ./chromeuser
後來發現Allow CORS插件比較好用,支持主流瀏覽器,建議使用。 sql
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限制。
圖1 網頁運行界面截圖
3 總結
re-frame寫SPA程序很是強大,總體架構比較清晰,值得學習。示例項目完整代碼。