Redux的副作用处理与No-Reducer开发模式

741

众所周知,React只是一个用于构建UI的JavaScript库,并非完整的框架,在代码的整体结构与组件间的相互通信——这两块大型Web项目开发中最关键的痛点——选择了留白。然而值得庆幸的是,Facebook给出了一个官方的半成品解决方案——Flux。

说它是半成品,是因为早在2014年 Facebook 就提出了 Flux 架构的概念,但其后却一直并没有一个官方版的完整实现,在社区实现方案中,最有名最成功的当属Redux

Redux的纯函数理想乡

我们首先来看一下原版的Flux流程:

可以看到,在Flux中,其最大的特点就是数据的单向流动。阮一峰老师的文章中已经讲得非常清楚,我在此仅引用要点,Flux的整个流程分为四个部分:

  1. View: 视图层
  2. Action(动作):视图层发出的消息(比如mouseClick)
  3. Dispatcher(派发器):用来接收Actions、执行回调函数
  4. Store(数据层):用来存放应用的状态,一旦发生变动,就提醒Views要更新页面

Redux作为Flux的实现,加入了一些函数式的编程思想,其中最主要的有三点:

  1. 数据的单一来源:即整个APP尽量不使用内部state,所有的状态全部源自于Redux
  2. 纯函数reducer:处理action并生成新的state,其形式为 newState = f(state,action),不允许任何副作用
  3. 不可变state:只有当state对象被整体替换时候,才会触发view层的更新。

Redux一经面世,其简单又优雅的设计便吸粉无数,官方更是自豪的宣称:

Redux is a predictable state container for JavaScript apps.

这的确是Redux最大的好处,由于任何被action触发的reducer都是纯函数,state状态的变迁都变得可预测了。也就是说,同一个reducer对于特定action,一定会返回特定的state,不会有任何不确定性因素。reducer函数的定义如下所示:

function reducer(state = initialState, action) {
  switch (action.type) {
    case SOME_ACTION:
      return Object.assign({}, state, {
        someKey: action.payload
      })
    default:
      return state
  }
}

我们看到了函数的入参仅有state和action,即使你使用了combineReducers将多个reducer绑定到一起,你所能看到的也仅仅是自己的state,其他reducer所绑定的state仿佛和你不在一个次元内。

纯函数使得Redux的编程模型非常的漂亮和简洁,我怀疑作者是如此的喜欢这个模型,所以在整整两年的时间里,Redux的主要功能竟然几乎没演进,也没有给出任何roadmap,仿佛一切就应该是这个样子,仿佛一切都是刚刚好。

我也非常喜欢Redux的可预测性,如果你完全按照Redux的理念进行开发,你会发现Redux-devtools是如此的美妙,不仅可以记录每一步程序的执行状态,它可以回退到之前任意一个状态,这是一般调试工具所做不到的。


副作用怎么办?

然而非常可惜的是,我们开发的APP无法保证如此的纯粹,一个小小的副作用就会使得Redux-devtools失效 。Redux-devtools严重依赖事件回放,也就是说当你屏蔽某一个action操作的时候,它实际上是先将整个state恢复为最近确定的state,然后一遍一遍的播放后续的action,生成新的state。这当中如果有任何副作用(异步操作,外部数据源读取),都会导致生成不可预测的state。

很多人期待Redux能给出一个终极解决方案,然而Redux似乎把这个皮球又踢回给了社区,我提供一个中间件机制,你们还是另请高明吧。

于是Redux扔给了社区一个叫thunk的例子,这个例子长的什么样子呢,反正我第一次见到的时候有点吃惊:

function createThunkMiddleware(extraArgument) {
  return ({ dispatch, getState }) => next => action => {
    if (typeof action === 'function') {
      return action(dispatch, getState, extraArgument);
    }

    return next(action);
  };
}

const thunk = createThunkMiddleware();
thunk.withExtraArgument = createThunkMiddleware;

export default thunk;

是的,这就是thunk全部的代码,非常简洁有力,而使用thunk的异步actionCreator长成这样:

export function fetchPostsIfNeeded(subreddit) {
  // Note that the function also receives getState()
  // which lets you choose what to dispatch next.

  // This is useful for avoiding a network request if
  // a cached value is already available.

  return (dispatch, getState) => {
    if (shouldFetchPosts(getState(), subreddit)) {
      // Dispatch a thunk from thunk!
      return dispatch(fetchPosts(subreddit))
    } else {
      // Let the calling code know there's nothing to wait for.
      return Promise.resolve()
    }
  }
}

没错,我把dispatch,getState都赤裸的暴露给你,你想取什么state就取什么state,dispatch什么action也随你心意。什么,你说功能太简单了?我们官方不管这个,你可以开发个middleware自己解决。

然而官方对于副作用处理中间件的态度也很是暧昧不清,原则上是支持的,但是在实践方面却没有任何指导,Redux-Saga 作为当前最为优秀的副作用处理中间件,Github上star数已快破万,Redux官网依旧没有任何一句表扬的意思。

