基於Ant Design Vue封裝一個表單控制元件

金色海洋(jyk)發表於2020-09-17

開原始碼

https://github.com/naturefwvue/nf-vue3-ant

有缺點本來是寫在最後的,但是博文寫的似乎有點太長了,估計大家沒時間往下看,於是就把有缺點寫在前面了,不喜歡可以先跳過。

缺點

靈活性肯定是沒有了,封裝的還是有些過度,靈活度大大降低,沒有使用slot,想加點啥目前是不可能的,等以後需要了再說,畢竟這個專案才剛剛開始。
(為啥缺點只有一條?那不那啥嗎,基於ant design vue封裝的,他們都那麼強大了,還能有啥缺點?封裝後除了失去靈活性還能差啥?)

優點

  1. 簡潔,程式碼很少,做好meta就可以了,另外meta也不需要手寫,有個小工具可以輔助建立。
  2. 風格統一,程式碼就是這樣,當需要寫新的表單的時候,也不需要複製貼上,只需要弄個meta就行了,想變風格都變不了。
  3. 可以統一修改升級。UI版本升級了,VUE版本升級了,咋辦?改一下元件內部程式碼即可,呼叫元件的程式碼並不需要修改。這樣還怕升級了嗎?
  4. 可以跨UI,甚至跨框架。之前看了一下element,本來想用的,但是不支援vue3.0只好作罷。element的使用方式也是大同小異,那麼我基於element也封裝一套元件,保證外部使用方式一致,那麼是不是可以做到UI隨便切換了呢?
  5. 便於專案升級。專案打包釋出後,如果需求有變更,一般修改完後需要重新打包釋出。而我們的專案是通過 meta 來控制表單的,也就是說如果有變動,那麼改json檔案即可,而json可以通過ajax來載入,不用打包到專案裡面。

為啥還要封裝

antdv都已經提供那麼的元件了,還不夠用嗎?為啥還要折騰

首先antd 是一個非常強大UI庫,提供了很強大的功能和漂亮的UI,使用方面也是非常的靈活,不僅有Form表單,還有各種Data Entry元件,非常靈活。只是魚和熊掌不能兼得,antdv為了靈活而犧牲了一些簡潔性。

select

比如a-select,官網程式碼如下:(有刪減)

<template>
  <div>
    <a-select
      v-model:value="value1"
      style="width: 120px"
      @focus="focus"
      ref="select"
      @change="handleChange"
    >
      <a-select-option value="jack">
        Jack
      </a-select-option>
      <a-select-option value="lucy">
        Lucy
      </a-select-option>
      <a-select-option value="disabled" disabled>
        Disabled
      </a-select-option>
      <a-select-option value="Yiminghe">
        yiminghe
      </a-select-option>
    </a-select>
  </div>
</template>
<script>
export default {
  data() {
    window.test = this;  // 話說這個是幹嘛用的?
    return {
      value1: 'lucy'
    };
  },
  methods: {
    focus() {
      console.log('focus');
    },
    handleChange(value) {
      console.log(`selected ${value}`);
    },
  },
};
</script>

首先要設定一些屬性,然後還要逐行設定 a-select-option,是不是有點麻煩?

form

再來看一下form的官網示例:(七個欄位的簡單表單)

<template>
  <a-form :model="form" :label-col="labelCol" :wrapper-col="wrapperCol">
    <a-form-item label="Activity name">
      <a-input v-model:value="form.name" />
    </a-form-item>
    <a-form-item label="Activity zone">
      <a-select v-model:value="form.region" placeholder="please select your zone">
        <a-select-option value="shanghai">
          Zone one
        </a-select-option>
        <a-select-option value="beijing">
          Zone two
        </a-select-option>
      </a-select>
    </a-form-item>
    <a-form-item label="Activity time">
      <a-date-picker
        v-model:value="form.date1"
        show-time
        type="date"
        placeholder="Pick a date"
        style="width: 100%;"
      />
    </a-form-item>
    <a-form-item label="Instant delivery">
      <a-switch v-model:checked="form.delivery" />
    </a-form-item>
    <a-form-item label="Activity type">
      <a-checkbox-group v-model:value="form.type">
        <a-checkbox value="1" name="type">
          Online
        </a-checkbox>
        <a-checkbox value="2" name="type">
          Promotion
        </a-checkbox>
        <a-checkbox value="3" name="type">
          Offline
        </a-checkbox>
      </a-checkbox-group>
    </a-form-item>
    <a-form-item label="Resources">
      <a-radio-group v-model:value="form.resource">
        <a-radio value="1">
          Sponsor
        </a-radio>
        <a-radio value="2">
          Venue
        </a-radio>
      </a-radio-group>
    </a-form-item>
    <a-form-item label="Activity form">
      <a-input v-model:value="form.desc" type="textarea" />
    </a-form-item>
    <a-form-item :wrapper-col="{ span: 14, offset: 4 }">
      <a-button type="primary" @click="onSubmit">
        Create
      </a-button>
      <a-button style="margin-left: 10px;">
        Cancel
      </a-button>
    </a-form-item>
  </a-form>
