Redux 及其生态的学习

基本概念

使用 Redux 时,程序员被要求通过向 Store 单例对象发送 action 对象(携带着 payload)来触发一个root reducer 函数(一个纯函数)的调用,其返回值将作为新的、全局唯一的、只读的 state 对象以供消费(通常用来渲染 UI)。

原教旨主义者会认为,应该使用 state 对象表述整个 UI 的状态,这样才能确保应用的所有状态变动都是可回溯的;而更现代一些的看法认为,从数据角度看,对于只被一个组件使用的数据(问自己这个问题:“如果此组件呈现两次,交互是否应反映在另一个副本中?” 若答案为“否”,则为本地状态),无需将其放入全局 state 对象中,保存于那个组件实例之上即可(尤其是表单组件);从组件角度上看,业务场景的组件适合绑定全局数据,业务无关的通用组件不适合绑定全局数据。

Action

action 对象应当是一个普通的 JS Object。一个 action 对象除了应当具有type属性外,没有其他约束(参阅官方推荐的 action 对象结构)。通常会使用 action 工厂函数(称为 Action Creators)来创建 action 对象。Action Creators 中通常会出现副作用bindActionCreators(actionCreators, dispatch)可以包装一个或多个 action creator,使它(们)在返回 action 对象后自动调用dispatch(通常是 store 实例的 dispatch方法)。

action 应当被视为整个应用中的事件对象而不是某种操作的触发。持这种视角,可以避免使用太多种类的 action(正如一个系统中,通常事件的种类比操作的种类少得多)、避免 dispatch 太多的 action(从而触发太多的 UI 渲染),也有利于程序员为 action 的 type 起名字(另见官方推荐的起名方式)。而且也利于使用多个 reducer 处理同一个 action

Reducers

reducers 函数在 action 对象被发送时被触发,并根据当前的 state 对象和传入的 action 对象,计算得出一个新的 state 对象(必须是一个 plain js object)。该类函数的签名如下:

(previousState, action) => newState

reducers 函数必须是纯的

典型模式是一个入口 reducer(称为 root reducer) 根据 action 的 type,组合调用不同的 reducer 来生成并返回新的 state 对象。root reducer 通常由各个子 reducer (它们只负责处理 state 对象的某一个部分)经由 Redux 提供的函数combineReducers组合而成。该函数的功能仅仅是令被组合的各 reducer 被逐一调用,并将调用后的值作为 state 的相应 key 的值而已。

其最佳实践包括:

Store 与 State

使用函数createStore(rootReducer, preloadedState, storeEnhancer)创建一个 Store 对象。一个应用使用唯一的 Store 对象。它负责:

  • 初始化并保存 state 对象(一个 plain js object)作为应用的唯一数据源,并提供一个只读版本(store.getState())
  • 接收 action 对象(dispatch(action)方法),然后调用 root reducer(root reducer 是createStore函数的第一个参数)。root reducer 可以通过store.replaceReducer随时更换
  • 注册 state 改变时的回调函数(store.subscribe(listener))。回调函数不接收任何参数。一般用于启动一次渲染过程

如何设计 state 对象的结构,是开发 Redux 应用的首要且核心的问题。它通常呈现为一个状态树:

  • 一个状态树节点只由一个模块来负责更新(但对所有模块可读)
  • 避免冗余数据,以避免维护数据一致性。可以考虑使用 reselect 这样的库来避免对“去规范化(denormalization)”的需求。但有时冗余数据也不可避免,例如应用基于路由驱动时,当前的路由信息通常会被保存在 URL 和 store 中,此时需要诸如 react-router-redux 这样的库来保持两处信息的同步
  • 状态树的结构不要太深,尽量保持扁平

Redux 扩展

Store Enhancer

createStore函数的第三个参数是一个 Store Enhancer 。Store Enhancer 是一个函数,它在createStore中是这样被使用的:

function createStore(reducer, preloadedState, enhancer) {
// ...
    if (typeof enhancer !== 'undefined') {
        if (typeof enhancer !== 'function') {
            throw new Error('Expected the enhancer to be a function.')
        }
        return enhancer(createStore)(reducer, preloadedState)
    }
//...
}

实现一个 Store Enhancer 的目的是为了重新实现(包装) store 对象的方法(dispatch、subscribe、getState)或增加新方法。

中间件

“中间件”指的是一类函数,该类函数(们)被用于通过 enhancer 机制扩展 dispatch 函数的功能。多个中间件实际构成了一个处理 action 对象 的管道, action 对象被这个管道中所有中间件依次处理过之后,才有机会(action 对象可能被某个中间件吞掉而不交给下一个中间件)被 reducer 处理。

使用applyMiddleware函数来得到 enhancer。该函数的实现如下:

export default function applyMiddleware(...middlewares) {
  return createStore => (...args) => {
    const store = createStore(...args)
    let dispatch = () => {
      throw new Error(
        'Dispatching while constructing your middleware is not allowed. ' +
          'Other middleware would not be applied to this dispatch.'
      )
    }

    const middlewareAPI = {
      getState: store.getState,
      dispatch: (...args) => dispatch(...args)
    }
    const chain = middlewares.map(middleware => middleware(middlewareAPI))
    dispatch = compose(...chain)(store.dispatch)

    return {
      ...store,
      dispatch
    }
  }
}

