做为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) 的异步方案,这些其实并不重要。
由于方案是一家之做,结论也是一家之言,不可能放之四海皆准。我的更但愿文中探讨过的常见问题和场景,好比模板代码、乐观更新、错误处理、竞态等,可以成为你选型时的尺子,为你的权衡提供更好的参考,而不是等到项目热火朝天的时候,才发现当初选型的硬伤。