</template>
<script>
export default {
  data() {
    return {
      labelCol: { span: 4 },
      wrapperCol: { span: 14 },
      form: {
        name: '',
        region: undefined,
        date1: undefined,
        delivery: false,
        type: [],
        resource: '',
        desc: '',
      },
    };
  },
  methods: {
    onSubmit() {
      console.log('submit!', this.form);
    },
  },
};
</script>

在Form表單裡面也是這樣的設定方式,而表單裡面有很多各種各樣的控制元件,一個一個寫起來實在是太累。看這樣的程式碼有點眼暈,似乎也不太便於維護,不知道大家是怎麼編寫和維護的。

大家都知道我很懶,我想用v-for來做表單,這樣即使一百個欄位也是一個for搞定,這樣程式碼就簡單多了。

那麼如何實現呢?

如何封裝?

vue的思路就是——資料驅動,那麼我就把這個思路做的更徹底一點,——讓資料驅動dom的屬性

統一標籤名稱

想要for迴圈,標籤必須統一,a-input、a-select等等都不一樣,這還怎麼迴圈?所以要先做一個統一的元件,以便於for迴圈。然後內部再分為多種不同的元件,這樣便於維護,要不然程式碼都寫到一起就太亂了。
於是結構就是這樣:

結構圖

統一屬性

除了標籤之外,屬性也要一致,否則還是不能for。那麼怎麼辦呢?不同的控制元件需要的屬性都不一樣呀,這個好辦,我們整合成兩個就行

v-model value

這個必須單獨拿出來。

meta

其他的屬性都統一放在這裡,把這個東東傳遞進去就好,然後內部識別領取自己的屬性。這樣就搞定了。

程式碼

我們來看meta的結構。

meta

以input為例,其他都大同小異

props: {
    modelValue: String,
    meta: {
      type: Object,
      default: () => {
        return {
          controlId: Number, // 編號,區別同一個表單裡的其他控制元件
          colName: String, // 欄位名稱
          controlType: Number, // 用型別編號表示type
          isClear: { // 連續新增時是否恢復預設值
            type: Boolean,
            default: false
          },
          defaultValue: String, // 預設值
          autofocus: { // 是否自動獲得焦點
            type: Boolean,
            default: false
          },
          required: { // 必填
            type: Boolean,
            default: true
          },
          disabled: {
            // 是否禁用
            type: Boolean,
            default: false
          },
          readonly: { // 只讀
            type: Boolean,
            default: false
          },
          pattern: String, // 用正則做驗證。
          placeholder: String,
          title: String, // 提示資訊
          maxlength: Number, // 最大字元數
          autocomplete: { // off
            type: String,
            default: 'on'
          }
        }
      }
    }
  },

不同型別的元件,會有所調整。

input

模板部分

<template>
  <div class="components-input-demo-presuffix">
    <a-input
      :id="'id' + meta.controlId"
      :name="'c' + meta.controlId"
      :value="modelValue"
      :autofocus="meta.autofocus"
      :disabled="meta.disabled"
      :readonly="meta.readonly"
      :placeholder="meta.placeholder"
      :title="meta.title"
      :maxlength="meta.maxlength"
      :autocomplete="meta.autocomplete"
      :key="'ckey_'+meta.controlId"
      size="small"
      @input="myInput"
      >
    </a-input>
  </div>
