Vue3+Vite+TypeScript基于Element plus 二次封装【表单】组件(含Vue3知识点)

6,049 阅读4分钟

前言

表单在web应用中是使用频率最高的组件之一,好用,可复用的表单,可以提升实际工作的效率,我们使用Vue3+Vite+TS基于Element plus 二次封装表单组件,注册挂载为全局使用。封装好我们的表单组件,使用时就可以以配置的方式来使用表单啦!

一、前置知识

如果要自己手写实现一遍的话,建议先从这篇文章的基础项目搭建开始

首先表单中,校验规则是必须的,在软件应用开发中有这样一句话“永远不要相信用户提交的数据”,其实就要通过校验限制用户的入手。一方面,从安全的角度,防止黑客提交含有恶意脚本表单,破环系统的正常运行,另一方面,从数据存储的角度,提交的数据是要干净的,例如一些字段要限制长度,一些图片和视频上传时要限制大小,减小服务器负担。

由此可见,表单在web 应用程序中的举足轻重。

二、功能介绍

  1. 可配置型的表单,通过json对象的方式自动生成表单
  2. 具备完善的功能, 表单验证, 动态删减表单,集成第三方插件
  3. 用法简单,扩展性好,便于维护
  4. 多场景,如: 弹框嵌套表单

三、准备阶段

  1. 分析element-plus表单能够用在哪些方面做优化
  2. 完善类型,支持ts
  3. 具备原有的element-plus表单功能
  4. 集成第三方插件: markdown编辑器,富文本编辑器等等。

四、所需资料

element-plus使用的表单校验插件也是async-validator这个库,

async-validator地址 下面表单的rules.ts 文件就是之间拷贝这个校验规则文件的

async-validator.gif

五、开始实现

我们使用的时TypeScript 使用类型校验也是必须的,

0、表单组件目录和options类型文件

目录结构

image.png

表单的rules.ts 文件就是拷贝上面动图校验规则文件的

async-validator地址

我们后面在页面使用表单使用options的时候需要的类型接口文件 types.ts

import { CSSProperties } from 'vue'
import { RuleItem } from './rules' // (1)这里导入了rules校验规则文件
import { ValidateFieldsError } from 'async-validator' // (2)安装element-plus的时候会下载这个依赖

interface Callback {
  (isValid: boolean, invalidFields?: ValidateFieldsError): void
}

// 表单每一项的配置选项
export interface FormOptions {
  // 表单项显示的元素
  type: 'cascader' | 'checkbox' | 'checkbox-group' | 'checkbox-button' | 'color-picker' |
  'date-picker' | 'input' | 'input-number' | 'radio' | 'radio-group' | 'radio-button' | 'rate' |
  'select' | 'option' | 'slider' | 'switch' | 'time-picker' | 'time-select' |
  'transfer' | 'upload' | 'editor',
  
  value?: any, // 表单项的值
  label?: string, // 表单项label
  prop?: string, // 表单项的标识
  rules?: RuleItem[], // 表单项验证规则
  placeholder?: string, // 占位符
  // 表单元素特有的属性
  attrs?: {
    clearable?: boolean,
    showPassword?: boolean,
    disabled?: boolean,
    // CSS 样式属性
    style?: CSSProperties,
  }
  // 表单项的子元素
  children?: FormOptions[],
  // 处理上传组件的属性和方法
  uploadAttrs?: {
    action: string,
    header?: string,
    method?: 'post' | 'put' | 'patch',
    multiple?: boolean,
    data?: any,
    name?: string,
    withCreadentials?: boolean,
    howFileList?: boolean,
    drag?: boolean,
    accept?:string,
    thumbnailMode?: boolean,
    listType?: 'text' | 'picture' | 'picture-card',
    autoUpload?: boolean,
    limit?:number,
  }
}

export interface ValidateFieldCallback { 
  (message: string, invalidFields?: ValidateFieldsError): void
}


