論前端框架元件狀態抽象方案, 基於 ClojureScript 的 Respo 為例

題葉發表於2020-11-20

Respo 是本文作者基於 ClojureScript 封裝的 virtual DOM 微型 MVC 方案.
本文使用的工具鏈基於 Clojure 的, 會有一些閱讀方面的不便.

背景

Backbone 以前的前端方案在文字作者的瞭解之外, 本文作者主要是 React 方向的經驗.
在 Backbone 時期, Component 的概念已經比較清晰了.
Component 例項當中儲存元件的區域性狀態, 而元件檢視根據這個狀態來進行同步.
到 React 出現, 基本形成了目前大家熟悉的元件化方案.
每個元件有區域性狀態, 檢視自動根據狀態進行自動更新, 以及專門抽象出全域性狀態.

React 之外還有 MVVM 方案, 不過本文作者認為 MVVM 偏向於模板引擎的強化方案.
MVVM 後續走向 Svelte 那樣的靜態分析和程式碼生成會更自然一些, 而不是執行時的 MVC.

React 歷史方案

React 當中區域性狀態的概念較為明確, 元件掛載時初始化, 元件解除安裝時清除.
可以明確, 狀態是儲存在元件例項上的. Source of Truth 在元件當中.
與此相區別的方案是元件狀態脫離元件, 儲存在全域性, 跟全域性狀態類似.

元件記憶體儲的狀態方便元件自身訪問和操作, 是大家十分習慣的寫法.
以往的 this.state 和現在的 useState 可以很容易訪問全域性狀態.
而 React 元件中訪問全域性狀態, 需要用到 Context/Redux connect 之類的方案,
有使用經驗的會知道, 這中間會涉及到不少麻煩, 雖然大部分會被 Redux 封裝在類庫內部.

Respo 是基於 ClojureScript 不可變資料實現的一個 MVC 方案.
由於函數語言程式設計隔離副作用的一貫的觀念, 在元件區域性維護元件狀態並不是優雅的方案.
而且出於熱替換考慮, Respo 選擇了全域性儲存元件狀態的方案, 以保證狀態不丟失. (後文詳述)

本文作者沒有對 React, Vue, Angular 等框架內部實現做過詳細調研,
只是從熱替換過程的行為, 推斷框架使用的就是普通的元件儲存區域性狀態的方案.
如果有疑點, 後續再做討論.

全域性狀態和熱替換

前端由 react-hot-loader 率先引入熱替換的概念. 此前在 Elm 框架當中也有 Demo 展示.
由於 Elm 是基於代數型別函數語言程式設計開發的平臺, 早先未必有明確的元件化方案, 暫不討論.
react-hot-loader 可以藉助 webpack loader 的一些功能對程式碼進行編譯轉化,
在 js 程式碼熱替換過程中, 先儲存元件狀態, 在 js 更新以後替換元件狀態,
從而達到了元件狀態無縫熱替換這樣的效果, 所以最初非常驚豔.
然而, 由於 React 設計上就是在區域性儲存元件狀態, 所以該方案後來逐漸被廢棄和替換.

從 react-hot-loader 的例子當中, 我們得到經驗, 程式碼可以熱替換, 可以儲存恢復狀態.
首先對於程式碼熱替換, 在函數語言程式設計語言比如 Elm, ClojureScript 當中, 較為普遍,
基於函數語言程式設計的純函式概念, 純函式的程式碼可以通過簡單的方式無縫進行替換,
譬如介面渲染用到函式 F1, 但是後來 F1 的實現替換為 F2, 那麼只要能更新程式碼,
然後, 只要重新呼叫 F1 計算並渲染介面, 就可以完成程式當中 F1 的替換, 而沒有其他影響.

其次是狀態, 狀態可以通過 window.__backup_states__ = {...} 方式儲存和重新讀取.
這個並沒有門檻, 但是這種方案, 怕的是程式當中有點大量的區域性狀態, 那麼編譯工具是難以追蹤的.
而函數語言程式設計使用的不可變資料特性, 可以大範圍規避此類的區域性狀態,
而最終通過一些抽象, 將可變狀態放到全域性的若干個通過 reference 維護的狀態當中.
於是上述方案才會有比較強的實用性. 同時, 全域性狀態也提供更好的可靠性和可除錯性.

