最近做了一个后台新项目,尝试了一下TypeScript的装饰器,使用了装饰器后,改变了一些代码书写方式,个人觉得,还是挺好的。
装饰器
首先,什么是装饰器?建议去官方文档查看,或者有时间总结一篇。这里简单说明,装饰器会在运行时先于逻辑代码首先执行。简单说有以下几种:
- 类装饰器,用在定义类的前面。
- 方法装饰器,用在定义方法的前面。
- 属性装饰器,用在定义属性的前面。
- 参数装饰器,用在定义参数的前面。 好吧,一堆废话,着实简单,有时间细聊。
示例图
先通过示例图了解下具体需求。
从GIF图可以看到,还是有一些逻辑在里面的
- 选择省的时候,才会出现市的选择。
- 省发生变化的时候,市的选项也会发生变化。 可以思考一下,通常我们都是怎么实现这个表单的及其逻辑的。
页面代码
有了需求,怎么实现呢?当然是先封装一个form组件,这里先不贴出form组件代码,先看如何在页面中使用。
// test.vue页面
<template>
<Form :model="model" :inline="false" />
</template>
<script lang="ts">
import Form from '@components/form/Form.vue'
import Student from './student'
export default {
components: { Form },
data () {
return {
model: new Student()
}
}
}
</script>
从上面代码可以看出,使用起来相当简单。那是不是说,图中展示那些逻辑写在了form组件里呢?并不是,form组件是一个业务无关的组件,属于通用的,基础组件,只不过,form这个组件稍微有些复杂,先不展开说,接下来看另一个关键角色,student类。
领域模型
什么是领域模型,非常具体且准确的定义,我也说不了,我这里简单理解是,现实世界或者具体需求中那些实体或者对象的具体描述或映射。我这里没有把“学生”这一实体的具体行为及属性全部描述,只是为了演示我结合装饰器,如何改变代码的书写方式。
// student.ts
import { ItemStyle, OptionsSource, ItemRelation, ItemRules, ItemShowRule } from '@/components/form/formDecorators'
import { ElementType } from '@components/form/FormBase'
export default class Student {
@ItemStyle({ type: ElementType.input, label: '姓名', order: 1 })
name: string = ''
@ItemStyle({ type: ElementType.select, label: '省', order: 2 })
@OptionsSource((model: Student, parent: string) => model.getProvinces(model, parent))
@ItemRelation('city')
@ItemRules({ required: true, message: '请选择省', trigger: 'blur' })
province?: number
getProvinces (model: Student, parent: string) {
return [{ label: '河南', value: 1 }, { label: '河北', value: 2 }]
}
@ItemStyle({ type: ElementType.select, label: '市', order: 3 })
@OptionsSource((model: Student, parent: string) => model.getCitys(model, parent))
@ItemShowRule((model: Student) => model.province)
@ItemRules({ required: true, message: '请选择市', trigger: 'blur' })
city: number = 0
getCitys (model: Student, parent: string) {
const province = model[parent]
const citys = [{ province: 1, label: '郑州', value: 1001 }, { province: 1, label: '开封', value: 1002 }, { province: 2, label: '石家庄', value: 2001 }]
return citys.filter(it => it.province === province)
}
}
这个类有三个属性,两个方法,每个属性上都使用了装饰器,这里没有展示全部的装饰器,能说明问题就好。下面做一些解释说明。
- ItemStyle,装饰属性在form表单中的样式。
- OptionsSource,只有样式是select的时候,才会起作用,它接受一个函数组作为参数,在渲染表单的时候,会执行传入的函数,该函数返回该select所需要的options。
- ItemRelation,关联关系,接受一个字符串,也就是这个类的某个属性。作用是,当前属性的值发生改变时,关联的那个属性也要发生变化,主要执行关联属性的OptionsSource函数。
- ItemRules,该属性在表单发生变化或者提交时的校验规则(form组件的封装依赖于elementUI)。
装饰器
装饰器的逻辑很简单,也很单调,主要就是把数据放在领域模型上,供form组件读取,form组件拿到这些数据,就可以在内部实现一些逻辑,同时也会把必要的逻辑反馈给模型。这里简单看几个装饰器:
import { IFormItemStyle, ElementType, PropertyType } from './FormBase'
const prefix = 'FormItem__'
export function ItemStyle (itemStyle: IFormItemStyle) {
return function (target: any, propertyKey: string) {
const key = prefix + propertyKey
target[key] = target[key] || {}
target[key] = Object.assign({
order: 9999,
propertyType: PropertyType.text,
show: () => true,
disabled: () => false,
key: propertyKey
}, target[key], itemStyle)
}
}
export function ItemRules (rule: any) {
return function (target: any, propertyKey: string) {
const key = prefix + propertyKey
target[key] = target[key] || {}
target[key].rules = target[key].rules || []
target[key].rules.push(rule)
}
}
export function ItemRulesOverwrite (rule: any) {
return function (target: any, propertyKey: string) {
const key = prefix + propertyKey
target[key] = target[key] || {}
target[key].rules = [rule]
}
}
export function OptionsSource (source: Function) {
return function (target: any, propertyKey: string) {
const key = prefix + propertyKey
target[key] = target[key] || {}
target[key].source = source
}
}
// 关联关系,标明被装饰者发生变化时,关联者也要发生变化
export function ItemRelation (item: string) {
return function (target: any, propertyKey: string) {
const key = prefix + propertyKey
target[key] = target[key] || {}
target[key].relation = item
}
}
以上代码可以看到,装饰器把数据放在领域模型的静态字段上面,这些字段只有在form组件里才会使用,因此都加了前缀,上面这几个装饰器也是在student类里使用的。
form组件
form组件的逻辑也不算复杂,它主要做的是把装饰器装饰在类上数据取出来,然后通过这些数据渲染页面,处理逻辑,反馈结果。该form组件基于elementUI开发,代码如下:
<template>
<div :class="$style.form">
<el-form
:name="'myform__' + id"
ref="myform"
:inline="inline"
:model="form"
:rules="rules"
size='mini'
:label-width="labelWidth"
>
<template v-for="(item, i) in items">
<el-form-item
:label="item.label"
:key="i"
v-if="itemIsShow(item)"
:prop="item.key">
<div v-if="item.type === 'input'">
<el-input
:name="item.key"
:disabled="item.disabled(model)"
:type='item.propertyType'
v-model="form[item.key]"
@change="itemValueChange(item, i)"
:placeholder="item.placeholder"
></el-input>
<div :class="$style.tip" v-if="item.tip">{{ item.tip }}</div>
</div>
<el-select
v-if="item.type === 'select'"
:disabled="item.disabled(model)"
v-model="form[item.key]"
@change="itemValueChange(item, i)"
:placeholder="item.placeholder"
>
<el-option
v-for="option in options[item.key] || []"
:label="option.label"
:value="option.value"
:key="option.value"
></el-option>
</el-select>
<!-- upload -->
<Upload
v-if="item.type === 'upload'"
:uploadObj = item
v-model = 'form[item.key]'
@change="itemValueChange(item, i)"
/>
<el-date-picker
v-if="item.type === 'datePicker'"
:disabled="item.disabled(model)"
v-model="form[item.key]"
@change="itemValueChange(item, i)"
:type="item.propertyType"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期">
</el-date-picker>
<el-switch
v-if="item.type === 'switch'"
v-model="form[item.key]"
@change="itemValueChange(item, i)"
:active-text = item.activeText
:class="$style.switch"
:inactive-text = item.inactiveText>
</el-switch>
<slot v-if="item.type === 'slot'" :name="item.slotName"></slot>
</el-form-item>
</template>
<el-form-item v-if="isBtn">
<el-button type="primary" @click="submitForm">{{submitLabel}}</el-button>
<el-button @click="resetForm">{{resetLabel}}</el-button>
</el-form-item>
</el-form>
</div>
</template>
<script lang="ts">
import { Component, Vue, Prop, Watch } from 'vue-property-decorator'
import Upload from './Upload.vue'
const prefix = 'FormItem__'
@Component({
components: { Upload }
})
export default class Form extends Vue {
@Prop() readonly model: any
@Prop({ default: true }) readonly inline?: boolean
@Prop({ default: '100PX' }) readonly labelWidth?: string
@Prop({ default: '查询' }) readonly submitLabel?: string
@Prop({ default: '重置' }) readonly resetLabel?: string
@Prop({ default: true }) readonly isBtn?: boolean
static count = 1
id?: number
items: Array<any> = []
form: any = {}
rules: any = {}
options: any = {}
created () {
this.id = Form.count++
this.findItemsByModel()
}
@Watch('model', { deep: true })
alterFormData (val: any, old: any) {
Object.keys(this.form)
.filter(key => val[key] !== this.form[key])
.forEach(async key => {
this.form[key] = val[key]
const item = this.items.find(it => it.key === key)
if (item.relation) {
const childRelation = this.items.find(it => it.key === item.relation)
const options = await childRelation.source(this.model, key)
Vue.set(this.options, childRelation.key, options)
}
})
}
findItemsByModel () {
const labels: Array<any> = []
// eslint-disable-next-line no-proto
this.findAllLabels(this.model.__proto__, labels)
const items = labels.sort((a, b) => a.order - b.order)
console.log({ items })
items.forEach((item, i) => {
Vue.set(this.items, i, item)
Vue.set(this.form, item.key, this.model[item.key])
Vue.set(this.rules, item.key, item.rules)
})
items.forEach(async item => {
if (item.source) {
const parentRelation = items.find(it => it.relation === item.key)
Vue.set(this.options, item.key, await item.source(this.model, parentRelation?.key))
}
})
}
findAllLabels (proto: any, labels: Array<any>) {
if (!proto) return
Object.keys(proto)
.filter(key => key.includes(prefix))
.map(key => proto[key])
.forEach(it => {
if (!labels.find(label => label.key === it.key)) { labels.push(it) }
})
// eslint-disable-next-line no-proto
this.findAllLabels(proto.__proto__, labels)
}
// 移除校验结果
clearValidate () {
const myform: any = this.$refs.myform
myform.clearValidate()
}
async itemValueChange (item: any) {
const key = item.key
if (typeof this.form[key] === 'string') {
this.form[key] = this.form[key].replaceAll(' ', '')
}
this.model[key] = this.form[key]
if (item.relation) {
const childRelation = this.items.find(it => it.key === item.relation)
const options = await childRelation.source(this.model, key)
this.model[childRelation.key] = this.form[childRelation.key] = undefined
Vue.set(this.options, childRelation.key, options)
}
}
itemIsShow (item: any) {
const isShow = item.show(this.model)
if (!isShow) {
this.model[item.key] = undefined
}
return isShow
}
submitForm () {
const myform: any = this.$refs.myform
myform.validate((valid: any) => {
if (valid) {
this.$emit('submit', this.form)
}
})
}
resetForm () {
this.items.forEach(item => {
if (!item.disabled(this.model)) {
this.model[item.key] = this.form[item.key] = undefined
}
})
this.$emit('cancel')
}
}
</script>
以上代码,主要看以下几个函数
- findAllLabels:当form组件创建的时候执行该函数,该函数找到领域模型上装饰过的所有字段及数据,数据存储在items数组里,由于用的elementUI,所以表单的rules单独拿出来。其中options是该页面用到select的时候,存放的数据。
- itemValueChange:当数据项发生变化的时候,找到该项数据有没有关联者,有的话,执行关联者的逻辑。
- alterFormData:当从form组件之外改变数据时,找到改变项,后续执行逻辑基本同itemValueChange函数。
总结
我们抛开form组件及装饰器代码,会发现,当我们在业务中使用form的时候,代码非常的简洁,并且由于可是使用领域模型这种代码组织方式,会让逻辑更紧凑,且与UI分离更彻底。