export interface FormInstance {
  registerLabelWidth(width: number, oldWidth: number): void,
  deregisterLabelWidth(width: number): void,
  autoLabelWidth: string | undefined,
  emit: (evt: string, ...args: any[]) => void,
  labelSuffix: string,
  inline?: boolean,
  model?: Record<string, unknown>,
  size?: string,
  showMessage?: boolean,
  labelPosition?: string,
  labelWidth?: string,
  rules?: Record<string, unknown>,
  statusIcon?: boolean,
  hideRequiredAsterisk?: boolean,
  disabled?: boolean,
  validate: (callback?: Callback) => Promise<boolean>,
  resetFields: () => void,
  clearValidate: (props?: string | string[]) => void,
  validateField: (props: string | string[], cb: ValidateFieldCallback) => void,
}

element-plus 官方文档

1、单一输入框组件处理(input)

这里的单一输入主要是input组件的处理,不过upload上传组件editor富文本编辑器也是单一的所以放在同一个template进行 v-if判断处理 ,根据每个itme的类 型 判断渲染方式, 如: v-if="item.type !== 'upload'", 主要也是用到了动态组件component

  <el-form-item
    v-if="!item.children || !item.children!.length"
    :prop="item.prop"
    :label="item.label"
   >
    <component
      v-if="item.type !== 'upload' && item.type !== 'editor'"
      v-bind="item.attrs"
      :is="`el-${item.type}`"
      :placeholder="item.placeholder"
      v-model="model[item.prop!]"
      >
    </component>
    <el-upload
      v-if="item.type === 'upload'"
      v-bind="item.uploadAttrs"
      :on-preview="onPreview"
      :on-remove="onRemove"
      :on-success="onSuccess"
      :on-error="onError"
      :on-progress="onProgress"
      :on-change="onChange"
      :before-upload="beforeUpload"
      :before-remove="beforeRemove"
      :http-request="httpRequest"
      :on-exceed="onExceed"
     >
      <slot name="uploadArea"></slot>
      <slot name="uploadTip"></slot>
    </el-upload>
    <div class="full-screen-container" >
      <div id="toolbar" v-if="item.type === 'editor'" style="z-index: 101;"></div>
      <div id="editor" v-if="item.type === 'editor'" style="height: 300px; z-index: 100;"></div>
    </div>
  </el-form-item>

2、多级嵌套框的处理(select、checkbox-group、radio-group)

有children属性,而且children有值的时候 判断是多级组件,这里边嵌套了两个component组件 对应的 不同的组和子项

  <el-form-item
    v-if="item.children && item.children.length"
    :prop="item.prop"
    :label="item.label"
   >
    <component
      v-bind="item.attrs"
      :is="`el-${item.type}`"
      :placeholder="item.placeholder"
      v-model="model[item.prop!]"
     >
      <component
        v-for="(child, i) in item.children"
        :key="i"
        :is="`el-${child.type}`"
        :label='child.label'
        :value='child.value'
      >
      </component>
    </component>
  </el-form-item>

3、上传组件的处理(upload)

   <el-upload
      v-if="item.type === 'upload'"
      v-bind="item.uploadAttrs"
      :on-preview="onPreview"
      :on-remove="onRemove"
      :on-success="onSuccess"
      :on-error="onError"
      :on-progress="onProgress"
      :on-change="onChange"
      :before-upload="beforeUpload"
      :before-remove="beforeRemove"
      :http-request="httpRequest"
      :on-exceed="onExceed"
     >
      <slot name="uploadArea"></slot>
      <slot name="uploadTip"></slot>
    </el-upload>

4、富文本组件处理(第三方插件wangeditor)

 <div class="full-screen-container" >
      <div id="toolbar" v-if="item.type === 'editor'" style="z-index: 101;"></div>
      <div id="editor" v-if="item.type === 'editor'" style="height: 300px; z-index: 100;"></div>
 </div>

富文本这部分可以参考官网快速开始,按照步骤配置即可

image.png

第三方插件wangeditor v5官网地址