</template>

先把需要的屬性,通過meta都給繫結上

js

<script>
export default {
  name: 'nf-form-input',
  model: {
    prop: 'modelValue',
    event: 'input'
  },
  props: {
    modelValue: String,
    meta: {
      type: Object,
      default: () => {
        return {
          controlId: Number, // 編號,區別同一個表單裡的其他控制元件
          colName: String, // 欄位名稱
          controlType: Number, // 用型別編號表示type
          isClear: { // 連續新增時是否恢復預設值
            type: Boolean,
            default: false
          },
          defaultValue: String, // 預設值
          autofocus: { // 是否自動獲得焦點
            type: Boolean,
            default: false
          },
          required: { // 必填
            type: Boolean,
            default: true
          },
          disabled: {
            // 是否禁用
            type: Boolean,
            default: false
          },
          readonly: { // 只讀
            type: Boolean,
            default: false
          },
          pattern: String, // 用正則做驗證。
          placeholder: String,
          title: String, // 提示資訊
          maxlength: Number, // 最大字元數
          autocomplete: { // off
            type: String,
            default: 'on'
          }
        }
      }
    }
  },
  methods: {
    myInput: function (e) {
      var returnValue = e.target.value
      var colName = this.meta.colName // event.target.getAttribute('colname')
      this.$emit('update:modelValue', returnValue) // 返回給呼叫者
      this.$emit('getvalue', returnValue, colName) // 返回給中間元件
    }
  }
}
</script>

這樣我們只要做好meta,就可以完全控制控制元件了。其他控制元件也是類似的思路,就不一一貼程式碼了。

form-Item

元件分的太零碎,使用的時候很麻煩,那麼怎麼辦呢?再做個元件整合一下。

<template>
  <span class="hello">
    <nfArea v-if="meta.controlType === 100" :modelValue="modelValue" @getvalue="sendValue" :meta="meta"/>
    <nfUrl v-else-if="meta.controlType === 105" :modelValue="modelValue" @getvalue="sendValue" :meta="meta"/>
    <nfInput v-else-if="meta.controlType <= 119" :modelValue="modelValue" @getvalue="sendValue" :meta="meta"/>
    <nfNumber v-else-if="meta.controlType === 131" :modelValue="modelValue" @getvalue="sendValue" :meta="meta"/>
    <nfSlider v-else-if="meta.controlType === 132" :modelValue="modelValue" @getvalue="sendValue" :meta="meta"/>
    <nfDatetime v-else-if="meta.controlType <= 149" :modelValue="modelValue" @getvalue="sendValue" :meta="meta"/>
    <nfUpload v-else-if="meta.controlType <= 159" :modelValue="modelValue" @getvalue="sendValue" :meta="meta"/>
    <nfColor v-else-if="meta.controlType === 160" :modelValue="modelValue" @getvalue="sendValue" :meta="meta"/>
    <nfCheck v-else-if="meta.controlType === 180" :modelValue="modelValue" @getvalue="sendValue" :meta="meta"/>
    <nfChecks v-else-if="meta.controlType === 182" :modelValue="modelValue" @getvalue="sendValue" :meta="meta"/>
    <nfRadios v-else-if="meta.controlType === 183" :modelValue="modelValue" @getvalue="sendValue" :meta="meta"/>
    <nfSelect v-else-if="meta.controlType <= 191" :modelValue="modelValue" @change="myChange" @getvalue="sendValue" :meta="meta"/>
    <nfInputMore v-else-if="meta.controlType === 200" :modelValue="modelValue" @getvalue="sendValue" :meta="meta"/>
  </span>
</template>

很笨的方法,挨個型別判斷。這裡使用了魔數,大概會被噴,不過早就習慣了。

<script>
import nfArea from './nf-form-textarea.vue' // 100
import nfInput from './nf-form-input.vue' // 100-107
import nfUrl from './nf-form-input-url.vue' // 105
import nfNumber from './nf-form-number.vue' // 131
import nfSlider from './nf-form-numslider.vue' // 132
import nfDatetime from './nf-form-datetime.vue' // 140-144
import nfUpload from './nf-form-upload.vue' // 150-151
import nfColor from './nf-form-color.vue' // 160
import nfCheck from './nf-form-check.vue' // 180
import nfChecks from './nf-form-checks.vue' // 182
import nfRadios from './nf-form-radios.vue' // 183
import nfSelect from './nf-form-select.vue' // 190
import nfInputMore from './nf-form-inputmore.vue' // 200