抽象方法

Respo 是基於 cljs 獨立設計的方案, 所以相對有比較大的自由度,
首先, 在 cljs 當中, 以往在 js 裡的物件資料, 要分成兩類來看待:

  • 資料. 資料就是資料, 比如 1 就是 1, 它是不能改變的,
    同理 {:name "XiaoMing", :age 20} 是資料, 也是不可以改變的.
    但這個例子中, 同一個人年齡會增加呀, 程式需如何表示年齡的增加呢,
    那麼就需要建立一條新的資料, {:name "XiaoMing", :ago 21} 表示新增加的.
    這是兩條資料, 雖然內部實現可以複用 :name 這個部分, 但是它就是兩條資料.
  • 狀態. 狀態是可以改變的, 或者說指向的位置是可以改變的,
    比如維護一個狀態 A 為<Ref {:name "XiaoMing", :age 20}>,
    A 就是一個狀態, 是 Ref, 而不是資料, 需要獲取資料要用 (deref A) 才能得到.
    同理, 修改資料就需要 (reset! A {...}) 才能完成了.
    所以 A 就像是一個箱子, 箱子當中的物品是可以改變的, 一箱蘋果, 一箱硬碟,
    你有一個蘋果, 那就是一個蘋果, 你有一個箱子, 別人在箱子裡可能放蘋果, 也可能放硬碟.

基於這樣的資料/狀態的區分, 我們就可以知道元件狀態在 cljs 如何看到了.
可以設定一個引用 S, 作為一個 Ref, 內部儲存著複雜結構的資料.
而程式在很多地方可以引用 S, 但是需要 (deref S) 才能拿到具體的資料.
而拿到了具體的資料, 那就是資料了, 在 cljs 裡邊是不可以更改的.

(defonce S (atom {:user {:name "XiaoMing", :age 20}}))

便於跟元件的樹形結構對應的話, 就會是一個很深的資料結構來表示狀態,

(defonce S (atom {
   :states {
     :comp-a {:data {}}
     :comp-b {:data {}}
     :comp-c {:data {}
              :comp-d {:data {}}
              :comp-e {:data {}}
              :comp-f {:data {}
                       :comp-g {:data {}}
                       :comp-h {:data {}}}}}}))

定義好以後, 我們還要解決後面的問題,

  • 某個元件 C 怎樣讀取到 S 的狀態?
  • 某個元件 C 怎樣對 S 內的狀態進行修改?

基於 mobx 或者一些 js 的方案當中, 拿到資料就是獲取到引用, 然後直接就能改掉了.
對於函數語言程式設計來說, 這是不能做到的一個想法. 或者說也不可取.
可以隨時改變的資料沒有可預測性, 你建立術語命名為 X1, 可以改的話你沒法確定 X1 到底是什麼.
在 cljs 當中如果是 Ref, 那麼會知道這是一個狀態, 會去監聽, 使用的時候會認為是有新的值.
但是 cljs 中的資料, 拿到了就認為是不變了的.
所以在這樣的環境當中, 修改全域性狀態要藉助其他一些方案. 所以上邊是兩個問題.

當然基於 js 的使用經驗, 或者 lodash 的經驗, 我們知道修改一個資料思路很多,
藉助一個 path 的概念, 通過 [:states :comp-a] 就可以修改 A 元件的資料,
同理, 通過 [:states :comp-c :comp-f :comp-h] 可以修掉 H 元件的資料.
具體修改涉及 Clojure 的內部函式, 在 js 當中也不難理解, lodash 就有類似函式.

本文主要講的是 Respo 當中的方案, 也就是基於這個 cljs 語言的方案.
這個方案當中基本上靠元件 props 資料傳遞的過程來傳遞資料的,
比如元件 A 會拿到 {:data {}} 這個部分, A 的資料就是 {},
而元件 C 拿到的是包含其子元件的整體的資料:

{:data {}
 :comp-d {:data {}}
 :comp-e {:data {}}
 :comp-f {:data {}
          :comp-g {:data {}}
          :comp-h {:data {}}}}

儘管 C 實際的資料還是它的 :data 部分的資料, 也還是 {}.
不過這樣一步步獲取, 元件 H 也就能獲取它的資料 {} 了.

