React 组件化在业务开发中的落地实践

当 React 融入实际业务开发,我们总是会遇见一个纠结的问题,组件如何设计:组件粒度如何控制、组件责任如何划分、组件应如何组合、组件数据如何交互?本文将探索讨论业务组件设计中的方方面面……

前言

React 作为 Facebook 出品的一个组件化前端框架,已经迅速深入前端开发的各个领域,同时也使组件化开发成为前端开发模式中的一个新常态。

笔者所在的团队负责开发和维护公司内部的一个 CRM 系统,该系统具有复杂且庞大的业务逻辑。为了提升开发和维护效率,其前端便采用 React 作为视图的主要框架,同时按业务切分为不同组件,使整个应用处于易装配、可追踪、可管控的状态。

接下来我们就聊聊该 CRM 系统开发中如何实践组件化开发,真正提升工作效率。

React 组件设计的重点

其实关于 React 组件设计的思想,已有官方文档 [1] 珠玉在前了,那么在实际运用 React 的过程中,我们又发现了什么值得注意的重点,以及总结出什么适用的解决方案呢?下面就是实际业务开发中会遇见的一些重点和实践。

[1] 官方文档《Thinking in React》: https://reactjs.org/docs/thinking-in-react.html

组件如何划分

作为一个 to B 的中后台应用,不可避免会与业务模式强绑定,所以我们的路由按照业务模块进行设计与划分,并根据路由划分顶层组件。为了保证顶层组件之间的逻辑是完全分离的,设计之初我们便需要与产品方确认每个业务模块的独立性。在这种情况下,我们可以做到能直接从业务模块定位到路由,再到顶层组件,然后一直定位到后端 Controller,都不会出现任何分叉。接下来再对顶层组件进行 UI 为准的子组件划分。

可以看出开发中我们采取了两种划分方式,一种以路由为准,划分出顶层组件,更形象的说法应该是业务组件;一种以 UI 为准,划分出展示组件,这样做的好处在于:

  1. 开发中针对每个业务模块的规则变动和需求更改是很常见的事。我们可以快速定位到相应模块进行响应。

  2. 业务组件由于与业务绑定,因为每个业务模块的隔离性,基本不会存在公用地带;而展示组件则存在很大地抽象为公有组件的空间。如此划分有助于在不断迭代的过程中,整理出那些可以抽象的组件。

  3. 有助于我们维护一个良好的数据模型,使自己的业务模块和数据模型是相同的信息架构。便于引入 Redux、Mobx 等任何数据管理框架,同时我们可以轻易地将自己的业务组件拆为不同 UI 组件,却不会带来数据模型更改的副作用。

假设我们有一个 React + Redux 的系统,划分后的应用结构应该会像下面这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
├── api
│ ├── apiA
│ └── apiB
├── action
│ ├── actionA
│ └── actionB
├── reducer
│ ├── reducerA
│ └── reducerB
├── components
│ ├── ComponentA
│ │ ├── index
│ │ │ // 业务组件,以路由为准,和数据模型保持一致,
│ │ │ // 负责展示组件的组合及状态处理、通信
│ │ │
│ │ └── other child component
│ │ // 展示组件,以 UI 为标准划分
│ │
│ └── ComponentB
│ ├── index
│ └── other child component
└── ...

组件状态的管理

React 提供了两个途径获得组件状态,一个是自身管理的 state,另一个是父组件传递下来的 props,那么它们在实践中是如何使用的?

如何使用 state

state 管理着组件自身的状态,可以想象成这个组件自身的血液。由于在 React 的更新机制中,state 的每一次改变都会触发组件的重新渲染(re-render),带来不必要的性能损耗。同时 state 中管理太多状态也会造成状态冗余。所以我们应尽量维持组件 stateless 化。在将状态塞进 state 之前都先思考一件事 “这个状态真的适合放进 state 吗?”

什么数据适合放进 state 呢,总结起来就是可能会改变 UI 的 flag。例如一个绑定了 UI 动效的 className,或者一个 JS 动画的判断条件 isButtonDisabled。适合放进 state 的状态通常只会有三种数据类型 NumberString 以及最常出现的 Boolean

所以 state 常见于以下几种场景:

  1. 需要进行 UI 展示的更新时,会通过更新组件状态来进行。例如一个按钮,我们要改变其是否可点击的展现状态。

    1
    2
    3
    4
    5
    6
    // good - 通过状态更新改变组件
    const { disabled } = this.state
    <button disabeld={disabled}></button>

    // not good - 直接操作组件
    this.refs.btn.disabled = true
  2. 保存一个条件判断结果。例如可以通过 JSX 条件表达式判断组件是否展示,如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    const { conditionA, conditionB } = this.state
    <div>
    {
    conditionA && ComponentA
    }
    {
    conditionB ? ComponentB : ComponentC
    }
    </div>
  3. 组件内表单内容的存储和变化

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    state = {
    username: ''
    }

    render() {
    const { username } = this.state
    return (
    <Input value={username} onChange={v => this.setState({username: v})} />
    )
    }

