Redux異步方案選型
作為react社區(qū)最熱門的狀態(tài)管理框架,相信很多人都準(zhǔn)備甚至正在使用Redux。
由于Redux的理念非常精簡,沒有追求大而全,這份架構(gòu)上的優(yōu)雅卻在某種程度上傷害了使用體驗:不能開箱即用,甚至是異步這種最常見的場景也要借助社區(qū)方案。
如果你已經(jīng)挑花了眼,或者正在挑但不知道是否適合,或者已經(jīng)挑了但不知道會不會有坑,這篇文章應(yīng)該適合你。
本文會從一些常見的Redux異步方案出發(fā),介紹它們的優(yōu)缺點,進而討論一些與異步相伴的常見場景,幫助你在選型時更好地權(quán)衡利弊。
簡單方案
redux-thunk:指路先驅(qū)
Github:https://github.com/gaearon/redux-thunk
Redux作者Dan寫的中間件,因官方文檔出鏡而廣為人知。
它向我們展示了Redux處理異步的原理,即:
Redux本身只能處理同步的Action,但可以通過中間件來攔截處理其它類型的action,比如函數(shù)(Thunk),再用回調(diào)觸發(fā)普通Action,從而實現(xiàn)異步處理,在這點上所有Redux的異步方案都是類似的。
而它使用起來***的問題,就是重復(fù)的模板代碼太多:
- //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;
- }
- }
這已經(jīng)是最簡單的場景了,請注意:我們甚至還沒寫一行業(yè)務(wù)邏輯,如果每個異步處理都像這樣,重復(fù)且無意義的工作會變成明顯的阻礙。
另一方面,像GET_DATA_SUCCESS、GET_DATA_FAILED這樣的字符串聲明也非常無趣且易錯。
上例中,GET_DATA這個action并不是多數(shù)場景需要的,它涉及我們將會提到的樂觀更新,保留這些代碼是為了和下面的方案做對比
redux-promise:瘦身過頭
由于redux-thunk寫起來實在是太麻煩了,社區(qū)當(dāng)然會有其它輪子出現(xiàn)。redux-promise則是其中比較知名的,同樣也享受了官網(wǎng)出鏡的待遇。
它自定義了一個middleware,當(dāng)檢測到有action的payload屬性是Promise對象時,就會:
- 若resolve,觸發(fā)一個此action的拷貝,但payload為promise的value,并設(shè)status屬性為"success"
- 若reject,觸發(fā)一個此action的拷貝,但payload為promise的reason,并設(shè)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
- }
- }
- }
進步巨大! 代碼量明顯減少! 就用它了! ?
請等等,任何能明顯減少代碼量的方案,都應(yīng)該小心它是否過度省略了什么東西,減肥是好事,減到骨頭就殘了。
redux-promise為了精簡而做出的妥協(xié)非常明顯:無法處理樂觀更新
場景解析之:樂觀更新
多數(shù)異步場景都是悲觀更新(求更好的翻譯)的,即等到請求成功才渲染數(shù)據(jù)。而與之相對的樂觀更新,則是不等待請求成功,在發(fā)送請求的同時立即渲染數(shù)據(jù)。
最常見的例子就是微信等聊天工具,發(fā)送消息時消息立即進入了對話窗,如果發(fā)送失敗的話,在消息旁邊再作補充提示即可。這種交互"樂觀"地相信請求會成功,因此稱作樂觀更新。
由于樂觀更新發(fā)生在用戶操作時,要處理它,意味著必須有action表示用戶的初始動作
在上面redux-thunk的例子中,我們看到了GET_DATA, GET_DATA_SUCCESS、GET_DATA_FAILED三個action,分別表示初始動作、異步成功和異步失敗,其中***個action使得redux-thunk具備樂觀更新的能力。
而在redux-promise中,最初觸發(fā)的action被中間件攔截然后過濾掉了。原因很簡單,redux認可的action對象是 plain JavaScript objects,即簡單對象,而在redux-promise中,初始action的payload是個Promise。
另一方面,使用status而不是type來區(qū)分兩個異步action也非常值得商榷,按照redux對action的定義以及社區(qū)的普遍實踐,個人還是傾向于使用不同的type,用同一type下的不同status區(qū)分action額外增加了一套隱形的約定,甚至不符合該redux-promise作者自己所提倡的FSA,體現(xiàn)在代碼上則是在switch-case內(nèi)再增加一層判斷。
redux-promise-middleware:拔亂反正
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仍然會觸發(fā),但是payload為空。
相對redux-promise于粗暴地過濾掉整個初始action,redux-promise-middleware選擇創(chuàng)建一個只過濾payload中的promise屬性的XXX_PENDING作為初始action,以此保留樂觀更新的能力。
同時在action的區(qū)分上,它選擇了回歸type的"正途",_PENDING、_FULFILLED 、_REJECTED等后綴借用了promise規(guī)范 (當(dāng)然它們是可配置的) 。
它的遺憾則是只在action層實現(xiàn)了簡化,對reducer層則束手無策。另外,相比redux-thunk,它還多出了一個_PENDING的字符串模板代碼(三個action卻需要四個type)。
社區(qū)有類似type-to-reducer這樣試圖簡化reducer的庫。但由于reducer和異步action通常是兩套獨立的方案,reducer相關(guān)的庫無法去猜測異步action的后綴是什么(甚至有沒有后綴),社區(qū)也沒有相關(guān)標(biāo)準(zhǔn),也就很難對異步做出精簡和抽象了。
redux-action-tools:軟文預(yù)警
無論是redux-thunk還是redux-promise-middleware,模板代碼都是顯而易見的,每次寫XXX_COMPLETED這樣的代碼都覺得是在浪費生命——你得先在常量中聲明它們,再在action中引用,然后是reducer,假設(shè)像redux-thunk一樣每個異步action有三個type,三個文件加起來你就得寫九次!
國外開發(fā)者也有相同的報怨:
有沒有辦法讓代碼既像redux-promise一樣簡潔,又能保持樂觀更新的能力呢?
- 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層面做的事情與前面幾個庫大同小異:同樣是派發(fā)了三個action:GET_DATA/GET_DATA_SUCCESS/GET_DATA_FAILED。這三個action的描述見下表:
type | When | payload | meta.asyncPhase |
---|---|---|---|
${actionName} |
異步開始前 | 同步調(diào)用參數(shù) | 'START' |
${actionName}_COMPLETED |
異步成功 | value of promise | 'COMPLETED' |
${actionName}_FAILED |
異步失敗 | reason of promise | 'FAILED' |
createAsyncAction參考了redux-promise作者寫的redux-actions ,它接收三個參數(shù),分別是:
- actionName 字符串,所有派生action的名字都以它為基礎(chǔ),初始action則與它同名
- promiseCreator 函數(shù),必須返回一個promise對象
- metaCreator 函數(shù),可選,作用后面會演示到
目前看來,其實和redux-promise/redux-promise-middleware大同小異。而真正不同的,是它同時簡化了reducer層! 這種簡化來自于對異步行為從語義角度的抽象:
當(dāng)(when)初始action發(fā)生時處理同步更新,若異步成功(done)則處理成功邏輯,若異步失敗(failed)則處理失敗邏輯
抽離出when/done/failed三個關(guān)鍵詞作為api,并使用鏈?zhǔn)秸{(diào)用將他們串聯(lián)起來:when函數(shù)接收兩個參數(shù):actionName和handler,其中handler是可選的,done和failed則只接收一個handler參數(shù),并且只能在when之后調(diào)用——他們分別處理`${actionName}_SUCCESS` 和 `${actionName}_FAILED`.
無論是action還是reducer層,XX_SUCCESS/XX_FAILED相關(guān)的代碼都被封裝了起來,正如在例子中看到的——你甚至不需要聲明它們!創(chuàng)建一個異步action,然后處理它的成功和失敗情況,事情本該這么簡單。
更進一步的,這三個action默認都根據(jù)當(dāng)前所處的異步階段,設(shè)置了不同的meta(見上表中的meta.asyncPhase),它有什么用呢?用場景說話:
場景解析:失敗處理與Loading
它們是異步不可回避的兩個場景,幾乎每個項目會遇到。
以異步請求的失敗處理為例,每個項目通常都有一套比較通用的,適合多數(shù)場景的處理邏輯,比如彈窗提示。同時在一些特定場景下,又需要繞過通用邏輯進行單獨處理,比如表單的異步校驗。
而在實現(xiàn)通用處理邏輯時,常見的問題有以下幾種:
1. 底層處理,擴展性不足
- function fetchWrapper(args) {
- return fetch.apply(fetch, args)
- .catch(commonErrorHandler)
- }
在較底層封裝ajax庫可以輕松實現(xiàn)全局處理,但問題也非常明顯:
一是擴展性不足,比如少數(shù)場景想要繞過通用處理邏輯,還有一些場景錯誤是前端生成而非直接來自于請求;
二是不易組合,比如有的場景一個action需要多個異步請求,但異常處理和loading是不需要重復(fù)的,因為用戶不需要知道一個動作有多少個請求。
2. 不夠內(nèi)聚,侵入業(yè)務(wù)代碼
- //action creator
- const getData = createAsyncAction(GET_DATA, function(id) {
- return api.getData(id)
- .catch(commonErrorHandler) //調(diào)用錯誤處理函數(shù)
- })
在有業(yè)務(wù)意義的action層調(diào)用通用處理邏輯,既能按需調(diào)用,又不妨礙異步請求的組合。但由于通用處理往往適用于多數(shù)場景,這樣寫會導(dǎo)致業(yè)務(wù)代碼變得冗余,因為幾乎每個action都得這么寫。
3. 高耦合,高風(fēng)險
也有人把上面的方案做個依賴反轉(zhuǎn),改為在通用邏輯里監(jiān)聽業(yè)務(wù)action:
- function commonErrorReducer(oldState, action) {
- switch(action.type) {
- case GET_DATA_FAILED:
- case PUT_DATA_FAILED:
- //... tons of action type
- return commonErrorHandler(action)
- }
- }
這樣做的本質(zhì)是把冗余從業(yè)務(wù)代碼中拿出來集中管理。
問題在于每添加一個請求,都需要修改公共代碼,把對應(yīng)的action type加進來。且不說并行開發(fā)時merge沖突,如果加了一個異步action,但忘了往公共處理文件中添加——這是很可能會發(fā)生的——而異常是分支流程不容易被測試發(fā)現(xiàn),等到發(fā)現(xiàn),很可能就是事故而不是bug了。
通過以上幾種常見方案的分析,我認為比較完善的錯誤處理(Loading同理)需要具備如下特點:
- 面向異步動作(action),而非直接面向請求
- 不侵入業(yè)務(wù)代碼
- 默認使用通用處理邏輯,無需額外代碼
- 可以繞過通用邏輯
而借助redux-action-tools提供的meta.asyncPhase,可以輕易用middleware實現(xiàn)以上全部需求!
- 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便觸發(fā)新的action去調(diào)用通用處理邏輯。面向action、不侵入業(yè)務(wù)、默認工作 (只要是用createAsyncAction聲明的異步) ! 輕松實現(xiàn)了理想需求中的前三點,那如何定制呢?既然攔截是面向meta的,只要在創(chuàng)建action時支持對meta的自定義就行了,而createAsyncAction的第三個參數(shù)就是為此準(zhǔn)備的:
- import _ from 'lodash'
- import { ASYNC_PHASES } from 'redux-action-tools'
- const customizedAction = createAsyncAction(
- type,
- promiseCreator, //type 和 promiseCreator此處無不同故省略
- (payload, defaultMeta) => {
- return { ...defaultMeta, omitError: true }; //向meta中添加配置參數(shù)
- }
- )
- function errorMiddleWare({dispatch}) {
- return next => action => {
- const asyncStep = _.get(action, 'meta.asyncStep');
- const omitError = _.get(action, 'meta.omitError'); //獲取配置參數(shù)
- if (!omitError && asyncStep === ASYNC_PHASES.FAILED) {
- dispatch({
- type: 'COMMON_ERROR',
- payload: {
- action
- }
- })
- }
- next(action);
- }
- }
類似的,你可以想想如何處理Loading,需要強調(diào)的是建議盡量用增量配置的方式進行擴展,而不要輕易刪除和修改meta.asyncPhase。
比如上例可以通過刪除meta.asyncPhase實現(xiàn)同樣功能,但如果同時還有其它地方也依賴meta.asyncPhase(比如loadingMiddleware),就可能導(dǎo)致本意是定制錯誤處理,卻改變了Loading的行為,客觀來講這層風(fēng)險是基于meta攔截方案的***缺點,然而相比多數(shù)場景的便利、健壯,個人認為特殊場景的風(fēng)險是可以接受的,畢竟這些場景在整個開發(fā)測試流程容易獲得更多關(guān)注。
進階方案
上面所有的方案,都把異步請求這一動作放在了action creator中,這樣做的好處是簡單直觀,且和Flux社區(qū)一脈相承(見下圖)。因此個人將它們歸為相對簡單的一類。
下面將要介紹的,是相對復(fù)雜一類,它們都采用了與上圖不同的思路,去追求更優(yōu)雅的架構(gòu)、解決更復(fù)雜的問題
redux-loop:分形! 組合!
眾所周知,Redux是借鑒自Elm的,然而在Elm中,異步的處理卻并不是在action creator層,而是在reducer(Elm中稱update)層:
圖片來源于: https://github.com/jarvisaoie...
這樣做的目的是為了實現(xiàn)徹底的可組合性(composable)。在redux中,reducer作為函數(shù)是可組合的,action正常情況下作為純對象也是可組合的,然而一旦涉及異步,當(dāng)action嵌套組合的時候,中間件就無法正常識別,這個問題讓redux作者Dan也發(fā)出感嘆 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)
- ); // 同時返回狀態(tài)與副作用
- 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中,當(dāng)處理LOADING_START時,并沒有直接返回state對象,而是用loop函數(shù)將state和Effect"打包"返回(實際上這個返回值是數(shù)組[State, Effect],和Elm的方式非常接近)。
然而修改reducer的返回類型顯然是比較暴力的做法,除非Redux官方出面,否則很難獲得社區(qū)的廣泛認同。更復(fù)雜的返回類型會讓很多已有的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的分形架構(gòu)有了解,想在Redux上繼續(xù)實踐的人來說,redux-loop是很好的參考素材,但對多數(shù)人和項目而言,***還是更謹慎地看待。
redux-saga:難、而美
Github: https://github.com/yelouafi/r...
另一個著名的庫,它讓異步行為成為架構(gòu)中獨立的一層(稱為saga),既不在action creator中,也不和reducer沾邊。
它的出發(fā)點是把副作用 (Side effect,異步行為就是典型的副作用) 看成"線程",可以通過普通的action去觸發(fā)它,當(dāng)副作用完成時也會觸發(fā)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的方案,它可以保證組件觸發(fā)的action是純對象,因此至少在項目范圍內(nèi)(middleware和saga都是項目的頂層依賴,跨項目無法保證),action的組合性明顯更加優(yōu)秀。
而它最為主打的,則是可測試性和強大的異步流程控制。
由于強制所有saga都必須是generator函數(shù),借助generator的next接口,可以輕易對異步行為的每個中間步驟做mock,實現(xiàn)對異步邏輯"step by step"的測試,這在其它方案中是很少看到的 (當(dāng)然也可以借鑒generator這一點,但缺少約束)。
而強大得有點眼花繚亂的API,特別是channel的引入,則提供了武裝到牙齒級的異步流程控制能力。
然而,回顧我們在討論簡單方案時提到的各種場景與問題,redux-saga并沒有去嘗試回答和解決它們,這意味著你需要自行尋找解決方案。而generator、相對復(fù)雜的API和單獨的一層抽象也讓不少人望而卻步。
包括我在內(nèi),很多人非常欣賞redux-saga。它的架構(gòu)和思路毫無疑問是優(yōu)秀甚至優(yōu)雅的,但使用它之前,***想清楚它帶來的優(yōu)點(可測試性、流程控制、高度解耦)與付出的成本是否匹配,特別是異步方面復(fù)雜度并不高的項目,比如多數(shù)以CRUD為主的管理系統(tǒng)。
小結(jié)
本文包含了一些redux社區(qū)著名、非著名 (恩,我的redux-action-tools) 的異步方案,這些其實并不重要。
因為方案是一家之作,結(jié)論也是一家之言,不可能放之四海皆準(zhǔn)。個人更希望文中探討過的常見問題和場景,比如模板代碼、樂觀更新、錯誤處理等,能夠成為你選型時的尺子,為你的權(quán)衡提供更好的參考,而不是等到項目熱火朝天的時候,才發(fā)現(xiàn)當(dāng)初選型的硬傷。