业务背景
有这样一个业务场景,一句话概括就是:通过一个表单页面收集用户的某些信息,比如用户的姓名,地址,银行卡相关信息等等。通常我们的做法就是建一个表单页面,把相关的字段罗列出来,在视图层做一个 for 循环显示对应的字段即可。下面使用 Element UI 做代码演示。
根据以上介绍的业务需求可以用下面的代码实现:
// App.vue
<template>
<div id="app">
<el-form :model="userInfo" :rules="rules" ref="userInfo" label-width="100px" label-position="top">
<template v-for="item in formFields">
<el-form-item :key="item.id" :label="item.label" :prop="item.id">
<el-input v-model="userInfo[item.id]" :placeholder="item.label"></el-input>
</el-form-item>
</template>
<el-form-item>
<el-button type="primary" @click="saveDate('userInfo')">保存</el-button>
</el-form-item>
</el-form>
</div>
</template>
<script>
export default {
name: 'APP',
data () {
return {
userInfo: {
name: '',
address: '',
cardNumber: ''
},
formFields: [
{
id: 'name',
label: '姓名',
type: 'input'
},
{
id: 'address',
label: '地址',
type: 'input'
},
{
id: 'cardNumber',
label: '银行卡号',
type: 'input'
}
],
rules: {
name: [
{ required: true, message: '请输入姓名', trigger: 'blur' }
],
address: [
{ required: true, message: '请输入地址', trigger: 'blur' }
],
cardNumber: [
{ required: true, message: '请输入银行卡号', trigger: 'blur' }
]
}
}
},
methods: {
saveDate (formName) {
this.$refs[formName].validate((valid) => {
if (valid) {
console.log('submit!')
} else {
console.log('error submit!!')
return false
}
})
}
}
}
</script>
复制代码
通过以上代码实现了需求的基本功能,后续如果需要收集用户的其他信息那我们直接修改 formFields 和 rules 的值即可,到目前为止可以说不管产品你想收集什么信息,我们只需要改 2 个字段就能满足你的需求,nice!
产品需求增加
果然,这个功能上线没几天,产品的需求又来了,这次要求增加一个银行代码的字段,这个字段是让用户选择银行卡对应的银行码,银行数据是由产品提供的,由前端写到代码里,供用户选择。
本以为只是增加字段就能解决的问题,结果又多了一个表单类型,原来的 input 类型已经不能满足产品需求了,还好改起来也不难,只需增加一个 select 类型即可,改!
上面我们说了,只需要修改 formFields 和 rules 2个字段就能完成产品的需求,所以下面就围绕这 2 个字段做文章了。 formFields 是我们这个这个功能的核心字段,一切的变化也是围绕着它做改动。
HTML 部分的修改:
<template v-if="item.type === 'input'">
<el-input v-model="userInfo[item.id]" :placeholder="item.label">
</el-input>
</template>
<template v-if="item.type === 'select'">
<el-select v-model="userInfo[item.id]" :placeholder="item.label">
<el-option
v-for="son in item.list"
:key="son.value"
:label="son.label"
:value="son.value">
</el-option>
</el-select>
</template>
复制代码
JS 部分修改:
// 在 script 中添加 bankList 数组
const bankList = [
{ label: '(001)中国农业银行', value: '001' },
{ label: '(002)中国工商银行', value: '002' }
]
// 在 formFields 中添加 bankList ,并将其中 的 type 改为 select
{
id: 'bankCode',
label: '银行码',
type: 'select',
list: bankList
}
// 在 rules 字段添加
bankCode: [
{ required: true, message: '请选择银行码', trigger: 'change' }
]
复制代码
到目前为止,我们只加了几行代码,又快速的完成了产品的需求。我们的解决思路是通过 formFields 中的 type 对表单的类型进行分类,如果是 input 类型则显示 input 组件,如果是 select 类型则显示 select 组件,如果以后的需求要增加日期组件,多选组件,那我们可能通过这个值做对应的扩展就行,而 formFields 中的 list 属性则保存了下拉框所需要的数据,后面如果新增了其他的下拉选择的字段,直接将数据存到 对应的 list 中即可。
需求到此就结果了吗?并没有!
产品需求再次增加
果然,这个功能上线没几天,产品的需求又来了,要求加一个手机号的字段,手机号要默认显示区号前缀,如 +86,且用户不可修改,保存时要将手机区号带入,修改时将区号隐藏。
这还不是几行代码的事吗,但是我们发现除了要修改上面提到的 2 个字段,还要对 HTML 部分做调整。
HTML 部分的修改:
// 在 input 组件中添加前缀相关的逻辑
<el-input v-model="userInfo[item.id]" :placeholder="item.label">
+ <template v-if="item.prefix" slot="prepend">{{item.prefix}}</template>
</el-input>
复制代码
JS 部分的修改:
// formFields 字段添加
{
id: 'phoneNumber',
label: '手机号',
type: 'input',
prefix: '+86'
}
// rules 字段添加
phoneNumber: [
{ required: true, message: '请输入手机号', trigger: 'blur' }
]
复制代码
到目前为止,我们只加了几行代码,又快速的完成了产品的需求。如果产品的需求到此为止的话那现在的代码也就够用了,也就不谈什么设计模式了。
需求变更导致的垃圾代码
假如这个表单的需求没有这么大的变更的话,我们可能依然会按上面的处理方法,加几行代码就能完成产品需求了。比如产品可能会在添加完手机号字段后,又提出了用户分组功能,将用户分成了 AB 2组,要求 A 组用户的手机号前缀,银行码字段和 B 组用户的手机号前缀,银行码不同,这时我们可能会想目前只有2个用户分组,做个 if else 的判断不就行了,于是我们又在原来代码的基础上加了个判断完成了产品的这次需求。
没过几天,产品又来找到我们说,部分用户选择的银行码和用户所填的银行卡号匹配不上,要求我们做个验证,根据不同的银行码对银行卡号做校验,于是我们在 rules 字段上加了个自定义校验方法。
通过上面的几次需求变更,我们可以看出即使一个很简单的表单页面都有可能随着产品需求的变化导致我们的代码越来越臃肿,页面中的 if else 也越来越多。
下面的代码有可能是我们常看到的处理方式:
// App.vue
<script>
const bankListA = [
{ label: '(001)中国人民银行', value: '001' },
{ label: '(002)中国工商银行', value: '002' }
]
const bankListB = [
{ label: '(003)中国建设银行', value: '003' },
{ label: '(004)中国商业银行', value: '004' }
]
export default {
name: 'APP',
data () {
const checkCardNumber = (rule, value, callback) => {
const code = this.userInfo.bankCode
if (!code) return
// 这里只做简单的正则校验,实际的校验规则可能更复杂
const regs = {
'001': /^\d{12}$/, // 12位长度
'002': /^\d{14}$/, // 14位长度
'003': /^\d{8,12}$/, // 8-12位长度
'004': /^\d{10}$/ // 10位长度
}
const reg = regs[code]
if (!reg.test(value)) {
callback(new Error('请输入正确的银行卡号'))
} else {
callback()
}
}
return {
userInfo: {
name: '',
address: '',
cardNumber: ''
},
userGroup: '',
formFields: [
{
id: 'name',
label: '姓名',
type: 'input'
}, {
id: 'phoneNumber',
label: '手机号',
type: 'input',
prefix: '+86'
},
{
id: 'address',
label: '地址',
type: 'input'
},
{
id: 'bankCode',
label: '银行码',
type: 'select',
list: []
},
{
id: 'cardNumber',
label: '银行卡号',
type: 'input'
}
],
rules: {
name: [
{ required: true, message: '请输入姓名', trigger: 'blur' }
],
phoneNumber: [
{ required: true, message: '请输入手机号', trigger: 'blur' }
],
address: [
{ required: true, message: '请输入地址', trigger: 'blur' }
],
bankCode: [
{ required: true, message: '请选择银行码', trigger: 'change' }
],
cardNumber: [
{ required: true, message: '请输入银行卡号', trigger: 'blur' },
{ validator: checkCardNumber, trigger: 'blur' }
]
}
}
},
methods: {
initFields () {
this.userGroup = sessionStorage.getItem('userGroup') || 'B' // 模拟从接口获取用户分组信息
const indexBankCode = this.formFields.findIndex(item => item.id === 'bankCode')
const indexPhone = this.formFields.findIndex(item => item.id === 'phoneNumber')
// 对银行码列表和手机事情前缀做处理
if (this.userGroup === 'A') {
this.formFields[indexBankCode].list = bankListA
this.formFields[indexPhone].prefix = '+86'
} else {
this.formFields[indexBankCode].list = bankListB
this.formFields[indexPhone].prefix = '+57'
}
},
saveDate (formName) {
this.$refs[formName].validate((valid) => {
if (valid) {
console.log('submit!')
} else {
console.log('error submit!!')
return false
}
})
}
},
mounted () {
this.initFields()
}
}
</script>
复制代码
在上面的代码中 initFields 方法处理不同分组用户需要的字段数据,如果产品需求变成了 A 组用户只显示 name, cardNumber 字段,用户 B 显示的字段不变,那我们很有可能继续在 initFields 方法中对 formFields 做处理,这样一来 initFields 方法会越来越重,随着业务的需求变化里面可能充斥着大量的 if else 代码。
除此之外,我们还发现,如果 A 组用户请求这个页面时,并不需要使用 B 组用户的数据,反过来同理。这样我们就会发现如果当 bankList 的数据很多时,就会增加代码的体积,页面的初次加载速度也会受到影响。
产品需求再次增加…
果然,这个功能上线没几天,产品的需求又来了,这次直接放了个大招。需求是这样的:
- 新增多个用户分组,不同分组的用户手机前缀要显示对应的分组区号(比如用户国家可能不同);
- 不同分组的银行码列表不同,由前端进行控制显示对应的银行列表;
- 不同分组显示的字段不同,如 A 组只显示姓名,手机号,银行卡号,B 组显示姓名,银行码列表,银行卡号,C 组显示姓名,地址,手机号,D 组显示 姓名,城市,地址,手机号;
- 不同分组的手机号,银行卡,姓名,地址等验证规则是不一样的;
- 多语言支持;
到这里,我们已经不能在原有的代码基础上进行修改了,如果依然在原有代码的基础上进行开发的话,那代码量而知有多复杂,估计 if else 要满天飞了。
发现当前代码实现的问题
现在我们已经对业务需求有了新的认识,虽然需求相比于第一次提出时复杂了很多,但其本质是没有变的,依然是表单提交,只不过多了些逻辑处理。
既然业务的本质没变,那我们只需要梳理一下业务需求,对当前代码进行抽象,找到变化的部分和不变的部分,这些变化的部分是由什么引起的,这对代码重构起到了至关重要的作用。
通过梳理我们发现,变化的依赖点是用户的所属分组:分组不同 -> 引发了表单字段(formFields)的的不同 -> 表单字段的不同又引发了验证规则(rules)的不同;而相同点是处理业务的逻辑:获取表单所需要的字段 -> 对字段的值进行校验 -> 校验通过进行表单提交。这就是一个简单的抽象的过程。
设计模式登场
知道了问题所在,现在就开始解决问题。将不变的部分和变化的部分隔开是每个设计模式的主题。
下面先引入两个设计模式:模板方法模式,策略模式:
模板方法模式由两部分结构组成,第一部分是抽象父类,第二部分是具体的实现子类。
策略模式的定义是:定义一系列的算法,把它们一个个封装起来,并且使它们可以相互替换。
不同的用户显示不同的字段,变化就在这个字段上,那我们就能抽象出一个模板,通过这个模板,我们只用来获取获取表单需要的值,对值进行校验,提交表单。针对每个不同的分组我把它放在单独的文件里,通过策略模式来进行具体业务的处理。这也是策略模式的目的:将算法的使用与算法的实现分离开来。
下面是重构后的目录结构
// 目录结构
src
|--group // 存放不同分组的处理方法
|--a-list.js
|--a.js
|--b.js
App.vue
main.js
复制代码
我们将每个分组的数据处理方法抽离出来,存放到 group 文件夹中,而 App.vue 做为模板,处理通用的业务逻辑。
首先看 App.vue 的代码
// App.vue
<template>
<div id="app">
<el-form :model="userInfo" :rules="rules" ref="userInfo" label-width="100px" label-position="top">
<template v-for="item in formFields">
<el-form-item :key="item.id" :label="item.label" :prop="item.id">
<template v-if="item.type === 'input'">
<el-input v-model="userInfo[item.id]" :placeholder="item.label">
<template v-if="item.prefix" slot="prepend">{{item.prefix}}</template>
</el-input>
</template>
<template v-if="item.type === 'select'">
<el-select v-model="userInfo[item.id]" :placeholder="item.label">
<el-option
v-for="son in item.list"
:key="son.value"
:label="son.label"
:value="son.value">
</el-option>
</el-select>
</template>
</el-form-item>
</template>
<el-form-item>
<el-button type="primary" @click="saveDate('userInfo')">保存</el-button>
</el-form-item>
</el-form>
</div>
</template>
<script>
import { getField } from './group/index'
export default {
name: 'APP',
data () {
return {
userInfo: {},
userGroup: '',
formFields: [],
rules: {}
}
},
methods: {
// 获取分组用户所需的表单字段
async initFields () {
this.userGroup = sessionStorage.getItem('userGroup') || 'B' // 模拟从接口获取用户分组信息
let fields = null
try {
// 这里就是使用策略模式获取对应的表单字段
fields = await getField(this.userGroup)
} catch {
console.log('加载失败')
}
this.initData(fields)
this.formFields = fields
},
// 设置 userInfo 和 rules
initData (fields) {
const info = {}
fields.forEach(item => {
info[item.id] = ''
if (item.rule) {
/* 由于 Element UI 自定义的验证函数无法传参,所以要对验证函数进行修改
* 能够在验证函数内部访问当前表单数据,可根据实际业务进行处理此处逻辑
*/
if (Array.isArray(item.rule)) {
item.rule.forEach(son => {
if (typeof son.validator === 'function') {
son.validator = son.validator.bind(this.userInfo)
}
})
}
this.rules[item.id] = item.rule
}
})
// 对 userInfo 进行赋值,实现双向绑定
this.userInfo = info
},
// 提交数据
saveDate (formName) {
this.$refs[formName].validate((valid) => {
if (valid) {
console.log('submit!')
} else {
console.log('error submit!!')
return false
}
})
}
},
mounted () {
this.initFields()
}
}
</script>
复制代码
在上面的代码中我们对页面中用到的数据 userInfo, formFields, rules 只做了定义,并没有实际的赋值,通过 initFields 方法,我们获取表单所需要的字段,并通过 initData 方法对其他数据做进行赋值。
同时我们还引入了一个外部的方法 getField, 下面看看 group/index.js 文件做了什么事情
// group/index.js
/**
* 通过用户的分组获取对应的表单字段
* @param {String} userGroup 用户分组
* @returns Array
*/
export const getField = async function (userGroup) {
const group = userGroup.toLocaleLowerCase()
const file = await require(`./${group}`)
const { formFields } = file
return formFields
}
复制代码
上面的代码实现了通过用户分组获取对应的表单所需数据的功能,这样一来我们就实现了算法使用与算法实现的分离,之后再有新的用户分组,我们只需要在 group 目录下添加新的 js 文件即可,老业务不会受到影响,新需求也能更好更方便的实现。
下面我们看下 a.js 中的代码
// a.js
import { bankList } from './a-list'
export const formFields = [
{
id: 'name',
label: '姓名',
type: 'input'
}, {
id: 'phoneNumber',
label: '手机号',
type: 'input',
prefix: '+86'
}, {
id: 'bankCode',
label: '银行码',
type: 'select',
list: bankList
}, {
id: 'cardNumber',
label: '银行卡号',
type: 'input',
rule: [
{ required: true, message: '请输入银行卡号', trigger: 'blur' },
{ validator: checkCardNumber, trigger: 'blur' }
]
}
]
function checkCardNumber (rule, value, callback) {
const code = this.bankCode
if (!code) return
// 这里只做简单的正则校验,实际的校验规则可能更复杂
const regs = {
'001': /^\d{12}$/, // 12位长度
'002': /^\d{14}$/, // 14位长度
'003': /^\d{8,12}$/, // 8-12位长度
'004': /^\d{10}$/ // 10位长度
}
const reg = regs[code]
if (!reg.test(value)) {
callback(new Error('请输入正确的银行卡号'))
} else {
callback()
}
}
复制代码
这里我们定义了表单需要的每个字段,同时将验证规则提到了每个字段下面,以后我们只需要维护一个这样的文件就能实现新的产品需求,是不是最初版的代码方便多了。
重写后的代码问题
我们重构后的代码确实比之前简单多了,但同时又暴露出来另一个问题,就是冗余代码增加,比如现在有 20 个分组,这 20 个分组中都包含了 name, phoneNumber 字段,这时我们发现居然要维护 20 个一模一样的 rule 验证规则,如果字段更多的话,冗余的代码就会成倍的增长;另一个问题是表单字段的 change 事件怎么处理,比如: 字段中增加了一个 city 字段,根据 city 的值来判断要不是显示 bankCode 。除了上面说到的 2 个问题外,还可能有其他问题,这里不一一列举了。针对上面提到的这 2 个问题,重构后的代码改起来依然要比之前的代码简单的多。
针对问题一,冗余代码问题我们可以新增一个公共字段列表,这个列表中存储了每个字段的默认的属性,我们只需要做个 Merge Option 操作即可,这里就不写实际代码了。
针对问题二,change 事件处理问题,也很简单,我们可以在 group/index.js 文件中定义一个 change 函数并将这个函数 抛出,而这个 change 函数的具体实现可在分组的 js 文件中重新定义。而在 App.vue 文件中我们只需要在表单的 change 事件中执行即可。
为什么会说到这 2 个问题呢,主要是这能体现出设计模式带给我们的好处,重构后代码的灵活性更高了,主业务逻辑的代码也更容易维护。
产品需求再次增加…
果然,这个功能上线没几天,产品的需求又来了,新增了分组 D,D 组用户的 bankCode 的数据要通过接口向后端获取;D 组在提交时由于特殊原因使用了另一个接口。这时如果是你,你怎么完成这次的产品需求?
总结
本篇文章只是拿一个表单页面做示例,简单介绍了设计模式对我们代码优化的好处。在平常写代码的过程中我们都或多或少的应用过设计模式,只不过有些可能不知道具体是哪种模式而已。除此之外我们还要关注的是如果更好的理解产品需求,这里的产品需求并不只是简单的写写业务,它是包含在方方面面的,比如大到 Vue.js 是一个产品,小到一个落地页面等等。
我们要有总结问题的能力,学会对问题归纳总结,发现当前业务,技术,产品等的痛点,多想一步。从技术角度讲我们现在的代码有没有问题,能不能满足当前的业务实现,有没有更好的解决方案,如果没有,那是不是自己的视野受到了限制,只是自己还没想到而已。如果有更好的解决方案,那就要想有几种实现方案,我能不能实现,各种实现的成本是多少,实现后的收益是多少。
源码
如果想本地运行上面的代码,可访问下面的地址获取:
github.com/hawuji/desi…