re-frame 應用中的隱藏課題
從認知壓力到關注點分離
我接手了一個前端是用 re-frame 的 web application ,而剛接手這個工作不久,就感到了些許不適應,於是我決定來研究不適應的原因。而第一個懷疑的對象,就是 re-frame 。
會有這樣子的懷疑,主要是因為在 Gaiwan 最常使用的 frontend stack 裡,我們多數的時候只有使用 reagent 而已。
那先用簡單一點的例子比較吧?說不定會有一些啟發。
用範例理解 re-frame 的抽象成本
功能描述:顯示一個按鈕和一個數字。每按一次按鈕,數字就加一。
用 reagent 實作的 counter
(ns example.core
(:require [reagent.core :as r]
[reagent.dom :as rdom]))
(defonce counter (r/atom 0))
(defn counter-component []
[:div
[:p "Count: " @counter]
[:button {:on-click #(swap! counter inc)}
"Increment"]])
(defn ^:export init []
(rdom/render [counter-component]
(.getElementById js/document "app")))用 re-frame 實作的 counter
(ns example.core
(:require [reagent.dom :as rdom]
[re-frame.core :as rf]))
;; -- Event Handlers ---------------------
(rf/reg-event-db
:initialize
(fn [_ _]
{:count 0}))
(rf/reg-event-db
:increment
(fn [db _]
(update db :count inc)))
;; -- Subscriptions ----------------------
(rf/reg-sub
:count
(fn [db _]
(:count db)))
;; -- View Component ---------------------
(defn counter-component []
(let [count @(rf/subscribe [:count])]
[:div
[:p "Count: " count]
[:button {:on-click #(rf/dispatch [:increment])}
"Increment"]]))
;; -- Entry Point ------------------------
(defn ^:export init []
(rf/dispatch-sync [:initialize])
(rdom/render [counter-component]
(.getElementById js/document "app")))經過比較之後,我很難做出「因為 re-frame 而讓我覺得不適應」的結論。
也許剛用 re-frame 時,看起來確實讓程式碼變多了,但是,考慮到我維護的專案是一個有許多頁面的 web application ,因為使用 re-frame 而必須從 view component 裡抽取出來撰寫的部分,即 subscriptions 與 event handlers ,在有數個頁面且單一頁面之內也有複雜的 UI 元件控制時,就算不使用 re-frame ,也還是會考慮抽取出來。
於是,我決定改變研究方法,直接從實際的開發困難來探討不適應的原因。
尋找修改點的困難
剛接手新專案時,通常都是在專案裡 grep 最多的時刻。由於缺乏熟悉度的關系,我即使很明確地大概知道我要實作什麼功能、大概怎麼實作,我還是會需要花一些時間來尋找修改點。
這邊用修改 UI 流程的例子來解釋:
參考下圖,使用者在這個流程裡會按下兩個不同的 submit button。當他按下 submit button 1 時,UI 的反應是要啟動一個 modal page ,用來向使用者取得更多詳細的資訊,同時顯示了 submit button 2 給使用者。當使用者按下 submit button 2 時,這個 UI 才會將頁面取得的許多資訊送交後端,去觸發一連串的後續工作。
考慮要做以下的修改:「由於後續觸發的工作改變了,要將本來兩步驟的提交 (two steps submit) 簡化,改成只有一個步驟提交。」
而以下是我在實際工作之中的作法:
略讀了 view page 的程式碼,找到 submit button 1 所送出的 event 名稱,然後,就可以把 view page 與對應的 event handler 1 連結起來。
略讀了 modal view 的程式碼,找到 submit button 2 所送出的 event 名稱,然後,就可以把 modal view 與對應的 event handler 2 連結起來。
我需要讓 submit button 1 在送出 event 時,就同時取得許多本來是在 submit button 2 送出 event 時才會取得的資料。也因此,我除了需要看到是哪一塊的 modal view 程式碼呼叫了 dispatch event 2 之外,還要往上去找該段 modal view 程式碼,所有的 subscriptions 。此外,還有 submit button 1 上方,所對應的 subscriptions 。
而就是這兩個從 dispatch event 往上找 subscriptions 的地方,我感到認知的壓力,因為我常常是往上找了一到三層函數呼叫之後才找到。還有,因為 view page 與 modal view 的程式碼也有相似之處,程式碼讀起來也很容易混淆。
整理清楚上述 1~4 的短期工作記憶之後,我終於可以開始著手改寫程式碼。
關注點混淆:真正的複雜來源
在認真地解析工作流程之中,特別感受到認知壓力的時刻之後,我總算可以用程式碼來描述我遇到的挑戰。
我遭遇到的程式碼是這樣子:
(defn modal-form [{:keys [data3]}]
(let [data4 @(rf/subscribe [:user/data4])]
[:div.modal
...
[:button {:on-click #(rf/dispatch [:event2 {:data3 data3 :data4 data4}])}
"Submit"]]))
(defn submit-button1 [data1]
...
(let [data2 @(rf/subscribe [:user/data2])]
...
[:button {:on-click #(rf/dispatch [:event1 {:data1 data1 :data2 data2}])}
"Submit"]))
(defn show-list-table [{:keys [data1]}]
...
... ;; table data
...
[submit-buttion-1 data1])
(defn main-page []
(let [other-states @(rf/subscribe [:ui/other-states])
should-show-modal @(rf/subscribe [:ui/show-modal])]
[:div
(when should-show-modal
[modal-form other-states])
[show-list-table other-states]]))在上述程式碼結構中,可以看到很多 rf/subscribe 與 rf/dispatch 混雜在一起散布於各個 component 裡,尤其當 modal-form 與 submit-button1 都是較小的元件時,卻要各自處理資料的訂閱與事件的派發,這會讓整體的控制流難以掌握。閱讀者需要同時理解 UI 呈現、其依賴的 state,以及互動後觸發的事件,這種跨 concern 的混合會帶來顯著的認知壓力。
關注點分離的設計模式
幸運的是,認知壓力是可以改善的,改善的方式就是 Presentational/Container Components Pattern。
套用 Presentational/Container Component Pattern 後,我們將資料的來源與使用者介面分離開來。簡單來說:
Presentational component 專注於「畫面該怎麼顯示」,只從參數讀資料,不知道資料從哪來,也不知道 event 該怎麼處理,只把事件以 injected handlers 的形式交由外部處理。
Container component 則專注於「這個畫面要顯示哪些 state、收到使用者互動後要 dispatch 哪些事件」,它是處理狀態與行為的地方。
上述程式碼套用了 Pattern 之後,將會修改如下:
;; Presentational Component(no subscribe)
(defn submit-button1-view [{:keys [data1 data2 on-submit]}]
[:button {:on-click #(on-submit {:data1 data1 :data2 data2})}
"Submit"])
(defn modal-form-view [{:keys [data3 data4 on-submit]}]
[:div.modal
...
[:button {:on-click #(on-submit {:data3 data3 :data4 data4})}
"Submit"]])
;; Container Component (only state and dispatch)
(defn show-list-table-container []
(let [data1 @(rf/subscribe [:user/data1])
data2 @(rf/subscribe [:user/data2])]
...
... ;; table data
...
[submit-button1-view
{:data1 data1
:data2 data2
:on-submit #(rf/dispatch [:event1 %])}]))
(defn modal-form-container []
(let [data3 @(rf/subscribe [:user/data3])
data4 @(rf/subscribe [:user/data4])]
[modal-form-view
{:data3 data3
:data4 data4
:on-submit #(rf/dispatch [:event2 %])}]))
;; main-page
(defn main-page []
(let [should-show-modal @(rf/subscribe [:ui/show-modal])]
[:div
(when should-show-modal
[modal-form-container])
[show-list-table-container]]))結語:正確地 re-frame 應用不只是 framework
回顧整個過程,re-frame 本身其實並不是造成我最初不適應的根本原因。它的 event-driven 架構和資料 flow,對於大型、狀態複雜的應用確實有其優勢。然而,一旦 view component 裡同時夾雜了資料的取得(subscribe)與行為的處理(dispatch),那麼即使是功能單純的 UI,也會產生閱讀與維護上的高認知負擔。
我真正遇到的挑戰,是程式碼的關注點沒有被良好分離。這並非 re-frame 特有的問題,而是所有 frontend 架構在缺乏組織時都可能產生的困擾。
因此,我最大的收穫是理解到:
使用 re-frame 時,若能有意識地套用 Presentational/Container Component 的模式,就能大幅降低認知壓力,並提升程式碼的可預測性與維護性。
回到 re-frame 本身。它提供的事件與訂閱機制,設計上是為了幫助開發者建立可預測、可測試、可組合的應用架構。但這些機制能否真正發揮作用,仍然仰賴開發者是否以清晰、有結構的方式來使用它。
re-frame 的設計適合複雜的系統,但也因此提醒我們:
越是複雜的系統,越需要主動建立良好的結構,而不是期待 framework 自動做出正確的決定。


