VUE Cookbook 系列:實現可配置組合表單

D2Projects開源組發表於2019-03-02

本案例將會講解如何使用 vue.js + ElementUI 開發一個簡單的 可配置組合表單 Demo

VUE Cookbook 系列:實現可配置組合表單

示例原始碼 github

操作演示(GIF 較大):

VUE Cookbook 系列:實現可配置組合表單

在左側新建表單區塊,選擇區塊標題和表單元件型別後點選確定,會在中間區域生成一個塊新的表單,右側展示了所有表單的資料合併結果。

在本示例中你主要可以看到以下知識點的運用:

  • vue.js 單檔案元件,
  • 元件傳參
  • 自定義 v-model
  • 資料監聽
  • 資料合併
  • 批量自動註冊元件
  • 使用 mixin 抽取公用程式碼
  • sass 語法
  • BEM 規範
  • 儘量避免使用 for 迴圈的寫法
  • <component> 元件
  • 動態繫結 v-model 到一組資料

上面列舉的這些是因為以前有群裡朋友詢問相關的實現方法,在此列出,可能正在讀這篇文章的你已經都掌握了,恭喜你!(本篇文章的起因也是群友提問)

下面開始正文

總覽

這個 demo 的所有元件和邏輯如果寫在一個檔案中大概會有幾百行,維護起來會有麻煩,所以首先設計這樣的目錄結構:

VUE Cookbook 系列:實現可配置組合表單

搭建基本框架

為了快速開發頁面本專案使用 ElementUI 和 D2Admin 快速搭建,以下示例中元件都來自這兩個開源專案,如果你不認識這些元件也沒有關係,大致瞭解意思就可。

首先寫出頁面的大致框架:

<template>
  <d2-container>
    <template slot="header">可配置問卷示例</template>
    <div class="questionnaire">
      <el-container>
        <!-- 左側位置 -->
        <!-- 中間位置 -->
        <!-- 右側位置 -->
      </el-container>
    </div>
    <template slot="footer">從左側選擇要新增的表單塊,右側檢視結果</template>
  </d2-container>
</template>
複製程式碼
<script>
export default {
  name: 'page1',
  components: {
    // 這裡以後要要登錄檔單區塊 左側邊欄 右側邊欄
  },
  data () {
    return {
      formList: [], // 所有註冊的表單區塊
      forms: [] // 使用者已經選擇的表單區塊
    }
  }
}
</script>
複製程式碼

css / sass 暫時先忽略,在最後會展示樣式程式碼

表單區塊

新建 page1/components/Form/Form1.vue 作為第一個表單區塊

<template>
  <el-form ref="form" :model="form" label-position="top">
    <el-form-item label="姓名">
      <el-input v-model="form.username"></el-input>
    </el-form-item>
    <el-form-item label="姓名">
      <el-radio-group v-model="form.usersex">
        <el-radio :label="1">男</el-radio>
        <el-radio :label="0">女</el-radio>
      </el-radio-group>
    </el-form-item>
  </el-form>
</template>

<script>
export default {
  name: 'Form1',
  props: {
    value: {
      default: () => ({
        username: '',
        usersex: 1
      })
    }
  },
  data () {
    return {
      form: {
        username: '',
        usersex: 1
      }
    }
  },
  watch: {
    form: {
      // 處理方法
      handler (value) {
        this.$emit('input', value)
      },
      // 深度 watch
      deep: true,
      // 首先自己執行一次
      immediate: true
    }
  }
}
</script>
複製程式碼

這是用 ElementUI 構建的很簡單的一個表單,甚至沒有校驗。

然後我們在頁面元件上註冊這個表單區塊:

<script>
components: {
  // 註冊元件
  Form1: () => import('./components/Form/Form1.vue')
},
data () {
  return {
    // 註冊到資料
    formList: [
      {
        title: '基礎',
        name: 'Form1'
      }
    ]
  }
}
</script>
複製程式碼

等等,假如我有 20 個區塊,難道要寫 20 遍註冊,在 formList 裡手動加 20 個物件嗎?

所以我們先新建了 7 個區塊,區塊內容都大同小異,並將程式碼稍加改造:

表單區塊示例

