關於前端資料&邏輯的思考

jonnyF發表於2018-02-07

最近重構了一個專案,一個基於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的完整閉環。

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

相關文章