# vue element web 表单设计工具

# 工具概述

# 简介

  项目名 dw-form-making (opens new window),基于 element-ui (opens new window) 组件库的Web端表单设计工具。

  项目技术栈vuevue-cli3,可视化设计element-ui输入框、选择器、单选框等控件组成的Form表单,配置表单字段、标签、校验规则等。

  较早版本采用vuex,由于发布npm包以及项目对vuex依赖性较高(即npm安装后还需配置vuex)等原因,故此种方案抛弃。使用vue.observable实现vuexstatemutations部分。

  项目第三方组件包括vuedraggable拖拽组件、tinymce富文本编辑器、clipboard复制插件、lodash函数库、ace代码编辑器等,其中element-ui未包含在npm发布包内,最大程度减小项目体积,避免二次引入。

  项目样式参考 vue-form-making (opens new window) 基础版本,表单组件未采用v-if判断方式渲染,原因一是表单组件较多,几乎全是v-if,容易造成代码冗余阅读性差,二是栅格布局采用组件递归,此种方式页面渲染性能差,每次递归页面v-if重复数次,故抛弃此种方式,采用动态组件方式渲染表单,不仅可读性高性能也好。

  由于经常使用vue-form-making,而后对其实现方式较为感兴趣,故在参考原样式基础上,项目js部分完全脱离vue-form-making方式,从零开始重构vue-form-making基础版本代码。

  项目可熟练巩固使用element-ui表单组件和部分Dialog对话框、Message消息提示、Container布局容器等。涉及递归组件内作用域插槽、组件循环引用处理、Git多远程库维护、npm包发布。

# 项目预览

  ;GitHub (opens new window) / Gitee (opens new window)

# 示意图

# 文件目录配置

├── dist
├── docs
├── lib
├── public
├── src
│   ├── assets
│   │   ├── fonts
│   │   ├── images
│   │   ├── js
│   ├── components
│   │   ├── ButtonView
│   │   │   ├── GenerateForm.vue
│   │   │   ├── ViewForm.vue
│   │   │   ├── Widget.vue
│   │   ├── ConfigOption
│   │   │   ├── FieldProperty.vue
│   │   │   ├── FormProperty.vue
│   │   ├── AceEditor.vue
│   │   ├── PublicDialog.vue
│   ├── elements
│   │   ├── input
│   │   │   ├── config.vue
│   │   │   ├── view.vue
│   │   ├── radio
│   │   │   ├── config.vue
│   │   │   ├── view.vue
│   │   ├── ...
│   │   ├── CommonField.vue
│   │   ├── CommonView.vue
│   │   ├── config.js
│   │   ├── index.js
│   │   ├── view.js
│   ├── layout
│   │   ├── index.vue
│   │   ├── components
│   │   │   ├── ButtonView.vue
│   │   │   ├── ConfigOption.vue
│   │   │   ├── ElementCate.vue
│   │   │   ├── LinkHeader.vue
│   ├── store
│   │   ├── index.js
│   │   ├── vuex.js
│   ├── styles
│   │   ├── index.scss
│   │   ├── layout.scss
│   ├── utils
│   │   ├── index.js
│   │   ├── format.js
│   │   ├── vue-component.js
│   ├── App.vue
│   ├── main.js
│   ├── index.js
│   ├── package.json
│   ├── README.md
│   ├── vue.config.js

# 初始化

# 脚手架初始化

  初始空脚手架vue-cli3仅配置BabelCSS Pre-processorsscss),删除其余业务不相关部分,文件夹部分根据需求逐步创建。

  项目核心组件库element-ui,由于整个项目完全依赖element-ui,所以可以直接全局引入。但是npm包发布不引入,最大程度减小项目体积,具体后续还会提到。

npm i element-ui -S

import ElementUI from 'element-ui'
import 'element-ui/lib/theme-chalk/index.css'
Vue.use(ElementUI)

  其次项目核心拖拽业务组件 vuedraggable (opens new window),拖拽页面部分引入即可,不用全局引入。

npm i -S vuedraggable

import draggable from 'vuedraggable'
...
export default {
  components: {
    draggable,
  },
}
...

  初始化样式使用 normalize.css (opens new window),项目定制样式初始化stylesindex.scss,其余布局相关、组件相关样式统一放在layout.scss

# 页面布局

  其中ButtonView视图区域components维护组件GenerateForm.vueViewForm.vueWidget.vueConfigOption配置参数维护组件FieldProperty.vue字段属性、FormProperty.vue表单属性。

  项目基本布局确定完毕,开始实现具体结构。创建layout文件夹,维护整个页面布局相关部分,App.vue中只做layout的引入,这样后期App.vue基本不作改动,同时最为关键的是,最终发布为npm包时,整个layout注册为组件,方便引入。

// App.vue
<div id="app">
  <Layout />
</div>

import Layout from './layout/index'
...
export default {
  name: 'App',
  components: {
    Layout,
  },
}
...

// index.js
import MakingForm from './layout/index'
...
export {
  ...
  MakingForm
}

  ;layout index.vue作为组件导出,其中layout内部使用element-ui container布局容器,四个页面主要区域放在同级components文件夹下,底部Powered by代码较少,不用再作抽离。四个主要区域设置类名,ElementCate固定宽度250pxConfigOption固定宽度300pxButtonView最小宽度440px,防止屏幕宽度较小样式错乱。

<el-container class="dw-form-making-container">
  <el-header class="dw-form-making-header">
    <link-header />
  </el-header>

  <el-container class="dw-form-making-body">
    <el-aside class="dw-form-making-elementcate" width="250px">
      <element-cate />
    </el-aside>

    <el-main class="dw-form-making-buttonview">
      <button-view />
    </el-main>

    <el-aside class="dw-form-making-configoption" width="300px">
      <config-option />
    </el-aside>
  </el-container>

  <el-footer class="dw-form-making-footer">...</el-footer>
</el-container>

  ;ElementCate部分首先考虑各个元素数据和图标,暂不考虑元素的其他情况(配置信息等),iconfont (opens new window) 创建个人项目,选择合适的图标,下载本地压缩包解压导入,注意iconfont.css导入路径前加上~符号,从vue.config.jsalias查询相关路径加载模块,不添加~默认为当前目录下路径。

@import '~assets/fonts/iconfont.css';

  ;ElementCate.vue中引入三个不同类别的表单组件,假设某个js文件(elements文件夹内index.js)对外导出三个数组,分别为basicadvancelayout,且每个数组对象暂时包含name标签、icon图标。

// element -> index.js
const basic = [
  ...
  {
    name: '单行文本',
    icon: 'icon-input'
  }
  ...
]

const advance = []

const layout = []

export {
  basic,
  advance,
  layout,
}

  ;ElementCate.vue引入三个数组,暂时使用ul li渲染出来,li设置为块级再指定宽度48%,其中图标和组件名均对齐中线,同时设置表单组件悬浮的样式。

  ;ButtonView按钮视图区域分为上下两部分,按钮区域和视图区域,按钮区域暂时放置对应按钮,事件后续接入逻辑详细处理,视图区域抽离为组件ViewForm.vue,现在暂时放置一个div盒子。

  ;ConfigOption配置参数区域分为字段属性、表单属性,实现最基本的Tabs切换即可。

# vuedraggable 拖动与 transition-group

  ;vuedraggable (opens new window) 官方文档提供了vuedraggabletransition-group配合使用的示例方法,这里详细说明项目元素分类和视图表单区域的配置参数。

  • tag: draggable渲染后的标签名
  • value: 和内部元素v-for指令引用相同的数组,不应该直接使用,可通过v-model
  • group.name: 同分组名可相互拖动,不同draggable列表也可以
  • group.pull: 拖动至其他分组克隆或复制,而非直接取出再移动
  • group.put: 其他组别拖动至当前分组是否放入
  • sort: 同分组拖动后不排序
  • animation: 单位ms,与transition-group产生过渡效果
  • ghostClass: 被拖动元素class类名
  • handle: 拖动列表元素上指定类名部分(拖动小图标)才能进行拖动
  • clone: 克隆事件,声明使用:,处理克隆后的元素
  • add: 添加事件,其他分组拖动至当前分组,处理添加前的元素
// ElementCate.vue
<draggable
  v-model="list"
  tag="ul"
  v-bind="{
    group: {
      name: 'view',
      pull: 'clone',
      put: false,
    },
    sort: false,
  }"
  :clone="handleClone"