异步操作(副作用)

副作用是阻碍代码清晰、以及无法回溯的第一道障碍,因此必须将其隔离在中间件内。 处理异步操作(即用于隔离副作用)的中间件一般要“吞噬”掉某些类型的 action 对象,这样的 action 对象不会交还给中间件管道;随后中间件会根据异步I/O的结果产生新的 action 对象,并使用 dispatch 函数派发。

在 Redux 的单向数据流中,什么时机插入异步操作(副作用)? 通过定制化 Store Enhancer,可以在 action 派发路径上任何一个位置插入异步操作(隔离副作用), 甚至作为纯函数的 reducer 都可以帮助实现异步操作(但别这么做)。

不同的解决方案提供了不同的解答。在选择一个异步解决方案时,主要应考虑上述问题。社区方案有从 redux-thunk 到 redux-saga,再到基于此的一套高阶封装框架,比如 dva。

action creators での副作用:redux-thunk/ redux-promise

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;

原理是判断每个经过它的action:如果是function类型(称其为thunk),就调用这个function(并传入 dispatch 和 getState 及 extraArgument 为参数)然后吞掉它。这个 function 通常包含各种副作用,如异步I/O,并在异步I/O获得结果后调用 dispatch。既然 action 是 function,则 action creators 就是作为函数工厂的高阶函数。

为什么能 dispatch 一个 thunk function 作为 action ,action 不应该是 plain JS object 么?实际上,只要将 thunk function 视为“延时求值的数据”(实际上这就是"thunk"这一术语的原意),那么“dispatch 一个 thunk function” 和 “dispatch 一个 plain JS object”在形式上就是统一的。

将上文所述的 thunk function 替换成 promise 对象,就是 redux-promise 库做的事。此处不再赘述。而为了克服它无法应对“乐观更新”的缺点,另有 redux-promise-middleware 作为解决方案。

reducer での副作用:redux-loop

(暂略)

额外抽象での副作用:redux-observable

该方案的要点如下:

  • 以流的形式提供 action 让 reducer 加以处理
  • 用 rxjs 的 creation operator 触发副作用产生数据源,而 action creators 保持纯函数

该库提供的核心抽象是 Epics,即这么一类用户定义的函数:接收 stream of actions(已经经由 reducer 处理过了)和 stream of store states,返回另一个用于 dispatch 的 stream of actions,其中在管道上可调用 creation operator(例如ajaxdelay等),从而把副作用置于 Epics 中。

多个Epics 可以通过combineEpics加以组合。可通过组合得到一个 rootEpics,并通过 EpicMiddleware 的 run 方法挂载。

额外抽象での副作用:redux-saga

redux-sage 提供一种称为 Saga 的抽象,用来实施副作用以及编排各种异步操作。Saga 是一个用户定义的 Generator 函数,其内部要 yield 出被称为 effects 的 plain js objects(使用 effect creator 生成),redux-sage 以它们为指令进行相应的(同步或异步的、可能有副作用的)动作(包括对 store 的 dispatch),动作的结果将和控制流一起回到 Saga 中。综上,Saga 中的内容是同步、无副作用的、对 effect creator 的调用,还可以使用try…catch语句进行包裹(因为 JS 支持将错误抛回 generator 内)。虽然 Saga 中的代码是同步的,但也支持以非阻塞/并发的方式触发副作用。

其它的技术细节包括:

  • redux-sage 选择 generator function 而非 async function,这是因为前者的功能比后者更强大,适用于更多场景
  • redux-sage 复写了 store 的 dispatch 方法,所有给 store 的 action 都会交由它处理,因此能对它们进行监听而不漏过任何一个

与 React 共用

模式

一个组件,两个部分

React 搭配 Redux 使用时,一个最小的功能单元应当用两个组件共同实现:容器组件展示组件。后者位于前者的内层,除了负责根据前者(以 props 的形式)提供的数据渲染 DOM (以及在上面绑定事件回调函数以对store 进行 dispatch)以外什么都不做(即,是一个无状态组件),而前者负责从 store 中取出需要的数据并使用它们算出展示组件需要的数据。导出组件时,仅需导出外层的容器组件即可。

因此,展示组件不过是容器组件的附属物,可以视为是一种配置(模板)而已。

为组件树提供 store

为组件树中的每个组件以 context 的形式提供那个全局唯一的 store。使用 React 的 Context 功能,在最顶层提供一个 context provider 即可实现。

react-redux

react-redux 这个库为程序员提供了上一节的两个模式的现成实现。它们分别对应该库的以下两个 API:

  • connect函数:该函数将生成一个容器组件工厂。它需要程序员提供一个“选取 store 上的部分数据并映射为内层组件的 props”的函数(一般称为 mapStateToProps 函数),以及一个“将内层组件需要加以事件绑定的回调函数映射为内层组件的 props”(一般称为 mapDispatchToProps 函数)。得到容器组件工厂后,程序员给工厂函数传入展示组件即可得到相应的容器组件。出于性能考虑,容器组件的粒度宜细不宜粗,否则会触发太多的渲染过程
  • 开箱即用的<Provider>组件

connect 函数生成的容器组件工厂生成的容器组件实现了shouldComponentUpdate方法,策略是浅比对 old props 和 new props 有无差异。

其它

参考React + Redux 最佳实践