高级 Redux 副作用处理 - Redux Saga 和 Redux Observable 实例分析

18,609 阅读6分钟

我们知道 Redux 中的副作用概念 —— 在理想的状态下系统中应该只有 Reducer 和纯 Action 构成单向数据流,但实际情况下系统需要与服务器通信获取数据,操作本地存储,打日志,或者在某些 Action 发生时触发新的 Action 等等,这些都被称作副作用。我们有很多工具来处理副作用,在官方文档中对他们有简单的介绍和示例代码。最简单的工具是 Redux Thunk,它的问题是在处理复杂问题时代码容易变得繁琐,可读性差。这个时候我们就需要用到高级的副作用处理工具 —— Redux Saga 和基于 RxJS 的 Redux Observable。本文将介绍这两个工具的特点,并结合实例分析他们的适用场景。

Redux Saga 和 Redux Observable 的基本思想是一致的,那就是在 Redux 单向数据流发布订阅模式上挂载处理副作用的订阅层,让副作用处理的逻辑独立出来,这样相比把副作用和纯 Action 混在一起的 Redux Thunk,代码层次会更清晰。在这个基本思想的基础上,Redux Saga 和 Redux Observable 在副作用逻辑定义上选择的方案是截然不同的,接下来我们来分别介绍。

Redux Saga

Redux Saga 定义副作用逻辑用的是 Generator,在 Generator 内部通过 yield 不同的 effect 来声明副作用逻辑。常用的 effect 有用来接收 Action 的 take / takeEvery / takeLatest,用来调用其他函数的 call / fork,用来发出新的 Action 的 put,用来控制流程的 delay / cancel / race / debounce 等等。Generator 将 push 形式的代码变为 pull,这使得复杂的流程控制变得更容易表达。结合 Redux Saga 官方文档和网上的资料,我总结了以下 Redux Saga 的常见适用场景。

Redux Saga 的适用场景

  • Todo list 创建三个 Todo 之后,给出鼓励 链接
  • 登录之后在固定间隔与服务通信刷新 token 直到 logout 链接
  • 控制 xhr 请求重试 链接
  • 单次 Undo 链接
  • 利用 put 和 take 做逻辑处理层内部的发布订阅模式,比如Dashboard 需要展示 user, depature date, flight 和 weather forecast 四方面信息,请求之间有依赖或者并行关系 链接
  • 用 Channel 做更复杂的流控 链接
    • 用 actionChannel 控制请求串行以及其他情况,比如每次处理积累请求的前五个
    • 最多允许并发三个,超过三个会等待
    • 接入外部事件流,比如 websocket 和其他自定义事件流等

网上的资料普遍不够完善,我自己写了两个实际场景的代码。

第一个是针对流程控制 —— Todo list,里面实现了上面提到创建三个 Todo 之后,给出鼓励,以及删除 todo 项目之后的单次 undo。代码在这里,其中关键的 Saga 代码如下:

function* removeTodoSaga() {
  while (true) {
    const action = yield take(REMOVE_TODO)
    let targetTodo = action.payload
    yield put({ type: SHOW_UNDO_BUTTON, payload: true })
    const { undo, remove } = yield race({
      undo: take(UNDO_REMOVE),
      remove: delay(5000),
    })
    yield put({ type: SHOW_UNDO_BUTTON, payload: false })
    if (undo) {
      yield put({ type: ADD_TODO, payload: targetTodo })
    } else if (remove) {
      yield call(syncLocalStorage)
    }
  }
}

function* addTodoSaga() {
  for (let i = 0; i < 3; i++) {
    yield take(action => action.type === ADD_TODO && !action.payload)
    yield call(syncLocalStorage)
  }
  yield put({type: SHOW_CONGRATULATION})
}

我们可以看到,代码逻辑很清晰。用到的 effect 主要有 take, put, race 和 delay。

另一个例子是针对更常见的场景 —— 电影搜索,通过输入框搜索内容,通过下拉框对内容进行筛选。其中的关键是输入框输入操作的 throttle,以及搜索内容变化和筛选项变化触发数据的重新获取。代码在这里,关键部分是:

function* controlParamChange() {
  yield throttle(2000, CHANGE_PARAM, handleParamChange)
}

