我的前端故事----關於前端數據&邏輯的思考


最近重構了一個項目,一個基於redux模型的react-native項目,目標是在混亂的代碼中梳理出一個清晰的結構來,為了實現這個目標,首先需要對項目的結構做分層處理,將各個邏輯分離出來,這里我是基於典型的MVC模型,那么為了將現有代碼重構為理想的模型,我需要做以下幾步:

  • 拆分組件
  • 邏輯處理
  • 抽象、聚合數據

組件化

這是一個老生常談的問題了,從16年起前端除了構建工具,討論的最多的就是組件化了,把視圖按照一定規則切分為若干模塊過程就是組件化,那么組件化的重點就是那個規則

那么這個規則又是什么呢?

按功能?按樣式?

我之前的項目里多數這兩種情況都存在,舉個簡單的例子,對於app的登錄模塊來說就是一個典型的按功能分組,而對於一個列表就是一個明顯的按樣式去組件化,他們兩個對應着兩種完全不同的寫法,因為他們一個是充血模型,一個是貧血模型。在redux中,明顯的區別是貧血組件中一切的狀態全部外置,組件自身不去管理自己的狀態,統統放到reducer;而在充血組件中,一部分狀態由全局的store去管理,一部分有自身的state控制。

    // 充血組件              // 貧血組件
    組件A | 組件B | 組件C    組件A | 組件B | 組件C
    邏輯A | 邏輯B | 邏輯C    ---------------------
    數據A | 數據B | 數據C           邏輯層
    -------------------    ---------------------
          全局邏輯                 數據層

在我重構的過程中更傾向於將組件內的狀態都放在reducer中,這樣View就可以更純粹的去渲染了,這樣的View在我看來會更加簡潔、更加清晰,對於組件的替換更是駕輕就熟。但狀態全外置這種實踐帶來的代價也是很大的。因為一個帶交互的組件,勢必需要一些事件的處理,生命周期的觸發等等操作,這會帶來一些問題:

  • 這種組件提煉出來的狀態只和自己有關,強制被放在Store中就會帶來Store復雜度的上升,如果你的組件足夠多,那么全局的Store會膨脹的特別明顯,更重要的是如果你的狀態是和組件成樹形對應的話,Store中將會冗余很多重復的數據。
  • 描述組件的狀態被轉移到外部,導致操作組件的成本變高,對於組件內的一些簡單操作將變得復雜繁瑣。

對於后一點我認為並沒有很大的問題,得益於分層和純渲染的設計,組件將控制自身的行為交出后可以將這些邏輯抽象為更加通用的邏輯,從而方便有類似需求的組件使用,因為邏輯應該只出現在一個地方,而不應分散在多個地方。例如控制一批組件的顯示或隱藏,將組件內部控制顯示的邏輯交出來反而會省去更多的重復代碼。

而我更擔心的是由於組件中私有狀態的轉移導致的Store膨脹的問題,為了避免這個問題首先做的便是盡可能的提取公用有相似作用的狀態,例如控制顯示/隱藏、多個列表的頁數/條數;等這些有着相似功能的字段。走到這一步就引出了另外一個問題了,對於組件的狀態描述是樹形的還是平行的。

  • 樹形結構

這種結構的特點是將一個組件的狀態通過一個樹的形式記錄下來,頁面是如何嵌套的,那么狀態樹就是如何嵌套的,這樣做的好處是組件接收到狀態后直接遞歸的顯示就行了,對於組件來說這是最簡單,效率最高的展現形式。但這樣做的問題就是如果有多個相似的組件就會造成Store中冗余大量重復數據,最終造成Store的膨脹。

  • 平行結構

這種結構和上面的樹形結構恰恰相反,可以最大程度的避免冗余數據的產生,將每一類數據拍平保存,但這種形式對於組件的展示卻很不友好,組件需要自己去消化多處數據源帶來的格式化操作,在redux中connect方法就是用來處理這種多數據源聚合用的。

那么上面兩種結構改如何取舍呢?我個人推薦第二種平行結構,既然選擇了平行結構,那么該如何去處理數據聚合的問題呢?在這里我推薦利用管道的思路來解決,這借鑒了 Angular 2 Pipe的概念,當然熟悉Linux的同學對於|操作符一定也不會陌生。在我們的項目中,數據是流動的,如同一個管道中的水一樣,Store就是一個水庫,匯集了各種各樣的數據(水),而頁面組件就如同需要灌溉的田,而從水庫到田間這段距離就需要水管的幫助了。同樣的,利用pipe我們可以將保存在Store中的數據轉換成期望看到的結構,而這一切操作都是在數據的流動中完成的,而不是放在數據已經傳遞到組件之后去處理了。

