Redux 异步数据流方案对比

8,923 阅读10分钟

Redux 的核心理念是严格的单向数据流,只能通过 dispatch(action) 的方式修改 store,流程如下:

view ->  action -> reducer -> store

而在实际业务中往往有大量异步场景,最原始的做法是在 React 组件 componentDidMount 的时候初始化异步流,通过 callback 或者 promise 的方式在调用 dispatch(action),这样做把 view 层和 model 层混杂在一起,耦合严重,后期维护非常困难。
之前的文章 解读 Redux 中间件的原理 可以知道,中间件(middleware)改写了 dispatch 方法,因此可以更灵活的控制 dispatch 的时机,这对于处理异步场景非常有效。因此 Redux 作者也建议用中间件来处理异步流。社区常见的中间件有 redux-thunkredux-promiseredux-sagaredux-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 类似,如果 actionaction.payloadPromise 类型则将其 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-thunkredux-promise 用法实际上比较类似,都是触发一个 function/promise 让中间件自己决定 dispatch 真正异步数据的时机,这对于大部分场景来说已经足够了。但是对于异步情况更复杂的场景,我们往往要写很多业务代码,一个异步结果返回后可能需要对应修改 store 里多个部分,这样就面临一个困惑的问题:业务代码是放在 action 层还是 reducer 里?例如,管理员冻结某用户的账户,需要同时更新 storeAllUserListPendingUserlist, 这时候面临两种选择 :

  1. 点击按钮时触发一个 PEND_USER 的 action,然后在 reducer 对应 switch 里同时更新 AllUserListPendingUserlist
  2. 点击按钮时触发 REFRESH_USER_LISTREFRESH_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-thunkredux-promise 来解决此问题的话有两种方式:

  1. 在 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})
             });
     }
    }
  2. 在 action 里带上微博 id,在 reducer 处理的时候判断这个 idurl 里的 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_STARTaction 到来时取消上一个 FETCH_COMMENT_STARTaction 的触发,这时候未返回结果的上一条网络请求(pending 状态)会被 cancel 掉。
另外 redux-saga 还提供了更多方法用来处理异步请求的阻塞、并发等场景,更多操作可以看 Redux-saga 中文文档

因此如果项目中有大量复杂异步场景,就非常适合采用 redux-saga。

采用 redux-saga 可以保持 actionreducer 的简单可读,逻辑清晰,通过采用 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 和传统 PromiseGenerator 的对比:

可以看到 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_STARTaction 并异步发起请求并返回携带相应数据的成功或失败的 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 提供的 ObservableGenerator 更灵活,得益于强大的 RxJSredux-observable 对异步的处理能力更为强大,这大概是目前最优雅的 redux 异步解决方案了。然而缺点也很明显,就是上手难度太高,光是 RxJS 的基本概念对于不熟悉响应式编程的同学来说就不是那么好啃的。但是通过此来接触 RxJS 的思想,能开阔自己眼界,也是非常值得的。因此在异步场景比较复杂的小项目中可以尝试使用 redux-observable,而大型多人协作的项目中得考虑整个团队学习的成本了,这种情况一般用 redux-saga 的性价比会更高。目前国内采用 redux-observable 的并不多,在这里也希望可以和大家多交流下 redux-observable 相关的实践经验。

总结

Redux 本身只会处理同步的 action,因此异步的场景得借助于社区形形色色的异步中间件,文中介绍了一些常见异步方案的使用,在实际项目中需要考虑多方面因素选择适合自己团队的异步方案。