5、表单的操作(取消与确认按钮)

使用slot具名插槽的方式,将表单的实例form 表单数据model ,传给父组件

  <el-form-item>
      <slot name="action" :form='form' :model='model'></slot>
  </el-form-item>

6、表单组件的整体(方法和属性传递)

这里我们把v-for 循环放到 templateoptions 是我们从父组件得到的配置对象数据。

image.png

重点知识

像这样,使用具名插槽表单的实例和绑定的model 数据进行传递

 <el-form-item>
  <slot name="action" :form='form' :model='model'></slot>
 </el-form-item>

使用vue3新特性 defineExpose 方法暴露出子组件方法到父组件实例中使用

// 使用vue3的新api 分发方法,它替代了之前的$children方法
defineExpose({
  resetFields,
  validate,
  getFormData
})

父组件使用

//设置form 为组件实例, ref="form"获取组件实例
<w-form  ref="form" /> 

let form = ref() 

form.value.resetFields()

script的业务逻辑部分

我们引入了 lodash 函数工具库wangeditor 富文本编辑器


<script lang="ts" setup>
import { PropType, ref, onMounted, watch, nextTick } from 'vue'
import { FormOptions, FormInstance } from './types/types'
import cloneDeep from 'lodash/cloneDeep'
import '@wangeditor/editor/dist/css/style.css'
import { createEditor, createToolbar, IEditorConfig, IDomEditor } from '@wangeditor/editor'


let props = defineProps({
  // 表单的配置项
  options: {
    type: Array as PropType<FormOptions[]>,
    required: true
  },
  // 用户自定义上传方法
  httpRequest: {
    type: Function
  }
})

let emits = defineEmits([
  'on-preview', 'on-remove', 'on-success', 'on-error', 
  'on-progress', 'on_change', 'before-upload', 'before-remove','on-exceed'
])

let model = ref<any>(null)
let rules = ref<any>(null)
let edit = ref()

let form = ref<FormInstance | null>()

// 初始化表单方法
let initForm = () => {
  if(props.options && props.options.length){
    let m: any = {username: '', password: ''}
    let r: any = {}
    props.options.map((item: FormOptions) => {
      m[item.prop!] = item.value
      r[item.prop!] = item.rules
      // 初始化富文本编辑器
      if(item.type === 'editor'){
        nextTick(()=> {
          if(document.getElementById('editor') && document.getElementById('toolbar')){
            const editorConfig: Partial<IEditorConfig> = {}
            editorConfig.placeholder = item.placeholder!
            editorConfig.onChange = (editor: IDomEditor) => {
              // 当编辑器选区、内容变化时,即触发
              // console.log('content', editor.children)
              // console.log('html', editor.getHtml())
              // console.log('text', editor.getText());
              
              model.value[item.prop!] = editor.getHtml()
            }

            // 创建编辑器
            const editor = createEditor({
              selector: '#editor',
              config: editorConfig,
              mode: 'default' // 或 'simple' 参考下文
            })
            // 创建工具栏
            const toolbar = createToolbar({
              editor,
              selector: '#toolbar',
              mode: 'default' // 或 'simple' 参考下文
            })
            edit.value = editor
          }
        })
      }
    })
    model.value = cloneDeep(m)
    rules.value = cloneDeep(r)
  }
}
onMounted(() => {
  initForm()
})

// 组件重写表单重置的方法
let resetFields = () => {
  // 1、重置element-plus 的表单
  form.value!.resetFields()
  // 2、重置富文本编辑器的内容
  if(props.options && props.options.length){
    // 清空富文本内容
    edit.value.clear()
  }
}

// 表单验证
let validate = () => {
  return form.value!.validate
}

// 获取表单数据
let getFormData = () => {
  return model.value
}

// 使用vue3的新api 分发方法,它替代了之前的$children方法
defineExpose({
  resetFields,
  validate,
  getFormData
})

// 监听父组件传递过来的options
watch(() => props.options, () => {
  initForm()
},{ deep: true })