這里引出了一個概念,就是數據流這個概念,在項目中我將所有數據的操作都成為數據的流動。舉個例子,當用戶在登錄框輸入了用戶名和密碼並點擊提交之后,這兩個input中的value就變成了兩個數據流:

   input => merge(name, password) => filter(校驗合法性) => post(服務器)

這個行為變成了一條流水線,先不管post輸出的結果如何,在上面的demo中我們的輸入行為被抽象成了兩個參數,最后通過合並、過濾、發送,最終到達服務器,這不是一個新概念,在很多的框架中都有體現:

在Cycle.js它被稱為 Intent(負責從外部的輸入中,提取出所需信息),Intent實際上做的是action執行過程的高級抽象,提取了必要的信息。由於View是純展示的,所以包括事件監聽在內的行為統統被Intent抽象成數據源,這在RxJs中很常見:

var clicks = Rx.Observable.fromEvent(document, 'click');
clicks.subscribe(x => console.log(x));

// 結果:
// 每次點擊 document 時,都會在控制台上輸出 MouseEvent 。

相比於從View中發出的同步數據源,我們遇到更多的是從HTTP中獲取的異步數據源。在redux中我們常用redux-thunk來處理異步操作,那么在流中呢?

邏輯處理

在之前的業務中我們有很多方式去處理異步操作,比如說最常用的redux-thunk(回調)、promise、async/await。現在很多人更願意用async/await操作符去寫異步邏輯,因為它讓代碼顯得更加“同步”,我之前也很喜歡這種方式,但現在在數據流的概念中,同步/異步已經被“模糊”了,它們都是數據源,它們都是“主動”發出數據的,那么同步還是異步就顯得不那么重要了,還是上面的例子,如果用戶名變成了一個異步獲取的過程,而不是用戶主動輸入的了:

 input => merge(async(name), password) => filter(校驗合法性) => post(服務器)

這種情況下在RxJs中可以通過zip來等待全部的數據流

let age$ = Observable.of<number>(27, 25, 29);
let name$ = Observable.of<string>('Foo', 'Bar', 'Beer');
let isDev$ = Observable.of<boolean>(true, true, false);

Observable
    .zip(age$,
         name$,
         isDev$,
         (age: number, name: string, isDev: boolean) => ({ age, name, isDev }))
    .subscribe(x => console.log(x));

// 輸出:
// { age: 27, name: 'Foo', isDev: true }
// { age: 25, name: 'Bar', isDev: true }
// { age: 29, name: 'Beer', isDev: false }

通過這樣的鏈式操作,我們可以很方便的控制和獲取數據流,這是對於數據的獲取,那么數據的分發呢?在redux中,我們通常會多次dispatch,在redux-thunk中我們會這樣寫:

const getInfo = (params) => async (dispatch, getState) => {

    // TODO...
    
    dispatch(actionaA);
    
    // TODO...
    
    dispatch(actionaA);
}

而在redux-observable中:

const somethingEpic = (action$, store) =>
  action$.ofType(SOMETHING)
    .switchMap(() =>
      ajax('/something')
        .do(() => store.dispatch({ type: SOMETHING_ELSE }))
        .map(response => ({ type: SUCCESS, response }))
    );

但是我認為到處dispatch是一個不好的行為,這會讓一個流變得混亂,因為你在流的最后不會得完整的結果(在過程中有一部分就已經派發出去了),這會讓邏輯看起來很散亂,所以我推薦應該寫成這樣的形式:

const somethingEpic = action$ =>
  action$.ofType(SOMETHING)
    .switchMap(() =>
      ajax('/something')
        .mergeMap(response => Observable.of(
          { type: SOMETHING_ELSE },
          { type: SUCCESS, response }
        ))
    );

// 上面這兩段demo來着redux-observable的文檔

結束了異步的處理,我們的流模型也完成了input->output的完整閉環了。在這里沒有詳細說output是因為基於redux,我任然是通過redux的connect方法將Store分發注入到組件的props中去的,因此如果你熟悉redux那么會很習慣現在的改變。

在處理完了同步/異步之后我們就來聊聊業務的邏輯該如何處理了。在redux中邏輯被分在了兩個地方,action和reducer中,一個是做數據的聚合,一個是做數據的格式化。上面提到了Intent 是action的高階抽象,其實是對action的拆分,剝離了action中獲取數據的部分邏輯,那么剩下的就是數據處理的部分了,這部分在我的實踐中被叫做Service

這是一個單例的實例,整個項目中一個服務只會有一個實例,不必將相同的代碼復制一遍又一遍,只需要創建一個單一的可復用的數據服務,並且把它注入到需要它的那些組件中。並且使用單獨的服務可以保持組件足夠的精簡,同時也更容易對組件進行單元測試。同樣reducer中的數據格式化邏輯也遷到了服務中去處理,在redux中reducer兼顧着數據的格式化和數據的保存這兩個功能,現在我們將徹底剝離出數據的處理部分,剩下的reducer將只做數據的保存,這就又引出了另一個概念Model,這一層我們一會討論,接着業務處理來看,在數據流獲取到數據並處理分發到Model中之后,input這一步基本算是結束了,接下來就是由Model到View的output了。

