一份配置,輕鬆搞定Vue表單渲染

VamWong發表於2019-11-08

背景

表單可以說是前端開發中最經常遇到的元素之一。在日常表單的開發中,存在著v-if條件渲染、滿屏magic number列舉值,再加上表單之間的複雜的聯動互動的情況,往往使得一個看似簡單的表單變得愈加臃腫不堪。

表單的聯動關係與狀態重置往往散落在各個函式方法中,隨著需求的不斷擴充與變更,使得表單之間的耦合複雜度上升,對於後續的開發者而言,很難清晰快速地瞭解表單中隱含的業務邏輯與聯動關係,這使得表單變得非常不便於維護。

配置表單構建

在業務開發中,使用表單最終的目的在於提交特定格式的資料。那是否有辦法通過配置某種資料結構,來清晰地表達各個表單項的引數、控制元件型別與聯動關係呢?

通過JSON來配置表單

我們從配置二字入手,將表單的開發看作是配置一些keyvalue的對映,理想情況下,我們希望能夠用一個JSON的結構來定義表單模型。

[
  {
    label: '表單項1',
    key: 'item1',
    type: 'input'
  },
  {
    label: '表單項2',
    key: 'item2',
    type: 'select',
    props: {
      options: []
    }
  }
]
複製程式碼

通過上述的配置結構,期望能夠在頁面中生成對應的表單模板:

一份配置,輕鬆搞定Vue表單渲染

在上述配置中,label表示表單標籤,type表示表單對應的控制元件型別,key表示表單中的資料引數。最終,根據使用者的輸入,我們最終可以獲取到以下資料模型用以提交:

{
  item1: '',
  item2: ''
}
複製程式碼

明確了表單的配置結構後,我們可以開始著手去構建一個表單元件,這個元件僅需要傳入JSON配置。

<template>
  <div>
    <ConfigForm :formModel="formModel" :formItems="formItems" />
  </div>
</template>

<script>
export default {
  data() {
    return {
      formModel: {
        item1: ''
      },
      formItems: [
        {
          label: '標籤1',
          key: 'item1',
          type: 'input'
        }
      ]
    }
  }
}
複製程式碼

其中,formModel是我們最後提交所需要的資料引數,而formItems對應的每一項是一個表單控制元件。

元件對映

首先,我們可以選擇自己喜歡的元件庫來作為表單元件的基礎模板,這裡選擇使用Element中的Form及其相關控制元件元件作為基礎。

在我們的JSON配置中,type欄位用來表示不同的表單控制元件。因此我們需要維護一份type與元件tag之間的對映關係:

const tagMap = {
  input: {
    tag: 'el-input',
    props: {
      clearable: true
    }
  },
  select: {
    tag: 'el-select'
  }
  // ...
}
複製程式碼

如上程式碼所示,定義了一個type與元件間的對映關係,例如typeinput時,對應渲染的是Element中的el-input元件。另外我們還可以在props中額外配置了各元件初始化屬性。

有了元件的對映關係之後,下一步要做的是通過v-for迴圈渲染表單中的各個元件,由於表單控制元件型別的不確定性,我們需要使用Vue中動態元件 <component>

<el-form-item
  v-for="item in configItems"
  :label="item.label"
  :key="item.key"
>
  <component
    :is="item.tag"
    v-model="formModel[item.key]"
    v-bind="item.props"
  ></component>
</el-form-item>
複製程式碼

如上程式碼所示,這裡的configItems是由我們傳入的JSON配置處理而來。

computed: {
  configItems() {
    return this.formItems.map(item => formatItem(item, this.formModel)
  }
}
複製程式碼

我們將configItems的轉化工作放在計算屬性中進行,這麼做的原因是能夠觸發formModel收集到configItems的依賴,使得在formModel變化時,表單能夠做出正確的響應渲染。

這裡的核心在於formatItem方法,它是表單項轉化的關鍵所在。

function formatItem(config, form) {
  let item = { ...config }
  const type = item.type || 'input'
  let comp = tagMap[type]
  // 對映標籤
  item.tag = comp.tag
  // 維護props
  item.props = { ...comp.props, ...item.props }
  return item
}
複製程式碼

這裡還有一個問題,當我們遇到el-select這樣本身是巢狀的元件時,還需要考慮下拉框el-option的渲染。這種情況下需要針對下拉框元件額外封裝一個自定義元件,例如:

<form-select :options=""></form-select>
複製程式碼

如上,我們需要在JSON配置中的props物件中設定options欄位來保證選項值的傳入。

由此我們可以初步構建了配置型表單元件的雛形,可以通過配置typekey,完成對應的元件對映渲染與資料繫結,並通過props欄位額外傳入元件原本的屬性或自定義元件需要的引數。

表單間的聯動

到這裡我們只是簡單的完成了表單控制元件按型別渲染的能力,在實際的開發中,表單往往存在各個項之間的聯動關係,因此我們還需要根據表單項之間的聯動來繼續擴充套件表單的能力。

條件渲染

即當某個表單項為一個特定值時,其他一個或多個表單項不顯示(或顯示)。

我們通過在配置項中增加ifShow欄位來控制表單項的展示與否。由於表單項的展示是根據另一個表單項的值的動態變化來決定的,因此我們需要將ifShow設定為一個函式,並且將formModel作為引數傳入。

[
  {
    label: '標籤1',
    key: 'item1'
  },
  {
    label: '標籤2',
    key: 'item2',
    ifShow(form) {
      return form.item1 !== 1
    }
  }
]
複製程式碼

如上程式碼所示,當item1的值不為1時,item2才會展示。對應需要修改formatItem方法:

item._ifShow = isFunction(item.ifShow) ? item.ifShow(form) : true
複製程式碼

對應需要在el-form-item的v-if中傳入item._ifShow

動態範圍限定

某些場景下,當表單項1為特定值時,表單項2僅能選擇固定範圍內的值。

[
  {
    label: '標籤1',
    key: 'item1',
    type: 'radio',
    props: [
      { 1: 'radio1', 2: 'radio2'}
    ]
  },
  {
    label: '標籤2',
    key: 'item2',
    type: 'select',
    ifShow(form) {
      return form.item1 === '1'
    },
    props(form) {
      let options = []
      if (form.item1 === 1) {
        options = { 1: 'select1', 2: 'select2'}
      } else {
        options = { 3: 'select3', 4: 'select4' }
      }
      return {
        options
      }
    }
  }
]
複製程式碼

相應的,在formatItem方法中,要判斷props的型別,如果是函式的情況下,傳入formModel

const _props = isFunction(item.props) ? item.props(form) : item.props
item.props = { ...comp.props, ..._props }
複製程式碼

特定值限制

即當表單項1為特定值時,表單項2也只能為某個特定值。

[
  {
    label: '標籤1',
    key: 'item1',
    type: 'radio',
    props: [
      { 1: 'radio1', 2: 'radio2'}
    ]
  },
  {
    label: '標籤2',
    key: 'item2',
    type: 'select',
    ifShow(form) {
      return form.item1 === '1'
    },
    watch(form) {
      if (form.item1 === 1)
      return 1
    },
    props(form) {
      return {
        disabled: form.item1 === '1',
        options: { 1: 'select1', 2: 'select2'}
      }
    }
  }
]
複製程式碼

如上程式碼所示,在JSON配置中增加了watch欄位來對item1的值進行檢測,當滿足特定值時,改變item2的值,有需要的情況下並還可以通過props中的disableditem2作出限制。

總結上述存在的情況,最終我們的元件模板與formatItem方法調整為:

<el-form-item
  v-for="item in configItems"
  v-if="item._ifShow"
  :label="item.label"
  :key="item.key"
>
  <component
    :is="item.tag"
    v-model="formModel[item.key]"
    v-bind="item.props"
  ></component>
</el-form-item>
複製程式碼
function isFunction(fn) {
  return typeof fn === 'function'
}
function formatItem(config, form) {
  let item = { ...config }
  const type = item.type || 'input'
  let comp = tagMap[type]
  // 對映標籤
  item.tag = comp.tag
  // 維護props
  const _props = isFunction(item.props) ? item.props(form) : item.props
  item.props = { ...comp.props, ..._props }
  // 是否顯示
  item._ifShow = isFunction(item.ifShow) ? item.ifShow(form) : true
  // 特定值限制
  const _watch = isFunction(item.watch) ? item.watch(form) : null
  if (_watch) {
    form[item.key] = _watch
  }
  return item
}
複製程式碼

表單功能擴充套件

目前為止,我們的表單元件已經具備了按需渲染不同型別控制元件與表單項之間簡單的聯動處理。在實際開發工作中,我們還需要在表單的基礎上進行非同步資料請求、呼叫控制元件元件自身方法等等需求。因此,我們還需要繼續擴充套件表單的功能。

元件屬性與方法傳遞

Element中,表單相關的元件例如Select通常帶有自身的Event API。而目前我們使用的動態元件無法很好地作到將API透傳。

這裡使用了高階元件的思想,結合Vue渲染函式render方法來實現目的。具體可以參考文件中渲染函式 & JSX章節。

在使用render函式返回元件之前,我們先來簡單熟悉一下createElement方法的引數,如官方文件所示:

// @returns {VNode}
createElement(
  // {String | Object | Function}
  // 一個 HTML 標籤名、元件選項物件,或者
  // resolve 了上述任何一種的一個 async 函式。必填項。
  'div',

  // {Object}
  // 一個與模板中屬性對應的資料物件。可選。
  {
    // (詳情見下一節)
  }
  // 省略...
)
複製程式碼

我們主要用到的是前兩個引數。

其中,第一個引數即是我們需要渲染元件,這裡可以傳入一個String,對應到我們JSON配置中,便是上文中提到的type到元件tag的對映,因此我們這個地方傳入item.tag即可。

第二個引數是一個物件,按官網描述如下:

{
  // 普通的 HTML 特性
  attrs: {
    id: 'foo'
  },
  // 元件 prop
  props: {
    myProp: 'bar'
  },
  // 事件監聽器在 `on` 屬性內,
  // 但不再支援如 `v-on:keyup.enter` 這樣的修飾器。
  // 需要在處理函式中手動檢查 keyCode。
  on: {
    click: this.clickHandler
  }
  // 省略...
}
複製程式碼

從這裡我們可以看出,需要透傳的Event API可以合併置於物件的on欄位中。

在之前的表單設計中,我們將元件的attr屬性也放在props欄位中,這裡為了方便觀察,我們將配置調整為與該物件引數保持一致。我們在配置中將屬性、方法與自定義元件prop進行拆分,分別為attrsonprops欄位,其中attrsprops可以設計為函式或物件的形式,方便某些屬性需要根據其它表單項的值變化。

例如,我們需要一個可搜尋,並且可監聽值變化的Select表單項,可以進行如下配置:

{
  label: '標籤2',
  key: 'item2',
  type: 'select',
  attrs: {
    filterable: true
  },
  on: {
    change: this.handleChange
  },
  props: {
    options: {
      1: 'select1',
      2: 'select2'
    }
  }
}
複製程式碼

那麼,瞭解了render函式的使用後,我們可以嘗試構建出一個高階元件來替換動態元件:

Hoc.vue

<script>
export default {
  props: {
    item: Object // formItems中的子項
  },
  render(h) {
    const { item } = this
    const WrapComp = item.tag
    return h(WrapComp, {
      on: {
        ...this.$listeners, // input事件
        ...item.on // 元件event
      },
      attrs: {
        ...this.$attrs, // v-model value
        ...item.attrs // 元件attr
      },
      props: {
        ...item.props // 自定義props
      }
    })
  }
}
</script>

複製程式碼

在我們的表單模板中,也需要稍微做一些修改:

<el-form-item
  v-for="item in configItems"
  v-if="item._ifShow"
  :label="item.label"
  :key="item.key"
>
  <hoc
    v-model="formModel[item.key]"
    :item="item"
  ></hoc>
</el-form-item>
複製程式碼

同樣地,我們需要調整formatItem方法:

// 維護props
const _props = isFunction(item.props) ? item.props(form) : item.props
item.props = _props
// attrs
const _attrs = isFunction(item.attrs) ? item.attrs(form) : item.attrs
item.attrs = Object.assign({}, comp.attrs || {}, _attrs)
複製程式碼

非同步取值

在某些業務場景下,表單中Select下拉框選項需要呼叫介面去非同步獲取。同時,這一份資料可能會在頁面中多個不同的表單中使用到,因此需要將獲取到的資料儲存在Vuedata中,假設變數名為tempOpts,那麼我們的JSON配置為:

{
  label: '標籤2',
  key: 'item2',
  type: 'select',
  props: () => {
    return {
      options: this.tempOpts
    }
    
  }
複製程式碼

這裡需要將props欄位寫成箭頭函式的形式。如果是物件的形式,則optionsundefined且不會更新。同樣地,我們需要調整formatItem方法:

// 維護props
const _props = isFunction(item.props) ? item.props(form) : item.props
item.props = Object.keys(_props).reduce((prev, key) => {
  prev[key] = _props[key]
  return prev
}, {})
複製程式碼

如果直接將_props賦給item.props仍然不會觸發更新,而經過遍歷_props時,觸發了tempOptsget攔截器函式,使得其收集到了configItems的依賴,因此在非同步獲取資料更新tempOpts時,表單也會隨之更新渲染。

自定義元件

表單中除了渲染Element提供的表單控制元件外,某些情況下還需要渲染自定義元件。

這時同樣需要用到render函式的能力:我們可以在定義的Hoc元件中使用JSX語法,並通過render方法渲染模板。

我們可以在JSON配置中增加renderCell欄位,假設當前有一個自定義元件:

{
  label: '標籤1',
  key: 'item1',
  renderCell: () => {
    return <button-counter prop1={this.prop1} />
  }
}
複製程式碼

同樣地,在Hoc中要進行鍼對renderCell的判斷:

render(h) {
  const { item } = this
  const WrapComp = item.tag
  if (item.renderCell) {
    return item.renderCell
  } else {
    return h(WrapComp, {
      on: {
        ...this.$listeners, // input事件
        ...item.on // 元件event
      },
      attrs: {
        ...this.$attrs, // v-model value
        ...item.attrs // 元件attr
      },
      props: {
        ...item.props // 自定義props
      }
    })
  }
}
複製程式碼

表單驗證

表單驗證功能同樣也是表單不可或缺的功能之一。我們直接可以使用el-form-item元件中的rules屬性,並在JSON配置中傳入rules欄位賦值給該屬性。

小結

通過以上的一系列調整,最終我們的表單元件具備了按需渲染控制元件、透傳API、渲染自定義元件、表單驗證等等。在日常專案開發中,基本能夠滿足需求。

最終我們呈現在template中的,僅僅是一行:

<ConfigForm :formModel="formModel" :formItems="formItems" />
複製程式碼

對於開發者而言,只需要寫好JSON配置,便可以將表單間的聯動耦合關係集中體現在配置中,讓後續的維護變得清晰容易。

如有缺陷,歡迎批評指正。

參考

相關文章