// 上传组件的所以方法
let onPreview = (file: File) => {
  emits('on-preview', file)
}

// 以下为up上传组件的回调方法
let onRemove = (file: File, fileList: FileList) => {
  emits('on-remove', { file, fileList })
}

let onSuccess = (response: any, file: File, fileList: FileList) => {
  // 上传图片成功, 给表单上传项赋值
  let uploadItem = props.options.find(item => item.type === 'upload')!
  model.value[uploadItem.prop!] = { response, file, fileList }
  emits('on-success', { response, file, fileList})
}

let onError = (err: any, file: File, fileList: FileList) => {
  emits('on-error', { err, file, fileList })
}

let onProgress = (event: any, file: File, fileList: FileList) => {
  emits('on-progress', { event, file, fileList })
}

let onChange = (file: File, fileList: FileList) => {
  emits('on-change', { file, fileList })
}

let beforeUpload = (file: File) => {
  emits('before-upload', file)
}

let beforeRemove = (file: File, fileList: FileList) => {
  emits('before-remove', { file, fileList})
}

let onExceed = (file: File, fileList: FileList) => {
  emits('on-exceed', { file, fileList })
}
</script>

上面的方法主要是来于elememt-plus 的 upload上传组件的内容

image.png

当然也不要忘记暴露组件到全局使用

import { App } from 'vue'
import wForm from './src/index.vue';
export default {
  install(app: App) {
    app.component('w-form',wForm)
  }
}

六 、使用表单组件

主要内容数据是options的对象数据,也可以写在外部,然后再导入这里。

1、在页面中使用表单

<template>
  <div>
    <w-form
      ref="form"
      label-width="100px"
      :options='options'
      @on-change="handleChange"
      @before-upload="handleBeforeUpload"
      @on-preview="handlePreview"
      @on-remove="handleRemove"
      @before-remove="beforeRemove"
      @on-success="handleSuccess"
      @on-exceed="handleExceed"
     >
      <template #uploadArea>
        <el-button type="primary" size="small">点击上传</el-button>
      </template>
      <template #uploadTip>
        <div style="color:#ccc;font-size:12px;">
          jpg/png files with a size less than 500KB.
        </div>
      </template>
      <template #action="scope">
        <el-button type="primary" @click="submitForm(scope)">提 交</el-button>
        <el-button @click='resetForm'>重 置</el-button>
      </template>
    </w-form>
  </div>
</template>

<script lang="ts" setup>
import { FormOptions, FormInstance} from '../../components/base/form/src/types/types'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ref } from 'vue'

let form = ref()

interface Scope {
  form: FormInstance,
  model: any,
}