<template>
  <el-form ref="form" :model="form" label-position="top">
    <el-form-item label="姓名">
      <el-input v-model="form.username"></el-input>
    </el-form-item>
    <el-form-item label="姓名">
      <el-radio-group v-model="form.usersex">
        <el-radio :label="1">男</el-radio>
        <el-radio :label="0">女</el-radio>
      </el-radio-group>
    </el-form-item>
  </el-form>
</template>

<script>
export default {
  // 排序使用
  index: 1,
  // 元件標題
  title: '基礎',
  // 元件名
  name: 'Form1',
  props: {
    value: {
      default: () => ({
        username: '',
        usersex: 1
      })
    }
  },
  data () {
    return {
      form: {
        username: '',
        usersex: 1
      }
    }
  },
  watch: {
    form: {
      handler (value) {
        this.$emit('input', value)
      },
      deep: true,
      immediate: true
    }
  }
}
</script>
複製程式碼

頁面元件(只展示重點部分)

<script>
import sortby from 'lodash.sortby'
const req = context => context.keys().map(context)
const forms = req(require.context('./components/Form/', false, /\.vue$/))
const components = {}
const formList = []
sortby(forms.map(e => {
  const component = e.default
  const { index, title, name } = component
  return { component, title, index, name }
}), ['index']).forEach(form => {
  const { component, title, name } = form
  components[name] = component
  formList.push({ title, name })
})
export default {
  components,
  data () {
    return {
      formList
    }
  }
}
</script>
複製程式碼

你可能要問,上面這一大坨是什麼鬼 ???

首先介紹 webpack 的 require-context 你可以點選連結檢視官方文件。

簡單通俗來講這個方法就是為了方便引入大量檔案用的,它接收三個引數

  • 你要引入檔案的目錄
  • 是否要查詢該目錄下的子級目錄
  • 匹配要引入的檔案

然後會返回一個 require 物件,物件有三個屬性:resolve 、keys、id

  • resolve: 是一個函式,他返回的是被解析模組的id
  • keys: 也是一個函式,他返回的是一個陣列,該陣列是由所有可能被上下文模組解析的請求物件組成
  • id:上下文模組的id

所以在上面程式碼中

const req = context => context.keys().map(context)
const forms = req(require.context('./components/Form/', false, /\.vue$/))
複製程式碼

最後得到的 forms 就是 ./components/Form/ 目錄下所有的 vue 檔案物件

然後通過

sortby(forms.map(e => {
  const component = e.default
  const { index, title, name } = component
  return { component, title, index, name }
}), ['index']).forEach(form => {
  const { component, title, name } = form
  components[name] = component
  formList.push({ title, name })
})
複製程式碼

處理 forms 物件,得到 vue 註冊元件時需要的的 components 格式,並且將所有的元件資訊儲存進 formList 供頁面邏輯使用。具體的轉換方式請檢視上面的程式碼。

這樣不管我們在 ./components/Form/ 下寫了多少單檔案元件,webpack 都會自動幫我們引入並通過我們的程式碼註冊到頁面中。

大量元件註冊的問題解決了,接下來我們還要一個需要優化的問題:

不管是 Form1 還是 Form2 還是 FormN,大家會發現其實程式碼裡有一些重複內容,還有一些是有邏輯關係的重複內容,下面我們通過寫一個 mixin 來減少重複程式碼:

mixin.js:

export default function (form) {
  return {
    props: {
      value: {
        default: () => form
      }
    },
    data () {
      return {
        form
      }
    },
    watch: {
      form: {
        handler (value) {
          this.$emit('input', value)
        },
        deep: true,
        immediate: true
      }
    }
  }
}
複製程式碼

這個 js 檔案匯出了一個函式,該函式接收一個 form 引數,並將這個引數賦值給 value prop 以及 data 中的 form 欄位並返回一個物件。

然後我們將這個 mixin 註冊進每個 Form 元件中,並且改造每個 Form 元件:

<template>
  <el-form ref="form" :model="form" label-position="top">
    <el-form-item label="姓名">
      <el-input v-model="form.username"></el-input>
    </el-form-item>
    <el-form-item label="姓名">
      <el-radio-group v-model="form.usersex">
        <el-radio :label="1">男</el-radio>
        <el-radio :label="0">女</el-radio>
      </el-radio-group>
    </el-form-item>
  </el-form>