上文中我說道了我推薦使用平行模式,那么在平行模式到View這種樹型結構該如果轉化呢?這是output中最重要的一步,在CycleJS中這一步通常由filter去完成,而在Angular中則是由Pipe去處理,無論它叫什么,它們都是這條流程上的一環,就像水管中的一節一樣,所有從Model通向View的數據都會進過這一環,從而被格式化。在代碼中我更推薦大家嘗試使用Decorator去過濾數據源:

@UserInfoPipe({ name: 'Model.UserInfo.name' })
class LoginDemo extends Component {
  constructor(props) {
    super(props);
  }

  render(){
    return (
      <View>
        <Text>{this.props.name}</Text>
      </View>
    );
  }
}

抽象、聚合數據

現在整體的骨架已經有了,剩下的就是該如何更好的抽象整合項目中的數據了。

  • 第一階段

最一開始的項目由於為了方便,我就按照API的結構去設計Store,那個時候一個頁面對應一個接口或者很少的幾個接口,這時候我將API返回的結構與本地的狀態一一對應,這在初期非常的方便,不需要我做過多的轉換,然而接下來為了應付接口的各種異常,不得不寫很多防御性的代碼(字段判空、屬性變更、接口數據拼裝),最后這些代碼變得臃腫不堪,在其它同學介入修改的時候總是一頭霧水,總是改了這里,那里出又出了問題。並且這其中也存在不少冗余的數據。

  • 第二階段

后來我發現既然數據都是最終給View去用的,那么我就按View的需求去設計Store好了,這個Store對於展示的組件來說,使用起來非常方便,當前應用處於哪種狀態,就用對應狀態的數組類型的數據渲染,不用做任何的中間數據轉換。不過這也同樣造成數據冗余的問題,並且如果我需要改動頁面的某個字段的話,需要在很多地方去修改,因為這個Store樹變得很深枝葉很多。

  • 第三階段

那么我現在該如何設計狀態呢?作為一個曾經做過一段時間后端的我來說,我決定模仿數據庫的結構去設計狀態樹。把Store當成一個數據庫,每個種類的狀態看做數據庫中的一張表,狀態中的每一個字段對應表的一個字段。

那么設計一個數據庫,應該要遵循哪些原則呢?

  • 數據按照域分類,存在不同的表中,每張表存儲的字段不重復
  • 每張表中每條數據都有一個唯一主鍵
  • 表中除了主鍵外其它列,相互不存在依賴關系

而基於上面這三條原則,我們怎么設計Store呢?

  • 把整個項目按照一定模型去分離為若干子狀態,這些子狀態之間不存在重復冗余的數據。

怎么理解這件事呢?舉個例子,我有一個長列表,每當我點擊列表中的某一列時就會有一個紅框出現包裹住這列,而這個列表中真正展示的數據應該是另外一個子狀態,它們的關系類似:

{
    activeLine: 1,
    list: [
        {
            name: 'test1',
        },
        {
            name: 'test2',
        },
        {
            name: 'test3',
        },
        {
            name: 'test4',
        },
    ]
}
  • 以鍵值對的結構存儲數據,用key/ID作為記錄的索引,記錄中的其他字段都依賴於索引。

有了唯一的key做主鍵,我們就可以很方便的去遍歷/處理數據。更進一步的,如果我們想去判斷一條數據有沒有變化,我們可以單純的去判斷主鍵是否一致,在一些情況下,這是一個不錯的思路,這避免了多層判斷,或者深拷貝帶來的復雜度和性能問題(這個可以參考immutable)。

  • 狀態樹中不保存可以通過已有數據計算出來的數據,也就是這些數據都是相互獨立的,都可以被稱為原子數據

什么是原子數據?頁面中使用到的數據都是由這些原子數據通過計算、拼裝得到的(注意:這里只有拼裝,沒有拆分,因為原子是最小的單位,所以是不可拆分的);這就保持了數據源的統一,不會出現一份一樣的數據來自多出數據源的問題了,這會避免很多不必要的問題,如多處數據源不同步導致的頁面展示異常等問題。

好了,數據層也設計完了,這樣一個完整的結構就清晰的擺在面前了,最終總結一下這個過程:

  • 按照貧血模型分離組件
  • 通過訂閱的形式采集數據源
  • 通過數據庫的形式去保存數據
  • 通過流的方式去處理和分發數據
  • 通過流的形式去格式化數據

經過以上幾步,我們就初步的完成了一個業務從input到output的完整閉環。

已上這些便是我這次重構總結的一些經驗,肯定不全對、不完善、不准確,但是這個大方向我覺得是值得去探索的。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM