Redux 非同步資料流方案對比

表示很不蛋定發表於2017-10-18

Redux 的核心理念是嚴格的單向資料流,只能通過 dispatch(action) 的方式修改 store,流程如下:

view ->  action -> reducer -> store複製程式碼

而在實際業務中往往有大量非同步場景,最原始的做法是在 React 元件 componentDidMount 的時候初始化非同步流,通過 callback 或者 promise 的方式在呼叫 dispatch(action),這樣做把 view 層和 model 層混雜在一起,耦合嚴重,後期維護非常困難。
之前的文章 解讀 Redux 中介軟體的原理 可以知道,中介軟體(middleware)改寫了 dispatch 方法,因此可以更靈活的控制 dispatch 的時機,這對於處理非同步場景非常有效。因此 Redux 作者也建議用中介軟體來處理非同步流。社群常見的中介軟體有 redux-thunkredux-promiseredux-sagaredux-observable 等。

redux-thunk:簡單粗暴

作為 Redux 作者自己寫的非同步中介軟體,其原理非常簡單:Redux 本身只會處理同步的簡單物件 action,但可以通過 redux-thunk 攔截處理函式(function)型別的 action,通過回撥來控制觸發普通 action,從而達到非同步的目的。其典型用法如下:

//constants 部分省略
//action creator
const createFetchDataAction = function(id) {
    return function(dispatch, getState) {
        dispatch({
            type: FETCH_DATA_START, 
            payload: id
        })
        api.fetchData(id) 
            .then(response => {
                dispatch({
                    type: FETCH_DATA_SUCCESS,
                    payload: response
                })
            })
            .catch(error => {
                dispatch({
                    type: FETCH_DATA_FAILED,
                    payload: error
                })
            }) 
    }
}
//reducer
const reducer = function(oldState, action) {
    switch(action.type) {
    case FETCH_DATA_START : 
        // 處理 loading 等
    case FETCH_DATA_SUCCESS : 
        // 更新 store 等處理
    case FETCH_DATA_FAILED : 
        // 提示異常
    }
}複製程式碼

可以看到採用 redux-thunk 後,action creator 返回的 action 可以是個 function,這個 function 內部自己會在合適的時機 dispatch 合適的普通 action。而這裡面也沒有什麼魔法,redux-thunk 其核心原始碼如下:

const thunk = ({ dispatch, getState }) => next => action => {
    if (typeof action === 'function') {
      return action(dispatch, getState);
    }
    return next(action);
  };複製程式碼

如果 action 是個 function,便將 dispatch 方法傳入該函式並執行之。
redux-thunk 在使用時非常方便,能滿足大部分場景,缺點就是樣板程式碼太多,寫起來費勁了點。

redux-promise:將 promise 貫徹到底

redux-thunk 是將從 api 返回的 promise resolve 後 dispatch 成不同 action,那直接將這個 promise 作為 action 給 dispatch,讓中介軟體來處理 resolve 這個過程,豈不是就可以少寫些 .then().catch() 之類的程式碼了嗎?redux-promise 正是解決了這個問題。同樣是從後端去資料,其典型用法為:

const FETCH_DATA = 'FETCH_DATA'
//action creator
const getData = function(id) {
    return {
        type: FETCH_DATA,
        payload: api.fetchData(id) // 直接將 promise 作為 payload
    }
}
//reducer
const reducer = function(oldState, action) {
    switch(action.type) {
    case FETCH_DATA: 
        if (action.status === 'success') {
             // 更新 store 等處理
        } else {
                // 提示異常
        }
    }
}複製程式碼

這樣下來比 redux-thunk 的寫法瘦身不少。其核心原始碼與 redux-thunk 類似,如果 actionaction.payloadPromise 型別則將其 resolve,觸發當前 action 的拷貝,並將 payload 設定為 promise 的 成功/失敗結果。

export default function promiseMiddleware({ dispatch }) {
  return next => action => {
    if (!isFSA(action))  {// 判斷是否是標準的 flux action
      return isPromise(action)
        ? action.then(dispatch)
        : next(action);
    }

    return isPromise(action.payload)
      ? action.payload.then(
          result => dispatch({ ...action, payload: result }),
          error => {
            dispatch({ ...action, payload: error, error: true });
            return Promise.reject(error);
          }
        )
      : next(action);
  };
}複製程式碼

仔細一看會發現 redux-promise 的寫法裡 reducer 收到 action 時就已經被 resolve 了,這樣如果要處理 loading 這種情景就還得寫額外程式碼,而且在 action 這樣一個簡單物件裡增加 status 屬性會給人不規範的感覺,這可能就是步子邁大了容易扯到蛋吧。

redux-thunkredux-promise 用法實際上比較類似,都是觸發一個 function/promise 讓中介軟體自己決定 dispatch 真正非同步資料的時機,這對於大部分場景來說已經足夠了。但是對於非同步情況更復雜的場景,我們往往要寫很多業務程式碼,一個非同步結果返回後可能需要對應修改 store 裡多個部分,這樣就面臨一個困惑的問題:業務程式碼是放在 action 層還是 reducer 裡?例如,管理員凍結某使用者的賬戶,需要同時更新 storeAllUserListPendingUserlist, 這時候面臨兩種選擇 :

  1. 點選按鈕時觸發一個 PEND_USER 的 action,然後在 reducer 對應 switch 裡同時更新 AllUserListPendingUserlist
  2. 點選按鈕時觸發 REFRESH_USER_LISTREFRESH_PENDING_USER_LIST 兩個 action,然後在 reducer 裡分別更新兩處 store
    一般來說使用者一個動作觸發一個 action 更符合常理,但是可能其他地方又有複用 REFRESH_USER_LIST 的地方,將 action 拆的更新更利於複用,這時候就得做個取捨了。

redux-saga:精準而優雅

redux-saga 就可以很好的解決這個問題,它在原來 Redux 資料流中增加了 saga 層(不要在意這個詭異的名字?),監聽 action 並衍生出新的 action 來對 store 進行操作,這一點接下來介紹的 redux-observable 一樣,核心用法可以總結為: Acion in,action out

用對於剛才的問題,redux-saga 的寫法為:

//action creator
const refreshUserListAction = (id)=>({type:REFRESH_USER_LIST,id:pendedUser.id})
const refreshPendingUserListAction = (id)=>({type:REFRESH_PENGDING_USER_LIST,id:pendedUser.id})
//saga
function* refreshLists() {
  const pendedUser = yield call(api.pendUser)
  // 將同時觸發(put)兩個 action
  yield put(refreshUserListAction())
  yield put(refreshPendingUserListAction())
}

function* watchPendUser() {
  while ( yield take(PEND_USER) ) {
    yield call(refreshLists) // 監聽 PEND_USER 的 action,並執行(call)refreshLists 方法
  }
}
//reducer 省略複製程式碼

這樣一來業務邏輯就非常明確了:由一個'PEND_USER'觸發了兩個 REFRESH 的 action 並進入 reducer。而且將業務程式碼分離出 action 層和 reducer 層,減少了程式碼耦合,對於後期維護和測試非常有益。
對於更復雜的非同步,例如競態問題,redux-saga 更能大顯身手了:

之前用過一個第三方的微部落格戶端,發現的一個 bug:當點選第一條微博 A,跳轉到 A 的評論頁,由於網速原因 loading 太久不願意再等了,就返回主頁,再點了另一條微博 B,跳轉到 B 的評論頁,這時候先前的 A 的評論列表請求返回了,於是在 B 微博的評論頁裡展示了 A 的評論。