export default {
  name: 'nf-form-item',
  components: {
    nfInput,
    nfUrl,
    nfArea,
    nfNumber,
    nfSlider,
    nfDatetime,
    nfUpload,
    nfColor,
    nfCheck,
    nfChecks,
    nfRadios,
    nfSelect,
    nfInputMore
  },
  props: {
    modelValue: Object,
    meta: Object
  },
  methods: {
    myChange: function (value) {
      this.$emit('change', value)
      this.$emit('update:modelValue', value)
    },
    sendValue: function (value, colName) {
      this.$emit('update:modelValue', value)
      this.$emit('getvalue', value, colName) // 返回給中間元件
    }
  }
}
</script>

在這裡統一註冊各種零散的元件,使用的時候就不用想,到底要用哪種元件了。

表單

好了,準備工作都做好了,我們可以開始for迴圈了。

找了半天,antdv沒有提供單純的table,只好手動找class了,於是程式碼變成了這樣。

    <div class="ant-table ant-table-body ant-table-default ant-table-bordered" >
      <table>
        <colgroup><col style="width: 30%; min-width: 30%;"><col>
        </colgroup>
        <tbody class="ant-table-tbody">
          <tr v-for="(item,index) in metaInfo" :key="index">
            <td align="right" style="padding:10px 10px;height:20px">
              {{item.title}}:
            </td>
            <td align="left" style="padding:10px 10px;height:20px">
              <nfInput v-model="modelValue[item.colName]" :meta="item" />
            </td>
          </tr>
        </tbody>
      </table>
    </div>

程式碼行數和控制元件(欄位)數量無關。程式碼數量也和有多少表單無關。

是不是看起來一點都不像一個表單?程式碼是不是少的有點可憐?
nfInput 控制元件有兩個屬性v-model 和 meta,他會根據meta自動建立需要的dom,並且繫結屬性。當然實際幹活的是vue和antdv,我只是做了一種嘗試。

<script>
import { ref } from 'vue'
import nfInput from '@/components/nf-form/nf-form-item.vue'

export default {
  name: 'FormDemo',
  components: {
    nfInput
  },
  setup () {
    const json = require('./FormDemo.json') // 載入meta資訊,json格式
    const modelValue = ref({}) // 放資料的model
    const metaInfo = ref(json.companyForm) // 表單需要的meta資訊
    const myClick = (key) => {
      // 更換表單的meta
      metaInfo.value = json[key]
      // 動態建立model
      modelValue.value = {}
      for (var k in metaInfo.value) {
        var item = metaInfo.value[k]
        modelValue.value[item.colName] = ''
      }
    }
    myClick('companyForm')
    return {
      modelValue,
      metaInfo,
      myClick
    }
  }
}
</script>

meta,單獨的json檔案

meta並不需要寫在程式碼裡,因為實在是太長了。可以寫在單獨的json檔案裡面,這樣便於載入。另外也可以做成ajax載入的方式,這樣專案釋出後,如何需求有變動,需要調整表單的話,那麼只需要單獨修改json檔案即可,不用重新打包釋出。