</template>

<script>
import mixin from './mixin'
export default {
  index: 1,
  title: '基礎',
  name: 'Form1',
  mixins: [
    mixin({
      username: '',
      usersex: 1
    })
  ]
}
</script>
複製程式碼

這樣每個 Form 元件都節省下了十幾行程式碼,關鍵是這些程式碼是重複冗餘的。

最後頁面元件是這個樣子:

<template>
  <d2-container>
    <template slot="header">
      可配置問卷示例
    </template>
    <div class="questionnaire">
      <el-container>
        <aside-left
          :all="formListUseful"
          :selected="forms"
          @select="handleAsideSelect"
          @remove="handleAsideRemove"/>
        <el-main class="questionnaire__main">
          <div class="questionnaire__container">
            <el-card
              v-for="(form, index) in forms"
              :key="index"
              shadow="never"
              class="questionnaire__card">
              <template slot="header">
                {{form.title}}
              </template>
              <div style="margin-bottom: -20px;">
                <component
                  :is="form.name"
                  v-model="forms[index].data"/>
              </div>
            </el-card>
          </div>
        </el-main>
        <aside-right :res="res"/>
      </el-container>
    </div>
    <template slot="footer">
      從左側選擇要新增的表單塊,右側檢視結果
    </template>
  </d2-container>
</template>

<script>
import sortby from 'lodash.sortby'
const req = context => context.keys().map(context)
const forms = req(require.context('./components/Form/', false, /\.vue$/))
const components = {}
const formList = []
sortby(forms.map(e => {
  const component = e.default
  const { index, title, name } = component
  return { component, title, index, name }
}), ['index']).forEach(form => {
  const { component, title, name } = form
  components[name] = component
  formList.push({ title, name })
})
export default {
  name: 'page1',
  components: {
    ...components,
    AsideLeft: () => import('./components/AsideLeft'),
    AsideRight: () => import('./components/AsideRight')
  },
  data () {
    return {
      formList,
      forms: []
    }
  },
  computed: {
    // 合併最後結果
    res () {
      return Object.assign({}, ...this.forms.map(e => e.data))
    },
    formListUseful () {
      return this.formList.filter(e => !this.forms.find(f => f.name === e.name))
    }
  },
  methods: {
    handleAsideSelect (val) {
      this.forms.push({
        ...val
      })
    },
    handleAsideRemove (index) {
      this.forms.splice(index, 1)
    }
  }
}
</script>

