前言
携程机票前台团队在使用 React Native 实现众多业务的过程中,经历了前期少量探索,中期大量应用,后期架构和性能优化的三个阶段。
在该技术栈积累了一定经验之后,结合不同业务的特点和复杂性,我们重新审视和思考一些前期实践项目的整体优化方向。在 App 国际机票查询列表页的相关业务模块,基于 Clean Architecture 整洁架构之道的思想,进行了一次技术大重构。
一、GUI 架构回顾
GUI 架构模式,一般分为两类:MV* 和 Unidirectional 。
最初的 MVC 将模块划分为展示界面的 View,数据模型 Model 和负责处理二者关系的 Controller 。从 MVC 到 MVP 的过程将 Model 和 View 完全隔离。随着 Databinding 技术的引入,MVP 进化到了 MVVM,使得 View 完全无状态化。
Unidirectional 系列相较于 MV*,则采用了消息队列式的数据流驱动的架构,其中具有代表性的 Redux 采用了统一的状态管理,带来了状态的有序性和可回溯性。
MV* 系列在 iOS、 Android 生态圈中已得到成熟广泛的应用,而在 React 技术栈的 Web 前端领域, Redux 是最主流的数据管理方案。
不同平台选择不同,这其中有框架 API 设计的原因,有编程语言的原因,以及面对的业务逻辑复杂度不同。React Native 是 React 和 Native 的混合体,原有的 Native 框架 API 被映射成 React Component 生命周期,编程语言也发生了变化,不变的是业务场景和逻辑复杂度。
Redux 曾是我们大型 RN 项目的标配,不过实践结果表明, Redux 的一些固有设计并不能很好的应对复杂的应用场景。因此,我们选择了相较于MV*系列,又对Presenter/Controller做了进一步拆分的Clean Architecture。
二、Clean Architecture
Clean Architecture (附录1)是 Uncle Bob 在2012年提出的用于构建可扩展、可测试软件系统的概要原则。这些架构产生的系统特点是:
基于以上原则的系统架构如下图所示,又称洋葱图。
从外到内,分为四层:
不同层代表软件系统中不同领域,外层是机制(mechanisms),内层是策略(policies)。
层与层之间遵循一个依赖关系原则:外层指向内层,机制指向策略。内层中的任何东西都不能知道外层中的某些东西。特别是外层中声明的内容的名称不得被内层中的代码提及,包括功能、类、变量或任何其他命名的软件实体。出于同样的原因,外层中使用的数据格式不应该被内层使用,特别是当这些格式是由外层中的框架生成时。外圈中的任何东西不应该影响内圈。
2.1 业务场景
App 国际机票查询预订流程中,列表页负责展示符合用户搜索条件的航班列表,并将用户带入中间页(舱位选择),其业务场景有以下特点:
2.2 应用结构
如下图,项目最外层分为公共库和业务两部分。
业务部分由多个Clean Architecture模块组成,最外层模块处理页面路由和页面初始化数据,低价日历、列表展示和筛选作为子模块嵌套其中。每个模块的内部结构相同,并且可以方便的成为另一个模块的子模块或父模块。
2.3 模块结构
模块内部遵循Clean Architecture原则,分为四层:
2.4 代码实现
2017下半年,我们在 React Native 实践初期,就决定全面使用 TypeScript,因为我们期望该技术栈未来能够可靠地支撑大型复杂项目工程。实践证明,Typescript 不负众望,在2019年变成了前端技术栈必备技能。
Typescript 补齐了 JavaScript 在数据类型方面的短板,这对大型项目的持续维护和稳定交付非常重要。
TS类型系统描述了数据结构、function的入参和返回值的类型和 class 对外暴露的方法,面向接口编程变得可能,我们编码时不再通过阅读代码了解上下文,而是面向接口实现逻辑,消灭TS error就好。
TS对 OOP 友好,对于部分场景,继承和多态是最优解,比如多态的单程、往返、多程列表页。
同时,IDE的支持带来了方便的代码智能提示和跳转,提升了开发效率。
在 TS 加持下,一个标准的模块由以下类和接口组成:
2.5 数据流
模块内部数据流、模块与外部通信关系如下:
2.6 具体案例
下面以筛选模块为案例,分析模块内部结构设计和数据流向。
筛选模块顶部为三个独立的筛选项;中部左侧为筛选大类栏,中部右侧为已选中大类对应的筛选项列表;底部可展开查看已选筛选项,以及符合当前筛选条件的航班数。
当用户选择中筛选项,如图中选中“中国国航”,会产生四处界面的改变:
这个案例很好地证明了:界面元素在布局关系上的亲密度,与界面状态逻辑的关联性并不成正比。
为了让界面逻辑和业务逻辑都能得到合理的表达,参照Clean Architecture 原则,模块内部划分为四层。
ViewModel层由多个React Component组合嵌套而成,这些勾选框,侧边栏,筛选项列表,按钮等界面元素按照如你所见的布局关系被 JSX 声明式表达为一棵组件树,所见即所得。
Model层则按照业务逻辑相关性拆分封装为多个业务逻辑高内聚的类:AirlineModel负责航司筛选逻辑,TimeModel负责时间筛选逻辑...
Interactor层是对 Model 层的高级封装,多个 Model 之间存在关联性逻辑包含在这层,例如“中转城市”与“仅看直飞”选项的互斥关系。
Presenter层将界面层和逻辑层联系起来,同时也负责筛选模块内部与外界的交流,例如点击“查看 XX 个结果”按钮,就是在 P 层发出Event,使得监听该事件的模块做出相应。
2.7 易用性
严格分层带来的副作用是要写不少模板代码。为了减少重复模板代码的编写和统一模块结构,我们提供了标准的模板代码。在开发过程中,只需要在模板代码基础上添加业务代码即可,无额外工作量。模板代码目录如下。
|____Builder
| |____Builder.tsx
| |____Page.tsx
|____BusStation
| |____OneSimpleEventDescription.ts
| |____OneSimpleApiDescription.ts
|____Contract
| |____IViewModel.ts
| |____IPresenter.ts
| |____IModel.ts
|____Model
| |____ModelOne.ts
| |____ModelTwo.ts
|____Interactor
| |____Interactor.ts
|____Presenter
| |____Presenter.ts
|____ViewModel
| |____ViewModel.tsx
|____View
| |____StatelessView.tsx
|____Log
| |____LogInfo.ts
为了提高模块编码的易用性,我们提供了各层的基类实现。各层派生于以下基类:
三、Why not use React Component
为什么不采用 React 的组件化设计,将状态逻辑放到 Component 内部?
回顾Thinking in react (附录2): 模块由多个 Component 组成,state放置在负责展示他们的 Component 中。当业务场景变得复杂后,会出现这些问题:
// 混乱的componentWillReceiveProps
public componentWillReceiveProps(nextProps) {
if (
nextProps.Filter_Model.changedTopFilter &&
this.props.Filter_Model.changedTopFilter !== nextProps.Filter_Model.changedTopFilter &&
nextProps.Filter_Model.changedTopFilter.length > 0
) {
const directFlightOnly = this.props.Filter_Model.isDirectFlight;
const changedList = new CompareLists().findDifferentItemNumber(
this.flightListFilterObj.getOriginalTab(),
nextProps.Filter_Model.changedTopFilter
);
this.hasFilterChanged = changedList.length > 0 || directFlightOnly;
}
if (nextProps.Filter_Model.selectedFilterCount !== this.props.Filter_Model.selectedFilterCount) {
this.updateSelectedFilterCount(nextProps.Filter_Model.selectedFilterCount);
}
if (nextProps.Filter_Model.AirportList && !this.props.Filter_Model.AirportList) {
this.setState({
selectedFilterCount: this.getSelectedFilterCount(nextProps)
});
}
}
针对以上问题,React提供了解决方案:状态提升、高阶组件和Render Props。
参照此思路,多个逻辑关联强的 Component 的 state,被提升到一个 Container 中统一管理,其余 Component 变成了 Stateless Component ,只负责界面展示。但是实践中遇到新问题:
四、Why not use React Hook
React Conf 2018会议上,React 的开发者指出 Class Component 存在的3个问题:
并认为这些问题的根因是:
React doesn't provide a stateful primitive simpler than a class component.
最终给出的解决方案:Hook。
为了复用组件间状态逻辑,可以将逻辑封装为一个 Hook,供其他组件使用。
为了 Class Component 的生命周期方法不被不相关的状态逻辑和副作用充斥,则换做在 Function Component 重复使用 Effect Hook ,将这些逻辑进行分类。
同时,相较于在 Class 要写类似 bind 的代码,Function Component可以少写很多代码。
// 使用Class Component的计数器
class Example extends React.Component {
constructor(props) {
super(props);
this.state = {
count: 0
};
}
render() {
return (
<div>
<p>You clicked {this.state.count} times</p>
<button onClick={() => this.setState({ count: this.state.count + 1 })}>
Click me
</button>
</div>
);
}
}
// 使用Hook的计数器
function Example() {
const [count, setCount] = useState(0);
return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>
Click me
</button>
</div>
);
诚然,Hook的出现,能帮助开发者更好的管理 Component 的 state 和 state logic,但是当面对复杂业务场景时,仍然需要考虑几个问题。
Hook 并不能很好的解决这些问题,而Clean Architecture则是参考答案。如果说 Hook 的出现,是为了让开发者更方便地把 state 放入 Component ,那么 Clean Architecture 则是让开发者不要把 state 放入 Component 中。
五、Why not use Redux
同样能做到和业务逻辑和界面展示解耦,为什么不使用 Redux ?
作为Unidirectional Architecture类架构的经典,Redux有其独特的优势:单向数据流和状态可预测。对于逻辑复杂度中等以下的 Web 网站和App工程,Redux 可以很好地提升开发体验。但是针对 App 国际机票列表页这样比较复杂(至少我们认为)的业务场景,它略显不足:
六、总结
App 客户端技术栈从原生快速迁移到 React Native 之类的混合技术方案, 平台 API 变了,编程语言变了,但不变的是业务复杂性。
为了摆脱基于界面元素在布局关系上编写状态逻辑,我们放弃 Component 和 Hook 方案。为了前端模块化和整体分形的项目结构,我们放弃 Redux 方案。
Clean Architecture不仅带来了逻辑与界面分离和统一的模块结构,还降低了单元测试的难度,减少了前端技术栈迁移的成本,同时加快了排查问题的速度,方便多团队间代码协作。
目前新架构洋葱版国际机票列表页已经全量上线运行一段时间,效果良好: