封裝Vue Element的form表單組件


前兩天封裝了一個基於vue和Element的table表格組件,閱讀的人還是很多的,看來大家都是很認同組件化、高復用這種開發模式的,畢竟開發效率高,代碼優雅,逼格高嘛。雖然這兩天我的心情很糟糕,就像“懂王”懟記者:“你是一個糟糕的記者;CNN,Fake news”一樣的心情,但我還是忍着難受的心情來工作和分享,畢竟工作是飯碗,分享也能化解我糟糕透頂的心情。

(文章最后有更新,是關於實現自適應布局和另一種更為簡便的所謂的分段布局的,有興趣的可以去看看)今天,就來分享一下基於vue和Element所封裝的form表單組件,其中所用到的技術,在上一篇文章封裝Vue Element的table表格組件中已介紹的差不多了,今天再來多講一個vue的動態組件component。關於動態組件component的介紹,vue官網倒是很吝嗇,就只是給了一個例子來告訴我們如何使用而已。我們可以把它理解成一個占位符,其具體展示什么,是由isattribute來實現的,比如官網給的例子:

<component v-bind:is="currentTabComponent"></component>

在上述示例中,currentTabComponent可以包括:

  • 已注冊組件的名字

  • 或一個組件的選項對象

就醬,對它的介紹完了。

如果你還想了解更多,可以去vue官網查看。

接下來就是封裝的具體實現,照例先來張效果圖:

1、封裝的form表單組件Form.vue:

<template>
  <el-form ref="form" :model="form" :rules="rules" size="small" :label-position="'top'">
    <el-row :gutter="20" v-for="(row, i) in columns" :key="i">
      <el-col :span="24 / rowSize" v-for="(x, idx) in row" :key="idx">
        <el-form-item :label="x.label" :prop="x.prop">
          <component v-model.trim="form[x.prop]" v-bind="componentAttrs(x)" class="width100" />
        </el-form-item>
      </el-col>
    </el-row>
    <div class="searchBtn" v-if="footer">
      <el-button class="filter-item" @click="reset">重置</el-button>
      <el-button class="filter-item" type="primary" @click="submit">查詢</el-button>
    </div>
  </el-form>
</template>

<script>
import { fromEntries, chunk } from '@/utils'
export default {
  props: {
    config: Object,
  },
  components: {
    selectBar: {
      functional: true,
      props: {value: String, list: Array, Enum: Object, callback: Function},
      render(h, {props: {value = '', list = [], Enum: {name = ''} = {}, callback}, data: {attrs = {}}, listeners: {input}, parent: {$store: {getters}}}){
        // 注意,這里的getters[name]就是通過接口異步獲取的數據,其中name就是對應的不同的getters,
        // 這里采用list.concat()主要是防止“下拉的前幾個是讓前端寫死,后幾個卻需要從接口拉取”的奇葩需求,如果沒有這樣的需求,則不需要concat。
        const enums = getters[name] && getters[name].length > 0 ? list.concat(getters[name]) : list
        return h('el-select', {class: 'width-full', props: {value, ...attrs}, on: {change(v) {input(v); callback(v)}}}, enums.map(o => h('el-option', {props: {...o, key: o.value}})))
      }
    },
    checkbox: {
      functional: true,
      props: {value: Boolean, label: String },
      render(h, {props: {value = '', label = ''}, data: {attrs = {}}, listeners: {input}}){
        return h('el-checkbox', {props: {value, ...attrs}, on: {change(v) {input(v)}}}, label)
      }
    },
    checkboxGroup: {
      functional: true,
      props: {value: Array, list: Array},
      render(h, {props: {value = [], list = []}, data: {attrs = {}}, listeners: {input}}){
        return h('el-checkbox-group', {props: {value, ...attrs}, on: {input(v) {input(v)}}}, list.map(o => h('el-checkbox', {props: {...o, label: o.value, key: o.value}}, [o.label])))
      }
    },
    radioGroup: {
      functional: true,
      props: {value: String, list: Array },
      render(h, {props: {value = '', list = []}, data: {attrs = {}}, listeners: {input}}){
        return h('el-radio-group', {props: {value, ...attrs}, on: {input(v) {input(v)}}}, list.map(o => h('el-radio', {props: {...o, key: o.label}}, [o.value])))
      }
    },
  },
  data(){
    const { columns = [], data = {}, rowSize = 3, footer = true } =  this.config || {};

    return {
      TYPE: {
        select: {
          is: 'selectBar',
          clearable: true,
        },
        text: {
          is: 'el-input',
          clearable: true,
        },
        switch: {
          is: 'el-switch',
        },
        checkbox: {
          is: 'checkbox',
          clearable: true,
        },
        checkboxGroup: {
          is: 'checkboxGroup',
          clearable: true,
        },
        radioGroup: {
          is: 'radioGroup',
          clearable: true,
        },
        daterange: {
          is: 'el-date-picker',
          type: 'daterange',
          valueFormat: 'yyyy-MM-dd',
          rangeSeparator: '至',
          startPlaceholder: '開始日期',
          endPlaceholder: '結束日期',
          editable: false,
        },
        date: {
          is: 'el-date-picker',
          type: "date",
          valueFormat: 'yyyy-MM-dd',
          editable: false,
        },
        auto: {
          is: 'el-autocomplete'
        }
      },
      form: columns.reduce((r, c) => Object.assign(r, {[c.prop]: data && data[c.prop] ? data[c.prop] : (c.is == 'checkboxGroup' ? [] : '')}), {}),
      rules: columns.reduce((r, c) => ({...r, [c.prop]: c.rules ? c.rules : []}), {}),
      columns: chunk(columns, rowSize),
      rowSize,
      footer,
    }
  },
  mounted(){
    this.reset();
  },
  methods: {
    componentAttrs(item) {
      const {is = 'text', label} = item, attrs = fromEntries(Object.entries(item).filter(n => !/^(prop|is|rules)/.test(n[0]))),
      placeholder = (/^(select|el-date-picker)/.test(is) ? '請選擇' : '請輸入') + label;
      
      return {...attrs, ...this.TYPE[is], placeholder}
    },
    reset() {
      this.$refs.form.resetFields();
    },
    /*
     * 這里區分了兩種情況(footer默認為true,代表默認會展示所封裝的form組件的查詢、重置按鈕):
     * 1、不使用封裝的form組件中的查詢、重置按鈕,則需要使用回調的方式獲取form表單的值
     * 2、使用封裝的form組件中的查詢、重置按鈕,則需要在使用時通過父組件向封裝的form子組件傳一個submit函數來獲取form表單的值
    */
    submit(cb = () => {}) {
      // 第一種情況
      !this.footer && this.$refs.form.validate(valid => valid && cb(this.form));
      // 第二種情況
      this.footer && this.$refs.form.validate(valid => valid && this.$emit('submit', this.form));
    },
  }
};
</script>

<style scoped>
.width100{width: 100%;}
</style>

在封裝的時候發現一個問題,就是有時候可能一行展示兩列表單,有時候呢可能一行又要展示三列或四列表單,這樣的話,也是需要在封裝的時候去實現可配置的效果的,那么本次封裝就順便封裝了一個類似lodash的_.chunk的工具來實現分段展示。

lodash對_.chunk的定義:將數組array拆分成多個size長度的區塊,並將這些區塊組成一個新數組。如果array無法被分割成全部等長的區塊,那么最后剩余的元素將組成一個區塊。

其實lodash這個工具庫就像它官網介紹的那樣,確實很實用,但需要經常使用才可以掌握它所包含的工具,否則,也是百臉懵逼。不過它的很多工具從字面意思來看,也不難理解其代表的意思。

自己封裝的分段chunk.js

export const chunk = (array, size) => {
  if (!array.length || size < 1) return [];
  let result = [];
  array.forEach((item, index) => {
     const rowSize = Math.floor(index / size);
     if(!(result[rowSize] instanceof Array)){
        result[rowSize] = [];
     }
     result[rowSize].push(item);
   })
   return result;
}

另外,在封裝時有一個Object.fromEntries的方法不兼容ie,比如這里我本來寫的是:

const attrs = Object.fromEntries(Object.entries(item).filter(n => !/^(prop|is|rules)/.test(n[0])))

但我們公司又要求項目可以兼容ie(我們公司的ie基本都是ie11),所以只能自己封裝了一個fromEntries方法來代替Object.fromEntries。

export const fromEntries = arr => {
  if (Object.prototype.toString.call(arr) === '[object Map]') {
    let result = {};
    for (const key of arr.keys()) {
      result[key] = arr.get(key);
    }

    return result;
  }

  if(Array.isArray(arr)){
    let result = {}
    arr.map(([key,value]) => {
      result[key] =  value
    })

    return result
  }
  throw 'Uncaught TypeError: argument is not iterable';
}

2、使用已封裝的表單組件:

<template>
  <Form :config="config" @submit="getList" ref="form" />
</template>

<script>
import Form from "./Form";

const statusLlist = [
  {label: '未提交', value: "0"},
  {label: '待審批', value: "1"},
  {label: '已通過', value: "2", disabled: true}
]

export default {
  components: {
    Form,
  },
  data() {
    const confimPass = (rule, value, callback) => {
      if (value === '') {
        callback(new Error('請再次輸入密碼'));
      } else if (value !== this.$refs.form.form.password) {
        callback(new Error('兩次輸入密碼不一致!'));
      } else {
        callback();
      }
    };

    return {
      config: {
        columns: [
          { prop: "name", label: "借款人名稱", is: "auto", fetchSuggestions: this.querySearch },
          { prop: "certificateId", label: "統一信用代碼", rules: [{required: true, message: '請輸入統一信用代碼'}] },
          { prop: 'daterange', label: "日期范圍", is: 'daterange', },
          { prop: 'date', label: "日期", is: 'date', },
          { prop: 'status', label: "狀態", is: 'select', list: statusLlist, callback: r => this.statusChange(r) },
          { prop: 'currencyId', label: "幣別", is: 'select', Enum: { name: 'currency' } },   //異步獲取字典項
          { prop: "password", label: "密碼", type: 'password' },
          { prop: "confimPass", label: "確認密碼", type: 'password', rules: [{validator: confimPass}] },
          { prop: 'remark', label: "備注", type: 'textarea' },
          { prop: "email", label: "郵箱", rules: [{ required: true, message: '請輸入郵箱地址' }, { type: 'email', message: '請輸入正確的郵箱地址' }] },
          { prop: 'remember', label: '記住密碼', is: 'checkbox' },
          { prop: 'gender', label: '性別', is: 'radioGroup', list: [{label: 'male', value: "男"}, {label: 'famale', value: "女", disabled: true}] },
          { prop: 'love', label: '愛好', is: 'checkboxGroup', list: [{label: '籃球', value: "0"}, {label: '排球', value: "1"}, {label: '足球', value: "2", disabled: true}] },
          { prop: "delivery", label: "即時配送", is: 'switch' },
        ],
        data: {},
        rowSize: 3,   //一行可以展示幾列表單,默認為3列
      },
    }
  },
  created(){
    let data = {
      name: '陳公子',
      certificateId: '222',
      status: '0',
      love: ['0']
    };
    this.config.data = data;
  },
  methods: {
    querySearch(q, cb){
      if (!q) {cb([]);return}
    },
    getList(res){
      console.log(res)
    },
    statusChange(r){
      console.log(r)
    },
  },
}
</script>

本次封裝的form表單組件,基本考慮到了在日常開發中會經常使用到的表單組件,如果還有其他的需求,可自行添加。另外,本次封裝也對表單的回顯(返顯)做了實現,比如我們在編輯數據時,需要將被修改的數據顯示在表單中,本次封裝就充分考慮到了這一點,只要你在傳給封裝的form組件的參數中加一個data參數,並將需要回顯的數據名稱一一對應並賦值就可以了。回顯的時候有一個問題需要注意:如果需要回顯的表單組件是在一個頁面中而非嵌套在彈窗組件中,那么需要將回顯的方法寫在頁面的created生命周期函數中,比如:

created(){
  let data = {
    name: '陳公子',
    certificateId: '222',
    status: '0',
    love: ['0']
  };
  this.config.data = data;
},

為什么要這么做呢?為什么不寫在mounted生命周期函數中呢?這就涉及到父子組件的生命周期函數的加載順序的問題了。為什么要考慮父子組件的生命周期函數的加載順序呢?是因為考慮到所封裝的表單組件的通用性原則,我把重置表單組件的方法寫在了所封裝的表單組件的mounted生命周期函數中了,而由於父子組件的生命周期函數的加載順序為:
父beforeCreate->父created->父beforeMount->子beforeCreate->子created->子beforeMount->子mounted->父mounted
因此如果把回顯的方法放在父組件的mounted周期函數中,那么經過Form子組件的先一步重置會使得無法正常回顯出數據。如果form組件是嵌套在了彈窗組件中,那么是把回顯的方法寫在了彈出彈窗組件的方法中,就沒有這個問題。

-------------------------------- 2020年12月10日更新 --------------------------------
結合自身在開發中遇到的實際需求,需要讓一行展示表單的個數自適應,如:瀏覽器窗口足夠大時,一行展示四列表單,當瀏覽器窗口小於一定的寬度時,一行展示三列表單,當瀏覽器窗口再小於一定的寬度時,比如小於750px時,一行就展示一列表單。那么之前封裝的表單組件中使用到的分段展示已經不能實現這樣的場景了,但是我又想在彈窗中只展示一列表單,所以這樣的form組件該如何封裝呢?

一開始,我也想到的是在原來分段展示的代碼的基礎上添加if else來實現,但轉念一想,這樣的封裝方式很不優雅啊。於是就想到了element的Layout布局中的響應式布局,這種布局方法就能很好的實現我們的場景,而且可以充分利用element的layout布局來實現前文的分段展示,我們也就不需要自己再去寫分段的js代碼了。

1、重新封裝的Form組件完整代碼如下:

<template>
  <el-form ref="form" :model="form" :rules="rules" label-position="top" size="small">
    <el-row :gutter="20">
      <el-col v-bind="attrs" v-for="(x, idx) in columns" :key="idx">
        <el-form-item :label="x.label" :prop="x.prop">
          <component v-model.trim="form[x.prop]" v-bind="componentAttrs(x)" class="width100" />
        </el-form-item>
      </el-col>
    </el-row>
    <div class="searchBtn" v-if="footer">
      <el-button class="filter-item" @click="reset">重置</el-button>
      <el-button class="filter-item" type="primary" @click="submit">查詢</el-button>
    </div>
  </el-form>
</template>

<script>
import { fromEntries } from '@/utils'

export default {
  props: {
    config: Object,
  },
  components: {
    selectBar: {
      functional: true,
      props: {value: String, list: Array, callback: Function},   //callback為傳過來的回調函數,可以方便在切換時處理一些業務邏輯
      render(h, {props: {value = '', list = [], callback = _.identity }, data: {attrs = {}}, listeners: {input = _.identity}}){  //_.identity是lodash回調函數的默認值
        return h('el-select', {class: 'width100', props: {value, ...attrs}, on: {change(v) {input(v); callback(v)}}}, list.map(o => h('el-option', {props: {...o, key: o.value}})))  //key為vue循環時的必須
      }
    },
    checkbox: {
      functional: true,
      props: {value: Boolean, label: String },
      render(h, {props: {value = '', label = ''}, data: {attrs = {}}, listeners: {input = _.identity}}){
        return h('el-checkbox', {props: {value, ...attrs}, on: {change(v) {input(v)}}}, label)
      }
    },
    checkboxGroup: {
      functional: true,
      props: {value: Array, list: Array},
      render(h, {props: {value = [], list = []}, data: {attrs = {}}, listeners: {input = _.identity}}){
        //像el-checkbox-group和el-radio-group這種組合級別的組件在綁定選中事件時不能像el-select組件(el-select組件是這樣觸發的on: {change(v) {input(v)}})那樣使用change,只能使用類似el-input組件的input方法來觸發選中
        return h('el-checkbox-group', {props: {value, ...attrs}, on: {input(v) {input(v)}}}, list.map(o => h('el-checkbox', {props: {...o, label: o.value, key: o.value}}, [o.label])))
      }
    },
    radioGroup: {
      functional: true,
      props: {value: String, list: Array },
      render(h, {props: {value = '', list = []}, data: {attrs = {}}, listeners: {input = _.identity}}){
        return h('el-radio-group', {props: {value, ...attrs}, on: {input(v) {input(v)}}}, list.map(o => h('el-radio', {props: {...o, key: o.label}}, [o.value])))
      }
    },
  },
  data(){
    const { columns = [], data = {}, footer = true } =  this.config || {}

    return {
      TYPE: {
        select: {
          is: 'selectBar',
          clearable: true,
        },
        text: {
          is: 'el-input',
          clearable: true,
        },
        switch: {
          is: 'el-switch',
        },
        checkbox: {
          is: 'checkbox',
          clearable: true,
        },
        checkboxGroup: {
          is: 'checkboxGroup',
          clearable: true,
        },
        radioGroup: {
          is: 'radioGroup',
          clearable: true,
        },
        daterange: {
          is: 'el-date-picker',
          type: 'daterange',
          valueFormat: 'yyyy-MM-dd',
          rangeSeparator: '至',
          startPlaceholder: '開始日期',
          endPlaceholder: '結束日期',
          editable: false,  //文本框是否可輸入,默認為true可輸入
        },
        date: {
          is: 'el-date-picker',
          type: "date",
          valueFormat: 'yyyy-MM-dd',
          editable: false,
        },
        auto: {
          is: 'el-autocomplete'
        }
      },
      attributes: {
        lg: 6,
        md: 8,
        xs: 24,
      },
      form: columns.reduce((r, c) => Object.assign(r, {[c.prop]: data && data[c.prop] ? data[c.prop] : (c.is == 'checkboxGroup' ? [] : null)}), {}),
      rules: columns.reduce((r, c) => ({...r, [c.prop]: c.rules ? c.rules : []}), {}),
      columns,
      footer,
    }
  },
  computed: {
    attrs(){
      const { attrs = this.attributes } =  this.config || {};
      return attrs
    },
  },
  mounted(){
    this.reset();
  },
  methods: {
    componentAttrs(item) {
      const {is = 'text', label} = item, attrs = fromEntries(Object.entries(item).filter(n => !/^(prop|is|rules)/.test(n[0]))),
      placeholder = (/^(select|el-date-picker)/.test(is) ? '選擇' : '輸入/搜索') + label;
      return {...attrs, ...this.TYPE[is], placeholder}
    },
    reset() {
      this.$refs.form.resetFields();
    },
    /*
     * 這里區分了兩種情況(footer默認為true,代表默認會展示封裝的form組件所自帶的查詢、重置按鈕):
     * 1、不使用封裝的form組件中自帶的查詢、重置按鈕,則需要使用回調的方式獲取form表單的值
     * 2、使用封裝的form組件中自帶的查詢、重置按鈕,則需要在使用時通過父組件向封裝的form子組件傳一個函數submit來獲取form表單的值
    */
    submit(cb = noop) {
      // 第一種情況
      !this.footer && this.$refs.form.validate(valid => valid && cb(this.form));
      // 第二種情況
      this.footer && this.$refs.form.validate(valid => valid && this.$emit('submit', this.form));
    },
  }
}
</script>
<style scoped>
.width100{width: 100%;}
</style>

從以上代碼可以看出,跟上文的實現過程大同小異,不同之處在於:

<el-col v-bind="attrs" v-for="(x, idx) in columns" :key="idx">
  <el-form-item :label="x.label" :prop="x.prop">
    <component v-model.trim="form[x.prop]" v-bind="componentAttrs(x)" class="width100" />
  </el-form-item>
</el-col>

在標簽el-col上,我將:span="24 / rowSize"換成了v-bind="attrs",這個v-bind="attrs"就是玄機,就是用來設置自適應布局的,在computed計算屬性中,我對attrs進行了賦值並給了一個默認值,請看代碼:

attrs(){
  const { attrs = this.attributes } =  this.config || {};
  return attrs
}

由於一個公司,同一個項目中,關於列表查詢項需要展示出來的表單形式一般都是固定的,比如一行展示四列,縮小到一定寬度后,一行展示三列等等,其他所有頁面基本都是這種形式,不會說這個頁面一行五列,那個頁面又一行三列,除非那個頁面的查詢項就只有三列,否則基本都是統一的。所以你在封裝組件時,按照你們的需求給attrs設置一個默認值就可以了。

2、使用方法:

<template>
  <Form :config="config" @submit="submit" ref="form" />
</template>

<script>
import Form from "./searchForm";

export default {
  components: {
    Form
  },
  data(){
    return {
      config: {
        columns: [
          { prop: "name", label: "借款人名稱", is: "auto" },
          { prop: "certificateId", label: "統一信用代碼", rules: [{required: true, message: '請輸入統一信用代碼'}] },
          { prop: 'daterange', label: "日期范圍", is: 'daterange', },
          { prop: 'date', label: "日期", is: 'date', },
          { prop: 'status', label: "狀態", is: 'select', list: [{label: '未提交', value: "0"}, {label: '待審批', value: "1"}, {label: '已通過', value: "2", disabled: true}] },
          { prop: "password", label: "密碼", type: 'password' },
          { prop: "email", label: "郵箱", rules: [{ required: true, message: '請輸入郵箱地址' }, { type: 'email', message: '請輸入正確的郵箱地址' }] },
          { prop: 'remember', label: '記住密碼', is: 'checkbox',},
          { prop: 'gender', label: '性別', is: 'radioGroup', list: [{label: 'male', value: "男"}, {label: 'famale', value: "女", disabled: true}] },
          { prop: 'love', label: '愛好', is: 'checkboxGroup', list: [{label: '籃球', value: "0"}, {label: '排球', value: "1"}, {label: '足球', value: "2", disabled: true}] },
          { prop: "delivery", label: "即時配送", is: 'switch' },
        ],
        // attrs: {
        //   span: 24,
        // }
      },
    }
  },
  methods: {
    submit(res){
      console.log(res)
    },
  },
}
</script>

在使用方法中,在定義的config中有一個attrs屬性,這個就是傳給子組件的用來控制一行展示幾列表單的關鍵,這里充分利用了element的layout布局方式,你可以根據自己的實際需求,隨意搭配,很是靈活呢!比如在彈窗中只需展示一列表單,那么你就可以給子組件傳如下的參數即可:

attrs: {
  //一行一列,這里的span就是24,一行三列,這里的span就是8,一行四列,這里的span就是6,同樣可以實現前文的分段展示,而且還簡便
  span: 24
}

多說一句,你是想用上文那種可分段展示的表單組件呢,還是想用這種自適應布局的表單組件呢,那就根據你們的實際需求來定吧,反正都能用,又不是不能用,我只是給你你提供了多一種選擇和實現方式,但還是建議用這種方式吧,也省的自己寫分段的代碼了。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM