用mobx構建大型專案的最佳實踐

嫌疑犯X發表於2019-03-03

下一篇:

用mobx構建大型專案的最佳實踐(2)

mobx是一款基於觀察者模式的響應式資料管理框架,相對於redux來說是後起之秀。

有一種觀點認為mobx不適合構建大型專案,這源於mobx過於靈活的特點。靈活即意味著隨意,這在開發日益複雜的大型專案是致命的弱點。redux則不然,它的唯一資料來源、reducer純函式、只能通過dispatch修改狀態等幾個特性保證了程式碼書寫格式的高度統一。

本文不會討論mobx的使用細節,只會在充分利用mobx優勢的基礎上,對開發格式進行統一,保證開發大型專案的可維護性。

mobx的優勢極其優秀,物件導向程式設計、響應式程式設計、mutable的資料處理方式、精準更新元件的能力,這裡不過多討論。

mobx劣勢

  • 0、資料可隨處定義。可以定義在元件內,來替代state的作用;也可以定義在單獨的store
  • 1、使用者互動邏輯可以寫在元件宣告的方法內,也可以寫在store宣告的方法內。
  • 2、使用者互動往往涉及多個store的資料處理,store間可能形成交叉引用的網狀結構。
  • 3、store往往按頁面和模組劃分,散落在各處,不好統一管理。
  • 4、store例項化的時機和方式不可控。
  • 5、當單例store因為業務變更需要支援多例項時,改造難度極大
  • 6、對服務端渲染不友好。node端在讀取資料填充頁面時,還需要把資料儲存到頁面,供前端載入時從資料恢復到storeredxucreateStore天然支援從initialState恢復資料的能力)

面對以上的種種問題,大部分人都會持有mobx不適合大型專案的觀點。

解決方案

在筆者用mobx+react做了諸多中大型的前端專案之後,對這些劣勢深惡痛絕,也逐漸摸索出了一些方案來解決上述的問題。

1、分層

為了解決資料定義,資料共享以及邏輯程式碼如何防止等問題,首先對專案結構進行分層。

  • 專案按照頁面進行分割
  • 頁面按照 storesactionsviews分為三層
  • stores定義頁面內各個資料模型及資料的操作方法,各個store之間互相獨立
  • views層作為檢視層,接收stores注入的資料負責渲染
  • actions層處理互動邏輯,引用各個store方法呼叫更新資料,又mobx自動觸發檢視重新整理

以上是一個典型的mvc分層結構,這種方式很大程度上解決了問題點0、1、2。

2、唯一資料來源

通過第一步的改造,專案的可維護性可謂上升一個臺階。

但是頁面的storeaction需要手動例項化並手動注入到每個頁面元件,著實是一個負擔。並且store例項化自由,管理起來較為混亂。並未解決3、4、5的問題。

所以需要開發一個狀態管理庫,主要實現如下功能

  • storeaction的自動查詢載入。storeaction分頁面放置,通過某種機制進行查詢
  • 查詢到的所有storeaction自動例項化,並形成全域性唯一資料來源
  • store提供配置單例或多例項的配置項,減少因需求變更導致的程式碼改造工作量
  • 按需例項化store。比如訪問頁面A,只需例項化A頁面依賴的store
查詢機制

storeaction的查詢方式簡單介紹兩種,一種是通過webpack提供的require.context動態的引入特定目錄下的storeaction模組,第二種是通過裝飾器模式進行載入。
虛擬碼如下

    //webpack 
    require.context(`./`,true,/^(.+/)*stores/(.+).(t|j)sx?$/i)
    
    //裝飾器
    @store({
        path:`pageA.storeA`, //在全域性store中的訪問路徑
        type:`singleton`|`multi` // 宣告單例還是多例項
    })
    class StoreA{
        
    }
    
    // store裝飾器的實現
    let store = (config) => target => {
      target[`__storeType`] = config.type //儲存
      App[`__stores`] = App[`__stores`] || [] //App為狀態管理類
      App[`__stores`].push({ target, path: config.path})
      return target;
    }
複製程式碼

拿到所有store的資訊之後,就可以在管理類裡對storesactions進行處理,組裝全域性唯一的rootStore了,action處理也是一樣。

按需例項化

如果為了追求效能,可以考慮實現這麼一個特性。實現方式可以用訪問器屬性,在訪問到store屬性時,再進行動態的例項化。虛擬碼如下

    Object.defineProperty(rootAction, `storeA`, {
          configurable: true,
          enumerable: true,
          get() {
            StoreA[`__instance`] = StoreA[`__instance`] || new StoreA()
            return StoreA[`__instance`]
          },
          set() {
            throw Error("can not set store")
          }
        })
複製程式碼

通過這麼一個狀態管理庫,我們解決了3、4、5,對於問題6 服務端渲染,也可以通過簡單的處理對rootStore進行恢復。

3、開發體驗優化

(1)path自動宣告

上面的裝飾器@store需要手動指定storerootStore中所處的節點,能不能通過store檔案所在的目錄名、檔名、store類名等資訊直接對映到對應的結構呢?

答案是可以的,只需要編寫一個babel轉換外掛,在編譯時對檔案的抽象語法樹進行分析替換,自動填充@storepath屬性就好了。(筆者專案用的是ts,提供了一個ts transformer完成同樣的功能)

(2)腳手架
  • 由於頁面結構保持了高度統一,無論是store檔案、action檔案,或是jsxcss檔案,都有或多或少的樣板程式碼。為了開發流程的自動化,可以開發腳手架工具,自動生成頁面骨架。一是為了提升開發效率,二可以規範開發流程。
  • 如果專案中用到ts的話,這種全域性自動載入形成的store會丟失型別資訊。所以需要自動的生成一份型別宣告檔案(.d.ts)幫助有更好的開發體驗。

4、開發規範限制

最後一個話題,如何更嚴格的規範程式碼的書寫方式。

即使我們限定了業務邏輯只能在action內處理,但終歸是口頭約定。老成員總有圖便利把邏輯寫到view層的時候,新成員剛加入時的程式碼更可能如此。

所以我們需要提供一種機制來保證只能在action內呼叫store的方法進行邏輯處理,而在action外的store呼叫都無效,並在開發環境給以警告。

這個問題如果你認為很簡單,可能是因為你還沒理解到這個的關鍵點在哪。下面通過例子來討論解決方案。

    //宣告一個store
    class StoreA{
        age = null;
        
        setAge(age){
            this.age = age;
        }
    }
    
    //宣告一個action
    class ActionA{
        //呼叫store方法
        setAge(age){
            this.storeA.setAge(age); //有效
        }
    }
    
    //元件內
    storeA.setAge(age)  //無效
複製程式碼

對於上述場景,處理方法比較簡單。只需要

  • 宣告一個變數flag
  • 在例項化storeaction時對例項的方法分別進行包裝
  • action的方法呼叫前設定flagtrue,執行action的方法,然後設定flagfalse
  • 這樣store的方法如果在action內呼叫時訪問到的flagtrue,在其他地方訪問到的flagfalse
  • store方法的包裝比較簡單,判斷flag,為true執行資料操作,為false進行友好提示

經過上述幾步,就完成了同步場景的限制處理。

但實際的專案中大量的存在非同步操作,如果action如下所示,會如何呢?

     class ActionA{
        //呼叫store方法
        async setAge(age){
            await saveAge(url); //介面呼叫
            this.storeA.setAge(age); //有效
        }
    }
複製程式碼

這時storeA.setAge雖然處於action內,但訪問到的flag卻是false,方案失效了。

對同步操作的處理如此簡單,非同步操作卻是一個巨大的難題。現在的課題可以抽象為如下描述

    如何實現在同一個方法內的呼叫(包括同步操作, setTimeout、promise、rAF、各種事件等非同步操作的回撥內...)都能訪問到同一個上下文(true),而在這個方法外訪問到的是另一個(false複製程式碼

內心隱隱約約有一個答案,如果在action呼叫時儲存這個上下文,並在各種非同步的回撥裡再取出這個上下文即可實現功能。但這是一個可怕的事情,意味著需要我們去代理所有的非同步呼叫,換句話說我們需要覆蓋原生的方法來做這麼一件事情!

這似乎是很難去實現的,直到我發現了zone.js

zone.js

簡單介紹一下,zone.jsangular框架的核心元件,angular利用zone.js監聽所有(可能導致資料變化)的非同步事件。

這跨度有點大,怎麼又扯到了angular

沒關係,重新介紹一下。zone.js描述了JavaScript執行過程的上下文,可以在非同步任務之間進行永續性傳遞。

重點就是這句話,我翻譯一下,zonejs能保持同一個方法內的呼叫(無論同步還是非同步的)都能訪問到同一個上下文物件。這不正好解決了我們的問題嗎?

現在利用zonejs來解決我們之前的問題。程式碼如下

    //這裡並沒有闡述zone.js如何使用,如果看過zonejs文件應該很容易理解下面的程式碼所做的事情
    const zone = Zone.root.fork({
      name: `__mobx__zone`
    });
    
    //包裝action的setAge方法,使得action內的方法呼叫訪問到Zone.current都為zone
    let oldFn = ActionA.setAge
    ActionA.setAge = (...args) => {
      return zone.run(oldFn, context, args)
    }
    
    //包裝store的方法,判斷Zone.current是否為zone,如果在action之外呼叫則為Zone.root
     let oldFn = StoreA.setAge
    StoreA.setAge = (...args) => {
      if(Zone.current === zone){
        return oldFn.apply(context,args)
      }else{
          //在action外呼叫store方法觸發警告
          console.error(`invalid call`)
      }
    }
    
    //以上的包裝方法均在內部處理,不暴露在業務程式碼中
複製程式碼

利用zone.js可以很容易的實現我們想要的功能,通過粗略的原始碼瀏覽發現zone.js正是暴力的代理了原生的api

通過上述幾步處理,我們就可以愉快的拿mobx進行大型專案的構建和持續迭代了。

結尾

本文並未涉及過多的程式碼細節,對於mobx如何使用也並未闡述。本文著重去解決在使用mobx過程中可能引發的問題,並且在規範成員的程式碼風格方面做了嘗試,使得在用mobx進行專案的開發時能最大限度的保證程式碼格式的統一,降低專案的維護成本。
關於如何開發和維護一個大型專案是一個很大的話題,應該在約定或者強制某些規範的基礎上,再根據所處的業務場景進行特定的設計才可能做好。

相關文章