在修改資料的階段, 在原來的 dispatch! 操作的位置, 就可以帶上 path 來操作,

(dispatch! :states [[:comp-c :comp-f :comp-h], {:age 21}])

在處理資料更新的位置, 可以提取出 path 和 newData 在全域性狀態當中更新,
之後, 檢視層重新渲染, 元件再通過 props 層層展開, H 就得到新的元件狀態資料 {:age 21} 了.

從思路上說, 這個是非常清晰的. 有了全域性狀態 S, 就可以很容易處理成熱替換需要的效果.

使用效果

實際操作當中會有一些麻煩, 比如這個 [:comp-c :comp-f :comp-h] 怎麼拿到?
這在實際當中就只能每個元件傳遞 props 的時候也一起傳遞進去了. 這個操作會顯得比較繁瑣.
具體這部分內容, 本文不做詳細介紹了, 從原理出發, 辦法總有一些, 當然是免不了繁瑣.
cljs 由於是 Lisp, 所以在思路上就是做抽象, 函式抽象, 語法抽象, 減少程式碼量.
寫出來的效果大體就是這樣:

(defonce *global-states {:states {:cursor []}})

(defcomp (comp-item [states]
 (let [cursor (:cursor states)
       state (or (:data states) {:content "something"})]
   (div {}
    (text (:content state))))))

(defcomp comp-list [states]
  (let [cursor (:cursor states)
        state (or (:data states) {:name "demo"})]
   (div {}
      (text (:name "demo"))
      (comp-item (>> states "task-1"))
      (comp-item (>> states "task-2")))))

其中傳遞狀態的程式碼的關鍵是 >> 這個函式,

(defn >> [states k]
  (let [cursor, (or (:cursor states) [])]
    (assoc (get states k)
           :cursor
           (conj cursor k))))

它有兩個功能, 對應到 states 的傳遞, 以及 cursor 的傳遞(也就是 path).
舉一個例子, 比如全域性拿到的狀態的資料是:

{:data {}
 :comp-d {:data {}}
 :comp-e {:data {}}
 :comp-f {:data {}
          :comp-g {:data {}}
          :comp-h {:data {:h 0}}}}

我們通過 (>> states :comp-f) 進行一層轉換, 獲取 F 元件的狀態資料,
同時 path 做了一次更新, 從原來的沒有(對應 []) 得到了 :comp-f:

{:data {}
 :cursor [:comp-f]
 :comp-g {:data {}}
 :comp-h {:data {:h 0}}}

到下一個元件傳遞引數時, 通過 (>> states :comp-h) 再轉化, 取得 H 的狀態資料,
同時對應給 H 的 cursor 也更新成了 [:comp-f :comp-h]:

{:data {:h 0}
 :cursor [:comp-f :comp-h]}

通過這樣的方式, 至少在傳遞全域性狀態上不用那麼多程式碼了.
同時也達到了一個效果, 對應元件樹, 拿到的就是對應自身元件樹(包含子元件)的資料.

當然從 js 使用者角度看的話, 這種方式是有著一些缺陷的,
首先程式碼量還是有點多, 初始化狀態寫法也有點怪, 需要用到 or 手動處理空值,
而 React 相比, 這個方案的全域性資料, 不會自動清空, 就可能需要手動清理資料.
另外, 這個方案對於副作用的管理也不友好, 譬如處理複雜的網路請求狀態, 就很麻煩.
由於 cljs 的函數語言程式設計性質, 本文作者傾向於認為那些情況還會變的更為複雜, 需要很多程式碼量.

就總體來說, 函數語言程式設計相對於 js 這類混合正規化的程式語言來說, 並不是更強大,
當然 Lisp 設計上的先進效能夠讓語言非常靈活, 除了函式抽象, macro 抽象也能貢獻大量的靈活度,
但是在資料這一層來說, 不可變資料是一個限制, 而不是一個能力, 也就意味著手段的減少,
減少這個手段意味著資料流更清晰, 程式碼當中狀態更為可控, 但是程式碼量會因此而增長.
那麼本文作者認為最終 js 的方式是可以造出更簡短精悍的程式碼的, 這是 Lisp 方案不擅長的.
而本文的目的, 限於在 cljs 方案和熱替換的良好配合情況下, 提供一種可行的抽象方式.

相關文章