业务开发中如何设计合适的组件
nitta-honoka opened this issue · comments
题图来源:https://pixabay.com
前言
React 作为 Facebook 出品的一个组件化前端框架,已经迅速深入前端开发的各个领域,同时也使得组件化开发成为前端开发模式中的一个新常态。
米奇是百姓网内部服务于销售团队的 CRM 系统,具有复杂且庞大的业务逻辑。为了提升开发和维护效率,其前端采用了 React 作为视图的主要框架,同时按业务切分为不同的组件,使得米奇整个应用处于易装配、可追踪、可管控的状态。
在业务开发中,我们应如何设计一个能良好适配业务功能的组件,需要确定以下几件事:
- 如何划分业务组件
- 如何组合业务组件
- 业务组件的状态管理
- 业务组件的数据通信
所以接下来就这几点聊聊组件设计中的一些经验。
如何划分业务组件
组件划分首先得遵循一个原则:单一职责化。每个组件的能力边界不能过宽,好好负责自己的一亩三分地。当组件边界过宽时,势必会造成不同组件间的能力有所重叠,使组件之间的组合变得不清晰。所以编程一个组件之前,我们就需要定义好这个组件能做什么?从外部接收什么?向外部提供什么?
能做什么?
组件能做什么的划分一般会由两个方向确定,一个方向从前到后,以 UI 为标准划分,另一方向可以从后到前,以数据层为标准划分。但是实际开发中由于业务场景的多变性,并不能只以其中一个方向为准,很难满足各类场景的需求。所以在米奇的开发中,顶端父层级的业务组件会以数据模型为标准划分,后端定义的每一个 Controller 模块都会映射到前端的一个数据模型,也就代表了一个业务模块。而父层级包含的展现类子层级组件会以 UI 为标准划分。
在理想的情况下,你会发现自己的业务模块和数据模型是相同的信息架构,这样就意味着你可以轻易地将自己的业务组件拆分为不同的 UI 组件,同时又能够良好地组织它们的数据逻辑。
例如如果我们是一个 React + Redux 的系统,那么按照上述**划分后的应用结构应该是像下面这样:
-- api
---- a
---- b
-- action
---- a
---- b
-- reducer
---- a
---- b
-- components
---- A
------ index
------ other child
---- B
------ index
------ other child
从外部接收什么?
组件一般都会通过 props
的方式从父组件中接受信息(单数据流)。对于一个业务组件而言,接受数据时需要做些什么呢?
- 通过 React 提供的
PropTypes
定义信息的属性名以及类型。如同前后端协作需要先约定好接口文档一般,通过PropTypes
我们也就可以事先约定好该组件的文档,后续开发可以一目了然地知道这个业务组件需要什么信息,信息应该是什么样的。 - 定义
props
中传递信息的默认值,增强组件的容错率,当接口请求出现问题的时候,我们也能正常渲染出初始状态的页面。
class Com extends Component {
static PropTypes = { xxx }
static defaultProps = { xxx }
}
向外部提供什么?
祖件与外部的通信一般会通过回调函数的形式进行,这个方法要做到的是通知 “我发生了什么” 而不是通知 “我要干什么”,这其中意味着你的组件设计得是否足够独立。
例如一个 Input
组件需要向外部提供填写的文本内容,那么它应该做的是使用 onInputChange
告诉关联的外部,我的文本内容变化了,你们可以通过这个回调方法去取,至于你们要干什么,我并不关心。而不是使用 changeUsername
通知外部拿着文本内容去干某一件具体的事情。
// good
<Input onChange={v => onInputChange(v)} />
// not good
<Input onChange={v => changeUsername(v)} />
如何组合业务组件
通常情况下,业务组件的组合和常见模式并无太大区别,拼接父子组件树,管理好组件树之间的通信。业务开发中,组件化的主要目标是可管理性,组件化后的整个应用应该是一个清晰的树状结构,没有太多的交叉引用,一眼能看出组件间的包含关系和数据的传递方向。所以存在以下几个注意点:
-
很多时候我们会把复用作为组件化的第一需求,但实际上在 UI 层,我们更应该使用组件化实现的目标是分治。
例如现在有两个不同业务模型的组件ComA
和ComB
,它们都需要显示列表。第一反应是不是设计一个通用的列表组件,传入不同的列表数据aList
和bList
就好了。但是考虑一下以下情况,ComA
和ComB
组件的列表有不同的业务处理规则,要怎么分别处理这些特定于某个业务模型的规则呢。
按照 React 主要贡献者 Dan Abramov 提出的智能组件和木偶组件的**,我们应该直接在顶端组件里面处理好数据再往下传递,列表组件只负责展示,没有任何业务逻辑的处理。但实际开发中顶端组件会由很多个子组件组合而成,而每个子组件可能都存在自己专属的业务逻辑,全部放在顶端组件中处理极易造成组件的信息冗余,这个时候将专属于子组件的业务逻辑放在对应的子组件中是更易维护的选择。
那么回到刚刚的情况,两个相似的列表组件却有各自的业务规则怎么处理?我的选择是部分抽象,先抽象出来最常用的基本组件如Table
,然后再分别开发两个业务模型下的列表组件。以后其中一个业务模型的逻辑有变动时,也可以灵活变动相应的列表组件,而不需要拓展公用组件。 -
业务代码中组件抽取的粒度一直是一个比较纠结的问题,粒度太粗项目中可能会存在太多的重复代码,粒度太细会影响后续可扩展性,大部分情况下只能根据实际业务情况进行评估。但是这其中还是有一些经验可以参考:
- 组件树的组合不宜过深,通常控制在 3 至 5 层之间比较理想,过深的组件层级容易造成组件通讯的负担。
- 有几种东西一般可以被提取为可复用的组件:基础控件、公共样式,以及拥有稳定业务逻辑的组件。
-
需要和其他非 React 架构的系统集成时,如以前和可视化库 ECharts 集成的时候,仍然只能直接进行 DOM 操作,这部分是很难做到全组件化的,不过我们仍然可以采用更加 React 的方式去操作 DOM(现在已经有 Recharts 这样的集成 ECharts 的 React 工具库)。
<chart id='chart' ref='chart'></chart> // good const ele = this.refs.chart // not good const ele = document.getElementById('chart')
-
虽然组件之间也是可以相互继承的,但我们应该尽量避免通过继承去扩展自行开发的组件,会增加额外的复杂性。如同很多面向对象语言所提倡的,优先使用组合而不是继承,将多个单一职责的组件组合为一个需要的组件,或者使用 HOC(高阶组件)的方式来拓展组件功能。
设计状态驱动的组件
对于组件的展示与更新,我们都应该通过状态变更的方式去操作。正如 React 官方文档 Thinking in React
中所说,设计一个组件的时候我们要确定最小但完备的 UI state。
-
最小的具体表现是能够通过计算得到的数据就不要存到
state
中,而是在render
的时候即时计算出来,一方面可以避免组件多次re-render
影响性能,另一方面也避免了状态冗余,只需关注state
的最小集即可。 -
完备是指我们所设计的状态集应该能够覆盖到组件所有展示和更新操作,它可以是
props
或者state
。当需要进行 UI 展示的更新时,会通过更新组件状态来进行。例如一个按钮,我们要改变其是否可点击的展现状态。// good, 通过状态更新改变组件 const { disabled } = this.state <button disabeld={disabled}></button> // not good,直接操作组件 this.refs.btn.disabled = true
-
通过状态驱动的组件设计意味着我们会频繁用到 JSX 条件表达式,如下:
<div> { conditionA && ComponentA } { conditionB ? ComponentB : ComponentC } </div>
当条件判断过多的时候,我们可以考虑直接在
render
中进行拆分,使得整个组件结构更加清晰。render() { if (conditionA) { return ComponentA } return ComponentB }
-
同时我们应尽量保持组件状态的纯净性,始终使用不可变数据的**进行状态变更,避免在组件逻辑中直接对原数据使用如
pop
、push
、splice
等会改变原数据的方法。这样会造成数据传递中产生难以观测的改变,后续不便于追踪和管理组件更新。// good const data = [...this.props.data] const new = data.pop() // not good const new = this.props.data.pop()
组件数据通讯设计
常用的数据传递是通过父子组件之间的通信来完成,但大型系统中总会出现跨组件通讯的情况,一般来说跨组件间的数据传递通常有以下方式:
- 以 Redux 为例的 Flux 单向数据流模式,从统一管理的 Store 一路传递,通过触发更新 Store 的操作来更新
- 通过触发和观察自定义事件(EventEmitter)来传递数据
通常我们还是优先选择 Redux 来进行跨组件的通信和更新,以保证数据流的可观察可追踪,除非对于性能比较敏感的更新(由于 Redux 自顶向下的 re-render
更新机制),可以考虑使用事件传递数据。
后记
上面是一个有趣的问题 “怎么把大象放进冰箱”,有趣之余也映射出一个道理,解决一个复杂问题的时候,我们总能找到重要的思路,但是执行却也不可缺少。特别对于业务开发而言,一百个人眼里就有一百种业务逻辑,很难用同一种模式去套用到每一种业务的设计上。但是我们可以做到通过一些既有的经验举一反三,以此为基础,解决更多特殊化的难题。设计组件的过程就是对整个应用不断拆分再不断组合的过程,在其中我们成长的不仅仅是编码能力,更是全局与局部的规划能力。