作者简介
滨峰,携程开发经理;国春,携程高级开发工程师。
本文主要介绍携程火车票模块在进行新业务开发和老代码重构时,使用rematch状态管理框架的实践经验总结,包括在过程中暴露出来的一系列问题以及相应的解决方案。
一、 背景
携程火车票业务迭代至今,已经实现了全流程的RN化。与此同时,之前基于redux状态管理方式写的业务代码,其中的问题也逐渐显现出来,主要体现在:
1)写法复杂,且状态改变的触发逻辑和处理逻辑很分散,代码可读性较差,新人上手难;
2)组件状态严重依赖于页面,与页面有强耦合,导致页面逻辑复杂难懂,组件也无法得到有效的复用。
问题的根源在于状态管理,于是我们开始尝试寻找新的状态管理方案。rematch作为redux的最佳实践,进入了我们的视线。
Rematch基于redux,进行了封装,简化了redux的使用方式,写同样的逻辑,所需的样板代码更少;且它有全局分发器dispatch,有利于页面和组件之间的解耦。因此,我们使用rematch这种新的状态管理方案,来进行了流程改造。
二、 改造流程设计
为了兼顾日常开发任务,我们将项目rematch化的改造计划分为了三期。
1)第一期:先在新页面中来尝试使用rematch框架,我们找了一个与流程几乎没有什么耦合的新页面来试水。一方面来用来熟悉rematch框架,另一方面也为了测试该框架在项目中的兼容性和稳定性;
2)第二期:火车票详情页使用rematch进行重构,积累一些重构经验,为后面的全流程推广奠定基础;
3)第三期:火车票全流程使用rematch框架。
三、 问题与解决
在实践过程中,我们遇到了很多问题,针对这些问题,我们总结了相关的经验。
3.1 Rematch和Redux的store如何兼容
rematch提供了相关接口,可以在同一个store中,兼容redux,这是一种渐进式的改造过程,适用于在原页面上添加一个使用rematch的新组件。这种方式会使页面处于redux和rematch共存的中间状态,后续还需要进行再次改造,略显麻烦。
我们的做法是,给rematch建立一个新的store,以页面为纬度进行改造。在根组件中,首先获取当前页面的路由。在事先声明的路由与store的映射表中,指定各个页面匹配对应的store,来达到切换store的目的。
3.1.1 配置需要使用新的store页面
代码语言:javascript复制const newStorePages = [
"Page1",
"Page2"
]
3.1.2 在模块初始化的时候,根据配置表加载不同的store
在页面跳转时拿到初始化页面initialPage。拿到初始化页面以后,根据之前的映射表来判断当前应该使用哪种store,从而保证在一个RN实例中只会存在一个store,实现了两个store在项目中的兼容。
代码语言:javascript复制render() {
......
return (
<Provider store = newStorePages.indexOf(urlQuery.initialPage || "") > -1 ? newStore : store>
<AppWithContext {...this.props} />
</Provider>
)
}
3.2 如何使用Rematch实现模块间完全解耦
在结构复杂、业务多变的互联网产品中,要做到模块具有较强的独立性、易用性、可移植性以及扩展性,那么模块之间完全解耦就显得尤为重要了。完全解耦的终极目的,是在删除、修改、迁移这个模块时,只需要对应地去操作这个模块文件以及这个文件的引用。除此之外,不需要修改任何其他模块、文件,如此即达到了组件最大化解耦。
因此,我们将组件放置在单独的文件夹中,其中包含两个文件index.js 以及 model.js, index文件主要是描述组件视图, model.js里封装了组件所有的逻辑。下面以一个弹窗逻辑为例,看下新老两种方式的对比:
3.2.1 传统方式
1)先在页面中声明一个state去控制组件的显示隐藏
代码语言:javascript复制this.state = {
showManualSpeed: false
}
2)作为属性传入组件
代码语言:javascript复制<ManualSpeedLayer
orderInfo={orderInfo}
isShow={this.state.showManualSpeed}
cancel={() => {
this.setState({showManualSpeed:false})
}}
/>
3)改变状态去控制组件的显示隐藏
代码语言:javascript复制this.setState({
showManualSpeed: true
})
可以看到,传统方式,主页面和组件之间耦合十分严重,组件的属性都在页面引入时传入,而这些属性页面其实都不用知道,页面只需引用组件就好了。组件的状态变化应该由实际触发其变化的地方去执行,而传统的方式将state都绑定在页面上,使得组件间通信必须经过页面来触发,导致主页面和组件的强耦合。
3.2.2 使用rematch的方式
1)先看看组件的结构
代码语言:javascript复制- ManualSpeedPopupView
- index.js // 组件UI
- model.js // 组件状态及逻辑
2)在model.js中暴露显示或隐藏弹窗的方法
代码语言:javascript复制const manualSpeedLayer = {
state : false,
reducers: {
show(state, playload) {
return true;
},
hide(state, playload) {
return false;
}
},
}
3)使用时,只需要在调用redux的connnect方法引入就可直接显示或隐藏该弹窗。这样能够避免多余的状态声明和管理,而且与父组件完全解耦。
代码语言:javascript复制......
const mapState = state => ({
isShow: state?.manualSpeedLayer
})
const mapDispatch = ({manualSpeedLayer}) => {
return {
hide: () => {manualSpeedLayer.hide()}
}
}
export default memo(connect(mapState, mapDispatch)(ManualSpeedPopupView))
4)在页面中的引入也变得十分简单
代码语言:javascript复制<ManualSpeedPopupView />
Rematch中把state、reducers和异步处理放在了一起,相比于redux的传统写法,这样的写法来的更加简洁方便。组件相关的逻辑都收到了一起,这样页面在引用时,无需再进行多余的状态声明和管理,代码可读性也大大提升。
组件和页面的强耦合,还体现在与组件操作相关的函数中。之前的处理方式,是将页面page传给函数。
代码语言:javascript复制export function clickSchoolActivityBtn(page) {
let {
orderInfo,
} = page.props;
......
page.props.setShowDialog(false);
}
由于状态和action都绑定在页面上,所以需要通过page来获取相关的状态以及触发一些action。但其实页面不需要关心这些状态和action,那么如何将这部分逻辑解耦出来呢?
使用rematch之后的做法是,将这个函数改为一个异步action,迁移到组件的model中去。
代码语言:javascript复制const clickSchoolActivityBtn = (rootState, dispatch) => {
let {
orderInfo,
} = rootState;
......
dispatch.schoolActivityShareLayer.hide();
}
const schoolActivityDetail = {
state: null,
reducers: {
setSchoolActivityDetail(state, playload) {
return playload;
},
clear(state, playload) {
return null;
}
},
effects: (dispatch) => ({
async clickSchoolActivityBtn(params, rootState) {
clickSchoolActivityBtn(rootState, dispatch);
}
})
}
异步action中的rootState包含了当前域内的所有状态,而dispatch可以索引到所有action函数,因此可以使用rootState和dispatch来接管原先page的工作,从而完全舍弃page,实现解耦。
3.3 如何实现组件复用
组件内容都抽到一个文件了,那么具体怎么去复用呢?开始我们想的方案是在组件绑定状态的地方更改数据源。例如,加入A、B两个页面,都需要用到该组件,且组件除了数据源不一样以外其余逻辑都相同,如下所示。
代码语言:javascript复制const mapState = state => ({
noticeDetail:state?.pageASource,
})
代码语言:javascript复制const mapState = state => ({
noticeDetail:state?.pageBSource,
})
但这个方案的缺点是,每次在一个新场景使用该组件,都要复制一份入口文件,且需要更改入口文件的数据源,这样一来不仅入口文件代码会重复而且操作也略显麻烦。那么有不需要改动入口文件的方案么?
这时我们想到了rematch的异步action,在异步action中,第一个参数是自定义的,可以传入任意自己所需的数据。因此可以通过异步action来暴露一个函数出来,单独给页面设置数据源。这样一来,对组件来说,就屏蔽了调用方的细节,组件内只需要这个数据类型,而组件外具体是哪个页面使用,数据来源是什么,都不用关心。
代码语言:javascript复制const noticeDetail = {
state: null,
reducers: {
setNoticeDetail(state, playload) {
return playload
},
clear(state, playload) {
return null
}
},
effects: (dispatch) => {
async setDataSource(params, rootState) {
let res = await getRecommendForOrder({orderNumber: params.orderNumber});
if (res) {
dispatch.noticeDetail.setNoticeDetail(res);
}
}
}
}
在上图这个场景中,我们暴露出了一个setDataSource方法,在页面中使用该组件时,只需引入组件,并在合适的地方给组件设置数据源即可。
代码语言:javascript复制dispatch.noticeDetail.setDataSource({'orderNumber': pageA.orderNumber});
代码语言:javascript复制dispatch.noticeDetail.setDataSource({'orderNumber': pageB.orderNumber});
这样一来,如果需要在新页面中加入这个组件,只需要在页面方设置数据源即可,组件无需任何改动。
3.4 其它问题
3.4.1 如何及时获取最新状态
在异步action中,如果在通过dispatch改变某个状态后,通过rootState去拿是无法拿到最新状态的,因为其状态改变最终都是通过setState来触发,而这个方法不是同步执行的。如果需要立即拿到最新的状态,可以直接从store中去获取。
代码语言:javascript复制import {newStore} from "../../../Store";
let orderInfo = newStore?.getState()?.orderInfo;
3.4.2 预加载组件缓存问题
为了加快二次启动的速度,之前在RN里做了预加载优化。RN在开了预加载的情况下,由于先前的状态仍然保存着,下次再进入该页面会造成页面数据显示不准确问题,所以就需要在页面退出之前,清除掉之前的状态。由于组件之间各自独立, 需要各个组件暴露自己的clear方法,用以清理自身的状态。
代码语言:javascript复制const isRefreshing = {
state: false,
reducers: {
setIsRefreshing(state, playload) {
return playload;
},
clear(state, playload) {
return true;
}
}
}
组件自己在销毁的时候需要清除掉自己的状态,如下所示:
代码语言:javascript复制useEffect(() => {
return () => {
props?.clear();
}
}, []);
另外页面在销毁的时候也需要清除所有子组件的状态。如下图,通过connect,将各个组件的状态引入,通过将各个组件的clear方法集中来达到清理所有状态的目的。
代码语言:javascript复制const mapDispatch = ({component1, component2, component3}) => {
clear: () => {
component1.clear();
component2.clear();
component3.clear();
}
}
四、 结果与回顾
目前我们的改造计划已经完成了第一期和第二期,实践下来效果达到了预期。
新页面使用rematch框架开发,写法简便,配合函数式和react hooks,大大节省了代码量。详情页使用rematch框架重构,主页面变得清晰可读,index文件的代码量简化到了原来的32%,且详情页各个组件变得独立可复用。
后续我们也会开展第三期的改造,将rematch框架应用到全流程当中去。