Redux 異步數據流方案對比

Redux 的核心理念是嚴格的單向數據流,只能經過 dispatch(action) 的方式修改 store,流程以下:javascript

view ->  action -> reducer -> store複製代碼

而在實際業務中每每有大量異步場景,最原始的作法是在 React 組件 componentDidMount 的時候初始化異步流,經過 callback 或者 promise 的方式在調用 dispatch(action),這樣作把 view 層和 model 層混雜在一塊兒,耦合嚴重,後期維護很是困難。
以前的文章 解讀 Redux 中間件的原理 能夠知道,中間件(middleware)改寫了 dispatch 方法,所以能夠更靈活的控制 dispatch 的時機,這對於處理異步場景很是有效。所以 Redux 做者也建議用中間件來處理異步流。社區常見的中間件有 redux-thunkredux-promiseredux-sagaredux-observable 等。java

redux-thunk:簡單粗暴

做爲 Redux 做者本身寫的異步中間件,其原理很是簡單:Redux 自己只會處理同步的簡單對象 action,但能夠經過 redux-thunk 攔截處理函數(function)類型的 action,經過回調來控制觸發普通 action,從而達到異步的目的。其典型用法以下:react

//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 其核心源碼以下:web

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 正是解決了這個問題。一樣是從後端去數據,其典型用法爲:json

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 的 成功/失敗結果。redux

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, 這時候面臨兩種選擇 :api

  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 outpromise

用對於剛纔的問題,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,所以異步的場景得藉助於社區形形色色的異步中間件,文中介紹了一些常見異步方案的使用,在實際項目中須要考慮多方面因素選擇適合本身團隊的異步方案。

相關文章
相關標籤/搜索