如果這個系統是用 react/redux 做的話,那這個 bug 的原因很明顯:action 在到達 reducer 的時候該 action 已經不需要了。如果用 redux-thunkredux-promise 來解決此問題的話有兩種方式:

  1. 在 promise 返回時判斷當前 store 裡的 id 和 promise 開始前的 id 是否相同:
    function fetchWeiboComment(id){
     return (dispatch, getState) => {
         dispatch({type: 'FETCH_COMMENT_START', payload: id});
         dispatch({type: 'SET_CURRENT_WEIBO', payload: id}); // 設定 store 裡 currentWeibo 欄位
         return api.getComment(id)
             .then(response => response.json())
             .then(json => { 
                 const { currentWeibo } = getState(); // 判斷當前 store 裡的 id 和 promise 開始前的 id 是否相同:
                 (currentFriend === id) && dispatch({type: 'FETCH_COMMENT_DONE', playload: json})
             });
     }
    }複製程式碼
  2. 在 action 裡帶上微博 id,在 reducer 處理的時候判斷這個 idurl 裡的 id 是否相同, 這裡就不上程式碼了。

總之這樣處理會比較多的程式碼,如果專案中有大量這種場景,最後維護起來會比較蛋疼。而用 redux-saga 可以處理如下:

import { takeLatest } from `redux-saga`

function* fetchComment(action) {
    const comment = yield call(api.getComment(action.payload.id))
    dispatch({type: 'FETCH_COMMENT_DONE', payload: comment})
}

function* watchLastFetchWeiboComment() {
  yield takeLatest('FETCH_COMMENT_START', fetchComment)
}複製程式碼

takeLatest 方法可以過濾 action,當下一個 FETCH_COMMENT_STARTaction 到來時取消上一個 FETCH_COMMENT_STARTaction 的觸發,這時候未返回結果的上一條網路請求(pending 狀態)會被 cancel 掉。
另外 redux-saga 還提供了更多方法用來處理非同步請求的阻塞、併發等場景,更多操作可以看 Redux-saga 中文文件

因此如果專案中有大量複雜非同步場景,就非常適合採用 redux-saga。

採用 redux-saga 可以保持 actionreducer 的簡單可讀,邏輯清晰,通過採用 Generator ,可以很方便地處理很多非同步情況,而 redux-saga 的缺點就是會新增一層 saga 層,增大上手難度;Generator 函式程式碼除錯也比普通函式更復雜。

redux-observable:更優雅的操作

可以看到 redux-saga 的思路和之前的 redux-thunk 有很大不同,它是響應式的(Reactive Programming):

在計算機中,響應式程式設計是一種面向資料流和變化傳播的程式設計正規化。這意味著可以在程式語言中很方便地表達靜態或動態的資料流,而相關的計算模型會自動將變化的值通過資料流進行傳播。

對於資料流的起點 action 層來說,只需要觸發 FETCH_COMMENT_START 的事件流便可完成整個資料的更新,無需關心後續資料的變化處理。
說起響應式,就不得不提 RxJS 了,RxJS 是一個強大的 Reactive 程式設計庫,提供了強大的資料流組合與控制能力。RxJS 中 “一切皆流” 的思想對於接觸函數語言程式設計(FP)不多的使用者來說會感到非常困惑,但在熟練了之後又會豁然開朗。在 RxJS 中,一個觀察者 (Observer) 訂閱一個可觀察物件 (Observable),下面是 Observable 和傳統 PromiseGenerator 的對比:

可以看到 Observable 可以 非同步 地返回 多個 結果,因此有著更強大的資料的操作控制能力。而 redux-observable 便是基於 RxJS 實現的通過組合和取消非同步動作去建立副作用的中介軟體。
redux-observable 中處理非同步的這一層叫 Epic(也不要在意這個詭異的名字),Epic 接收一個以 action 流為引數的函式,並返回一個 action 流。
先來看看簡單的例子:

//epic
const fetchWeiboCommentEpic = action$=>
    action$.ofType(FETCH_COMMENT_START) //ofType 表示過濾type 為 FETCH_COMMENT_START 的 action
        .switchMap(action=>//switchMap 的作用類似 saga 中的 takeLatest,新的 action 會將老的 action 取消掉
            Observable.fromPromise(api.getComment(action.payload.id))// 將 promise 轉化成 Observable
                .map(comment=>({type: 'FETCH_COMMENT_DONE', payload: comment})) // 將返回的 Obsevable 對映(map)成一個普通 action
                .catch(err=>({type: 'FETCH_COMMENT_ERROR', payload: err})) // 這裡的 err 也是一個 Observable,被捕獲並對映成了一個 action
            )複製程式碼