<style lang="scss">
@import '~@/assets/style/public.scss';
.questionnaire {
  @extend %full;
  .el-container {
    @extend %full;
  }
  .questionnaire__aside--left {
    border-right: 1px solid #cfd7e5;
    padding: 20px;
  }
  .questionnaire__aside--right {
    border-left: 1px solid #cfd7e5;
    padding: 20px;
    .questionnaire__res-key {
      font-size: 12px;
      line-height: 14px;
      color: $color-text-sub;
    }
    .questionnaire__res-value {
      font-size: 14px;
      line-height: 20px;
      color: $color-text-normal;
      margin-bottom: 10px;
    }
  }
  .questionnaire__main {
    background-color: rgba(#000, .05);
  }
  .questionnaire__container {
    max-width: 400px;
    margin: 0px auto;
    .questionnaire__card {
      border: 1px solid #cfd7e5;
      margin-bottom: 20px;
      .el-form-item__label {
        line-height: 16px;
      }
    }
  }
}
</style>
複製程式碼

左側頁面元件

左側右側元件不是重點內容,所以一次性展示出帶有註釋的程式碼

新建 page1/components/AsideLeft/index.vue 作為左側頁面元件

<template>
  <el-aside
    width="200px"
    class="questionnaire__aside--left">
    <!-- 已經選擇的區塊列表 點選每個按鈕後開始刪除響應的區塊 -->
    <div
      v-for="(item, index) in selected"
      :key="index"
      class="d2-mb-10">
      <el-button
        @click="handleRemove(item, index)"
        style="width: 100%;">
        {{item.title}}
      </el-button>
    </div>
    <!-- 新建區塊按鈕 -->
    <div>
      <el-button
        type="primary"
        style="width: 100%;"
        @click="dialogVisible = true">
        <d2-icon name="plus"/> 新增
      </el-button>
    </div>
    <!-- 選擇區塊介面 -->
    <el-dialog
      title="選擇區塊"
      :append-to-body="true"
      :close-on-click-modal="false"
      :visible.sync="dialogVisible">
      <p class="d2-mt-0">區塊標題</p>
      <el-input v-model="title"></el-input>
      <p>區塊元件</p>
      <el-alert
        v-if="all.length === 0"
        type="error"
        title="沒有可用區塊"/>
      <el-radio-group
        v-else
        v-model="name"
        size="small">
        <el-radio-button
          v-for="(item, index) in all"
          :key="index"
          :label="item.name">
          {{item.title}}
        </el-radio-button>
      </el-radio-group>
      <span slot="footer">
        <el-button
          @click="dialogVisible = false">
          取 消
        </el-button>
        <!-- 如果沒有區塊可用 不顯示確定按鈕 -->
        <el-button
          v-if="all.length !== 0"
          type="primary"
          @click="handleSelect">
          確 定
        </el-button>
      </span>
    </el-dialog>
  </el-aside>
</template>

<script>
export default {
  name: 'AsideLeft',
  data () {
    return {
      // 新建區塊的 dialog 顯示控制
      dialogVisible: false,
      // 新建區塊時設定的區塊標題
      title: '新區塊',
      // 新建區塊時選擇的區塊
      name: ''
    }
  },
  props: {
    // 所有可選區塊
    all: {
      default: () => []
    },
    // 使用者已經選擇的區塊
    selected: {
      default: () => []
    }
  },
  watch: {
    // 使用者選擇一個區塊後,標題自動改為這個區塊的預設標題
    name (value) {
      this.title = this.all.find(e => e.name === value).title
    }
  },
  methods: {
    // 使用者選擇區塊完畢
    handleSelect () {
      // 關閉 dialog
      this.dialogVisible = false
      // 傳送事件
      this.$emit('select', {
        name: this.name,
        title: this.title,
        data: {}
      })
    },
    // 使用者刪除區塊
    handleRemove (item, index) {
      this.$confirm(`刪除 "${item.title}" 區塊嗎`, '確認操作', {
        confirmButtonText: '確定',
        cancelButtonText: '取消',
        type: 'warning'
      }).then(() => {
        // 傳送事件
        this.$emit('remove', index)
      }).catch(() => {
        this.$message({
          type: 'info',
          message: '已取消刪除'
        })
      })
    }
  }
}
</script>
複製程式碼

右側頁面元件

左側右側元件不是重點內容,所以一次性展示出帶有註釋的程式碼

新建 page1/components/AsideRight/index.vue 作為右側頁面元件

<template>
  <el-aside
    width="200px"
    class="questionnaire__aside--right">
    <div
      v-for="(item, index) in reslist"
      :key="index">
      <div
        class="questionnaire__res-key">
        {{item.keyName}}
      </div>
      <div
        class="questionnaire__res-value">
        {{item.value === '' ? '未填寫' : item.value}}
      </div>
    </div>
  </el-aside>
</template>

<script>
export default {
  props: {
    // 接收表單結果
    res: {
      default: () => ({})
    }
  },
  computed: {
    // 處理資料格式
    reslist () {
      return Object.keys(this.res).map(keyName => ({
        keyName,
        value: this.res[keyName]
      }))
    }
  }
}
</script>
複製程式碼

所有程式碼就結束了,其實我們就寫了五個檔案

  • 頁面元件
  • 兩個側邊欄
  • 表單區塊
  • 表單區塊 mixin

這是一個很小但是涉及知識還不算少的小例子,如果上面的程式碼你有疑惑,可以來 D2 Projects 的 QQ 交流群 806395827 提問。

VUE Cookbook 系列:實現可配置組合表單

本文首發於 D2 開源專案組官方公眾號 D2 Projects

VUE Cookbook 系列:實現可配置組合表單

參考

地址 描述
掘金專欄 掘金專欄
團隊主頁 開源團隊主頁
D2Admin 中文文件 中文文件
D2Admin 預覽地址 完整版 預覽地址
D2Admin github 完整版 Github 倉庫
ElementUI ElementUI 元件庫

相關文章