let options: FormOptions[] =[
  {
    type: 'input',
    value: '',
    label: '用户名',
    prop: 'username',
    rules: [
      {
        required: true,
        message: '用户名不能为空',
        trigger: 'blur'
      },
      {
        min: 2,
        max:6,
        message: '用户名在2-6位置之间',
        trigger: 'blur'
      }
    ],
    attrs: {
      clearable: true,
    }
  },
  {
    type: 'input',
    value: '',
    label: '密码',
    prop: 'password',
    rules: [
      {
        required: true,
        message: '密码不能为空',
        trigger: 'blur'
      },
       {
        min: 6,
        max:10,
        message: '密码长度6-10位置之间',
        trigger: 'blur'
      }
    ],
    attrs: {
      showPassword: true,
      clearable: true
    }
  },
  {
    type: 'select',
    value: '',
    placeholder: '请选择职位',
    prop: 'role',
    label: '职位',
    attrs:{
      style: {
        width: '100%',
      },
    },
    rules: [
      {
        required: true,
        message: '职位不能为空',
        trigger: 'blur'
      }
    ],
    children: [
      {
        type: 'option',
        label: '经理',
        value: '1'
      },
      {
        type: 'option',
        label: '主管',
        value: '2'
      },
      {
        type: 'option',
        label: '销售',
        value: '3'
      }
    ]
  },
  {
    type: 'checkbox-group',
    value: [],
    prop: 'like',
    label: '爱好',
    rules: [
      {
        required: true,
        message: '爱好不能为空',
        trigger: 'blur'
      }
    ],
    children: [
      {
        type: 'checkbox',
        label: '足球',
        value: '1'
      },
      {
        type: 'checkbox',
        label: '篮球',
        value: '2'
      },
      {
        type: 'checkbox',
        label: '排球',
        value: '3'
      }
    ]
  },
   {
    type: 'radio-group',
    value: '',
    prop: 'gender',
    label: '性别',
    rules: [
      {
        required: true,
        message: '性别不能为空',
        trigger: 'blur'
      }
    ],
    children: [
      {
        type: 'radio',
        label: '男',
        value: '1'
      },
      {
        type: 'radio',
        label: '女',
        value: '2'
      },
      {
        type: 'radio',
        label: '保密',
        value: '3'
      }
    ]
  },
  {
    type: 'upload',
    label: '上传',
    prop: 'pic',
    uploadAttrs:{
      action: 'https://jsonplaceholder.typicode.com/posts/',
      multiple: true,
      limit:3
    },
    rules: [
      {
        required: true,
        message: '哈哈哈',
        trigger: 'blur'
      }
    ]
  },
  {
    type: 'editor',
    value: '',
    prop: 'desc',
    placeholder: '请输入内容hh',
    label: '描述',
    rules: [
      {
        required: true,
        message: '描述不能为空',
      }
    ]
  }
]

let submitForm = (scope: Scope) => {
  console.log('提交');
  scope.form.validate((valid) => {
    if(valid){
      console.log(scope.model);
      ElMessage.success('提交成功')
      resetForm()
    } else {
      ElMessage.error('表单填写错误')
    }
  });
}

let resetForm = () => {
  console.log('重置');
  form.value.resetFields()
}

let handleRemove = (file: any, fileList: any) => {
  console.log('handleRemove')
  console.log(file, fileList)
}

let handlePreview = (file: any) => {
  console.log('handlePreview')
  console.log(file)
}

let beforeRemove = (val: any) => {
  console.log('beforeRemove')
  return ElMessageBox.confirm(`Cancel the transfert of ${val.file.name} ?`)
}

let handleExceed = (val: any) => {
  console.log('handleExceed', val)
  ElMessage.warning(
    `The limit is 3, you selected ${val.files.length
    } files this time, add up to ${val.files.length + val.fileList.length} totally`
  )
}

let handleSuccess = (val: any) => {
  console.log('success')
  console.log(val)
}

let handleChange = (val: any) => {
  console.log('change')
  console.log(val)
}

let handleBeforeUpload = (val: any) => {
  console.log('handleBeforeUpload')
  console.log(val)
}

</script>

<style lang="" scoped>

</style>

效果动图

form.gif

2、在弹出框中使用表单

我们封装了弹出框组件,将表单放在弹出框组件里使用,然后在页面再使用弹出框组件时,其实是由了三层嵌套的组件关系。

嵌套关系如图

image.png

弹出框框组件的代码

在弹出框组件中使用了封装的表单组件

<template>
 <el-dialog
   v-model="dialogVisible"
   v-bind="$attrs"
 >
  <template #default>
    <w-form
      :options="options"
      label-width="80px"
      ref="form"
      @on-change="onChange"
      @before-upload="beforeUpload"
      @on-preview="onPreview"
      @on-remove="onRemove"
      @before-remove="beforeRemove"
      @on-success="onSuccess"
      @on-exceed="onExceed"
     >
      <template #uploadArea>
         <slot name="uploadArea"></slot>
      </template>
      <template #uploadTip>
         <slot name="uploadTip"></slot>
      </template>
    </w-form>
  </template>
  <template #footer>
    <slot name="footer" :form="form"></slot>
  </template>
 </el-dialog>