>
  <li>...</li>
  ...
</draggable>

// ViewForm.vue
<draggable
  v-model="list"
  v-bind="{
    group: 'view',
    animation: 200,
    ghostClass: 'move',
    handle: '.drag-icon',
  }"
  @add="handleAdd"
>
  <transition-group>...</transition-group>
</draggable>

  ;ElementCate部分根据上述配置,引入分类列表basicadvancelayout,注册Draggable组件,其中分类列表长度若为0,对应列表标题也不显示,不用外层添加DOM元素,使用template配合v-if使用。clone函数拖动时触发,参数为拷贝的元素对象,暂时打印,返回拷贝的对象。

  视图表单部分使用absolute绝对定位使高度为整个下半部分区域,draggable覆盖区域高度不够不会产生拖动且内部绑定的list暂时为data内变量。add函数参数解构newIndex(列表内索引),通过索引可获取拖入后的元素。控制台查看视图表单内列表,当元素拖入(鼠标不松开),元素类名为element-cate-item move,鼠标松开渲染为视图表单列表元素,layout.scss设置拖入样式。由于视图表单也存在元素的拖动情况,故样式声明为变量,使用时引入。

@mixin form-item-move {
  outline-width: 0;
  height: 3px;
  overflow: hidden;
  border: 2px solid #409eff;
  ...
}

.element-cate-item {
  &.move {
    @include form-item-move;
  }
  ...
}

  ;FormProperty可配置按钮视图中对齐方式、宽度、组件尺寸等,故将按钮视图中draggable放入el-form组件内,每一个列表元素渲染为el-form-itemel-form配置固定,el-form-item暂时渲染label和输入框。注意transition-group内部元素必须设置key值,否则元素无法渲染并且控制台会打印警告。

<el-form size="small" label-width="100px" label-position="right">
  <draggable ... @add="handleAdd">
    <transition-group>
      <div v-for="(element, index) in data" :key="index" class="view-form-item">
        <el-form-item :label="element.name">
          <el-input />
        </el-form-item>
      </div>
    </transition-group>
  </draggable>
</el-form>

handleAdd({ newIndex }) {
  this.select = this.data[newIndex]
}

  ;ElementCate元素拖入ViewForm可以看见蓝色长条,鼠标松开渲染为输入框和标签,设置view-form-item样式和hover样式,边框色同ElementCate元素一致。当点击view-form-item时,data中变量select保存点击的view-form-item,判断显示出蓝色边框和拖动图标。

<div :class="['view-form-item', { active: select.key === element.key }]" @click="handleSelect(element)">
  <el-form-item ...>...</el-form-item>
  ...
  <div v-if="select.key === element.key" class="item-drag">
    <i class="iconfont icon-drag drag-icon"></i>
  </div>
  ...
</div>

handleSelect(element) {
  this.select = element
}

  首先要明确的是,分类元素中clone事件返回的对象就是视图表单放入的对象,故可以在clone回调时添加key属性或者表单视图add事件内newIndex获取元素添加key属性。但是两种方式有明显差异,前者鼠标拖动clone返回对象并添加key值,鼠标松开add活动元素select设为当前元素(拖入的元素高亮),后者鼠标拖动clone返回对象,鼠标松开add添加key后再设置活动元素。虽然实现效果并无差异,但是后者一个函数做了两件事(添加key、高亮),不符合单一职责原则SRP

  ;key值使用4位随机字符串和时间戳方式。clone函数参数为拖动元素引用,故返回对象时要另拷贝,对象拷贝使用 lodash (opens new window).deepClone,也可以使用JSON深拷贝,但是JSON.stringify序列化时会丢失掉函数等类型,不推荐使用。utils工具类下暴露出uuiddeepClone

// ElementCate.vue
handleClone(element) {
  return Object.assign(deepClone(element), { key: uuid() })
}

// utils -> index.js
import lodash from 'lodash'

function deepClone(object) {
  return lodash.cloneDeep(object)
}

function S4() {
  return (((1 + Math.random()) * 0x10000) | 0).toString(16).substring(1)
}

function uuid() {
  return Date.now() + '_' + S4()
}

export {
  uuid,
  deepClone
  ...
}

# Elements 元素参数和 vuex

  上述部分已基本实现元素拖动和单击高亮,但是view-form-item还渲染为输入框,若ElementCate元素有配置参数,可根据不同配置渲染不同表单元素,暂时采用v-if方式。

// elements -> index.js
const basic = [
  {
    name: '单行文本',
    icon: 'icon-input',
    type: 'input'
  },
  {
    name: '多行文本',
    icon: 'icon-textarea',
    type: 'textarea'
  }
  ...
]

// ViewForm.vue
<el-form-item :label='element.name'>
  <el-input v-if='element.type === "input"' />

  <el-input type='textarea' v-if='element.type === "textarea"' />
  ...
</el-form-item>

  ;ElementCate元素拖入,高亮同时字段属性能配置不同参数,但是字段属性与视图表单没有关联,vue-form-making基础版本内部采用组件传值,活动元素select传递到顶层layout再发送至FieldProperty.vue,首先组件层级较深且代码可读性差,优化组件层级,组件树结构又不合理,很难兼备。若存在全局状态管理,解决方式就很灵活,同时也不影响组件层级和结构。

  ;vuex的确能很好地解决上述问题,但是项目对vuex依赖性不高并且项目不大,仅仅使用state状态管理显得多余。而vue.observable方式不仅可以实现部分vuex功能,项目也会显得轻量。视图表单内select活动元素state下维护,视图表单内computed引入,元素拖入和单击时调用mutations设置活动元素。FieldProperty.vue同理引入select,暂时可配置元素标签名。

