TypeScript的装饰器在项目中的实际应用(一)

787 阅读5分钟

最近做了一个后台新项目,尝试了一下TypeScript的装饰器,使用了装饰器后,改变了一些代码书写方式,个人觉得,还是挺好的。

装饰器

首先,什么是装饰器?建议去官方文档查看,或者有时间总结一篇。这里简单说明,装饰器会在运行时先于逻辑代码首先执行。简单说有以下几种:

  1. 类装饰器,用在定义类的前面。
  2. 方法装饰器,用在定义方法的前面。
  3. 属性装饰器,用在定义属性的前面。
  4. 参数装饰器,用在定义参数的前面。 好吧,一堆废话,着实简单,有时间细聊。

示例图

先通过示例图了解下具体需求。

从GIF图可以看到,还是有一些逻辑在里面的

  1. 选择省的时候,才会出现市的选择。
  2. 省发生变化的时候,市的选项也会发生变化。 可以思考一下,通常我们都是怎么实现这个表单的及其逻辑的。

页面代码

有了需求,怎么实现呢?当然是先封装一个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)
  }
}

这个类有三个属性,两个方法,每个属性上都使用了装饰器,这里没有展示全部的装饰器,能说明问题就好。下面做一些解释说明。

  1. ItemStyle,装饰属性在form表单中的样式。
  2. OptionsSource,只有样式是select的时候,才会起作用,它接受一个函数组作为参数,在渲染表单的时候,会执行传入的函数,该函数返回该select所需要的options。
  3. ItemRelation,关联关系,接受一个字符串,也就是这个类的某个属性。作用是,当前属性的值发生改变时,关联的那个属性也要发生变化,主要执行关联属性的OptionsSource函数。
  4. 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>

以上代码,主要看以下几个函数

  1. findAllLabels:当form组件创建的时候执行该函数,该函数找到领域模型上装饰过的所有字段及数据,数据存储在items数组里,由于用的elementUI,所以表单的rules单独拿出来。其中options是该页面用到select的时候,存放的数据。
  2. itemValueChange:当数据项发生变化的时候,找到该项数据有没有关联者,有的话,执行关联者的逻辑。
  3. alterFormData:当从form组件之外改变数据时,找到改变项,后续执行逻辑基本同itemValueChange函数。

总结

我们抛开form组件及装饰器代码,会发现,当我们在业务中使用form的时候,代码非常的简洁,并且由于可是使用领域模型这种代码组织方式,会让逻辑更紧凑,且与UI分离更彻底。