配置好 redux-observable 中介軟體後即可監聽 FETCH_COMMENT_STARTaction 並非同步發起請求並返回攜帶相應資料的成功或失敗的 action。可以看到,得益於 RxJS 強大的諸如 switchMap 的操作符,redux-observable 能用簡短的程式碼完成複雜的資料控制過程。我們還可以在這個 fetchWeiboCommentEpic 中增加更復雜的操作,比如當收到 FETCH_COMMENT_START 時延遲 500ms 再發請求,並收到人為取消的 actionFETCH_COMMENT_FORCE_STOP 時(比如使用者點了取消載入的按鈕)終止請求,拿到微博評論後同時提醒 “重新整理成功”:

//epic
const fetchWeiboCommentEpic = action$=>
    action$.ofType(FETCH_COMMENT_START) 
        .delay(500) // 延遲 500ms 再啟動
        .switchMap(action=>
            Observable.fromPromise(api.getComment(action.payload.id))
                .map(comment=>[
                    {type: 'FETCH_COMMENT_DONE', payload: comment},
                    {type: 'SET_NOTIFICATION', payload: comment} // 同時提醒 “重新整理成功”
                ])
                .catch(err=>({type: 'FETCH_COMMENT_ERROR', payload: err}))
                .takeUntil(action$.ofType('FETCH_COMMENT_FORCE_STOP')) // 人為取消載入
            )複製程式碼

再來看個場景,使用者在搜尋框打字時,實時從後端取結果返回最匹配的提示(類似在 Google 搜尋時展示的提示)。使用者打字不停地觸發 USER_TYPING 的 action,不停去請求後端,這種時候用 redux-thunk 處理就會比較麻煩,而 redux-observable 可以優雅地做到:

const replaceUrl=(query)=>({type:'REPLACE_URL',payload:query})
const receiveResults = results=>({type:'SHOW_RESULTS',payload:results})
const searchEpic = action$=>action$.ofType('USER_TYPING')
    .debounce(500) // 這裡做了 500ms 的防抖,500ms 內不停的觸發打字的操作將不會發起請求,這樣大大節約了效能
    .map(action => action.payload.query) // 返回 action 裡的 query 欄位,接下來的函式收到引數便是 query 而不是 action 整個物件了
    .filter(query => !!query) // 過濾掉 query 為空的情況
    .switchMap(query =>
        .takeUntil(action$.ofType('CLEARED_SEARCH_RESULTS'))
        .mergeMap(() => Observable.merge( // 將兩個 action 以 Observable 的形式 merge 起來
          Observable.of(replaceUrl(`?q=${query}`)), 
          Observable.fromPromise(api.search(query))
            .map(receiveResults) 
        ))
    );複製程式碼

另外 RxJS 還提供了 WebSocketSubject 物件,可以很容易優雅地處理 websocket 等場景,這裡就不展開了。
redux-observable 提供的 ObservableGenerator 更靈活,得益於強大的 RxJSredux-observable 對非同步的處理能力更為強大,這大概是目前最優雅的 redux 非同步解決方案了。然而缺點也很明顯,就是上手難度太高,光是 RxJS 的基本概念對於不熟悉響應式程式設計的同學來說就不是那麼好啃的。但是通過此來接觸 RxJS 的思想,能開闊自己眼界,也是非常值得的。因此在非同步場景比較複雜的小專案中可以嘗試使用 redux-observable,而大型多人協作的專案中得考慮整個團隊學習的成本了,這種情況一般用 redux-saga 的價效比會更高。目前國內採用 redux-observable 的並不多,在這裡也希望可以和大家多交流下 redux-observable 相關的實踐經驗。

總結

Redux 本身只會處理同步的 action,因此非同步的場景得藉助於社群形形色色的非同步中介軟體,文中介紹了一些常見非同步方案的使用,在實際專案中需要考慮多方面因素選擇適合自己團隊的非同步方案。

相關文章