阅读 14062

vue element web 表单设计工具

1 工具概述

1.1 简介

  项目名 dw-form-making,基于 element-ui 组件库的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 基础版本,表单组件未采用v-if判断方式渲染,原因一是表单组件较多,几乎全是v-if,容易造成代码冗余阅读性差,二是栅格布局采用组件递归,此种方式页面渲染性能差,每次递归页面v-if重复数次,故抛弃此种方式,采用动态组件方式渲染表单,不仅可读性高性能也好。

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

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

1.2 项目预览

  dw-form-making

1.3 示意图

在这里插入图片描述

1.4 文件目录配置

├── 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
复制代码

2 初始化

2.1 脚手架初始化

  初始空脚手架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,拖拽页面部分引入即可,不用全局引入。

npm i -S vuedraggable

import draggable from 'vuedraggable'
...
export default {
    components: {
       draggable
    }
...
复制代码

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

2.2 页面布局

  其中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 创建个人项目,选择合适的图标,下载本地压缩包解压导入,注意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 切换即可。

2.3 vuedraggable 拖动与 transition-group

  vuedraggable 官方文档提供了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
   tag="ul"
   v-model="list"
   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 
               class='view-form-item' 
              v-for='(element, index) in data' 
              :key='index'
            >
                <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 class='item-drag' v-if="select.key === element.key">
      <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.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
    ...
}
复制代码

2.4 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 
       :placeholder='element.options.placeholder' 
       v-if='element.type === "input"' 
   />
   <el-input type='textarea' v-if='element.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>
复制代码

3 表单元素操作

3.1 全局表单配置

  其实表单也是一个全局变量,包含表单配置(对齐方式、宽度、组件尺寸等)和内部元素。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
    }
  }
}
复制代码

3.2 动态组件

  目前元素可拖动至视图表单,同时配置标签等,表单也可全局配置。但是按钮视图的元素还很单一,逐渐完善后数量多达20个左右,若输入框等组件仅仅通过v-if判断渲染,首先全篇几乎是v-if全等判断,阅读性非常差,其次每渲染一个组件就会经过20次的v-if,视图表单引入栅格后,栅格每嵌套一级,v-if重复20次,表单一旦栅格层级较深、元素较多,渲染性能会非常差,再者后期自定义添加表单组件,每添加一个组件,调整代码的地方会非常多,维护非常困难。参考 vue-form-making 基础版本,高级版可能已重构,而且性能很好。表单配置也同理,全篇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 class='view-form-item' v-for='element in data.list' ...>
    <component :is='item.component' :element='element'>
    
    <div class='item-drag' v-if="select.key === element.key">
         <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
    }
  }
}
复制代码

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

  通用字段属性包括字段标识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 slot="label" v-if="!element.options.hideLabel">
      {{ element.name }}
  </template>

  <slot></slot>
</el-form-item>
复制代码

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

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

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

// ViewForm.vue
<div 
    class='view-form-item'
    v-for='element in data.list'
    :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 class='view-form-item'
    v-for='(element, index) in data.list'
>
    ...
    <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)
}
复制代码

4 ElementCate 组件

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

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

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

4.1 单行文本

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

// 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>
复制代码

4.2 多行文本

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

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

4.3 计数器

  计数器操作按钮位置传递参数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
     :max="data.options.max"
     :min="data.options.min"
     :step="data.options.step"
     v-model="data.options.defaultValue"
   />
</el-form-item>
复制代码

4.4 单选框组

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

// 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">{{ }}</el-radio>
   ...
</li>
复制代码

4.5 多选框组

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

// elements -> checkbox -> config.vue
handleDeleteOptions(element, index) {
   var 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);
}
复制代码

4.6 时间选择器

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

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

handleRangeChange() {
   this.data.options.defaultValue = this.data.options.isRange ? null : "";
}
复制代码

4.7 日期选择器

  日期选择器显示类型包括月份、年份、日期、多日期、日期范围等,范围类型默认值为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;
      ...
    }
  }
}
复制代码

4.8 评分

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

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

4.9 颜色选择器

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

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

4.10 下拉选择器

  下拉选择器添加选项与单选框组一致,删除元素即单选框组和多选框组的合并,单选多选切换保留默认值方式有差异。单选过渡多选,单选未选择默认值,值为空数组,单选选择默认值,值为包含默认值的数组。多选过渡单选,多选未选择默认值,值为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;
}
复制代码

4.11 开关

  开关参考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 : ''"
/>
复制代码

4.12 滑块

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

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

4.13 文字

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

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

4.14 html

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

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

4.15 级联选择器

  级联选择器一般异步获取数据源,默认含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="[]"
  ...
/>
复制代码

4.16 分割线

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

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

5 栅格布局

  上述部分仅仅支持单行单表单组件,尚无法满足简单的栅格布局,即一行无法显示多个表单组件,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
       v-for="(element, index) in list" :key="index"
       :is="element.component"
       :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 官方给出了示例,由于是批量注册,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 class="item-drag" v-if="select.key === element.key">
           <i class="iconfont icon-drag drag-icon"></i>
       </div>

       <div class="item-action" v-if="select.key === element.key">
           <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"
       :index='index'
       :key="element.key"
       :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"
       :index='index'
       :key="element.key"
       :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 
      :span="12" 
      v-for="(column, index) in element.columns"
      :key="index"
    >
      <draggable v-model="column.list" ...>
          ...
          <widget
             v-for="(element, index) in column.list"
             :index='index'
             :key="element.key"
             :element="element"
          />
      </draggable>    
    </el-col>
</el-row>
复制代码

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

// 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"
   :index='index'
   :data='data'
   :key="element.key"
   :element="element"
/>

// elements -> grid -> view.vue
<widget
   v-for="(element, index) in column.list"
   :index="index"
   :data='column'
   :key="element.key"
   :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) => {
              var key = uuid();
              el.key = key;
              el.model = el.type + "_" + key;
              if (el.type === "grid") {
                  resetGridKey(el);
              }
          });
      });
   }
}
复制代码

6 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。使用navigator.clipboard来访问剪切板,readText()异步读取剪切板内容,由于浏览器出于安全考虑,非本地或者网站是http协议,都不能读取剪切板内容。可在httphttps网站控制台打印navigator.clipboardhttp协议网站为undefined。故只有当https网站或者用户授予才可粘贴,否则显示取消按钮,由用户手动粘贴。

// layout -> components -> ButtonView.vue
...
<template slot="action">
   <el-button size="small" v-if="showPasteBtn" @click="handlePaste"
   >粘贴</el-button>
   <el-button size="small" v-else @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) {
    ...
    var 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,声明传入组件的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();
  },
  handleSetModels() {
      var 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 :data="data" ref="generateForm" .../>

handleGetData() {
      this.models = this.$refs.generateForm.getData();
      this.showPreviewData = true;
}
复制代码

7 Tinymce 富文本编辑器

  最初决定使用tinymce作为富文本编辑器主要是由于tinymce操作按钮很容易控制,图片上传很方便,中文文档 也容易上手,不足部分就是依赖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({ model, blobInfo, success, failure }) {
    success("data:image/jpeg;base64," + blobInfo.base64());
}
复制代码

8 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() {
  var models = {};
  var 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>
复制代码

9 表单功能

9.1 重置

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

<el-form :model='form' ref="form">
  <el-form-item prop='times' label="时间范围">
    <el-time-picker
      value-format="HH-mm-ss"
      is-range
      v-model="form.times"
    ></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;
    }
  }
}
复制代码

9.2 校验规则

  校验规则大多数涉及必填,只有单行文本和多行文本较为特殊。单行文本支持验证器验证和正则表达式验证,多行文本支持正则表达式验证。必填字段星号可由组件传值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

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>

handleSetModels() {
      ...
      var 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 
        type='number'
        @input='input'
        v-if='["number", "integer", "float"].includes(element.options.type)'
    />
    <el-input @input='input' v-else/>
</common-view>

input(value) {
  const type = this.element.options.type;
  if (["number", "integer", "float"].includes(type) && value !== "") {
     value = Number(value);
  }
  this.$emit("change", value);
}
复制代码

9.3 生成代码

  生成代码部分需要根据表单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('图片src')/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))
复制代码

9.4 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
}
复制代码

10 发布维护

10.1 NPM 组件

  为方便后期使用,可将项目发布为npm包。与mian.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为构建名称,最后一个参数为入口文件。参数以及构建库输出文件详细参考 vuecli 库

// 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官方账号后,npm login登录npm,包根目录下运行npm publish发布。

10.2 Git 多远程库维护

  githubgitee新建仓库(使用Readme文件初始化),若关联有不必要远程库,可删除。再分别关联giteegithubGitee服务Gitee Pages可预览网页,部署lib目录更新即可。

// 查看远程库信息
git remote -v

// 删除远程库
git remote rm origin

// 关联 GiHub 远程库
git remote add github https://github.com/username/repo.git

// 推送 GitHub
git push github master

// 拉取远程分支
git pull github master
复制代码

11 后记

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

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

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

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

12 更新日志

12.1 20/12/11 17:18

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

import store from './store'
Vue.prototype.$store = store
复制代码

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

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

文章分类
开发工具
文章标签