// store -> index.js
export default new Vuex.Store({
  state: {
    select: {}
  },
  mutations: {
    SET_SELECT(state, select) {
      if (state.select === select) return
      state.select = select
    }
  }
}

// ViewForm.vue
import store from 'store/index.js'

export default {
  ...
  computed: {
    select() {
      return store.state.select
    },
  },
  methods: {
    handleSelect(element) {
      store.commit('SET_SELECT', element)
    },

    handleAdd({ newIndex }) {
      store.commit('SET_SELECT', this.data.list[newIndex])
    },
  },
}

// FieldProperty.vue
<el-form size="small" label-position="top">
  <el-form-item label="标签">
    <el-input v-model="data.name"></el-input>
  </el-form-item>
</el-form>

export default {
  ...
  computed: {
    data() {
      return store.state.select
    }
  }
}

  若单行文本含placeholder,多行文本不含placeholderFieldProperty.vue内渲染配置项就会不一样,也采用v-if方式。placeholdername标签不同,属于元素具体配置,放在options下。

// elements -> index.js
const basic = [
  {
    name: '单行文本',
    icon: 'icon-input',
    type: 'input',
    options:{
      placeholder:''
    }
  },
  {
    name: '多行文本',
    icon: 'icon-textarea',
    type: 'textarea'
  }
  ...
]

// ViewForm.vue
<el-form-item :label="element.name">
  <el-input v-if="element.type === 'input'" :placeholder="element.options.placeholder" />

  <el-input v-if="element.type === 'textarea'" type="textarea" />
  ...
</el-form-item>

// FieldProperty.vue
<el-form size="small" label-position="top">
  <el-form-item label="标签">
    <el-input v-model="data.name"></el-input>
  </el-form-item>

  <el-form-item v-if="data.type === 'input'" label="占位内容">
    <el-input v-model="data.options.placeholder"></el-input>
  </el-form-item>
</el-form>

# 表单元素操作

# 全局表单配置

  其实表单也是一个全局变量,包含表单配置(对齐方式、宽度、组件尺寸等)和内部元素。ViewForm.vue内部data维护至store,对表单和活动元素的操作,基本都在mutations内部。

// store -> index.js
export default new Vuex.Store({
  state: {
    select: {},
    data: {
      list: [],
      config: {
        labelWidth: 100,
        labelPosition: 'right',
        size: 'small',
        customClass: '',
      },
    },
  },
})

// ViewForm.vue
<el-form
  :size="data.config.size"
  :label-width="data.config.labelWidth + 'px'"
  :label-position="data.config.labelPosition"
>
  ...
</el-form>

export default {
  computed: {
    data() {
      return store.state.data
    },
  },
}

// FormProperty.vue
<el-form label-position="top" size="small">
  <el-form-item label="标签对齐方式">
    <el-radio-group v-model="data.labelPosition"> ... </el-radio-group>
  </el-form-item>
</el-form>

export default {
  ...
  computed: {
    data() {
      return store.state.data.config
    },
  },
}

# 动态组件

  目前元素可拖动至视图表单,同时配置标签等,表单也可全局配置。但是按钮视图的元素还很单一,逐渐完善后数量多达20个左右,若输入框等组件仅仅通过v-if判断渲染,首先全篇几乎是v-if全等判断,阅读性非常差,其次每渲染一个组件就会经过20次的v-if,视图表单引入栅格后,栅格每嵌套一级,v-if重复20次,表单一旦栅格层级较深、元素较多,渲染性能会非常差,再者后期自定义添加表单组件,每添加一个组件,调整代码的地方会非常多,维护非常困难。参考 vue-form-making (opens new window) 基础版本,高级版可能已重构,而且性能很好。表单配置也同理,全篇v-if不是最终解决办法,动态组件将会很好解决这个问题。

  ;ElementCate每一个元素都对应一个表单组件、表单配置组件,根据ElementCate的组件名动态渲染,代码量会大大精简,只是视图表单初始化、字段属性初始化需要引入多个组件,需要用到require.context自动化导入模块,避免重复代码和手动导入。

  ;elements放置表单组件,ElementCate若添加组件,element下新增组件即可,不用去考虑视图表单内部的渲染。index.js配置ElementCate元素,view.jsconfig.js自动化导入config.vue并注册组件。

  添加单行文本组件,elements下新建input,创建config.vueview.vue,必须配置组件名。

// elements 组件目录
│   ├── elements
│   │   ├── input
│   │   │   ├── config.vue
│   │   │   ├── view.vue
│   │   ├── ...
│   │   ├── config.js
│   │   ├── index.js
│   │   ├── view.js

// index.js
const basic = [
  {
    name: '单行文本',
    icon: 'icon-input',
    type: 'input',
    component: 'DwInput',
    options: {
      placeholder: '',
    },
  },
]

// view.vue
<el-form-item ...>
  <el-input :placeholder="element.options.placeholder" ...></el-input>
</el-form-item>

export default {
  name: 'DwInput',
  props: {
    element: {
      type: Object,
    },
  },
  ...
}

// config.vue
<el-form size="small" label-position="top">
  <el-form-item label="标签">
    <el-input v-model="data.name"></el-input>
  </el-form-item>
</el-form>

export default {
  name: 'DwInputConfig',
  ...
}

  ;view.js动态导入elements下所有view.vue文件,对外导出为组件列表,config.js同理。

// view.js
const components = {}
const requireComponent = require.context('elements/', true, /(view.vue)$/)

requireComponent.keys().forEach(fileName => {
  const componentOptions = requireComponent(fileName)
  const component = componentOptions.default || componentOptions

  components[component.name] = component
})

export default components

// ViewForm.vue
<div v-for="element in data.list" class="view-form-item">
  <component :is='item.component' :element='element'>

  <div v-if="select.key === element.key" class="item-drag">
    <i class="iconfont icon-drag drag-icon"></i>
  </div>
</div>

import Draggable from 'vuedraggable'
import components from 'elements/view'

export default {
  ...
  components: {
    Draggable,
    ...components,
  },
}

// FormProperty.vue
<component :is="component.component && `${component.component}Config`" :data="component" />

import components from 'elements/config'

export default {
  components,
  computed: {
    component() {
      return store.state.select
    },
  },
}

# 公共字段属性和公共视图

  通用字段属性包括字段标识model、标签name、标签宽度(isLabelWidthlabelWidth)、隐藏标签hideLabel、自定义Class customClass五个属性,字段标识即字段名,默认生成的字段标识为元素typekey值,字段标识modelkey值一起生成。

// ElementCate
handleClone(element) {
  const key = uuid()

  return Object.assign(deepClone(element), {
    key,
    model: element.type + '_' + key,
  })
},

  五个属性封装为公共组件CommonField.vue,放置插槽,组件config.vue引用,组件独有配置插入插槽即可。要注意的是,组件传值是单向的,但是CommonField.vue内部却能修改传入的值,原因是组件传引用类型的值实际传递的是引用地址,所以组件内部修改外部依然同步。组件传值不仅可以使用sync实现双向传值,也可传递引用类型实现组件双向传值。

<common-field :data="data">
  <template slot="custom"> ... </template>
</common-field>

import CommonField from '../CommonField'

export default {
  components: {
    CommonField,
  },
}

  公共组件CommonField.vueCommonField.vue同步,el-form-item插槽labellabel-width显隐标签,el-form-item添加class自定义customClassisLabelWidth控制标签宽度,标签不显示,宽度为0,标签显示且自定义宽度,宽度为自定义值,标签显示但不自定义宽度,宽度为表单标签宽度。

<el-form-item
  :label-width="
    element.options.hideLabel
      ? '0px'
      : (element.options.isLabelWidth ? element.options.labelWidth : config.labelWidth) + 'px'
  "
  :class="element.options.customClass"
>
  <template v-if="!element.options.hideLabel" slot="label">
    {{ element.name }}
  </template>

  <slot></slot>
</el-form-item>

# 元素拷贝、删除和伪元素

  ;ViewForm内元素的字段标识model可创建div盒子定位或者运用css伪元素实现。

  ;view-form-item自定义属性data-modelcss伪元素content内部attr函数获取。ViewForm表单元素禁止输入,同理可定位div盒子或者css伪元素,伪元素绝对定位四个参数都设为0且父元素相对定位,可实现宽高等于父元素。

// ViewForm.vue
<div v-for="element in data.list" class="view-form-item" :data-model="element.model">...</div>

// layout.scss
.view-form-item {
  position: relative;
  ...
  &::before {
    content: attr(data-model);
    position: absolute;
    top: 3px;
    right: 3px;
    font-size: 12px;
    color: rgb(2, 171, 181);
    z-index: 5;
    font-weight: 500;
  }

  &::after {
    position: absolute;
    content: '';
    left: 0;
    right: 0;
    top: 0;
    bottom: 0;
    z-index: 5;
  }
}

  ;view-form-item内部添加克隆图标,传递参数包括克隆元素、索引值、列表元素,处理函数在store中维护,拷贝元素key值和model重新生成,克隆后活动元素select重置。

// ViewForm.vue
<div v-for="(element, index) in data.list" class="view-form-item">
  ...
  <i class="iconfont icon-clone" @click="handleClone(element, index, data.list)" />
</div>

handleClone(element, index, list) {
  store.commit('CLONE_ELEMENT', { index, element, list })
},

// store -> index.js
CLONE_ELEMENT(state, { index, element, list }) {
  const key = uuid()
  const el = deepClone(element)

  list.splice(
    index + 1,
    0,
    Object.assign(el, {
      key,
      model: element.type + '_' + key,
    })
  )

  state.select = list[index + 1]
},

  删除按钮为避免重复点击,只在第一次点击时触发,元素删除动画触发过程中不可再点击。元素删除前更新活动元素select,被删除元素处在列表末尾且长度大于1,活动元素为上一个元素。若处在列表末尾且长度等于1,即列表只有一个元素,活动元素为空。不满足上述则元素处在中部,删除后活动元素为下一个元素。

// ViewForm.vue
<div class="view-form-item">
  ...
  <i class="iconfont icon-trash" @click.once="handleDelete(data.list, index)" />
</div>

handleDelete(list, index) {
  store.commit('DELETE_ELEMENT', { list, index })
},

// store -> index.js
DELETE_ELEMENT(state, { list, index }) {
  if (list.length - 1 === index) {
    state.select = index ? list[index - 1] : {}
  } else {
    state.select = list[index + 1]
  }

  list.splice(index, 1)
},

# ElementCate 组件

  组件参数引用CommonFild.vue公共字段属性,默认含有5个公共属性,宽度、操作属性、校验规则等根据实际情况加入,定制化属性添加至组件,再由插槽插入内部。组件视图引用CommonView.vue公共视图,负责表单活动样式、标签、字段属性等,组件引用后不考虑表单呈现,仅专注同步组件参数部分。

  组件视图部分view.vue,由于表单预览可获取表单内部值,显然组件实现v-model双向绑定,组件内部暂时接收传值value,预览部分再自定义组件v-model

  下面简述组件定制化部分,诸如placeholer占位内容、style宽度等参考源代码。

# 单行文本

  单行文本参数包括宽度、默认值、占位内容、操作属性等,校验规则较为复杂,暂不考虑。新增组件参数和视图部分均可参考单行文本源码,单行文本禁用和只读属性二者择其一,不能同时作用于同一表单元素。

// elements -> input -> config.vue
<template slot="option">
  <el-checkbox v-model="data.options.disabled" :disabled="data.options.readonly">禁用</el-checkbox>
  <el-checkbox v-model="data.options.readonly" :disabled="data.options.disabled">只读</el-checkbox>
  ...
</template>

# 多行文本

  多行文本参数部分,默认值使用文本域。

// elements -> textarea -> config.vue
<el-form-item label="默认值">
  <el-input type="textarea" ... />
</el-form-item>

# 计数器

  计数器操作按钮位置传递参数controls-position,默认为default,默认值受最大值、最小值、步数限制。

// elements -> number -> view.vue
<common-view>
  <el-input-number
    :value="element.options.defaultValue"
    :controls-position="element.options.controlsPosition"
  />
</common-view>

// elements -> number -> config.vue
<el-form-item label="默认值">
  <el-input-number
    v-model="data.options.defaultValue"
    :max="data.options.max"
    :min="data.options.min"
    :step="data.options.step"
  />
</el-form-item>

# 单选框组

  单选框组布局方式分为块级和行内,选项包括静态数据和动态数据,暂不考虑动态数据,选项为label-value对形式,内部引用draggable拖动列表,选项可删除和新增,添加选项生成随机label-value对,选中选项设置默认值,清空列表时默认值清空,选中项删除,清空默认值。注意el-radio组件,若不显示label,可传入nbsp空位符。

// elements -> radio -> view.vue
<el-radio
  v-for="(item, index) in element.options.options"
  style="{
    display: element.options.inline
    ? 'inline-block' : 'block'
  }"
  >{{ item.label }}</el-radio>

