不按套路出牌的面试官,一句“你们这个xxx应急管理系统,主要卖的啥?”,把我堵在了三面的门口。
toB的业务,一个后台管理系统着实无法撑门面。事后我请教了同事怎么回答这个问题,他出了一个高招:商业机密,无法奉告
。
昨晚和同事们又复盘了这个项目,以小见大,还是有不少值得去总结的。
业务
以下纯属个人理解
这个项目主要还是做解决方案,给决策者做应急决策的作用。
当发生天灾人祸等应急事件时,低一级的管理局可以把信息收集汇总,通过“信息发布”系统的事件上报功能,上报上级,由上级来做进一步的决策。系统内嵌有GIS地图,能直观的显示应急地点,通过系统的应急分析,能快速显示离应急地点附近的应急物资,以便后续的物资调派。“预案管理”系统中,有对应的应急事件预备处理方案,此时决策者可以因地制宜,选择一套最佳方案,快速做出应急响应,指挥大局。“资源管理”系统中,有对应应急物资的详细情况。在物资调派,以及后续应急救援过程中,“融合通信”屏,起到了指挥沟通的作用。
运筹帷幄,决胜千里之外。我想,这就是这套管理系统的价值所在。毕竟应急救援,快速,周到,有序,才是这场灾难的制胜法宝。
技术理解
这个项目是多页应用系统,每个系统的后端都部署在不同的服务器上。toB业务的需求又是在不断变动,而改变的需求,项目负责人也没法拍板,毕竟屁股决定脑袋的决策,没有谁能随便定下来(后续随便哪个领导的一句话,我们又得屁颠屁颠的周末加班改需求)。toB业务又决定了这是个紧急的需求,毕竟要给领导演示。紧急的业务,也决定了我们这个团队是兵荒马乱的过程中,由多个部门临时组建起来的,其中的隐患就是,接口规范不统一,很多问题开会提出来后,又得不到快速解决。
一、通用组件封装
作为一位优秀的前端开发,面对以上的种种问题,就要考虑组件封装的通用性,扩展性,以及易用性。因为易用,才意味着更少的加班,扩展性,意味着能应对突如其来的需求变动。
1、大佬开发的very-page(基于element-ui)
后台管理系统,大部分页面都是表格,所以满足不同页面的表格需求,并封装到一个通用组件里面,而且还要用的方便,这样的组件势在必行。好在very-page组件是大佬在平时开发时候的技术沉淀,组件经受住了这次项目千奇百怪的需求的考验(大佬最近在重构这个组件,期待npm发包)。
组件的设计原则中有一条是,单一职责,职责单一就可以最大可能性地复用组件,但颗粒度也不能太细,太细维护起来比较麻烦。
使用方法思考
组件的配置抽取出来,这样使用起来就很灵活。新手使用时,只需要依葫芦画瓢,能很快实现业务功能。
- 表格传入
table-fields.js
配置,通过预留的属性名做一些业务逻辑,比如custom: true
,表明该字段可以自定义使用,在very-table
中,可以走slot插槽,方便扩展,而不是组件默认的渲染。isKey: true
表明在删除表单项时,将该字段的id作为参数。很多功能可以自己扩展,扩展性此时就有了。 - 功能区域传入
filter-field.js
配置,导出默认函数(这样页面使用时,可以绑定this,方便拿到数据),返回的对象中包括formConfig和fields两个属性。页面需求会有一行显示几个输入组件,一个输入组件多长,然后输入组件的label怎么显示,这些需求可以在formConfig中配置。fields是包含拥有v-model作用的组件的数组,可以是element-ui提供的组件,也可以自己封装,但原则是必须要实现v-model。组件相关的配置放在componentProps
这个属性中,并预留preSubmitHandler()
和preFormatter()
回调函数,使用过程中,有可能会对数据进行处理。 - 编辑框传入
add-or-update-fields.js
配置,用法和filter-field.js
大同小异,此处不再赘述。
以上配置的拆分,在业务开发过程中,使用良好。遇到问题,就会到专门的配置项中排查。
组件架构思考
组件分的越细,后续被多次复用的可能性就更大。组件中大量使用slot,以及作用域插槽。父组件使用slot,可以把布局写好,通过作用域插槽,把子组件的控制权交到父组件中,不少业务开发中会很方便。作用域插槽好用,非常好用,值得细细品尝。
组件的一级架构是very-page
,通过vue的slot,将页面分为三个二级架构
- 页头
very-page-header
, - 功能区插槽(包括搜索和按钮组)
very-func-area
, - 默认插槽表格
very-table
在very-page
中,把api配置文件传进去,并把增删改查方法写好,再通过回调预留接口。大部分表单接口使用到的参数都一样,所以预设表单提交参数presetFormParams,并通过props传进去。
very-header
组件,页头可以做很多事情,面包屑,按钮区域功能等等。
very-func-area
组件,功能区域,有各种条件的输入框,选择框,还有功能按钮。这些都是为了筛选表单。此时又可以细分三级组件,各种条件的输入框,可以封装成筛选表单very-form-pure
组件,可以在后续的弹框中使用;功能按钮,又可以拆分成按钮组件very-func-area-btns
,可以在后续very-table
表格中使用,表格中的每一条记录可能都会有增删改查的需求。
very-table
组件,主要功能区域。包含表头,操作按钮,表格主区域,分页组件。封装了分页组件,使用时候,只需传入总记录数,以及一页显示多少条即可。增删改查操作按钮通过props传进enableOps,可以给默认值。
// 启用的操作按钮
enableOps: {
type: Array,
default() {
// 也可以通过这种方式来自定义默认按钮的名称
// [ // {type: 'update', name: '修改', visibleHandler: () => {}}, // ]
// 最多支持下面三个值['update', 'detail', 'delete'],因为detail不常用,故不是默认值
return ['update', 'delete']
},
},
very-table
还包含可编辑的弹框very-form-dialog
组件,该弹框组件又可以包含very-form-pure
表单组件。
最后细分的是表单组件的表单项very-form-item
,仍然是基于element-ui的表单项。里面处理的逻辑是,是否是详情页的只读模式,编辑页的可编辑模式。所以要考虑两者不同的样式,以及对应的功能。
数据使用思考
动态绑定的参数,通过v-bind="xxxProps"
统一传进去,xxxProps
使用计算属性,这种方式页面简洁,管理方便。当某个动态属性是一个对象值时,尽量也用计算属性。如果不用计算属性而在页面直接赋值,有可能会产生多次调用api的奇怪bug。
html中对大小写不敏感,所以事件名统一都使用kebab-case 的事件名,如:a-b-c。
不同于组件和 prop,事件名不会被用作一个 JavaScript 变量名或 property 名,所以就没有理由使用 camelCase 或 PascalCase 了。并且 v-on 事件监听器在 DOM 模板中会被自动转换为全小写 (因为 HTML 是大小写不敏感的),所以 v-on:myEvent 将会变成 v-on:myevent——导致 myEvent 不可能被监听到。
因此,我们推荐你始终使用 kebab-case 的事件名。
数据类型考虑,当只有一个数据时,使用对象,多个数据则使用数组。
配置项文件,要做好分类,这样方便后续扩展,比如使用componentProps
,组件的动态数据整合起来,而不是散落在外面,一来是阅读性差,二来是扩展性低。
props传递null
和undefined
,也是有坑在。
详细请点击。
2、axios封装
本项目是多页应用,而后端又部署在不同的服务器上,端口不一致,因此在axios的封装过程中,要考虑不同端口的情况。
采用class来封装,在constructor中,因为在一个系统中,可能会使用其他系统的api,所以要传入apiType
,来区分不同的系统。
使用的时候,在不同的页面组件,定义一个sync.js
配置文件,通过new Sync()来生成一个实例。
拦截器中,可以在请求和响应中分别做不同的操作。
因为要在请求中携带cookie,可配置withCredentials: true
。
export default class Sync {
constructor({
warn = true, tipFn, apiType, timeout = 20000,
} = {}) {
this.protocol = window.location.protocol
this.host = window.location.host
const module = apiType || window.location.pathname.split('/')[1]
this.prefix = `${this.protocol}//${this.host}/api/${module}`
this.warn = warn
this.tipFn = tipFn
this.timeout = timeout
this.createAxios()
this.interceptors()
}
createAxios() {
this.axios = axios.create({
responseType: 'json',
withCredentials: true, // 是否允许带cookie这些
baseURL: this.prefix,
timeout: this.timeout,
headers: {
'content-type': 'application/json',
},
})
}
// 拦截器
interceptors() {
this.axios.interceptors.response.use(
(res) => res,
(error) => {
this.tipFn && this.tipFn(error || '网络故障~~')
return Promise.reject(error)
}
)
}
/**
*
* @param {String} path [请求地址]
* @param {Object} param [附带参数]
*/
GET(path, param = {}) {
return this.fetch(path, {params: param}, 'get')
}
/**
*
* @param {String} path [请求地址]
* @param {Object} param [附带参数]
*/
POST(path, param = {}) {
return this.fetch(path, param, 'post')
}
/**
*
* @param {String} path [请求地址]
* @param {Object} param [附带参数]
*/
PUT(path, param = {}) {
return this.fetch(path, param, 'put')
}
/**
*
* @param {String} path [请求地址]
* @param {Object} param [附带参数]
*/
DELETE(path, param = {}) {
return this.fetch(path, param, 'delete')
}
/**
*
* @param {String} path [请求地址]
* @param {Object} param [附带参数]
* @param {String} type [请求类型]
*/
fetch(path, param = {}, type) {
return new Promise((resolve, reject) => {
this.axios[type](path, param).then(
resolve,
reject
)
})
}
/**
* 上传表单方法
* @param {*} path
* @param {*} formdata
*/
FORMDATA(path, formdata, option = {}) {
return new Promise((resolve, reject) => {
this.axios(path, {
method: 'post',
data: formdata,
headers: {
'content-type': 'multipart/form-data;charset=UTF-8',
},
...option,
}).then(
resolve,
reject
)
})
}
}
3、开发编码规范
-
充分使用webpack中alias使用别名的功能,通用工具文件的路径可以用带下划线来命名,如:
_bus: resolve('./src/assets/js/util/eventBus.js')
。 -
统一使用eslint规范,安利一波本项目的
eslint-config-75team
。package.json中,在scripts定义:
"eslint-fix-js": "cross-env NODE_ENV=development eslint 'src/**/*.js' --fix",
"eslint-fix-vue": "cross-env NODE_ENV=development eslint 'src/**/*.vue' --fix",
可利用gitHooks的pre-commit
做强制提交校验。或者手动执行。
"gitHooks": {
"pre-commit": "lint-staged"
},
"lint-staged": {
"*.js": [
"cross-env NODE_ENV=development eslint 'src/**/*.js' --fix",
"git add"
],
"*.vue": [
"cross-env NODE_ENV=development eslint 'src/**/*.vue' --fix",
"git add"
]
}
- css使用BEM命名规范,好用。
二、用户体验优化
项目采用SSO单点登录,从一个系统跳到另一个系统时,要做身份验证,登录过期时,还会重新到第三方的登录页面进行登录。
一开始有个不好的体验,就是每次登录信息过期要重新登录时,会先进入目的系统一会,然后跳出到登录页面,登录完再进入目的系统。这样无意义的跳进跳出体验不好,于是我就在考虑,能不能在路由变换的时候,显示loading页面,通过是否能获取到用户信息,来跳到不同页面。
利用了路由的全局守卫beforeEach
,在vuex中查找是否当前用户信息,没有(有可能是刷新了页面,也有可能是根本没登录)则调用获取用户信息的接口继续获取用户信息,最后没有则不调用路由的next()方法。这个过程中,页面是一片空白。此时可以在HTML页面加入loading的css样式。后续不管是跳转到登录页面,还是登录成功跳转到目的系统,可以监听DOMContentLoaded
事件,监听到之后,移除loading的css样式,即停止loading。
三、后端联调
与后台对接口又是一个斗智斗勇的过程,所以一定要在项目开始前,商量好接口统一规范,采用restfull风格,则一定要执行下去。接口文档一定要统一,不然有些很懒的后端开发,习惯放在postman,而且会经常变动字段,没有历史记录,前端只能吃哑巴亏。后端开发坑不坑,在对删除接口就能体现出来,负责任的后端,只会让你传一个id,不负责的则会让你传各种奇奇怪怪的数据。
以下是我的总结:
-
编辑:当数据中出现需要删除的字段,前端不记录id,后端都可以做。先删除->更新-> 插入。
-
删除:前端只传id,其它所有需要的数据,后端自己查。
-
事务回滚:前端可用promise.all查数据,但涉及到增删改,都由后端事务回滚控制。
-
涉及大量的数据操作,影响到性能。可从产品端进行反驳。其次再与后端讨论怎样做能提高性能。
-
一定要有接口文档。统一接口规范,restful以及返回数据格式。