Redux 的核心理念是严格的单向数据流,只能通过 dispatch(action)
的方式修改 store,流程如下:
view -> action -> reducer -> store
而在实际业务中往往有大量异步场景,最原始的做法是在 React 组件 componentDidMount
的时候初始化异步流,通过 callback
或者 promise
的方式在调用 dispatch(action)
,这样做把 view
层和 model
层混杂在一起,耦合严重,后期维护非常困难。
之前的文章 解读 Redux 中间件的原理 可以知道,中间件(middleware)改写了 dispatch
方法,因此可以更灵活的控制 dispatch
的时机,这对于处理异步场景非常有效。因此 Redux 作者也建议用中间件来处理异步流。社区常见的中间件有 redux-thunk
、redux-promise
、redux-saga
、redux-observable
等。
redux-thunk:简单粗暴
作为 Redux 作者自己写的异步中间件,其原理非常简单:Redux 本身只会处理同步的简单对象 action,但可以通过 redux-thunk
拦截处理函数(function)类型的 action
,通过回调来控制触发普通 action
,从而达到异步的目的。其典型用法如下:
//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
其核心源码如下:
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
正是解决了这个问题。同样是从后端去数据,其典型用法为:
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
类似,如果 action
或 action.payload
是 Promise
类型则将其 resolve,触发当前 action
的拷贝,并将 payload 设置为 promise 的 成功/失败结果。
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-thunk
和 redux-promise
用法实际上比较类似,都是触发一个 function/promise 让中间件自己决定 dispatch
真正异步数据的时机,这对于大部分场景来说已经足够了。但是对于异步情况更复杂的场景,我们往往要写很多业务代码,一个异步结果返回后可能需要对应修改 store
里多个部分,这样就面临一个困惑的问题:业务代码是放在 action
层还是 reducer
里?例如,管理员冻结某用户的账户,需要同时更新 store
里 AllUserList
和 PendingUserlist
, 这时候面临两种选择 :
- 点击按钮时触发一个
PEND_USER
的 action,然后在 reducer 对应 switch 里同时更新AllUserList
和PendingUserlist
- 点击按钮时触发
REFRESH_USER_LIST
和REFRESH_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 out
。
用对于刚才的问题,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-thunk
/redux-promise
来解决此问题的话有两种方式:
- 在 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}) }); } }
- 在 action 里带上微博
id
,在 reducer 处理的时候判断这个id
和url
里的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_START
的 action
到来时取消上一个 FETCH_COMMENT_START
的 action
的触发,这时候未返回结果的上一条网络请求(pending 状态)会被 cancel 掉。
另外 redux-saga
还提供了更多方法用来处理异步请求的阻塞、并发等场景,更多操作可以看 Redux-saga 中文文档 。
因此如果项目中有大量复杂异步场景,就非常适合采用 redux-saga。
采用 redux-saga
可以保持 action
和 reducer
的简单可读,逻辑清晰,通过采用 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 和传统 Promise
、Generator
的对比:
可以看到 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_START
的 action
并异步发起请求并返回携带相应数据的成功或失败的 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
提供的 Observable
比 Generator
更灵活,得益于强大的 RxJS
,redux-observable
对异步的处理能力更为强大,这大概是目前最优雅的 redux
异步解决方案了。然而缺点也很明显,就是上手难度太高,光是 RxJS
的基本概念对于不熟悉响应式编程的同学来说就不是那么好啃的。但是通过此来接触 RxJS
的思想,能开阔自己眼界,也是非常值得的。因此在异步场景比较复杂的小项目中可以尝试使用 redux-observable
,而大型多人协作的项目中得考虑整个团队学习的成本了,这种情况一般用 redux-saga
的性价比会更高。目前国内采用 redux-observable
的并不多,在这里也希望可以和大家多交流下 redux-observable
相关的实践经验。
总结
Redux
本身只会处理同步的 action
,因此异步的场景得借助于社区形形色色的异步中间件,文中介绍了一些常见异步方案的使用,在实际项目中需要考虑多方面因素选择适合自己团队的异步方案。