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

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

背景

Backbone 之前的前端方案在文本做者的瞭解以外, 本文做者主要是 React 方向的經驗.
在 Backbone 時期, Component 的概念已經比較清晰了.
Component 實例當中保存組件的局部狀態, 而組件視圖根據這個狀態來進行同步.
到 React 出現, 基本造成了目前你們熟悉的組件化方案.
每一個組件有局部狀態, 視圖自動根據狀態進行自動更新, 以及專門抽象出全局狀態.react

React 以外還有 MVVM 方案, 不過本文做者認爲 MVVM 偏向於模板引擎的強化方案.
MVVM 後續走向 Svelte 那樣的靜態分析和代碼生成會更天然一些, 而不是運行時的 MVC.webpack

React 歷史方案

React 當中局部狀態的概念較爲明確, 組件掛載時初始化, 組件卸載時清除.
能夠明確, 狀態是保存在組件實例上的. Source of Truth 在組件當中.
與此相區別的方案是組件狀態脫離組件, 存儲在全局, 跟全局狀態相似.web

組件內存儲的狀態方便組件自身訪問和操做, 是你們十分習慣的寫法.
以往的 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 方案和熱替換的良好配合狀況下, 提供一種可行的抽象方式.

相關文章
相關標籤/搜索