// elements -> radio -> config.vue
<li ...>
  <el-radio :label="item.value" class="hidden-label">&nbsp;</el-radio>
  ...
</li>

# 多选框组

  多选框组与单选框组大同小异,多选框组默认值为数组,多选框组默认值可选择多个,删除选中项时,首先获取选中项value值在默认值中的索引,满足则删除默认值对应项,不满足只删除选中项。

// elements -> checkbox -> config.vue
handleDeleteOptions(element, index) {
  const i = this.data.options.defaultValue.indexOf(element.value)

  if (i > -1) {
    this.data.options.defaultValue.splice(i, 1)
  }

  this.data.options.options.splice(index, 1)
},

# 时间选择器

  时间选择器默认值受格式控制,也包括禁用和只读,同单行文本一致,只能二者选其一。时间选择为占位内容,范围选择包括开始占位内容、范围分隔符、结束占位内容。开启范围选择时默认值只能为null,关闭时设为空字符。el-time-picker组件v-bind绑定is-range,范围选择切换导致选择器定位错乱,是element组件自身的bugv-if与范围选择参数一致并且指定key值可解决,两者缺一不可。

// elements -> time -> config.vue
<el-form-item label="默认值">
  <el-time-picker v-if="data.options.isRange" key="range" is-range ... />
  <el-time-picker v-else key="default" ... />
</el-form-item>

handleRangeChange() {
  this.data.options.defaultValue = this.data.options.isRange ? null : ''
},

# 日期选择器

  日期选择器显示类型包括月份、年份、日期、多日期、日期范围等,范围类型默认值为null,其余为空字符,格式对应,切换类型选择器错乱处理方式与时间选择器一致。

// elements -> date -> config.vue
export default {
  data() {
    return {
      type: [
        {
          label: '日期时间范围',
          value: 'datetimerange',
          format: 'yyyy-MM-dd HH:mm:ss',
          type: null,
          isRange: true,
        },
      ],
    }
  },
  methods: {
    handleTypeChange(value) {
      const showType = this.type.find(e => e.value === value)

      this.data.options.format = showType.format
      this.data.options.defaultValue = showType.type
      ...
    },
  },
}

# 评分

  评分默认值受半选、最大值控制,最大值最小为1,默认值清空为0

// elements -> rate -> config.vue
<el-rate ... :allow-half="data.options.isAllowhalf" :max="data.options.max" />

# 颜色选择器

  颜色选择器选择颜色后,元素默认值为hex十六进制,勾选透明度,点击颜色选择器,默认值颜色并未改变,是el-color-picker组件自身的bug,解决方式类似时间选择器,v-ifkey值两者共同作用。

// elements -> color -> config.vue
<el-form-item label="默认值">
  <el-color-picker v-if="data.options.showAlpha" key="alpha" ... show-alpha />
  <el-color-picker v-else key="default" ... />
</el-form-item>

# 下拉选择器

  下拉选择器添加选项与单选框组一致,删除元素即单选框组和多选框组的合并,单选多选切换保留默认值方式有差异。单选过渡多选,单选未选择默认值,值为空数组,单选选择默认值,值为包含默认值的数组。多选过渡单选,多选未选择默认值,值为null,多选选择默认值,值为数组首个元素值。

// elements -> select -> config.vue
handleMultipleChange(multiple) {
  var value = this.data.options.defaultValue

  this.data.options.defaultValue = multiple
    ? value === null
      ? []
      : [value]
    : value.length
    ? value[0]
    : null
},

# 开关

  开关参考el-switch参数,可自定义开启和关闭的文字颜色、文字描述。

// elements -> switch -> view.vue
<el-switch
  :active-color="element.options.isColor ? element.options.activeColor : '#409EFF'"
  :inactive-color="element.options.isColor ? element.options.inactiveColor : '#C0CCDA'"
  :active-text="element.options.isText ? element.options.activeText : ''"
  :inactive-text="element.options.isText ? element.options.inactiveText : ''"
/>

# 滑块

  滑块默认值受最大值、最小值、步长限制。

// elements -> slider -> config.vue
<el-slider
  v-model="data.options.defaultValue"
  :max="data.options.max"
  :min="data.options.min"
  :step="data.options.step"
/>

# 文字

  文字仅是一小段段落,丰富组件列表和部分表单的描述信息,由于可指定宽度,则元素为块级元素。

// elements -> text -> view.vue
<div :style="{ width: element.options.width }">
  <span style="word-break: break-all">{{ value }}</span>
</div>

# html

  ;html组件默认值暂时为文本域,可填写html代码即可,视图部分利用v-html指令。

// elements -> html -> view.vue
<div :style="{ width: element.options.width }">
  <div v-html="value" />
</div>

# 级联选择器

  级联选择器一般异步获取数据源,默认含labelvaluechildren字段,也可指定属性配置,可选项数据源options暂时为空数组。

// elements -> cascader -> view.vue
<el-cascader
  :props="{
    value: element.options.props.value,
    label: element.options.props.label,
    children: element.options.props.children,
  }"
  :options="[]"
  ...
/>

# 分割线

  分割线content-position控制文本位置。

// elements -> divider -> view.vue
<el-divider :content-position="element.options.textPosition">
  {{ element.name }}
</el-divider>

# 栅格布局

  上述部分仅仅支持单行单表单组件,尚无法满足简单的栅格布局,即一行无法显示多个表单组件,vue-form-making基础版本不支持栅格布局,但是其样式和参数可作为参考。

  栅格样式不同于其他组件,view-form-item判断是否为栅格元素,动态生成类名。栅格样式权重应高于普通样式,栅格样式代码顺序在普通样式后层叠。

// ViewForm.vue
<div
  :class="[
    'view-form-item',
    {
      active: select.key === element.key,
      grid: element.type === 'grid',
    },
  ]"
  ...
>
  ...
</div>

// layout.scss
.view-form-item {
  ...
}

.view-form-item.grid {
  ...
}

  栅格参数暂不考虑,一行显示两列。对比ViewForm.vueElementCate内元素若能拖入栅格内,首先栅格内渲染的列表要绑定draggable,即draggleble包含栅格列表,其次draggable覆盖区域必须足够高,否则元素拖不进来。栅格内暂时渲染元素对象,v-model绑定data内变量,元素拖入后可以观察到数据已拖入并渲染。

// elements -> grid -> view.vue
<el-row type="flex">
  <el-col :span="12">
    <draggable
      v-model="list"
      v-bind="{
        group: 'view',
      }"
    >
      <transition-group tag="div" class="el-col-list">
        <div v-for="(element, index) in list" :key="index">
          <span>{{ element }}</span>
        </div>
      </transition-group>
    </draggable>
  </el-col>
  <el-col :span="12">
    <div class="el-col-list"></div>
  </el-col>
</el-row>

...
export default {
  data() {
    ...
    return {
      list: [],
    }
  },
}

  ;el-col-list内元素渲染为表单组件,局部批量注册组件。

// elements -> grid -> view.vue
<transition-group tag="div" class="el-col-list">
  <component :is="element.component" v-for="(element, index) in list" :key="index" :element="element" />
</transition-group>

import Draggable from 'vuedraggable'
import components from 'elements/view'

export default {
  ...
  name: 'DwGrid',
  components: {
    Draggable,
    ...components,
  },
  data() {
    return {
      list: [],
    }
  },
}

  ;ElementCate元素拖入,控制台会报错组件未注册,但是代码内明确注册了组件。在生命周期beforeCreate内打印this.$options.components,页面注册的组件只有Draggable和栅格DwGrid。其余批量注册的组件均不存在,即组件并未注册。造成错误的原因是组件之间的循环引用,若表单元素全局注册,这种错误不会存在。但是组件局部注册,DwGrid内部引用DwGrid,就变成了一个循环,组件不知道如何完全解析出自身。解决方式有两种,vue (opens new window) 官方给出了示例,由于是批量注册,webpack的异步import不适用,在生命周期beforeCreate时去注册它。

import components from 'elements/view'

export default {
  ...
  beforeCreate() {
    Object.assign(this.$options.components, components)
  },
}

  此时ElementCate元素拖入,对应表单可渲染,参考ViewForm.vue内部view-form-list,配置draggable参数,部分克隆、删除事件暂不考虑,函数设为空函数。

// elements -> grid -> view.vue
<transition-group class="el-col-list" ...>
  <div
    v-for="element in list"
    :key="element.key"
    :class="[
      'view-form-item',
      {
        active: select.key === element.key,
        grid: element.type === 'grid',
      },
    ]"
    :data-model="element.model"
    @click.stop="handleSelect(element)"
  >
    <component :is="element.component" :element="element" />

    <div v-if="select.key === element.key" class="item-drag">
      <i class="iconfont icon-drag drag-icon"></i>
    </div>

    <div v-if="select.key === element.key" class="item-action">
      <i class="iconfont icon-clone" @click.stop="handleClone"></i>
      <i class="iconfont icon-trash" @click.stop.once="handleDelete"></i>
    </div>
  </div>
</transition-group>

export default {
  methods: {
    handleSelect(element) {
      store.commit('SET_SELECT', element)
    },

    handleClone() { },

    handleDelete() { },
  },
}

  细致发现,ViewForm.vue和栅格内部view-form-item代码完全一致(逻辑部分暂不考虑),一般抽离公共代码,封装成一个组件,但是可以梳理页面结构并最终发现,代码一致是必然的。首先ViewForm.vue是一个单一的列表,组件拖入并渲染单个元素,引入栅格后,每个栅格代表一个列表,栅格列表与ViewForm.vue的列表实质是同一种列表,拖入的组件也是同一类组件,所以最终列表(view-formel-col-list)内代码是一致的。

  公共部分代码为Widget.vue小部件,即对每个组件的一层包装,包括点击高亮、拖动、克隆、删除事件,组件传值暂时为elementindex(元素索引)。

// ViewFrom.vue
<transition-group class="view-form">
  <widget v-for="(element, index) in data.list" :key="element.key" :index="index" :element="element" />
</transition-group>

import Widget from './Widget'

export default {
  components: {
    Widget,
  },
}

  栅格组件内部引入小部件,拖入ElementCate元素,页面报错组件渲染失败。根据报错信息很难排查问题原因,细致梳理页面结构,小部件批量引入表单组件,其中包含输入框、栅格等,栅格内部引入小部件,又是组件的循环引用,由于是单个组件,采用webpack的异步import

// elements -> grid -> view.vue
<transition-group class="el-col-list">
  <widget v-for="(element, index) in list" :key="element.key" :index="index" :element="element" />
</transition-group>

// import Widget from 'components/ButtonView/Widget.vue'

export default {
  components: {
    Widget: () => import('components/ButtonView/Widget.vue'),
  },
}

  栅格列数未与栅格json数据绑定,栅格列表内表单元素是栅格json的一部分,columns数组保存栅格对象,栅格对象参数暂不考虑,只包括list列表字段,draggable双向绑定column.list,未绑定或绑定错误都不能显示。

// elements -> index.js
const layout = [
  {
    ...
    type: 'grid',
    name: '栅格布局',
    columns: [
      {
        list: [],
      },
      ...
    ],
  },
]

// elements -> grid -> view.vue
<el-row>
  <el-col v-for="(column, index) in element.columns" :key="index" :span="12">
    <draggable v-model="column.list" ...>
      ...
      <widget
        v-for="(element, index) in column.list"
        :key="element.key"
        :index="index"
        :element="element"
      />
    </draggable>
  </el-col>
</el-row>

  栅格参数可配置水平、垂直排列方式,栅格方式分为flex和响应式,默认为flex,参数具体描述参考 Layout (opens new window) 布局。

// elements -> index.js
const layout = [
  {
    ...
    type: 'grid',
    name: '栅格布局',
    options: {
      gutter: 0,
      isFlex: true,
      justify: 'start',
      align: 'top',
    },
    columns: [
      {
        span: 12,
        xs: 12,
        sm: 12,
        md: 12,
        lg: 12,
        xl: 12,
        list: [],
      },
      ...
    ],
  },
]

// elements -> grid -> view.vue
<el-row
  type="flex"
  :gutter="element.options.gutter"
  :justify="element.options.justify"
  :align="element.options.align"
>
  <el-col
    :xs="element.options.isFlex ? undefined : column.xs"
    :sm="element.options.isFlex ? undefined : column.sm"
    :md="element.options.isFlex ? undefined : column.md"
    :lg="element.options.isFlex ? undefined : column.lg"
    :xl="element.options.isFlex ? undefined : column.xl"
    :span="column.span"
    ...
  >
    ...
  </el-col>
</el-row>

  栅格内元素拖入高亮,类比ViewForm.vue,根据索引找出元素即可。

// elements -> grid -> view.vue
<el-row ...>
  <el-col ...>
    <draggable ... @add="handleAdd($event, column)"> ... </draggable>
  </el-col>
</el-row>

handleAdd({ newIndex }, column) {
  store.commit('SET_SELECT', column.list[newIndex])
},

  类比原始ViewForm.vue,删除元素传参包括索引值、元素列表,Widget.vue声明组件传值data,栅格内也是如此。

// ViewForm.vue
<widget
  v-for="(element, index) in data.list"
  :key="element.key"
  :index="index"
  :data="data"
  :element="element"
/>

// elements -> grid -> view.vue
<widget
  v-for="(element, index) in column.list"
  :key="element.key"
  :index="index"
  :data="column"
  :element="element"
/>

  小部件内克隆与ViewForm.vue大同小异,若栅格内部多层嵌套或包含其他表单组件,克隆后不仅要生成副本,而且副本下所有元素的key值不能和之前相同,需递归更新元素的key值。

// store -> index.js
CLONE_ELEMENT(state, { index, element, list }) {
  if (el.type === 'grid') {
    resetGridKey(el)
  }

  function resetGridKey(element) {
    element.columns.forEach(column => {
      column.list.forEach(el => {
        const key = uuid()

        el.key = key
        el.model = el.type + '_' + key

        if (el.type === 'grid') {
          resetGridKey(el)
        }
      })
    })
  }
},

# Dialog 公共对话框和 AceEditor

  导入json,可粘贴json数据快速配置表单,点击确定,根据配置的json数据渲染表单,但是数据不限制会发生很多错误,utilsformat.js验证传入的json数据格式是否正确,格式不正确reject并返回错误原因,格式正确更新state内表单数据data

// layout -> components -> ButtonView.vue
handleUploadJson() {
  formatJson(this.$refs.uploadAceEditor.getValue())
    .then(json => {
      store.commit('SET_DATA', json)
      this.showUpload = false
    })
    .catch(err => {
      this.$message({
        message: '数据格式有误',
        type: 'error',
        center: true,
      })
      console.error(err)
    })
},

  粘贴json数据,只有用户事先授予网站或应用对剪切板的访问许可后,才能使用异步剪切板读取方法 MDN (opens new window)。使用navigator.clipboard来访问剪切板,readText()异步读取剪切板内容,由于浏览器出于安全考虑,非本地或者网站是http协议,都不能读取剪切板内容。可在httphttps网站控制台打印navigator.clipboardhttp协议网站为undefined。故只有当https网站或者用户授予才可粘贴,否则显示取消按钮,由用户手动粘贴。

// layout -> components -> ButtonView.vue
...
<template slot="action">
  <el-button v-if="showPasteBtn" size="small" @click="handlePaste">粘贴</el-button>

  <el-button v-else size="small" @click="showUpload = false">取消</el-button>
</template>

...
export default {
  data() {
    return {
      showPasteBtn: !!navigator.clipboard,
    }
  },
  methods: {
    ...
    handlePaste() {
      navigator.clipboard.readText().then(res => {
        this.$refs.uploadAceEditor.setValue(res)
      })
    },
  },
}

  清空时清除活动元素select,视图内列表清空。生成json,即显示表单json信息。复制功能引入第三方复制插件clipboard,剪切板实例参数为按钮类名、复制内容,二次封装提示信息,复制完成销毁剪切板实例。

// layout -> components -> ButtonView.vue
<el-button ... class="copyJson" @click="handleCopyJson"> 复制</el-button>

handleCopyJson() {
  this.handleCopyText('jsonAceEditor', '.copyJson')
},

handleCopyText(ref, className) {
  copyText(this.$refs[ref].getValue(), className)
    .then(res => {
      this.$message({
        message: '复制成功',
        type: 'success',
        center: true,
      })
    })
    .catch(err => {
      this.$message({
        message: '复制失败',
        type: 'error',
        center: true,
      })
    })
},

// utils -> index.js
function copyText(text, className) {
  const clipboard = new Clipboard(className, {
    text: () => text,
    ...
  })

  return new Promise((resolve, reject) => {
    clipboard.on('success', () => {
      resolve()
      clipboard.destroy()
    })

    clipboard.on('error', () => {
      reject()
      clipboard.destroy()
    })
  })
}

  设计工具目的是设计json表单数据,某个独立组件传参表单数据渲染为表单。封装独立组件GenerateForm.vueButtonView.vue引入并插入预览弹框插槽,传入全局statedata

// layout -> components -> ButtonView.vue
<public-dialog>
  ...
  <generate-form :data="data" />
</public-dialog>

...
export default {
  computed: {
    data() {
      return store.state.data
    },
  },
}

// components -> ButtonView -> GenerateForm.vue
<div class="generate-form">
  <el-form ...>
    <component ... />
  </el-form>
</div>

export default {
  props: {
    data: { ... },
  },
}

  点击预览,单个组件正常渲染(文字、html未显示),栅格内单个组件渲染后残留部分图标。原因是因为栅格组件内部引入的小部件,小部件内部含图标,即预览时栅格内部不应渲染小部件,而应渲染表单元素。GenerateForm.vue批量引入组件,组件传值不可拖动,栅格接收参数,仅渲染为表单元素。栅格内批量引入组件,组件内又包括栅格,即组件循环引用,在beforeCreate再次注册。

// components -> ButtonView -> GenerateForm.vue
<component ... :draggable="false" />

// elements -> grid -> view.vue
<el-col>
  <draggable v-if="draggable"> ... </draggable>
  
  <template v-else>
    <component ...>
  </template>
</el-col>

import components from 'elements/view'

export default {
  beforeCreate() {
    Object.assign(this.$options.components, components)
  },
  ...
}

  除文字、html外基本可实现预览,获取数据还不可用,表单元素字段属性配置默认值后,ViewForm.vue视图还未显示,但是几乎全部表单元素都将value作为组件传值,小部件传递组件默认值即可显示,栅格内列表也引入的小部件,故栅格内表单元素也会显示默认值。

// elements -> input -> view.vue
<input :value="value" />

...
export default {
  ...
  props: {
    value: {},
  },
}

// components -> ButtonView -> Widget.vue
<component ... :value="element.options.defaultValue" draggable />

  点击预览尚不可显示默认值,而且默认值必然与表单组件双向绑定。故需自定义表单组件元素的 v-model (opens new window),声明传入组件的prop,同时表单值变化触发某个事件的时候,更新prop。文字和html不做双向绑定,但是内部依然可以组件传值value,另外分割线无需组件传值。

// elements -> input -> view.vue
<el-input ... :value="value" @input="value => $emit('change', value)" />

export default {
  ...
  model: {
    prop: 'value',
    event: 'change',
  },
  props: {
    ...
    value: {},
  },
}

  ;GenerateForm.vue内部是el-form,内部引入不同表单元素,表单元素已实现双向绑定,接下来需要与数据绑定,栅格和分割线不用绑定变量,但是栅格内部元素和外部其他元素均绑定modelGenerateForm.vue初始化的models传入栅格,由于对象传值引用,故栅格内部元素也能绑定,栅格内部可能嵌套栅格,models需传递下去。

// components -> ButtonView -> GenerateForm.vue
<el-form :model="models" ...>
  <components v-model="models[element.model]" :models="models" ... />
</el-form>

export default {
  data() {
    return {
      models: {},
    }
  },
  created() {
    this.handleSetModels()
  },
  methods: {
    handleSetModels() {
      const models = {}

      getGridModel(this.data.list)

      this.models = models

      function getGridModel(list) {
        list.forEach(element => {
          if (element.type === 'grid') {
            element.columns.forEach(column => {
              if (column.list.length) {
                getGridModel(column.list)
              }
            })
          } else {
            if (element.type !== 'divider') {
              models[element.model] = element.options.defaultValue
            }
          }
        })
      }
    },
  },
}

// elements -> grid -> view.vue
<components v-model="models[element.model]" :models="models" ... />

export default {
  props: {
    models: { ... },
  },
}

  点击获取数据后,将models数据放入编辑器,GenerateForm.vue内部加入getData方法返回modelsmodels需拷贝副本返回,保证组件内models不被污染。

// components -> ButtonView -> GenerateForm.vue
getData() {
  return deepClone(this.models)
},

// layout -> components -> ButtonView.vue
<generate-form ref="generateForm" :data="data" ... />

handleGetData() {
  this.models = this.$refs.generateForm.getData()
  this.showPreviewData = true
},

# Tinymce 富文本编辑器

  最初决定使用tinymce作为富文本编辑器主要是由于tinymce操作按钮很容易控制,图片上传很方便,中文文档 (opens new window) 也容易上手,不足部分就是依赖tinymce-vue且很多功能声明后还需单独引入才能使用,组件语言部分要单独引入js。其他编辑器图片上传很复杂,并且最主要的是上传的图片大小不可控制,有的文档也不完善,后续可能会替换其他编辑器,wangEditor可作为尝试。

  组件内配置详细可参考源代码,其中图片上传部分详细描述。使用images_upload_handler自定义图片上传,参数分别为blobInfosuccessfailureblobInfo为图片文件详细信息(文件名、base64等),success为图片上传成功回调,传参图片url地址,failure为图片上传失败回调,传参错误描述信息。用户引入GenerateForm.vue不可见自定义图片上传函数体,若要获取文件信息、回调函数只能通过子组件传值父组件,且栅格引入后需逐层向上传递。editorUploadImage函数内可获取文件信息,也可异步调用失败和成功回调函数。

// elements -> editor -> view.vue
images_upload_handler: (blobInfo, success, failure) => {
  this.$emit('editor-upload-image', {
    blobInfo,
    success,
    failure,
    model: this.element.model,
  })
},

// elements -> grid -> view.vue
<component @editor-upload-image="data => $emit('editor-upload-image', data)" />

// components -> ButtonView -> GenerateForm.vue
<component @editor-upload-image="data => $emit('editor-upload-image', data)" />

// layout -> components -> ButtonView.vue
<generate-form ... @editor-upload-image="editorUploadImage" />

editorUploadImage({ blobInfo, success }) {
  success('data:image/jpeg;base64,' + blobInfo.base64())
},

# blank 自定义区域

  若设计工具仅仅支持上述表单组件,设计工具的局限性会非常大,尚不支持Tabs标签页、表格,也不支持引入第三方的表单组件,所以需提供自定义区域插槽,用户再根据实际情况插入不同的表单组件,以此增加表单延展性。

  首先要明确的是,组件内部若有多个同名具名插槽,外部插入元素时,均会插入到同名具名插槽内部。组件外部插入多个不同插槽名元素时,只有和组件内插槽名相同的元素才能插入。GenerateForm.vue初始化时不仅需要创建绑定表单models,还要获取表单内所有自定义区域的model,即是自定义区域内插槽的名称。

  若GenerateForm.vue外部插入ABC等若干个不同插槽名的元素,表单内含自定义区域A、栅格嵌套多层的BGenerateForm.vue根据表单内自定义区域个数创建slots数组(AB),创建AB对应具名插槽,自定义区域A外部AB两个元素待插入,但是由于自定义区域A内部只有插槽A,则A元素插入自定义区域A内部。

<generate-form>
  <div slot="A">A</div>
  <div slot="B">B</div>
  <div slot="C">C</div>
  ...
</generate-form>

// generate-form
<div>
  <blank-A>
    <template slot="A">
      <slot name="A" />
    </template>
    <template slot="B">
      <slot name="B" />
    </template>
  </blank-A>
</div>

// black-A
<div>
  <slot name="A" />
</div>

  自定义区域假设嵌套两层栅格,传入slots数组(AB),第一层栅格外部插入AB元素,第一层栅格内部解析为第二层栅格并创建AB插槽,第二层栅格外部插入AB元素,内部解析为自定义区域B并创建AB插槽,由于自定义区域B内部只有插槽B,则B元素插入自定义区域B内部。

<generate-form>
  <div slot="A">A</div>
  <div slot="B">B</div>
  <div slot="C">C</div>
  ...
</generate-form>

// generate-form
<div>
  <grid-1>
    <template slot="A">
      <slot name="A" />
    </template>
    <template slot="B">
      <slot name="B" />
    </template>
  </grid-1>
</div>

// grid1
<div>
  <grid-2>
    <template slot="A">
      <slot name="A" />
    </template>
    <template slot="B">
      <slot name="B" />
    </template>
  </grid-2>
</div>

// grid-2
<div>
  <black-B>
    <template slot="A">
      <slot name="A" />
    </template>
    <template slot="B">
      <slot name="B" />
    </template>
  </black-B>
</div>

// black-B
<div>
  <slot name="B" />
</div>

  上述原理基本接近源代码,页面创建初始化modelsslots。自定义区域将models放在model字段上,无论栅格嵌套多少层,scope.model始终为models,并且models与表单内部models是同一个models,自定义组件双向绑定models变量,预览获取数据始终为表单值models(包括自定义组件)。

// components -> ButtonView -> GenerateForm.vue
<component :slots="slots">
  <template v-for="slot in slots" :slot="slot" slot-scope="scope">
    <slot :name="slot" :model="scope.model" />
  </template>
</component>

handleSetModels() {
  const models = {}
  const slots = []

  getGridModel(this.data.list)

  this.models = models
  this.slots = slots

  function getGridModel(list) {
    list.forEach(element => {
      if (element.type === 'grid') {
        ...
      } else {
        if (element.type === 'blank') {
          slots.push(element.model)
        }
        ...
      }
    })
  }
},

// elements -> grid -> view.vue
<component :slots="slots">
  <template v-for="slot in slots" :slot="slot" slot-scope="scope">
    <slot :name="slot" :model="scope.model" />
  </template>
</component>

// elements -> blank -> view.vue
<div>
  <slot :name="element.model" :model="models">
    <div class="custom-area">{{ element.model }}</div>
  </slot>
</div>

# 表单功能

# 重置

  表单重置其实调用el-form resetFields方法基本就能完成重置,但是时间和日期选择器重置存在bug。时间范围下绑定值times,初始值null,选择时间后重置,times值为[ null ],绑定值空数组[],打开时间选择器无法选择时间。日期范围下均存在这种bug,故范围选择方式统一默认值null,表单内部重置重写一次。

<el-form ref="form" :model="form">
  <el-form-item prop="times" label="时间范围">
    <el-time-picker v-model="form.times" value-format="HH-mm-ss" is-range></el-time-picker>
  </el-form-item>
</el-form>

<el-button @click="$refs.form.resetFields()">重置</el-button>

export default {
  data() {
    return {
      form: {
        times: null,
      },
    }
  },
}

// components -> ButtonView -> GenerateForm.vue
reset() {
  this.$refs.modelsForm.resetFields()
  this.resetTimePicker()
},

resetTimePicker() {
  for (const key of Object.keys(this.models)) {
    if (
      Array.isArray(this.models[key]) &&
      this.models[key].length === 1 &&
      this.models[key][0] === null
    ) {
      this.models[key] = null
    }
  }
},

# 校验规则

  校验规则大多数涉及必填,只有单行文本和多行文本较为特殊。单行文本支持验证器验证和正则表达式验证,多行文本支持正则表达式验证。必填字段星号可由组件传值el-fom-item required属性控制。

<el-form-item :required="element.options.required">...</el-form-item>

  ;element-ui el-form表单验证方式包括四种,必填方式最为常用,指定required true开启必填,正则表达式方式pattern可直接指定正则表达式,验证器方式包括字符串string、数字number、整数interger、浮点数floatURL类型、16进制hex、电子邮箱email等,自定义验证方式最灵活,可定制化设置验证规则,详细参考 element-ui (opens new window)

rules: {
  name: [{ required: true, message: '请输入姓名' }],
  phone: [{ pattern: /^1[3456789]d{9}$/, message: '格式有误' }],
  email: [{ type: 'email', message: '格式有误' }],
  psw: [{ validator: validatePsw }],
},

  表单初始化时不仅创建modelsslots,验证规则rules也要创建。所有属性均会添加必填方式,但是requiredfalse不会开启必填。isPattern为正则验证方式,其中单行文本和多行文本独有,new RegExp实例化正则。

// components -> ButtonView -> GenerateForm.vue
<el-form />

function handleSetModels() {
  const rules = {}
  ...
  getGridModel(this.data.list)

  this.rules = rules

  function getGridModel(list) {
    list.forEach(element => {
      if (element.type === 'grid') {
        ...
      } else {
        rules[element.model] = [
          {
            required: !!element.options.required,
            message: element.options.requiredMessage || `请输入${element.name}`,
          },
        ]

        if (element.options.isType) {
          rules[element.model].push({
            type: element.options.type,
            message: element.options.typeMessage || `${element.name}验证不匹配`,
          })
        }

        if (element.options.isPattern) {
          rules[element.model].push({
            pattern: new RegExp(element.options.pattern),
            message: element.options.patternMessage || `${element.name}格式不匹配`,
          })
        }
      }
    })
  }
}

  验证器方式中数字、整数、浮点数较为特殊,若指定其中一种,表单验证是不会通过的,因为单行文本双向绑定值始终为字符串,不会为数字类型。解决方式指定需指定el-input为数字类型,且触发change事件需转换为数值。

<common-view ...>
  <el-input
    v-if="element.options.isType && ['number', 'integer', 'float'].includes(element.options.type)"
    type="number"
    @input="input"
  />

  <el-input v-else @input="input" />
</common-view>

input(value) {
  const { type, isType } = this.element.options

  if (isType && ['number', 'integer', 'float'].includes(type) && value !== '') {
    value = Number(value)
  }

  this.$emit('change', value)
},

# 生成代码

  生成代码部分需要根据表单json往固定模板填充数据,默认包括提交、重置按钮,组件传参jsonData为表单json数据,editData为表单初始值,remoteOption是级联选择器列表数据。

// elements -> cascader -> view.vue
<el-cascader ... :options="remoteOption && remoteOption[element.options.remoteOption]" />

  若组件使用了富文本编辑器,默认包含事件editor-upload-image及其对应处理函数。

editorUploadImage({ model, blobInfo, success, failure }) {
  // success(图片地址) / failure(失败说明) 可异步调用
  // success('http://xxx.xxx.xxx/xxx/image-url.png')
  // failure('上传失败')
  success('data:image/jpeg;base64,' + blobInfo.base64())
}, 

  插槽部分根据slots列表插入组件内部,默认包含插槽名和变量绑定方式。editData传入组件内部,GenerateForm.vue内部需要合并默认值和editData

this.models = Object.assign(models, deepClone(this.value))

# HTML 默认值

  ;html默认值最初放置的是多行文本框,现在引入AceEditor后需要调整。AceEditor内部修改值后需更新html默认值,通过change事件触发。

// compoents -> AceEditor.vue
this.editor.session.on('change', delta => {
  this.$emit('change', this.getValue())
})

// elements -> html -> config.vue
<ace-editor ... @change="handelChange" />

handelChange(text) {
  this.data.options.defaultValue = text
},

  若表单内包括多个html组件,切换组件发现AceEditor内部值并未发生改变。html字段属性始终是同一个AceEditor,所以值不会发生改变。内部需要监听html对象的改变,调用组件内部setValue方法赋值。元素拖入立即执行监听hander,默认对象非深度监听,但是不能开启深度监听,原因是因为组件内部change事件触发更新默认值时,开启深度监听后,hander会触发再次调用组件内setValuesetValue再次触发change,会造成循环调用页面卡死。即监听对象引用改变,不监听对象内容改变即可。

data: {
  handler() {
    this.$nextTick(() => {
      this.$refs.htmlAceEditor.setValue(this.data.options.defaultValue)
    })
  },
  deep: false,
  immediate: true,
},

# 发布维护

# NPM 组件

  为方便后期使用,可将项目发布为npm包。与main.js同级目录创建index.js,引入需导出的组件和样式。Vue.use(cpn)默认调用cpninstall方法注册组件,参数默认为Vue

// index.js
const components = [GenerateForm, MakingForm]

const install = Vue => {
  components.forEach(component => {
    Vue.component(component.name, component)
  })
}

export default {
  install,
}

// 引用方式
import DwFormMaking from 'dw-form-making'
Vue.use(DwFormMaking)

  组件部分引入方式。

// index.js
import GenerateForm from 'components/ButtonView/GenerateForm'
import MakingForm from './layout/index'

export { GenerateForm, MakingForm }

// 调用方式
import { GenerateForm, MakingForm } from 'dw-form-making'

Vue.component(GenerateForm.name, GenerateForm)
Vue.component(MakingForm.name, MakingForm)

  新增script命令,name为构建名称,最后一个参数为入口文件。参数以及构建库输出文件详细参考 vue-cli (opens new window) 库。

// package.json
"scripts": {
  ...
  "publish": "vue-cli-service build --target lib --name DwFormMaking ./src/index.js",
},

  配置package.json,详细描述发布包所需字段。

  • name:包名,名字是唯一的,可在npm官网查询是否重复
  • description:描述,npm官网查询出包后的描述信息
  • version:版本号,每次发布版本号不能和历史版本号相同
  • author:作者
  • private:是否私有,false公开才能发布到npm
  • keywords:关键字,通常用于npm关键字搜索
  • main:入口文件,指向编译后的包文件
  • filesnpm白名单,只有files中指定的文件或文件夹会被打包到项目中

  配置完成后,运行npm run publish会生成相关的包文件,且默认位于dist目录下。

  申请npm官方账号后,运行npm login,输入用户名、密码和邮箱,注意密码输入后是不可见的,输入后回车请求登录到npm

  登录成功后控制台输出如下。

  紧接着再运行npm publish命令,将package.jsonfiles属性指定的文件发布到npm

  控制台输出如下命令,即表示发布成功,后续邮箱中也会收到发布成功的邮件。

  现在就可以运行npm i dw-form-making下载刚才发布的包了,展开node_modulesdw-form-making文件夹,果然是将files指定的文件发布了。

# Git 远程库维护

  ;GitHub新建仓库后,本地再关联远程仓库。

git remote add origin https://github.com/username/repository.git

  将本地master分支推送至远程仓库。

git push origin master

# 在线预览

  ;GitHub提供了静态页面的预览功能,并默认展示分支下的index.html文件。

  因此可以单独创建一个page分支。

git branch page

  然后运行npm run build,删除目录内除libnode_modules.gitignore外的所有文件,移动lib内文件到外层,最终根目录结构大致如下。

  暂存并提交page分支的修改,推送至远程仓库。

git push origin page

  假设上述都没有问题,那么将会在GitHub查看到如下分支结构。

  点击仓库内Settings按钮,找到侧边Pages项。

  选择page分支,root根目录,点击Save

  呐,你的预览页就已经发布到https://xxx.github.io/dw-form-making/了,点击试试吧。

# 后记

  开源的表单设计器基础版本使用范围很小,设计器内部非常多的bug,最为基本的栅格也不支持。某天空闲突然对其源码感兴趣,大致梳理发现其业务逻辑繁杂,组件层级非常深,部分组件代码冗余,甚至单个组件内部代码接近500行,可读性和拓展性很差。于是参考其样式,直接重构了js部分。

  最为基础的表单组件基本实现并可预览,较为复杂的栅格布局需要仔细梳理,理解了其中栅格的递归嵌套逻辑,很快就能实现。基于此为基础的自定义区域,也就是递归组件内的作用域插槽,最为耗时,由于是空闲时间做的工具,工作时间稍微有点想法会实现一个demo,考虑过render函数,也考虑过缩小组件层级,最终的插槽v-for也是某个时刻偶然想到的。项目之前包括选择树、代码编辑器,仔细考虑后决定删除。最大原因还是为了缩小项目体积,其中组件更多不过是完善,差异也只是大同小异,不同基本组件都涉及,定制组件自定义引入,简单而不简单。

  项目整体难度也不高,此笔记仅是记录重构过程的部分思路。重构初一方面由于兴趣使然,另一方面对其内部逻辑和npm包的发布新奇。整体下来可以巩固element-ui表单组件的使用,部分其它组件也有涉及,对于页面布局、类名的设定、代码规范都是一次练习。其中递归组件、作用域插槽、组件循环引用较复杂,仔细梳理也能明白其中原理。代码管理方面也可巩固Git基本命令的使用、多远程库的管理。

  工具可在线预览或克隆,之前Git提交次数过多导致版本库较大,已重新创建了仓库。源代码在GitHub开源,工具名 dw-form-making (opens new window)

# 更新日志

# 2020/12/11 17:18

  可能你也注意到了,代码中用到store的地方都是引入再使用,为什么不放在vue原型对象prototype上,代码中将最大程度还原vuex的调用方式。

import store from './store'
Vue.prototype.$store = store

  但是发布为 vue (opens new window) 插件,store放在vue的原型上不是那么理想,首先来看看组件install方法,第一个参数是Vue构造器,也就是执行Vue.use()时的Vue,倘若像上述给予Vue原型上添加$store,有一个很糟糕的情况,则是$store关键字被占用了,页面只有单独定义其他关键字,否则$store直接被覆盖掉。更糟糕的情况则是引用vuex状态管理的项目,由于vuexbeforeCreate首行注入$store,若同时集成表单工具,可能会导致工具崩溃,出现意料之外的bug

  此次更新修复了栅格复制key值的bug,删除掉组件内部分未引用的变量。并且在工具内控制台输出了彩蛋(试一试 (opens new window)),不包括npm组件。

# 2022/03/05 17:35

  虽然是个人维护的小项目,代码风格大多数都是统一的,但是也会存在很多不合理的风格。因此根据 vscode 插件与 npm 包,保存时自动修复代码格式错误 一文,引入了eslintprettier来规范和格式化项目的代码。组件内props传值类型和默认值也极为不合理,通过eslint检测已经全部修复。

  另外在仓库下新增了deploy.sh脚本,主要用来快捷部署Pages静态页面。

  由于GitHub的仓库经常访问缓慢或者无响应,因此利用Gitee镜像了此仓库(看一看 (opens new window))。

# 🎉 写在最后

🍻伙伴们,如果你已经看到了这里,觉得这篇文章有帮助到你的话不妨点赞👍或 Star (opens new window) ✨支持一下哦!

手动码字,如有错误,欢迎在评论区指正💬~

你的支持就是我更新的最大动力💪~

GitHub (opens new window) / Gitee (opens new window)GitHub Pages (opens new window)掘金 (opens new window)CSDN (opens new window) 同步更新,欢迎关注😉~

最后更新时间: 3/6/2022, 9:06:37 PM