function* handleParamChange() {
  const { movieSearch: { params } } = yield select()
  if (params.query) {
    const queryString = Object.keys(params)
      .map(key => `${key}=${params[key]}`).concat([`api_key=${API_KEY}`])
      .join('&')
    let res = yield fetch(`https://api.themoviedb.org/3/search/movie?${queryString}`)
    const { results: movies } = yield res.json()
    yield put({type: GET_MOVIES_DONE, payload: movies})
  } else {
    yield put({type: GET_MOVIES_DONE, payload: []})
  }
}

我们可以看到,changeParam 的 param 在 state 中的同步逻辑和 changeParam 触发数据请求的逻辑分离了开来,输入操作的 throttle 控制的引入也比较方便。

Redux Saga 的另一个特点是可测性强,因为 Generator yield 出来的是一个个 effect,我们只要运行 Generator,将 mock 结果作为参数传入,然后依次判断 Generator 给出的 effect 是否符合预期就可以了。

Redux Observable

Redux Observable 是完全基于 RxJS 的,用 Epic 来定义副作用逻辑,Epic 的参数是 Action 的 Observable 事件流和 State 的 Observable 事件流,Epic 中定义基于事件流的逻辑,如果要发出新的 Action,需要让事件流最终给出的事件值为新的 Action 值,并将事件流 return 出来。

很多人说 RxJS 难,但我认为前期主要是要理解它事件流的思维方式,可以结合官方文档掘金上的这篇文章,后期则是要熟悉各种 operator 的用法,可以结合这个网站上的各种例子,当然,operator 比较多,需要多花一些时间练习才能掌握。

因为 RxJS 是事件流思想,它最擅长的也就是处理事件,结合网上资料,我找到这些适用场景。

RxJS 的适用场景

  • 连续按"上上下下左右左右BABA"触发彩蛋,每次按键间隔不能超过1秒 链接
  • 图形解锁(鼠标事件监听)链接

更多的例子还是可以看这个网站,不过这里的例子大多是游戏,不是实际的用户场景。因而我又将上面 Redux Saga 的两个场景用 Redux Observable 写了一下,代码在这里

大家首先可以看到,Redux Observable 的配置方式与 Redux Saga 极其相似,只是把 Saga 换成了 Epic,这正是因为他们与 Redux 的结合方式是一样的,都是在 Redux 上挂载专门处理副作用的订阅层。

Redux Saga 和 Redux Observable 的配置对比

电影搜索实现起来是比较容易的,逻辑也比较清晰:

export const handleParamChangeEpic = (action$, state$) => {
  const latestParamChange$ = action$.pipe(
    ofType(CHANGE_PARAM),
    combineLatest(state$, (action, state) => state.movieSearch.params),
    distinctUntilChanged(),
    throttle(() => interval(2000), { leading: true, trailing: true }),
  )

  const emptyQueryChange$ = latestParamChange$.pipe(
    filter(params => !params.query),
    map(() => ({ type: GET_MOVIES_DONE, payload: [] }))
  )

  const nonEmptyQueryChange$ = latestParamChange$.pipe(
    filter(params => params.query),
    map(params => Object.keys(params)
      .map(key => `${key}=${params[key]}`).concat([`api_key=${API_KEY}`])
      .join('&')
    ),
    switchMap(queryString => 
      from(fetch(`https://api.themoviedb.org/3/search/movie?${queryString}`)).pipe(
        switchMap(res => from(res.json()).pipe(
          map(({results: movies}) => ({ type: GET_MOVIES_DONE, payload: movies }))
        )),
      )
    )
  )

  return merge(emptyQueryChange$, nonEmptyQueryChange$)
}

我们可以看到输入操作的 throttle 控制在这里显得更为自然,只是简单地在 pipe 中增加一环。另外 RxJS 表达 if else 这种 branching 逻辑的方式很不一样,需要分流。还有,用 switchMap 引入 http 请求事件时的代码显得稍微冗长了一些。

Todo list 的单步 undo 功能的实现就有些困难了,最后我是借助 sequenceEqal 实现了功能,但体验仍然不是完全友好,连续删除时会有 bug。

通过这些探索,我们可以看到,RxJS 更适合有复杂事件流的场景,而对于不同事件间流程关系的表达并不是特别简洁。但同时我们也可以看到,RxJS 是非常强大的,对于事件流逻辑的表达工具多,粒度细,我相信一个熟练的 RxJS Developer 能够很好地表达各种复杂场景。而 Redux Saga 则是更擅长流程控制,同时也通过 race 和 debounce 等 effect 和 channel 功能在一定程度上支持了事件流逻辑。