{
    "companyForm":{
        "1000":{
            "controlId": 1000,
            "colName": "companyName",
            "controlType": 101,
            "isClear": true,
            "disabled": false,
            "required": true,
            "readonly": false,
            "pattern": "",
            "class": "",
            "placeholder": "請輸入公司名稱",
            "title": "公司名稱",
            "autocomplete": "on",
            "size": 30,
            "maxlength": 100,
            "optionList": [] 
        },
        "1001":{
            "controlId": 1001,
            "colName": "companyCode",
            "controlType": 131,
            "isClear": true,
            "disabled": false,
            "required": true,
            "readonly": false,
            "pattern": "",
            "class": "",
            "placeholder": "公司郵編",
            "title": "公司郵編",
            "autocomplete": "on",
            "min": 100000,
            "max": 999999,
            "step": 1,
            "maxlength": 6,
            "optionList": [] 
        },
        "1002":{
            "controlId": 1002,
            "colName": "legalPerson",
            "controlType": 101,
            "isClear": true,
            "disabled": false,
            "required": true,
            "readonly": false,
            "pattern": "",
            "class": "",
            "placeholder": "請輸入法人姓名",
            "title": "法人",
            "autocomplete": "on",
            "size": 20,
            "maxlength": 50,
            "optionList": [] 
        },
        "1003":{
            "controlId": 1003,
            "colName": "liaisonMan",
            "controlType": 101,
            "isClear": true,
            "disabled": false,
            "required": true,
            "readonly": false,
            "pattern": "",
            "class": "",
            "placeholder": "請輸入聯絡人姓名",
            "title": "聯絡人",
            "autocomplete": "on",
            "size": 20,
            "maxlength": 50,
            "optionList": []
        },
        "1004": {
            "controlId": "1004",
            "colName": "address",
            "controlType": 101,
            "isClear": true,
            "defaultValue": "",
            "autofocus": false,
            "disabled": false,
            "required": true,
            "readonly": false,
            "pattern": "",
            "class": "",
            "placeholder": "請輸入公司地址",
            "title": "公司地址",
            "autocomplete": "on",
            "size": 30,
            "maxlength": 50,
            "optionKey": "",
            "optionList": [
            ]
          },
          "1005": {
            "controlId": "1005",
            "colName": "telphone",
            "controlType": 103,
            "isClear": true,
            "defaultValue": "",
            "autofocus": false,
            "disabled": false,
            "required": true,
            "readonly": false,
            "pattern": "",
            "class": "",
            "placeholder": "請輸入公司電話",
            "title": "公司電話",
            "autocomplete": "on",
            "size": 30,
            "maxlength": 50,
            "optionKey": "",
            "optionList": [
            ]
          },
          "1006": {
            "controlId": "1006",
            "colName": "URL",
            "controlType": 105,
            "isClear": true,
            "defaultValue": "",
            "autofocus": false,
            "disabled": false,
            "required": true,
            "readonly": false,
            "pattern": "",
            "class": "",
            "placeholder": "https://www.",
            "title": "公司網址",
            "autocomplete": "on",
            "size": 30,
            "maxlength": 50,
            "optionKey": "",
            "optionList": [
            ]
          },
          "1007": {
            "controlId": "1007",
            "colName": "Email",
            "controlType": 104,
            "isClear": true,
            "defaultValue": "",
            "autofocus": false,
            "disabled": false,
            "required": true,
            "readonly": false,
            "pattern": "",
            "class": "",
            "placeholder": "@",
            "title": "公司郵件",
            "autocomplete": "on",
            "size": 30,
            "maxlength": 50,
            "optionKey": "",
            "optionList": [
            ]
          },
          "1008": {
            "controlId": 1008,
            "colName": "type",
            "title": "公司型別",
            "controlType": 190,
            "isClear": true,
            "defaultValue": "",
            "autofocus": false,
            "disabled": false,
            "required": true,
            "pattern": "",
            "class": "",
            "optionList": [
              { "value": 1, "title": "有限責任公司" },
              { "value": 2, "title": "股份有限責任公司" },
              { "value": 3, "title": "個人獨資企業" },
              { "value": 4, "title": "合夥企業" },
              { "value": 5, "title": "個體工商戶" }
            ]
          },
          "1009": {
            "controlId": 1009,
            "colName": "createDate",
            "controlType": 140,
            "isClear": true,
            "defaultValue": "",
            "autofocus": false,
            "disabled": false,
            "required": true,
            "readonly": false,
            "pattern": "",
            "class": "",
            "title": "成立日期",
            "min": "1910-01-01",
            "max": "2999-12-31",
            "step": 1
          }
    }
}

資料和程式碼分離,是不是很完美。

為啥不直接用antdv提供的 Form 表單?

這個嘛,思路不太一樣。好吧,其實是官網的程式碼,在本地還沒有除錯成功,等研究明白了還是會用的。

相關文章