前言
如何写出可维护和可读性高的代码,这一直是一个困扰很多人的问题。关于变量如何起名、如何优化 if...else
之类的小技巧,这里就不做介绍了,推荐去看《代码大全2》,千书万书,都不如一本《代码大全2》。
工作以来,我一直在写一些重复且交互复杂的页面,也没有整理过自己的思路,这篇文章是我工作一年半来在项目中总结出来的一些经验。
分层
对于业务代码来说,大部分的前端应用都还是以展示数据为主,无非是从接口拿到数据,进行一系列数据格式化后,显示在页面当中。
首先,应当尽可能的进行分层,传统的 mvc 分层很适用于前端开发,但对于复杂页面来说,随着业务逻辑增加,往往会造成 controller 臃肿的问题。因此,在此之上,可以将 controller 再分成 formatter、service 等等。
下面这是一些分层后简单的目录结构。
代码语言:javascript复制 pages
hotelList
components
Header.jsx
formatter
index.js
share
constants.js
utils.js
view.js
controller.js
model.js
Service
统一管理所有请求路径,并且将页面中涉及到的网络请求封装为class。
代码语言:javascript复制// Service.js
class Service {
fetchHotelList = (params) => {
return fetch('/hotelList', params);
}
fetchHotelDetail = (params) => {
return fetch('/hotelList', params);
}
}
export default new Service
这样带来的好处就是,很清楚的知道页面中涉及了哪些请求,如果使用了 TypeScript,后续某个请求方法名修改了后,在所有调用的地方也会提示错误,非常方便。
formatter
formatter 层储存一些格式化数据的方法,这些方法接收数据,返回新的数据,不应该再涉及到其他的逻辑,这样有利于单元测试。单个 format 函数也不应该格式化过多数据,函数应该根据功能进行适当拆分,合理复用。
mvc
顾名思义,controller 就是 mvc 中的 c,controller 应该是处理各种副作用操作(网络请求、缓存、事件响应等等)的地方,这里的 controller 对应传统服务端的 service。
当处理一个请求的时候,controller 会调用 service 里面对应的方法,拿到数据后再调用formatter 的方法,将格式化后的数据存入 store 中,展示到页面上。
代码语言:javascript复制class Controller {
fetchHotelList = () => async (dispatch) => {
const params = {}
this.showLoading();
try {
const res = await Service.fetchHotelList(params)
const hotelList = formatHotelList(res.Data && res.Data.HotelList)
dispatch({
type: 'UPDATE_HOTELLIST',
hotelList
})
} catch (err) {
this.showError(err);
} finally {
this.hideLoading();
}
}
}
view 则是指 react 组件,建议尽量用纯函数组件,有了 hooks 之后,react 也会变得更加纯粹(实际上有状态组件也可以看做一个 mvc 的结构,state 是 model,render 是 view,各种handler 方法是 controller)。
在这里,容器组件里面的一些逻辑也可以剥离出来放到 controller中(react-imvc就是这种做法),这样就可以给 controller 赋予生命周期,容器组件只用于纯展示。
在这里,容器组件里面的一些逻辑也可以剥离出来放到 controller 中(react-imvc 就是这种做法),这样就可以给 controller 赋予生命周期,容器组件只用于纯展示。
我们将容器组件的生命周期放到 wrapper 这个高阶组件中,并在里面调用 controller 里面封装的生命周期,这样我们可以就编写更加纯粹的 view,例如:
wrapper.js
代码语言:javascript复制// wrapper.js(伪代码)
const Wrapper = (components) => {
return class extends Component {
constructor(props) {
super(props)
}
componentWillMount() {
this.props.pageWillMount && this.props.pageWillMount()
}
componentDidMount() {
this.props.pageDidMount && this.props.pageDidMount()
}
}
componentWillUnmount() {
this.props.pageWillLeave && this.props.pageWillLeave()
}
render() {
const {
store: state,
actions
} = this.props
return view({state, actions})
}
}
}
view.js
代码语言:javascript复制// view.js
function view({
state,
actions
}) {
return (
<>
<Header
title={state.title}
handleBack={actions.goBackPage}
/>
<Body />
<Footer />
</>
)
}
export default Wrapper(view)
controller.js
代码语言:javascript复制// controller.js
class Controller {
pageDidMount() {
this.bindScrollEvent('on')
console.log('page did mount')
}
pageWillLeave() {
this.bindScrollEvent('off')
console.log('page will leave')
}
bindScrollEvent(status) {
if (status === 'on) {
this.bindScrollEvent('off');
window.addEventListener('scroll', this.handleScroll);
} else if (status === 'off') {
window.removeEventListener('scroll', this.handleScroll);
}
}
// 滚动事件
handleScroll() {
}
}
因此,在这里我们完全将 view 和 model 挂载到 controller 上面,每个页面就以 controller 为入口。
也有一些传统 mvc 框架是以 view 为入口的,将 controller、model 等文件配置信息放到自定义。
代码语言:javascript复制<script type="template">
{
controller: PATH 'home.controller.js',
model: PATH 'home.model.js'
}
</script>
如果对路由进行一些处理,我们还可以获得更多生命周期钩子,比如 pageDidBack 等等。
其他
对于埋点来说,原本也应该放到 controller 中,但我更喜欢将埋点操作统一处理,我比较喜欢用发布订阅的形式。
如果还涉及到缓存,那我们也可以再分出来一个 storage 层,这里存放对缓存进行增删查改的各种操作。
对于一些常用的固定不变的值,也可以放到 constants.js,通过引入 constants 来获取值,这样便于后续维护。
代码语言:javascript复制// constants.js
export const cityMapping = {
'1': '北京',
'2': '上海'
}
export const traceKey = {
'loading': 'PAGE_LOADING'
}
// tracelog.js
class TraceLog {
traceLoading = (params) => {
tracelog(traceKey.loading, params);
}
}
export default new TraceLog
// storage.js
export default class Storage {
static get instance() {
//
}
setName(name) {
//
}
getName() {
//
}
}
数据与交互
不过也不代表着这样写就够了,分层只能够保证代码结构上的清晰,真正想写出好的业务代码,最重要的还是你对业务逻辑足够清晰,页面上的数据流动是怎样的?数据结构怎么设计更加合理?页面上有哪些交互?这些交互会带来哪些影响?
以如下酒店列表页为例,这个页面看似简单,实际上包含了很多复杂的交互。
上方的是四个筛选项菜单,点开后里面包含了很多子类筛选项,比如筛选里面包括了双床、大床、三床,价格/星级里面包含了高档/豪华、¥150-¥300等等。
下方是快捷筛选项,对应了部分筛选项菜单里面的子类筛选项。
当我们选中筛选里面的双床后,下方的双床也会被默认选中,反之当我们选中下方的双床后,筛选类别里面的双床也会被选中,名称还会回显到原来的筛选上。
除此之外,我们点击搜索框后,输入'双床',联想词会出现双床,并表示这是个筛选项,如果用户选中了这个双床,我们依然需要筛选项和快捷筛选项默认选中。
这三个地方都涉及到了筛选项,并且修改一个,其他两个地方就要跟着改变,更何况三者的数据来自于三个不同的接口数据,这是多么蛋疼的一件事情!
我借助这个例子来说明,在开始写页面之前,一定要对页面中的隐藏交互和数据流动很熟悉,也需要去设计更加合理的数据结构。
对于深层次的列表结构,键值对会比数组查询速度更快,通过 key 也会更容易和其他数据进行联动,但是却不能保证顺序,有时候可能就需要牺牲空间来换时间。
代码语言:javascript复制// 假设筛选项床型type为1,大床id为1,双床id为2.
const bed = {
'1-1': {
name: '大床',
id: 1,
type: 1
},
'1-2': {
name: '双床',
id: 2,
type: 1
}
}
const bedSort = ['1-1', '1-2'] // 保证展示顺序
当我们选中大床的时候,只需要保存 '1-1' 这个 key,再和 store 中快捷筛选项列表里面的 key 进行 mapping(快捷筛选项里面的项也应该格式化为 {'type-id': filterItem}
的键值对格式),这样从时间复杂度上说,比直接遍历两个数组更高效。
总结
在开始写业务之前,理应先想清楚需求和业务逻辑,设计出合理的数据结构,对代码进行好的分层,这样在一定程度上可以写出可维护性更高的代码。