做爲react社區最熱門的狀態管理框架,相信不少人都準備甚至正在使用Redux。html
因爲Redux的理念很是精簡,沒有追求大而全,這份架構上的優雅卻在某種程度上傷害了使用體驗:不能開箱即用,甚至是異步這種最多見的場景也要藉助社區方案。前端
若是你已經挑花了眼,或者正在挑但不知道是否適合,或者已經挑了但不知道會不會有坑,這篇文章應該適合你。react
本文會從一些常見的Redux異步方案出發,介紹它們的優缺點,進而討論一些與異步相伴的常見場景,幫助你在選型時更好地權衡利弊。git
Github:https://github.com/gaearon/redux-thunkgithub
Redux做者Dan寫的中間件,因官方文檔出鏡而廣爲人知。ajax
它向咱們展現了Redux處理異步的原理,即:編程
Redux自己只能處理同步的Action,但能夠經過中間件來攔截處理其它類型的action,好比函數(Thunk),再用回調觸發普通Action,從而實現異步處理,在這點上全部Redux的異步方案都是相似的。json
而它使用起來最大的問題,就是重複的模板代碼太多:redux
//action types const GET_DATA = 'GET_DATA', GET_DATA_SUCCESS = 'GET_DATA_SUCCESS', GET_DATA_FAILED = 'GET_DATA_FAILED'; //action creator const getDataAction = function(id) { return function(dispatch, getState) { dispatch({ type: GET_DATA, payload: id }) api.getData(id) //注:本文全部示例的api.getData都返回promise對象 .then(response => { dispatch({ type: GET_DATA_SUCCESS, payload: response }) }) .catch(error => { dispatch({ type: GET_DATA_FAILED, payload: error }) }) } } //reducer const reducer = function(oldState, action) { switch(action.type) { case GET_DATA : return oldState; case GET_DATA_SUCCESS : return successState; case GET_DATA_FAILED : return errorState; } }
這已是最簡單的場景了,請注意:咱們甚至還沒寫一行業務邏輯,若是每一個異步處理都像這樣,重複且無心義的工做會變成明顯的阻礙。api
另外一方面,像GET_DATA_SUCCESS
、GET_DATA_FAILED
這樣的字符串聲明也很是無趣且易錯。
上例中,GET_DATA
這個action並非多數場景須要的,它涉及咱們將會提到的樂觀更新
,保留這些代碼是爲了和下面的方案作對比
因爲redux-thunk
寫起來實在是太麻煩了,社區固然會有其它輪子出現。redux-promise則是其中比較知名的,一樣也享受了官網出鏡的待遇。
它自定義了一個middleware,當檢測到有action的payload屬性是Promise對象時,就會:
若resolve,觸發一個此action的拷貝,但payload爲promise的value,並設status屬性爲"success"
若reject,觸發一個此action的拷貝,但payload爲promise的reason,並設status屬性爲"error"
提及來可能有點很差理解,用代碼感覺下:
//action types const GET_DATA = 'GET_DATA'; //action creator const getData = function(id) { return { type: GET_DATA, payload: api.getData(id) //payload爲promise對象 } } //reducer function reducer(oldState, action) { switch(action.type) { case GET_DATA: if (action.status === 'success') { return successState } else { return errorState } } }
進步巨大! 代碼量明顯減小! 就用它了! ?
請等等,任何能明顯減小代碼量的方案,都應該當心它是否過分省略了什麼東西,減肥是好事,減到骨頭就殘了。
redux-promise爲了精簡而作出的妥協很是明顯:沒法處理樂觀更新
多數異步場景都是悲觀更新
(求更好的翻譯)的,即等到請求成功才渲染數據。而與之相對的樂觀更新
,則是不等待請求成功,在發送請求的同時當即渲染數據。
最多見的例子就是微信等聊天工具,發送消息時消息當即進入了對話窗,若是發送失敗的話,在消息旁邊再做補充提示便可。這種交互"樂觀"地相信請求會成功,所以稱做樂觀更新
。
因爲樂觀更新
發生在用戶操做時,要處理它,意味着必須有action表示用戶的初始動做
在上面redux-thunk的例子中,咱們看到了GET_DATA
, GET_DATA_SUCCESS
、GET_DATA_FAILED
三個action,分別表示初始動做
、異步成功
和異步失敗
,其中第一個action使得redux-thunk具有樂觀更新的能力。
而在redux-promise中,最初觸發的action被中間件攔截而後過濾掉了。緣由很簡單,redux承認的action對象是 plain JavaScript objects,即簡單對象,而在redux-promise中,初始action的payload是個Promise。
另外一方面,使用status
而不是type
來區分兩個異步action也很是值得商榷,按照redux對action的定義以及社區的廣泛實踐,我的仍是傾向於使用不一樣的type,用同一type下的不一樣status區分action額外增長了一套隱形的約定
,甚至不符合該redux-promise做者本身所提倡的FSA
,體如今代碼上則是在switch-case內再增長一層判斷。
redux-promise-middleware相比redux-promise,採起了更爲溫和和漸進式的思路,保留了和redux-thunk相似的三個action。
示例:
//action types const GET_DATA = 'GET_DATA', GET_DATA_PENDING = 'GET_DATA_PENDING', GET_DATA_FULFILLED = 'GET_DATA_FULFILLED', GET_DATA_REJECTED = 'GET_DATA_REJECTED'; //action creator const getData = function(id) { return { type: GET_DATA, payload: { promise: api.getData(id), data: id } } } //reducer const reducer = function(oldState, action) { switch(action.type) { case GET_DATA_PENDING : return oldState; // 可經過action.payload.data獲取id case GET_DATA_FULFILLED : return successState; case GET_DATA_REJECTED : return errorState; } }
若是不須要樂觀更新,action creator可使用和redux-promise徹底同樣的,更簡潔的寫法,即:
const getData = function(id) { return { type: GET_DATA, payload: api.getData(id) //等價於 {promise: api.getData(id)} } }
此時初始actionGET_DATA_PENDING
仍然會觸發,可是payload爲空。
相對redux-promise於粗暴地過濾掉整個初始action,redux-promise-middleware選擇建立一個只過濾payload中的promise屬性的XXX_PENDING
做爲初始action,以此保留樂觀更新的能力。
同時在action的區分上,它選擇了迴歸type
的"正途",_PENDING
、_FULFILLED
、_REJECTED
等後綴借用了promise規範 (固然它們是可配置的) 。
它的遺憾則是隻在action層實現了簡化,對reducer層則一籌莫展。另外,相比redux-thunk,它還多出了一個_PENDING
的字符串模板代碼(三個action卻須要四個type)。
社區有相似type-to-reducer這樣試圖簡化reducer的庫。但因爲reducer和異步action一般是兩套獨立的方案,reducer相關的庫沒法去猜想異步action的後綴是什麼(甚至有沒有後綴),社區也沒有相關標準,也就很難對異步作出精簡和抽象了。
不管是redux-thunk
仍是redux-promise-middleware
,模板代碼都是顯而易見的,每次寫XXX_COMPLETED
這樣的代碼都以爲是在浪費生命——你得先在常量中聲明它們,再在action中引用,而後是reducer,假設像redux-thunk
同樣每一個異步action有三個type,三個文件加起來你就得寫九次!
國外開發者也有相同的報怨:
有沒有辦法讓代碼既像redux-promise同樣簡潔,又能保持樂觀更新的能力呢?
redux-action-tools是我給出的答案:
const GET_DATA = 'GET_DATA'; //action creator const getData = createAsyncAction(GET_DATA, function(id) { return api.getData(id) }) //reducer const reducer = createReducer() .when(getData, (oldState, action) => oldState) .done((oldState, action) => successState) .failed((oldState, action) => errorState) .build()
redux-action-tools在action層面作的事情與前面幾個庫大同小異:一樣是派發了三個action:GET_DATA
/GET_DATA_SUCCESS
/GET_DATA_FAILED
。這三個action的描述見下表:
type | When | payload | meta.asyncPhase |
---|---|---|---|
${actionName} |
異步開始前 | 同步調用參數 | 'START' |
${actionName}_COMPLETED |
異步成功 | value of promise | 'COMPLETED' |
${actionName}_FAILED |
異步失敗 | reason of promise | 'FAILED' |
createAsyncAction
參考了redux-promise做者寫的redux-actions ,它接收三個參數,分別是:
actionName 字符串,全部派生action的名字都以它爲基礎,初始action則與它同名
promiseCreator 函數,必須返回一個promise對象
metaCreator 函數,可選,做用後面會演示到
目前看來,其實和redux-promise/redux-promise-middleware大同小異。而真正不一樣的,是它同時簡化了reducer層! 這種簡化來自於對異步行爲從語義角度的抽象:
當(when)初始action發生時處理同步更新,若異步成功(done)則處理成功邏輯,若異步失敗(failed)則處理失敗邏輯
抽離出when
/done
/failed
三個關鍵詞做爲api,並使用鏈式調用將他們串聯起來:when
函數接收兩個參數:actionName和handler,其中handler是可選的,done
和failed
則只接收一個handler參數,而且只能在when
以後調用——他們分別處理`${actionName}_SUCCESS` 和 `${actionName}_FAILED`.
不管是action仍是reducer層,XX_SUCCESS
/XX_FAILED
相關的代碼都被封裝了起來,正如在例子中看到的——你甚至不須要聲明它們! 建立一個異步action,而後處理它的成功和失敗狀況,事情本該這麼簡單。
更進一步的,這三個action默認都根據當前所處的異步階段,設置了不一樣的meta(見上表中的meta.asyncPhase),它有什麼用呢?用場景說話:
它們是異步不可迴避的兩個場景,幾乎每一個項目會遇到。
以異步請求的失敗處理爲例,每一個項目一般都有一套比較通用的,適合多數場景的處理邏輯,好比彈窗提示。同時在一些特定場景下,又須要繞過通用邏輯進行單獨處理,好比表單的異步校驗。
而在實現通用處理邏輯時,常見的問題有如下幾種:
底層處理,擴展性不足
function fetchWrapper(args) { return fetch.apply(fetch, args) .catch(commonErrorHandler) }
在較底層封裝ajax庫能夠輕鬆實現全局處理,但問題也很是明顯:
一是擴展性不足,好比少數場景想要繞過通用處理邏輯,還有一些場景錯誤是前端生成而非直接來自於請求;
二是不易組合,好比有的場景一個action須要多個異步請求,但異常處理和loading是不須要重複的,由於用戶不須要知道一個動做有多少個請求。
不夠內聚,侵入業務代碼
//action creator const getData = createAsyncAction(GET_DATA, function(id) { return api.getData(id) .catch(commonErrorHandler) //調用錯誤處理函數 })
在有業務意義的action層調用通用處理邏輯,既能按需調用,又不妨礙異步請求的組合。但因爲通用處理每每適用於多數場景,這樣寫會致使業務代碼變得冗餘,由於幾乎每一個action都得這麼寫。
高耦合,高風險
也有人把上面的方案作個依賴反轉,改成在通用邏輯裏監聽業務action:
function commonErrorReducer(oldState, action) { switch(action.type) { case GET_DATA_FAILED: case PUT_DATA_FAILED: //... tons of action type return commonErrorHandler(action) } }
這樣作的本質是把冗餘從業務代碼中拿出來集中管理。
問題在於每添加一個請求,都須要修改公共代碼,把對應的action type加進來。且不說並行開發時merge衝突,若是加了一個異步action,但忘了往公共處理文件中添加——這是極可能會發生的——而異常是分支流程不容易被測試發現,等到發現,極可能就是事故而不是bug了。
經過以上幾種常見方案的分析,我認爲比較完善的錯誤處理(Loading同理)須要具有以下特色:
面向異步動做(action),而非直接面向請求
不侵入業務代碼
默認使用通用處理邏輯,無需額外代碼
能夠繞過通用邏輯
而藉助redux-action-tools
提供的meta.asyncPhase,能夠輕易用middleware實現以上所有需求!
import _ from 'lodash' import { ASYNC_PHASES } from 'redux-action-tools' function errorMiddleWare({dispatch}) { return next => action => { const asyncStep = _.get(action, 'meta.asyncStep'); if (asyncStep === ASYNC_PHASES.FAILED) { dispatch({ type: 'COMMON_ERROR', payload: { action } }) } next(action); } }
以上中間件一旦檢測到meta.asyncStep
字段爲FAILED的action便觸發新的action去調用通用處理邏輯。面向action、不侵入業務、默認工做 (只要是用createAsyncAction聲明的異步) ! 輕鬆實現了理想需求中的前三點,那如何定製呢?既然攔截是面向meta的,只要在建立action時支持對meta的自定義就好了,而createAsyncAction
的第三個參數就是爲此準備的:
import _ from 'lodash' import { ASYNC_PHASES } from 'redux-action-tools' const customizedAction = createAsyncAction( type, promiseCreator, //type 和 promiseCreator此處無不一樣故省略 (payload, defaultMeta) => { return { ...defaultMeta, omitError: true }; //向meta中添加配置參數 } ) function errorMiddleWare({dispatch}) { return next => action => { const asyncStep = _.get(action, 'meta.asyncStep'); const omitError = _.get(action, 'meta.omitError'); //獲取配置參數 if (!omitError && asyncStep === ASYNC_PHASES.FAILED) { dispatch({ type: 'COMMON_ERROR', payload: { action } }) } next(action); } }
相似的,你能夠想一想如何處理Loading,須要強調的是建議儘可能用增量配置的方式進行擴展,而不要輕易刪除和修改meta.asyncPhase。
好比上例能夠經過刪除meta.asyncPhase
實現一樣功能,但若是同時還有其它地方也依賴meta.asyncPhase
(好比loadingMiddleware),就可能致使本意是定製錯誤處理,卻改變了Loading的行爲,客觀來說這層風險是基於meta攔截方案的最大缺點,然而相比多數場景的便利、健壯,我的認爲特殊場景的風險是能夠接受的,畢竟這些場景在整個開發測試流程容易得到更多關注。
上面全部的方案,都把異步請求這一動做放在了action creator中,這樣作的好處是簡單直觀,且和Flux社區一脈相承(見下圖)。所以我的將它們歸爲相對簡單的一類。
下面將要介紹的,是相對複雜一類,它們都採用了與上圖不一樣的思路,去追求更優雅的架構、解決更復雜的問題
衆所周知,Redux是借鑑自Elm的,然而在Elm中,異步的處理卻並非在action creator層,而是在reducer(Elm中稱update)層:
這樣作的目的是爲了實現完全的可組合性(composable)。在redux中,reducer做爲函數是可組合的,action正常狀況下做爲純對象也是可組合的,然而一旦涉及異步,當action嵌套組合的時候,中間件就沒法正常識別,這個問題讓redux做者Dan也發出感嘆 There is no easy way to compose Redux applications而且開了一個至今仍然open的issue,對組合、分形與redux的故事,有興趣的朋友能夠觀摩以上連接,甚至瞭解一下Elm,篇幅所限,本文難以盡述。
而redux-loop,則是在這方面的一個嘗試,它更完全的模仿了Elm的模式:引入Effects的概念並將其置入reducer,官方示例以下:
import { Effects, loop } from 'redux-loop'; import { loadingStart, loadingSuccess, loadingFailure } from './actions'; export function fetchDetails(id) { return fetch(`/api/details/${id}`) .then((r) => r.json()) .then(loadingSuccess) .catch(loadingFailure); } export default function reducer(state, action) { switch (action.type) { case 'LOADING_START': return loop( { ...state, loading: true }, Effects.promise(fetchDetails, action.payload.id) ); // 同時返回狀態與反作用 case 'LOADING_SUCCESS': return { ...state, loading: false, details: action.payload }; case 'LOADING_FAILURE': return { ...state, loading: false, error: action.payload.message }; default: return state; } }
注意在reducer中,當處理LOADING_START
時,並無直接返回state對象,而是用loop
函數將state和Effect"打包"返回(實際上這個返回值是數組[State, Effect]
,和Elm的方式很是接近)。
然而修改reducer的返回類型顯然是比較暴力的作法,除非Redux官方出面,不然很難得到社區的普遍認同。更復雜的返回類型會讓不少已有的API,三方庫面臨危險,甚至combineReducer
都須要用redux-loop提供的定製版本,這種"破壞性"也是Redux做者Dan沒有采納redux-loop進入Redux核心代碼的緣由:"If a solution doesn’t work with vanilla combineReducers(), it won’t get into Redux core"。
對Elm的分形架構有了解,想在Redux上繼續實踐的人來講,redux-loop是很好的參考素材,但對多數人和項目而言,最好仍是更謹慎地看待。
Github: https://github.com/yelouafi/r...
另外一個著名的庫,它讓異步行爲成爲架構中獨立的一層(稱爲saga),既不在action creator中,也不和reducer沾邊。
它的出發點是把反作用 (Side effect,異步行爲就是典型的反作用) 當作"線程",能夠經過普通的action去觸發它,當反作用完成時也會觸發action做爲輸出。
import { takeEvery } from 'redux-saga' import { call, put } from 'redux-saga/effects' import Api from '...' function* getData(action) { try { const response = yield call(api.getData, action.payload.id); yield put({type: "GET_DATA_SUCCEEDED", payload: response}); } catch (e) { yield put({type: "GET_DATA_FAILED", payload: error}); } } function* mySaga() { yield* takeEvery("GET_DATA", getData); } export default mySaga;
相比action creator的方案,它能夠保證組件觸發的action是純對象,所以至少在項目範圍內(middleware和saga都是項目的頂層依賴,跨項目沒法保證),action的組合性明顯更加優秀。
而它最爲主打的,則是可測試性和強大的異步流程控制。
因爲強制全部saga都必須是generator函數,藉助generator的next接口,異步行爲的每一箇中間步驟都被暴露給了開發者,從而實現對異步邏輯"step by step"的測試。這在其它方案中是不多看到的 (固然也能夠借鑑generator這一點,但缺乏約束)。
而強大得有點眼花繚亂的API,特別是channel的引入,則提供了武裝到牙齒級的異步流程控制能力。
然而,回顧咱們在討論簡單方案時提到的各類場景與問題,redux-saga並無去嘗試回答和解決它們,這意味着你須要自行尋找解決方案。而generator、相對複雜的API和單獨的一層抽象也讓很多人望而卻步。
包括我在內,不少人很是欣賞redux-saga。它的架構和思路毫無疑問是優秀甚至優雅的,但使用它以前,最好想清楚它帶來的優勢(可測試性、流程控制、高度解耦)與付出的成本是否匹配,特別是異步方面複雜度並不高的項目,好比多數以CRUD爲主的管理系統。
說到異步流程控制不少人可能以爲太抽象,這裏舉個簡單的例子:競態。這個問題並不罕見,知乎也有見到相似問題。
簡單描述爲:
因爲異步返回時間的不肯定性,後發出的請求可能先返回,如何確保異步結果的渲染是按照請求發生順序,而不是返回順序?
這在redux-thunk爲表明的簡單方案中是要費點功夫的:
function fetchFriend(id){ return (dispatch, getState) => { //步驟1:在reducer中 set state.currentFriend = id; dispatch({type: 'FETCH_FIREND', payload: id}); return fetch(`http://localhost/api/firend/${id}`) .then(response => response.json()) .then(json => { //步驟2:只處理currentFriend的對應response const { currentFriend } = getState(); (currentFriend === id) && dispatch({type: 'RECEIVE_FIRENDS', playload: json}) }); } }
以上只是示例,實際中不必定須要依賴業務id,也不必定要把id存到store裏,只要爲每一個請求生成key,以便處理請求時可以對應起來便可。
而在redux-saga中,一切很是地簡單:
import { takeLatest } from `redux-saga` function* fetchFriend(action) { ... } function* watchLastFetchUser() { yield takeLatest('FETCH_FIREND', fetchFriend) }
這裏的重點是takeLatest,它限制了同步事件與異步返回事件的順序關係。
另外還有一些基於響應式編程(Reactive Programming)的異步方案(如redux-observable)也能很是好地處理競態場景,由於描述事件流之間的關係,正是整個響應式編程的抽象基石,而競態在本質上就是如何保證同步事件與異步返回事件的關係,正是響應式編程的用武之地。
本文包含了一些redux社區著名、非著名 (恩,個人redux-action-tools) 的異步方案,這些其實並不重要。
由於方案是一家之做,結論也是一家之言,不可能放之四海皆準。我的更但願文中探討過的常見問題和場景,好比模板代碼、樂觀更新、錯誤處理、競態等,可以成爲你選型時的尺子,爲你的權衡提供更好的參考,而不是等到項目熱火朝天的時候,才發現當初選型的硬傷。