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

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

示例源代碼 githubvue

操做演示(GIF 較大):webpack

在左側新建表單區塊,選擇區塊標題和表單組件類型後點擊肯定,會在中間區域生成一個塊新的表單,右側展現了全部表單的數據合併結果。git

在本示例中你主要能夠看到如下知識點的運用:github

  • vue.js 單文件組件,
  • 組件傳參
  • 自定義 v-model
  • 數據監聽
  • 數據合併
  • 批量自動註冊組件
  • 使用 mixin 抽取公用代碼
  • sass 語法
  • BEM 規範
  • 儘可能避免使用 for 循環的寫法
  • <component> 組件
  • 動態綁定 v-model 到一組數據

上面列舉的這些是由於之前有羣裏朋友詢問相關的實現方法,在此列出,可能正在讀這篇文章的你已經都掌握了,恭喜你!(本篇文章的原由也是羣友提問)web

下面開始正文數組

總覽

這個 demo 的全部組件和邏輯若是寫在一個文件中大概會有幾百行,維護起來會有麻煩,因此首先設計這樣的目錄結構:sass

搭建基本框架

爲了快速開發頁面本項目使用 ElementUI 和 D2Admin 快速搭建,如下示例中組件都來自這兩個開源項目,若是你不認識這些組件也沒有關係,大體瞭解意思就可。app

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

<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 提問。

本文首發於 D2 開源項目組官方公衆號 D2 Projects

參考

地址 描述
掘金專欄 掘金專欄
團隊主頁 開源團隊主頁
D2Admin 中文文檔 中文文檔
D2Admin 預覽地址 完整版 預覽地址
D2Admin github 完整版 Github 倉庫
ElementUI ElementUI 組件庫
相關文章
相關標籤/搜索