</template>

<script lang="ts" setup>
import { ref, watch, PropType } from "vue"
import { FormOptions } from '../../form/src/types/types'

let props = defineProps({
  visible: {
    type: Boolean,
    defalut: false
  },
  // 表单的配置项
  options: {
    type: Array as PropType<FormOptions[]>,
    required: true
  },
  // 下面方法对应为处理上传事件
   onChange: {
    type: Function
  },
  beforeUpload: {
    type: Function
  },
  onPreview: {
    type: Function
  },
  onRemove: {
    type: Function
  },
  beforeRemove: {
    type: Function
  },
  onSuccess: {
    type: Function
  },
  onExceed: {
    type: Function
  },
})

let emits = defineEmits(['update:visible'])

let dialogVisible = ref<boolean>(props.visible)

let form = ref()

watch(() => props.visible, (val: any) => {
  dialogVisible.value = val
})

watch(() => dialogVisible.value, (val: any) => {
  emits('update:visible', val)
})
</script>

全局注册弹出框组件

import { App } from 'vue'
import modelForm from './src/index.vue';
export default {
  install(app: App) {
    app.component('w-model-form',modelForm)
  }
}

在页面中使用弹出框组件

<template>
  <div>
    <el-button type="primary" @click='open'>open</el-button>
    <w-model-form
      title="编辑"
      v-model:visible="visible"
      :options="options"
      :on-change="handleChange"
      :on-success="handleSuccess"
     >
      <template #uploadArea>
        <el-button size="small" type="primary">点击上传</el-button>
      </template>
      <template #uploadTip>
        <div style="color:#ccc;font-size:12px;">
          jpg/png files with a size less than 500KB.
        </div>
      </template>
      <template #footer="{ form }">
        <span class="dialog-footer">
          <el-button @click="Cancel">取 消</el-button>
          <el-button type="primary" @click="Confirm(form)">确 认</el-button
          >
        </span>
      </template>
    </w-model-form>
  </div>
</template>

<script lang="ts" setup>
import { ref } from "vue"
import { FormOptions } from '../../components/base/form/src/types/types'
import { ElMessage } from 'element-plus'
let visible = ref<boolean>(false)

let open = () => {
  visible.value = true
}

let Cancel = () => {
  console.log('取消');
  // visible.value = false
}
let Confirm = (form: any) => {
  let validate = form.validate()
  let model = form.getFormData()
  validate((valid: any) => {
    if(valid){
      ElMessage.success('验证成功')
      console.log(model);
      visible.value = false
    } else {
      ElMessage.error('验证失败')
    }
  })
}
let options: FormOptions[] = [...略] // 这部分这里略了,可以看上面的options 或下载源码

let handleSuccess = (val: any) => {
  console.log('success')
  console.log(val)
}

let handleChange = (val: any) => {
  console.log('change')
  console.log(val)
}
</script>

效果动图

modelform.gif

七 、代码地址

上面的代码难免贴不全,想学习的话可以,到如下地址下载噢!,也可以star一下哈,后面还会更新

Vue3+Vite+TypeScript基于Element plus 二次封装组件

上一期内容回顾:项目结构的建设和基础组件

八、总结

本节为表单组件的封装和弹出框组件的封装,再将封装好的表单组件嵌入弹出框组件使用。我们引入了element-plus同款表单校验规则的async-validatorrules,根据表单的数据项options 封装了对应的数据类型接口types.ts,用是否含有children选项区别了表单的单一项和多级嵌套项,使用了upload组件,引入了第三方插件wangeditor富文本编辑器 lodash 函数库按需引入了cloneDeep方法。

用到的vue3知识点主要有父组件传值的defineProps,子组件事件发射的defineEmits<component :is='组件'></component>动态组件, 使用富文本编辑器时,用了nextTick来获取回调更新前的实例, 用了defineExpose它替代了之前的$children方法。

如果文章对你有帮助,不妨点赞、评论、关注~

【待续……】