如何使用 props

props 可想象成组件外部的输血管,组件会从 props 中取得自己需要的血液。对于子组件来说,父组件传递过来的 props 几乎等于一个黑盒子,子组件只能将手伸进去试着找到自己需要的状态,或者抓出一团 undefined。所以在 props 的使用中,我们需要做两件事来保证自己能更好地取到所需状态。

  1. 通过 React 提供的 PropTypes 约定信息的属性名以及类型。如同前后端协作需要先约定好接口文档,通过 PropTypes 我们也可以事先约定好该组件的 “文档”,后续开发就能够一目了然地知道这个业务组件需要什么信息,信息应该是什么样的。

  2. 定义 props 中传递信息的默认值,增强组件的容错率。当接口请求出现问题的时候,我们也能正常渲染出初始状态的页面。

1
2
3
4
class Com extends Component {
static PropTypes = { xxx }
static defaultProps = { xxx }
}

组件数据通信

组件间的数据通信会分两种情况,一种是父子通信,一种是跨组件通信,两种有其不同的处理方法。

父子组件通信

React 提供了 props 以及回调函数来解决父子数据通信的问题,从父组件到子组件当然是通过 props 来传递,只要按照遵循上面所提及的 props 使用建议,已经不会出现太大问题了。不过从子组件到父组件的回调函数通信倒还有可谈的地方。

回调函数通信是什么呢,先举个简单的例子说明一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class ParentCom extends Component {
render() {
// 这里通过回调函数 onChange 拿到了子组件传回的状态,并在组件方法中进行处理
return (
<ChildCom onChange={childState => function(childState)} />
)
}
}

class ChildCom extends Component {
render() {
// 回调函数通过 props 传给子组件,然后在子组件中调用传回相应状态
return (
<Com onChange={() => this.props.onChange(childState)} />
)
}
}

我们需要注意的是子组件中的回调方法要做到的是通知 “我发生了什么” 而不是通知 “我要干什么”,这意味着你的组件设计得是否足够独立,特别对于抽象出来的公用组件。

例如一个 Input 组件需要向外部提供填写的文本内容,那么它应该做的是使用 onInputChange 告诉关联的外部,我的文本内容变化了,你们可以通过这个回调方法去取,至于你们要干什么,我并不关心。而不是使用 changeUsername 通知外部拿着文本内容去干某一件具体的事情。

1
2
3
4
5
// good
<Input onChange={v => onInputChange(v)} />

// not good
<Input onChange={v => changeUsername(v)} />

跨组件通信

开发大型应用的时候我们难免会遇见需要跨组件通信的情况,或者不同业务组件都要用到一套状态(如用户信息等),那么跨组件通信又有哪些方法可以选择呢?

  • 以 Redux 为例的 Flux 单向数据流模式,从统一管理的 Store 一路传递,通过触发更新 Store 的操作来更新

  • 通过触发和观察自定义事件(EventEmitter)来传递数据

通常我们还是优先选择 Redux 来进行跨组件的通信和更新,以保证数据流的可观察可追踪,除非对于性能比较敏感的更新(由于 Redux 自顶向下的 re-render 更新机制),可以考虑使用事件传递数据。

React 组件设计实践的难点

将 React 应用于系统开发实践中时,为了更高的可维护性及健壮性,提高多人协作的效率,真正发挥 React 的强大威力,那么就有一些坑终究是绕不过去的。

组件的复用与分治

遵循着单一职责化划分好的组件,总是会被我们赋予更多复用的美好愿景,于是开始努力复用抽象自己的组件,最后发现一些令人头疼的事情。

  • 组件怎么如此之多调用关系,修改状态牵一发而动全身,影响一大堆父组件。
  • 组件不太适用最新业务,得加逻辑,结果组件内一长串的为不同业务设计的逻辑。业务改变之后找相应逻辑都得找很久。

其实在组件化的开发过程中,我们或许更应该注意到组件化带来的另一个重要福利分治

例如现在有两个不同业务模型的组件 ComponentAComponentB,它们都需要在页面上展示列表。接下来,第一反应是不是得设计一个通用的列表组件,传入不同的列表数据 ListAListB 就好了。但是考虑一下以下情况,ComponentAComponentB 组件的列表有不同的业务处理规则,要怎么分别处理这些特定于某个业务模型的规则呢。

