构建可扩展的 React Redux 应用第一部分 - React 组件设计

由于 React 和 Redux 本身的灵活性,要摸索出一套最佳实践需要很多经验积累。当功能达到几十上百个时,项目要如何组织?如何降低代码耦合度?要怎样才能在业务需求变动时较少地改动?如何让代码可测试?如何保持性能?

由此也产生了 Dva 等业界流行的一揽子解决方案。但到目前为止,社区里还没有对中大型应用如何组织和维护有完整的总结。在这样的条件下,我们一直在思考和实践如何构建 长期可维护、可扩展 的中大型应用。

我们的项目持续迭代开发一年多,同时支持移动端和 PC 端,单页面代码量达到了 30k+ 行。在这一年多时间里我们不断反思和总结,广泛参考社区里的各种讨论,随着项目功能的迭代,经历了数次大大小小的重构,在这个过程中我们有了一些积累。

我们希望在这一系列文章中通过解释 React 和 Redux 背后的哲学,分析最佳实践及其背后的逻辑,帮助大家深入理解 React 与 Redux,总结出自己的最佳实践。

我们从 React 组件设计开始吧!

Container 和 Presentational

Container 和 Presentational 是 Redux 作者 Dan Abramov 提出的模式。核心观点是将组件中逻辑剥离到 React-Redux 的 connect 中,使得组件分成了只负责渲染的 Presentational 和 被赋予了 store 数据和业务逻辑的 Container。

它们的差别在于:

这一部分可以参考 Redux 官方文档:https://redux.js.org/docs/basics/UsageWithReact.html#presentational-and-container-components

因为 Container 都是由 connect 方法生成的,在下文中我们只讨论自己编码实现的 Presentational 组件。

组件应该是无状态的

函数式思想始终贯穿于 React 和 Redux 中,而无副作用是函数式的核心内涵之一,即对一个模块,当有相同输入时,它始终应该有相同的输出。

我们可以这样理解:对一个 React 组件,它的输入是 props,它的输出是 render 的结果,那么为了使得在 props 相同时都 render 出相同的结果,我们就不应该使用组件内部的 state,render 必须只依赖组件的 props。

我们为什么要保证无副作用呢?

  1. 可预测
    如果组件是无状态的,在 render 方法正确的情况下,开发者只需要关心输入的 props 是否正确,而这些 props 都是从 Redux store 里获得的,利用 Redux 我们能够容易地跟踪应用的状态,从而快速地定位问题。
  2. 可重用
    如果组件是有状态的,通常意味着组件负责了某些业务逻辑,组件一旦与特定的业务逻辑绑定,就意味着其很难在其他场景中使用。
  3. 易测试
    在复杂软件开发中,自动化测试能够保证新功能的开发不会对原有代码造成破坏。无状态组件在测试代码中只需要测试 render 的结果是否一致。

组件只负责渲染

单一职责 是软件设计的基本原则,对于 Redux 和 React 环境下的 React 组件,其基本职责是 渲染界面 ,业务逻辑则应该放在组件之外。

例如一个开关组件,它只需要根据 props 中 on 这个属性来渲染出当前开关的状态,在被点击时触发 props 中 onChange 回调,由组件调用方来处理点击之后的逻辑,再把处理之后的开关状态通过 props 传回组件。

使用高阶函数增强组件

一个组件可以视为变换 f, f = props => render result。那么,我们就可以使用函数式编程中的各种模式来操作这个变换 f,使得它满足不同场景的需求。这种模式成为高阶组件,可以表示如下:

hoc = enhancer => (component => enhancedComponent)

关于高阶组件的文章有很多,附上链接,这里就不赘述了。
https://zhuanlan.zhihu.com/p/24776678
https://zhuanlan.zhihu.com/p/29250138

某些情况下局部状态是可以接受的

尽管上面这些原则带来了可预测、低耦合、易测试等特性,在某些场景下,我们依然可以牺牲这些原则来换取开发的便利、代码的可读性或者应用的性能等等。

例如在一个编辑商品信息的弹窗中,我们可以把正在编辑的临时状态保存在弹窗内部的 state 中,而不是 Redux store 里。因为这些状态是临时的,并且不会被其他部分用到,放到 store 里使得数据传递变得繁琐、表单验证变得困难。社区里有一些第三方库帮助我们解决内部状态带来的问题,例如 Redux-UI

再如一个滚动加载的列表,它需要在 componentDidMount 里注册 DOM 滚动事件。这不符合我们无副作用的原则,但有时不得不如此。

antd 之类的第三方组件库,为了使用者方便,都会支持有状态和无状态两种使用方式。例如 Switch 组件 ,如果要使用有状态的,即开关状态由 Switch 组件自己管理的,则调用方通过 defaultChecked 只控制它的初始状态;而如何使用无状态的,即开关状态由调用方管理的,则调用方通过 checked 控制它实时的状态。

业务逻辑放在哪里

理想情况下我们的组件都是无状态的,只负责渲染,不负责逻辑和行为,那么我们的业务逻辑要在哪里处理呢?

小规模应用中,在 mapDispatchToProps 中使用 thunk 函数就可以满足业务需求。在接下来的文章中我们会讨论在更复杂的场景下要如何处理业务逻辑。

PS

ReactRedux 官方文档都推荐仔细阅读,它们都清晰地解释了背后的逻辑和思想,对于理解运用这些模式十分有帮助。

之后我们还将讨论:

  • 如何设计 Redux 状态树
  • 如何对 Redux 状态树进行封装,降低需求改动时的影响
  • 如何组织项目结构
  • 如何减少模板代码
  • 如何处理复杂的业务和异步逻辑
  • 如何减少无用的渲染
  • 如何将功能按需加载
  • 使用 Redux 的 enhancer 和 middleware 能做什么
  • Redux 为什么会这么设计
  • 不同数据管理框架的比较
知识共享许可协议
本作品采用 知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议 进行许可。