副作用处理神器,Redux-Saga

在我们团队的实践中,选择了Redux/Redux-Saga加No-Reducer的开发方式。

首先我们回顾一下最普通的Redux/Redux-thunk副作用处理流程:

这种模式除了开发模式简单,几乎没有任何优点,简单的代价就是开发者需要自己动手处理各种dirty things。

  • 所有的异步请求全部放在ActionCreator中,大量的异步回调地狱让开发者和维护者都心力憔悴
  • 没有使用任何其他的副作用处理框架,实现诸如Async call/cancel非常的困难,需要开发人员大量手工编码,经常会出现先发的请求后处理,导致显示错误的旧数据。

在引入Redux-Saga后,上述两点问题得到了完美解决,处理流程变为

Redux-Saga充分利用了ES6的Generator特性,一切异步调用都可以通过yield交给Redux-Saga去处理,并在异步代码执行完成后返回yield处继续执行,从此和回调形式的异步调用说再见,同时支持try/catch语法,可以更方便的对代码块进行异常捕获。为了不使这篇文章变成Redux-Saga的使用技巧集锦,我对Redux-Saga不再做更多介绍,只是在这里真心再夸一句,Redux-Saga完美解决了我们所面临所有的异步处理/竞争条件的难题。

No-Reducer开发模式

在上面这种模式中,我们最开始的时候是相当满意的,对Action的类型进行严格区分,有副作用的一律走Redux-Saga,纯函数一律走Reducer。当然还是有美中不足的地方,就是Redux的事件定义机制过于“繁琐”了,如果是一个简单的功能,如模态表单的弹出/隐藏,都需要定义Action类型,有可能定义类型的时间,功能代码都写好了。

这种简单Action类型定义过多的话,上百的Action类型也会让后来者看得眼花缭乱,不利于代码的维护。为了解决这个问题,我们使用了No-Reducer开发模式:

  1. 简化reducer,仅接收和处理SET_STATE、REPLACE_STATE两种Action,且最终的状态变换一定要调用这两个方法之一。
  2. 简单的Action不设立单独的Action类型,如弹出窗口开关,简单的加减等Action,直接调用SET_STATE完成状态变换。
  3. 复杂的Action都需要有单独的类型定义,如需要从后台获取数据,或者处理步骤超过10行的Action。并且处理函数要求在Saga中独立注册,以供复用或组合。

采用这种开发模式后,终于使得开发人员从Action类型定义的地狱中解脱出来,大部分组件的Action类型可以控制在10个以内。

简化后的noReducer代码如下:

function noReducer(state = initialState, action) {
  switch (action.type) {
    case SET_STATE:
      return Object.assign({}, state, action.state)
    case REPLACE_STATE:
      return action.state
    default:
      return state
  }
}

在noReducer中,我们只接受两种数据类型,SET_STATE和REPLACE_STATE,使用方法类似于React组件内部的setState与state初始化,分别对应将新的state状态merge到现存的state对象中,以及直接替换当前state,在绝大多数情况下,我们仅需要使用SET_STATE,因为initialState的存在,我们一般不需要对state进行手动初始化。

No-Reducer处理流程图如下:

可以看到,所有原本属于reducer的工作都在ActionCreator和Redux-Saga中完成了,reducer变得名存实亡。

Redux state 即内部state

仔细回想一下,我们使用了No-Reducer开发模式后,仿佛真的回到了起点,在React组件内部,我们不就是这么管理state的吗,在函数中处理处理state,然后使用setState刷新页面。

本质上,No-Reducer开发模式就是一个增强版的内部state,setState模式。前文说过,Redux推崇数据单一来源,Redux的state原本就是被设计取代组件内部state的,所有组件渲染需要的state都应该被放入Redux中集中管理。而SET_STATE的表现和setState几乎一致,可以让开发者更容易接收Redux,并且付出很小的代价将内部state迁移到Redux中统一管理。

将state从React内部移到外部的好处非常明显,不管是测试、管理还是状态共享,一个统一的状态来源,都比一个需要手动层层传递,并且零碎分布于各个组件要强得多。当然我们还需要一个好的工具来组织化外部state,比如反应出state与真实组件对应的层次结构,而不是扁平的展现在开发者面前,以及更好的状态共享方案。这些功能都在我们开发的 Redux-Arena 框架的RoadMap中。

我本人非常喜欢Redux,它的函数式思想深刻的影响了我的编程理念,全局state和不可变state机制如果能得以贯彻,前端开发工作会变得更加简单和高效。仅有的不满大概是Redux已经很久没有功能上的进步,以及对副作用处理的漠不关心。

最后再安利一波吧,我们的 Redux-Arena 框架就是在No-Reducer开发模式下诞生的。它的设计目标是实现Redux/Redux-Saga与React更好的整合。目前已经实现了Redux的模块化,并提供React-Router兼容高阶组件,可以自动清理被卸载的React组件绑定的Redux信息,下一个版本会提供Virtual ReducerKey状态共享方案,使父子组件间的状态共享更加方便,敬请期待。