Redux非同步方案選型

作為react社區最熱門的狀態管理框架,相信很多人都準備甚至正在使用Redux。

由於Redux的理念非常精簡,沒有追求大而全,這份架構上的優雅卻在某種程度上傷害了使用體驗:不能開箱即用,甚至是非同步這種最常見的場景也要藉助社區方案。

如果你已經挑花了眼,或者正在挑但不知道是否適合,或者已經挑了但不知道會不會有坑,這篇文章應該適合你。

本文會從一些常見的Redux非同步方案出發,介紹它們的優缺點,進而討論一些與非同步相伴的常見場景,幫助你在選型時更好地權衡利弊。

簡單方案

redux-thunk:指路先驅

Github:gaearon/redux-thunk

Redux作者Dan寫的中間件,因官方文檔出鏡而廣為人知。

它向我們展示了Redux處理非同步的原理,即:

Redux本身只能處理同步的Action,但可以通過中間件來攔截處理其它類型的action,比如函數(Thunk),再用回調觸發普通Action,從而實現非同步處理,在這點上所有Redux的非同步方案都是類似的

而它使用起來最大的問題,就是重複的模板代碼太多:

//action typesnconst GET_DATA = GET_DATA,n GET_DATA_SUCCESS = GET_DATA_SUCCESS,n GET_DATA_FAILED = GET_DATA_FAILED;n//action creatornconst getDataAction = function(id) {n return function(dispatch, getState) {n dispatch({n type: GET_DATA, n payload: idn })n api.getData(id) //註:本文所有示例的api.getData都返回promise對象n .then(response => {n dispatch({n type: GET_DATA_SUCCESS,n payload: responsen })n })n .catch(error => {n dispatch({n type: GET_DATA_FAILED,n payload: errorn })n }) n }n}nn//reducernconst reducer = function(oldState, action) {n switch(action.type) {n case GET_DATA : n return oldState;n case GET_DATA_SUCCESS : n return successState;n case GET_DATA_FAILED : n return errorState;n }n}n

這已經是最簡單的場景了,請注意:我們甚至還沒寫一行業務邏輯,如果每個非同步處理都像這樣,重複且無意義的工作會變成明顯的阻礙。

另一方面,像GET_DATA_SUCCESS、GET_DATA_FAILED這樣的字元串聲明也非常無趣且易錯。

上例中,GET_DATA這個action並不是多數場景需要的,它涉及我們將會提到的樂觀更新,保留這些代碼是為了和下面的方案做對比

redux-promise:瘦身過頭

由於redux-thunk寫起來實在是太麻煩了,社區當然會有其它輪子出現。redux-promise則是其中比較知名的,同樣也享受了官網出鏡的待遇。

它自定義了一個middleware,當檢測到有action的payload屬性是Promise對象時,就會:

  • 若resolve,觸發一個此action的拷貝,但payload為promise的value,並設status屬性為」success」
  • 若reject,觸發一個此action的拷貝,但payload為promise的reason,並設status屬性為」error」

說起來可能有點不好理解,用代碼感受下:

//action typesnconst GET_DATA = GET_DATA;nn//action creatornconst getData = function(id) {n return {n type: GET_DATA,n payload: api.getData(id) //payload為promise對象n }n}nn//reducernfunction reducer(oldState, action) {n switch(action.type) {n case GET_DATA: n if (action.status === success) {n return successStaten } else {n return errorStaten }n }n}n

進步巨大! 代碼量明顯減少! 就用它了! ?

請等等,任何能明顯減少代碼量的方案,都應該小心它是否過度省略了什麼東西,減肥是好事,減到骨頭就殘了。

redux-promise為了精簡而做出的妥協非常明顯:無法處理樂觀更新

場景解析之:樂觀更新

多數非同步場景都是保守更新的,即等到請求成功才渲染數據。而與之相對的樂觀更新,則是不等待請求成功,在發送請求的同時立即渲染數據

最常見的例子就是微信等聊天工具,發送消息時消息立即進入了對話窗,如果發送失敗的話,在消息旁邊再作補充提示即可。這種交互」樂觀」地相信請求會成功,因此稱作樂觀更新(Optimistic update)。

由於樂觀更新發生在用戶操作時,要處理它,意味著必須有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-middleware相比redux-promise,採取了更為溫和和漸進式的思路,保留了和redux-thunk類似的三個action。

示例:

//action typesnconst GET_DATA = GET_DATA,n GET_DATA_PENDING = GET_DATA_PENDING,n GET_DATA_FULFILLED = GET_DATA_FULFILLED,n GET_DATA_REJECTED = GET_DATA_REJECTED;n//action creatornconst getData = function(id) {n return {n type: GET_DATA,n payload: {n promise: api.getData(id),n data: idn }n }n}nn//reducernconst reducer = function(oldState, action) {n switch(action.type) {n case GET_DATA_PENDING :n return oldState; // 可通過action.payload.data獲取idn case GET_DATA_FULFILLED : n return successState;n case GET_DATA_REJECTED : n return errorState;n }n}n

如果不需要樂觀更新,action creator可以使用和redux-promise完全一樣的,更簡潔的寫法,即:

const getData = function(id) {n return {n type: GET_DATA,n payload: api.getData(id) //等價於 {promise: api.getData(id)}n }n}n

此時初始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-action-tools:軟文預警

無論是redux-thunk還是redux-promise-middleware,模板代碼都是顯而易見的,每次寫XXX_COMPLETED這樣的代碼都覺得是在浪費生命——你得先在常量中聲明它們,再在action中引用,然後是reducer,假設像redux-thunk一樣每個非同步action有三個type,三個文件加起來你就得寫九次!

國外開發者也有相同的報怨:

有沒有辦法讓代碼既像redux-promise一樣簡潔,又能保持樂觀更新的能力呢?

redux-action-tools是我給出的答案:

const GET_DATA = GET_DATA;nn//action creatornconst getData = createAsyncAction(GET_DATA, function(id) {n return api.getData(id)n})nn//reducernconst reducer = createReducer()n .when(getData, (oldState, action) => oldState)n .done((oldState, action) => successState)n .failed((oldState, action) => errorState)n .build()n

redux-action-tools在action層面做的事情與前面幾個庫大同小異:同樣是派發了三個action:GET_DATA/GET_DATA_SUCCESS/GET_DATA_FAILED。這三個action的描述見下表:

ntypenWhennpayloadnmeta.asyncPhasen${actionName}n非同步開始前n同步調用參數n『START』n${actionName}_COMPLETEDn非同步成功nvalue of promisen『COMPLETED』n${actionName}_FAILEDn非同步失敗nreason of promisen『FAILED』n

createAsyncAction參考了redux-promise作者寫的redux-actions ,它接收三個參數,分別是:

  1. actionName 字元串,所有派生action的名字都以它為基礎,初始action則與它同名
  2. promiseCreator 函數,必須返回一個promise對象
  3. 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),它有什麼用呢?用場景說話:

場景解析:失敗處理與Loading

它們是非同步不可迴避的兩個場景,幾乎每個項目會遇到。

以非同步請求的失敗處理為例,每個項目通常都有一套比較通用的,適合多數場景的處理邏輯,比如彈窗提示。同時在一些特定場景下,又需要繞過通用邏輯進行單獨處理,比如表單的非同步校驗。

而在實現通用處理邏輯時,常見的問題有以下幾種:

  1. 底層處理,擴展性不足

    function fetchWrapper(args) {n return fetch.apply(fetch, args)n .catch(commonErrorHandler)n }n

    在較底層封裝ajax庫可以輕鬆實現全局處理,但問題也非常明顯:

    一是擴展性不足,比如少數場景想要繞過通用處理邏輯,還有一些場景錯誤是前端生成而非直接來自於請求;

    二是不易組合,比如有的場景一個action需要多個非同步請求,但異常處理和loading是不需要重複的,因為用戶不需要知道一個動作有多少個請求。

  2. 不夠內聚,侵入業務代碼

    //action creatorn const getData = createAsyncAction(GET_DATA, function(id) {n return api.getData(id)n .catch(commonErrorHandler) //調用錯誤處理函數n })n

    在有業務意義的action層調用通用處理邏輯,既能按需調用,又不妨礙非同步請求的組合。但由於通用處理往往適用於多數場景,這樣寫會導致業務代碼變得冗餘,因為幾乎每個action都得這麼寫。

  3. 高耦合,高風險

    也有人把上面的方案做個依賴反轉,改為在通用邏輯里監聽業務action:

    function commonErrorReducer(oldState, action) {n switch(action.type) {n case GET_DATA_FAILED:n case PUT_DATA_FAILED:n //... tons of action typen return commonErrorHandler(action)n }n }n

    這樣做的本質是把冗餘從業務代碼中拿出來集中管理。

    問題在於每添加一個請求,都需要修改公共代碼,把對應的action type加進來。且不說並行開發時merge衝突,如果加了一個非同步action,但忘了往公共處理文件中添加——這是很可能會發生的——而異常是分支流程不容易被測試發現,等到發現,很可能就是事故而不是bug了。

通過以上幾種常見方案的分析,我認為比較完善的錯誤處理(Loading同理)需要具備如下特點:

  • 面向非同步動作(action),而非直接面向請求
  • 不侵入業務代碼
  • 默認使用通用處理邏輯,無需額外代碼
  • 可以繞過通用邏輯

而藉助redux-action-tools提供的meta.asyncPhase,可以輕易用middleware實現以上全部需求!

import _ from lodashnimport { ASYNC_PHASES } from redux-action-toolsnnfunction errorMiddleWare({dispatch}) {n return next => action => {n const asyncStep = _.get(action, meta.asyncStep);nn if (asyncStep === ASYNC_PHASES.FAILED) {n dispatch({n type: COMMON_ERROR,n payload: {n actionn }n })n }n next(action);n }n}n

以上中間件一旦檢測到meta.asyncStep欄位為FAILED的action便觸發新的action去調用通用處理邏輯。面向action、不侵入業務、默認工作 (只要是用createAsyncAction聲明的非同步) ! 輕鬆實現了理想需求中的前三點,那如何定製呢?既然攔截是面向meta的,只要在創建action時支持對meta的自定義就行了,而createAsyncAction的第三個參數就是為此準備的:

import _ from lodashnimport { ASYNC_PHASES } from redux-action-toolsnnconst customizedAction = createAsyncAction(n type, n promiseCreator, //type 和 promiseCreator此處無不同故省略n (payload, defaultMeta) => {n return { ...defaultMeta, omitError: true }; //向meta中添加配置參數n }n)nnfunction errorMiddleWare({dispatch}) {n return next => action => {n const asyncStep = _.get(action, meta.asyncStep);n const omitError = _.get(action, meta.omitError); //獲取配置參數nn if (!omitError && asyncStep === ASYNC_PHASES.FAILED) {n dispatch({n type: COMMON_ERROR,n payload: {n actionn }n })n }n next(action);n }n}n

類似的,你可以想想如何處理Loading,需要強調的是建議盡量用增量配置的方式進行擴展,而不要輕易刪除和修改meta.asyncPhase

比如上例可以通過刪除meta.asyncPhase實現同樣功能,但如果同時還有其它地方也依賴meta.asyncPhase(比如loadingMiddleware),就可能導致本意是定製錯誤處理,卻改變了Loading的行為,客觀來講這層風險是基於meta攔截方案的最大缺點,然而相比多數場景的便利、健壯,個人認為特殊場景的風險是可以接受的,畢竟這些場景在整個開發測試流程容易獲得更多關注。

進階方案

上面所有的方案,都把非同步請求這一動作放在了action creator中,這樣做的好處是簡單直觀,且和Flux社區一脈相承(見下圖)。因此個人將它們歸為相對簡單的一類。

下面將要介紹的,是相對複雜一類,它們都採用了與上圖不同的思路,去追求更優雅的架構、解決更複雜的問題

redux-loop:分形! 組合!

眾所周知,Redux是借鑒自Elm的,然而在Elm中,非同步的處理卻並不是在action creator層,而是在reducer(Elm中稱update)層:

圖片來源於: jarvisaoieong/redux-architecture

這樣做的目的是為了實現徹底的可組合性(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;nimport { loadingStart, loadingSuccess, loadingFailure } from ./actions;nnexport function fetchDetails(id) {n return fetch(`/api/details/${id}`)n .then((r) => r.json())n .then(loadingSuccess)n .catch(loadingFailure);n}nnexport default function reducer(state, action) {n switch (action.type) {n case LOADING_START:n return loop(n { ...state, loading: true },n Effects.promise(fetchDetails, action.payload.id)n ); // 同時返回狀態與副作用nn case LOADING_SUCCESS:n return {n ...state,n loading: false,n details: action.payloadn };nn case LOADING_FAILURE:n return {n ...state,n loading: false,n error: action.payload.messagen };nn default:n return state;n }n}n

注意在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是很好的參考素材,但對多數人和項目而言,最好還是更謹慎地看待。

redux-saga:難、而美

Github: yelouafi/redux-saga

另一個著名的庫,它讓非同步行為成為架構中獨立的一層(稱為saga),既不在action creator中,也不和reducer沾邊。

它的出發點是把副作用 (Side effect,非同步行為就是典型的副作用) 看成」線程」,可以通過普通的action去觸發它,當副作用完成時也會觸發action作為輸出。

import { takeEvery } from redux-saganimport { call, put } from redux-saga/effectsnimport Api from ...nnfunction* getData(action) {n try {n const response = yield call(api.getData, action.payload.id);n yield put({type: "GET_DATA_SUCCEEDED", payload: response});n } catch (e) {n yield put({type: "GET_DATA_FAILED", payload: error});n }n}nnfunction* mySaga() {n yield* takeEvery("GET_DATA", getData);n}nnexport default mySaga;n

相比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){n return (dispatch, getState) => {n //步驟1:在reducer中 set state.currentFriend = id;n dispatch({type: FETCH_FIREND, payload: id}); nn return fetch(`http://localhost/api/firend/${id}`)n .then(response => response.json())n .then(json => { n //步驟2:只處理currentFriend的對應responsen const { currentFriend } = getState();n (currentFriend === id) && dispatch({type: RECEIVE_FIRENDS, playload: json})n });n }n}n

以上只是示例,實際中不一定需要依賴業務id,也不一定要把id存到store里,只要為每個請求生成key,以便處理請求時能夠對應起來即可。

而在redux-saga中,一切非常地簡單:

import { takeLatest } from `redux-saga`nnfunction* fetchFriend(action) {n ...n}nnfunction* watchLastFetchUser() {n yield takeLatest(FETCH_FIREND, fetchFriend)n}n

這裡的重點是takeLatest,它限制了同步事件與非同步返回事件的順序關係。

另外還有一些基於響應式編程(Reactive Programming)的非同步方案(如redux-observable)也能非常好地處理競態場景,因為描述事件流之間的關係,正是整個響應式編程的抽象基石,而競態在本質上就是如何保證同步事件與非同步返回事件的關係,正是響應式編程的用武之地。

實際項目中可以用高階函數模仿takeLatest的功能,redux-thunk類方案也可以較低成本地處理競態

小結

本文包含了一些redux社區著名、非著名 (恩,我的redux-action-tools) 的非同步方案,這些其實並不重要。

因為方案是一家之作,結論也是一家之言,不可能放之四海皆準。個人更希望文中探討過的常見問題和場景,比如模板代碼、樂觀更新、錯誤處理、競態等,能夠成為你選型時的尺子,為你的權衡提供更好的參考,而不是等到項目熱火朝天的時候,才發現當初選型的硬傷。

(註:題圖來自網路,侵立刪)


推薦閱讀:

React 是如何重新定義前端開發的
阿里雲前端周刊 - 第 21 期
感謝《深入淺出React和Redux》的所有讀者
基於React.js開發IM即時通訊系統,觸摸大型互聯網公司真實項目

TAG:Redux | React | 前端开发 |