通常的组合方式为直接在业务组件处理好数据再往下传递,负责展示的列表组件就只负责展示,没有任何业务逻辑的处理。也就是说,我们要在业务组件中按照不同业务规则处理好数据,然后传入展示组件做到不同的渲染。但实际开发中业务组件会由很多个子组件组合而成,而每个子组件可能都存在自己专属的业务逻辑,全部放在业务组件中处理极易造成组件的信息冗余。

想象一下,一个业务组件中有三四个子组件的处理逻1辑,那么我们最少也需要四个方法去干这些事,如果还需要因为业务逻辑的复杂性去拆分方法,是不是已经能看到一个超长代码的组件诞生了。所以这个时候将专属于子组件的业务逻辑放在对应的子组件中是更易维护的选择。

回到刚刚的情况,两个相似的列表组件却有各自的业务规则怎么处理?我的实践是部分抽象,先抽象出来最常用的基本组件如 Table,然后再分别开发两个业务模型下的列表组件 ListAListB,它们都用到了 Table。以后其中一个业务规则有变动时,也可以灵活变动相应的列表组件,而无需拓展公用组件,也无需在父组件中寻找列表处理的逻辑在哪儿。

组件的粒度如何控制

业务代码中组件抽取的粒度一直是一个比较纠结的问题,粒度太粗项目中可能会存在太多的重复代码,粒度太细会影响后续可扩展性,大部分情况下只能根据实际业务情况进行评估。但是这其中还是有一些经验可以参考:

  • 组件树的组合不宜过深,通常控制在 3 至 5 层之间比较理想,过深的组件层级容易造成组件通讯的负担。
  • 有几种东西一般可以被提取为可复用的组件:基础控件、公共样式,以及拥有稳定业务逻辑的组件。

直接进行 DOM 操作

需要和其他非 React 架构的系统集成时,如以前和可视化库 ECharts 集成的时候,仍然只能直接进行 DOM 操作,这部分是很难做到完全组件化的,不过我们仍然可以采用更加 React 的方式去操作 DOM(现在已经有 Recharts 这样集成 ECharts 的 React 工具库)。

1
2
3
4
5
6
<chart id='chart' ref='chart'></chart>
// good
const ele = this.refs.chart

// not good
const ele = document.getElementById('chart')

避免对状态进行会产生副作用的操作

开发过程中应尽量保持组件状态的纯净性,始终使用不可变数据的思想进行状态变更,避免在组件逻辑中直接对原数据使用如 poppushsplice 等会改变原数据的方法。这样会造成数据传递中产生难以观测的改变,后续不便于追踪和管理组件更新。

1
2
3
4
5
6
// good
const data = [...this.props.data]
const new = data.pop()

// not good
const new = this.props.data.pop()

错误使用 state 的情况

上面有谈及到 state 的正确使用模式,维护一个最小集但完备的 state,回顾以前的代码,常常会出现这么几种错误使用 state 的场景。

  • 将计算后的数据放进 state。这里是非必要的行为,更推荐将计算过程放在 render 生命周期中,直接用计算后的数据进行渲染。如果把计算数据也交给 state 管理,就意味着需要进行许多额外的 setState 操作去保持计算数据的同步。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    // good
    render() {
    // 直接在 render 中计算后用于渲染
    const data = function(this.props.xxx)
    <Com data={data} />
    }

    // not good
    componentDidMount() {
    // 在其它生命周期或者方法中计算后放进 state
    const data = function(this.props.xxx)
    this.setState({data})
    }
    render() {
    const { data } = this.state
    <Com data={data} />
    }
  • 同样不推荐将 React 组件直接放进 state,更应直接在 render 生命周期中进行组装。

  • 将 props 中已经存在的数据放进 state 通常是一种画蛇添足的行为,直接使用 props 中的数据就好。会导致这个行为的大部分原因在于我们并不清楚 props 中究竟有什么,所以通过 PropTypes 进行 props 的约束说明是一件很有助于减少这类问题的事情。

后记

大象放进冰箱
(图片来源:Pixabay)

有这么一个有趣的问题 “怎么把大象放进冰箱”,总是会被拿来吐槽一些语焉不详的说明。不过放到应用设计中也能映射出一个道理,解决一个复杂问题的时候,我们总能找到重要的思路,但如何执行却步步维艰。对于业务开发而言,一百个人眼里就有一百种业务逻辑,很难用同一种模式套用到所有业务的设计上。

但是我们可以通过一些既有的经验举一反三,以此为基础,解决更多特殊化的难题。设计组件的过程就是对整个应用不断拆分再不断组合的过程,在其中我们成长的不仅仅是编码能力,更是全局与局部的规划能力。

坚持原创技术分享,您的支持将鼓励我继续创作!