likes
comments
collection
share

Vue3+Element 封装FormModal(Dialog) 思路

作者站长头像
站长
· 阅读数 2

从头说起

在管理后台的项目里,最基础的需求就是增删改查,最最常见的页面结构就是一个表格,可以进行新增,删除,编辑。

Vue3+Element 封装FormModal(Dialog) 思路

这四个基础功能中查和删还好说,其中增和删,目前最常见的做法是,弹出一个窗口,窗口中有一个表单,然后点击确定提交,成功后关闭窗口,刷新表格。

Vue3+Element 封装FormModal(Dialog) 思路

这个东西使用的频率非常高,大部分时候结构都很类似,input select radio等等组成的表单,需要进行校验必填项,校验成功开始提交(网络请求)...在Element中大致是这样写的:

<template>
  <div>
    <el-button type="primary" @click="visible = true">新增</el-button>

    <el-dialog v-model="visible" title="Title">
      <el-form ref="formRef" :model="form" :rules="rules" @submit.prevent="handleSubmit(formRef)">
        <el-form-item label="Name" prop="name">
          <el-input v-model="form.name" placeholder="请输入内容"></el-input>
        </el-form-item>
        <el-form-item>
          <el-button type="primary" native-type="submit">确 定</el-button>
          <el-button @click="visible = false">取 消</el-button>
        </el-form-item>
      </el-form>
    </el-dialog>
  </div>
</template>

<script setup>
import { reactive, ref } from 'vue'

const form = reactive({
  name: ''
})

const visible = ref(false)

const rules = {
  name: [
    { required: true, message: '请输入姓名', trigger: 'blur' }
  ]
}

const formRef = ref(null)

const handleSubmit = async ref => {
  try {
    await ref.validate()
    console.log('submit', form)
  } catch (error) { }
}
</script>

这样就能写出一个最最简单的新增表单了

Vue3+Element 封装FormModal(Dialog) 思路

接下来还要写一个编辑表单,如果有需要的话,可能还要写一个查看表单,其实他们都大同小异,但是我不得不在每个页面的代码中书写大量雷同的Dialog和内容,要管理各个窗口的显示隐藏,然后在另一个页面复制粘贴,这样就延伸出来几个问题:

  1. 代码冗余且类似,总是写这样的东西一点提不起劲。
  2. 增加和编辑表单可能完全相同,可能略有区别,但是一旦将来需要调整,就要每个地方都改,增加风险和负担。
  3. 因为大部分情况结构类似,很多时候会增加和编辑共用一个窗口,然后或者根据点击的按钮来改变title,改变文字和提示等等,但是!如果业务简单还好说,一旦情况越来越复杂,需要查看,需要禁用某几项,需要。。。这个窗口组件就会越来越臃肿,越来越难以修改。

因此为了解决这些不爽的点,我详细说一下我封装FormModal(其实并没有那么准确,因为使用时必须要搭配一个useFormModal)的思路和过程。

封装思路

展示组件和容器组件

这个思路是已经被证明了的解决方案,这里我不详细说了,它大致是这样的:

Vue3+Element 封装FormModal(Dialog) 思路

展示组件只管如何展示,根据容器组件传递过来的数据来展示不同内容,展示组件不管提交逻辑,只抛出事件,让容器组件来决定点击确定时进行的不同网络请求,这样只需要根据需求写出一个个不同的容器组件(新增,编辑,查看),就能做到逻辑分离,不互相干扰,改一个容器组件的逻辑完全不会影响到另一个,如果表单展示有变化,修改展示组件即可。

那么代码大致应该是这样的:

// 展示组件
<template>
  <el-dialog v-model="visible" :title="title">
    <el-form ref="formRef" :model="form" :rules="rules" @submit.prevent="handleSubmit(formRef)">
      <el-form-item label="Name" prop="name">
        <el-input v-model="form.name" placeholder="请输入内容"></el-input>
      </el-form-item>
      <el-form-item>
        <el-button type="primary" native-type="submit">{{ submitText }}</el-button>
        <el-button @click="visible = false">取 消</el-button>
      </el-form-item>
    </el-form>
  </el-dialog>
</template>

<script setup>
import { ref } from 'vue'
const props = defineProps(['rules', 'title', 'submitText'])
const emit = defineEmits(['finish'])

const formRef = ref(null)

const visible = defineModel('visible')
const form = defineModel('form')

const handleSubmit = async ref => {
  try {
    await ref.validate()
    emit('finish')
  } catch (error) { }
}
</script>
// 容器组件
<template>
  <DemoView title="新增" v-model:visible="visible" v-model:form="form" submitText="新增" @finish="handleFinish" />
</template>

<script setup>
import DemoView from './DemoView.vue';

const emit = defineEmits(['finish'])

const visible = defineModel('visible')
const form = defineModel('form')

const handleFinish = () => {
  setTimeout(() => {
    // 新增成功
    visible.value = false
    emit('finish')
  }, 1000);
}

</script>
// 页面
<template>
  <div>
    <el-button type="primary" @click="addVisible = true">新增</el-button>

    <AddDemoView v-model:visible="addVisible" v-model:form="form" @finish="handleAdd" />
  </div>
</template>

<script setup>
import { reactive, ref } from 'vue'
import AddDemoView from './components/AddDemoView.vue';

const form = reactive({
  name: ''
})

const addVisible = ref(false)

const handleAdd = () => {
  console.log('refresh table');
}

</script>

这样与理想中的设计似乎有偏差,各种状态仍然需要页面来维护,本该写在容器组件中的新增或编辑后的逻辑仍然需要写到页面中,为什么呢?因为这是一个Modal承载的Form,需要在点击新增的时候改变visible,需要在提交请求结束时,刷新页面中的其他数据,还有不少细节要处理,但是虽然如此,看起来也挺像那么回事的了。。吗?

思考

到这里其实已经可以投入使用了,但是它其实并没有解决我不爽的点,那就是它 写起来实在太麻烦了!!,每一个页面都要创建至少三个文件(展示组件、新增容器、编辑容器),大家为什么总是在页面中写一个Dialog的原因之一,就是这个Form的复杂程度在很多时候其实也并不会很大,这个展示组件和容器组件的思路用来解决复杂的Form是合理的,但是很多情况下使用这种写法似乎有点大材小用,且相同的代码也不少,所以我也在想:

我希望的使用方式是什么呢?

我希望我既可以使用展示组件和容器组件的设计,又不用写大量重复的代码,只需要写一个展示组件,简化容器组件的部分,且页面中尽可能少和简单的使用这些FormModal组件,于是我先写下了一个我希望的使用方式:

<template>
  <div>
    <el-button type="primary" @click="openAdd()">新增</el-button>
    <el-button type="primary" @click="openView()">查看</el-button>
    <el-button type="primary" @click="openEdit({ name: 'foo' })">编辑</el-button>
  </div>

  <AddModal />
  <ViewModal />
  <EditModal />
</template>

<script setup>
import TemplateForm from './components/TemplateForm.vue';
import useFormModal from '@/hooks/useFormModal';

const [
  { component: AddModal, open: openAdd },
  { component: ViewModal, open: openView },
  { component: EditModal, open: openEdit },
] = useFormModal(TemplateForm, [
  { title: '新增', finish: handleAdd, successMsg: '新增成功' },
  { title: '查看', showFooter: false, templateData: { disabledit: true } },
  { title: '编辑', finish: handleEdit, successMsg: '编辑成功' },
])
</script>

TemplateForm是展示组件,我希望那里面没有需要重复书写的代码,useFormModal可以使用它和第二个参数来简化写容器组件的过程,然后它返回容器组件,可以直接放入页面中,还提供了一个打开Modal的方法open。

下面来一步一步实现吧。

我希望的展示组件

我希望它能有明确定义的数据来源,而不是有隐式的只有我自己才知道写法;它可以很自由,就像正常写在页面里最原始写Form一样;

那么它也许应该是这个样子:

<template>
  <FormModal>
    <template #default="{ form, templateData, resetFields }">
      <el-form-item label="name" prop="name">
        <el-input v-model="form.name" :disabled="templateData.disabledit" />
      </el-form-item>
    </template>
  </FormModal>
</template>


<script setup>
import FormModal from '@/components/FormModal.vue';
</script>

在FormModal的默认插槽会传递来form和templateData,templateData就是之前在容器组件中定义的各种展示规则和数据,也就是useFormModal第二个参数中定义的数据,resetFields在简单form中用不太到,通常在需要几个表单项联动时可能会用到。

之后每个页面的某一个增、改、看等需求只需要写一个这个组件就可以了。

反向推导出FormModal

有了使用的需求后,其实可以发现,并不是我在封装FormModal组件,而是需求在告诉我那应该是什么样子,我觉得这个FormModal组件也许应该是这个样子:

<template>
  <el-dialog :model-value="_visible" :title="title" @closed="resetFields()" :before-close="syncVisible">
    <el-form :model="_form" :rules="rules" :label-width="labelWidth" ref="formRef"
      @submit.prevent="handleSubmit(formRef)" v-loading="loading">
      <slot :form="_form" :templateData="readonly(templateData)" :resetFields="resetFields" />
      <el-form-item v-if="showFooter">
        <slot name="footer" :resetFields="resetFields" :close="close">
          <el-button v-if="showSubmit" type="primary" native-type="submit">{{ submitText }}</el-button>
          <el-button v-if="showCancel" type="primary" @click="close">{{ cancelText }}</el-button>
        </slot>
      </el-form-item>
    </el-form>
  </el-dialog>
</template>

<script setup>
import { ref, readonly } from 'vue';
import useVModel from '@/hooks/useVModel';

const props = defineProps({
  visible: Boolean,
  title: String,
  loading: Boolean,
  labelWidth: {
    type: String,
    default: '130px'
  },
  form: {
    type: Object,
    default: () => ({})
  },
  rules: {
    type: Object,
    default: () => ({})
  },
  templateData: {
    type: Object,
    default: () => ({})
  },
  submitText: {
    type: String,
    default: '确定'
  },
  cancelText: {
    type: String,
    default: '取消'
  },
  showSubmit: {
    type: Boolean,
    default: true
  },
  showCancel: {
    type: Boolean,
    default: true
  },
  showFooter: {
    type: Boolean,
    default: true
  },
})

const emit = defineEmits(['update:visible', 'update:form', 'finish'])
const _visible = useVModel(props, 'visible', emit)
const _form = useVModel(props, 'form', emit)

const formRef = ref(null)

const handleSubmit = async (ref) => {
  try {
    await ref.validate()
    emit('finish')
  } catch (error) { }
}

const syncVisible = (close) => {
  _visible.value = false
  close()
}

const resetFields = fields => {
  formRef.value.resetFields(fields)
}

const close = () => {
  _visible.value = false
}

</script>

这里面有一些需要注意的细节:

为什么visible要这样处理

在上面 展示组件和容器组件 那里,是这样处理visible的

<el-dialog v-model="visible">
</el-dialog>

const visible = defineModel('visible')

这里是使用了vue3.4之后的defineModel语法糖,它实际上等同于之前这样的写法

const props = defineProps({
  visible: Boolean,
})
const emit = defineEmits(['update:visible'])
const _visible = computed({
  get: () => props.visible,
  set: val => emit('update:visible', val)
})

为了保证单向数据流,子组件是不能直接修改父组件传来的props的,遵循谁拥有数据谁修改数据的原则,由子组件抛出一个事件,父组件去修改,在3.4之前,为了省事,也会用vueuse中的useVModel来做这种操作: const _visible = useVModel(props, 'visible', emit)

但是这里有一个问题,那就是Element的Dialog组件,不管你是设置v-model="visible",还是设置:model-value="visible",在点击背景的遮罩区域或右上角的关闭按钮时,都会直接关闭这个dialog,没办法保证和父组件visible的一致,但是Dialog提供了一个参数before-close,可以用这个参数来拦截关闭Dialog的行为,所以可以间接的在这里去同步visible保持一致:

<el-dialog :model-value="_visible" :before-close="syncVisible"></el-dialog>

const syncVisible = (close) => {
  _visible.value = false
  close()
}

除此之外,我这里使用的import useVModel from '@/hooks/useVModel';,并不是vueuse的useVModel, 具体下面说。

form和useVModel

同样的,之前也是使用了defineModel来处理的form,这里似乎也一样可以使用计算属性抛出事件的方式来处理?不是的,form和visible的区别在于,visible是基本类型Boolean,form却是一个对象,如果直接写form = newForm,那确实是会触发update:form,但是form.name = newName却并不会,就像用const定义了一个对象const form = {},按理说他代表了不可被修改(form = {}会报错),但是我们可以随意修改其中的属性form.foo = 'bar'而不会有任何问题,在表单里会把form的属性用v-model绑定给input等组件,那么这些子组件就可以绕过父组件随便的修改父组件的数据了。所以为了维护单项数据流的原则,在之前使用计算属性的基础上,写了一个可以拦截对象的useVModel:

// useVModel
import { computed } from "vue";

export default function (props, propName, emit) {
  return computed({
    set: (value) => emit(`update:${propName}`, value),
    get: () => {
      if (typeof props[propName] !== 'object') {
        return props[propName];
      }
      return new Proxy(props[propName], {
        set(target, key, value) {
          emit(`update:${propName}`, { ...target, [key]: value });
          return true;
        },
        get(target, key) {
          return Reflect.get(target, key);
        },
      })
    }
  })
}

其原理简单讲就是使用Proxy代理了访问的对象,在子组件修改数据时会调用form.foo = 'bar',那么就会被代理拦截,返回一个新的对象并抛出update事件,网上讲这个的有很多就不细说了。这里因为既然不使用defineModel了为了方便都使用useVModel,那就照顾到基本属性,在使用时如果不是对象类型的就直接返回。

PS:这里其实还有一个问题,那就是无法覆盖form[propName]为数组的情况。

其他

为什么@closed="resetFields()"

如果不处理的话会有几个小问题,在新增时,如果在表单中输入了某些值,但是并未提交,而是关闭了窗口,那么再次打开时数据还会残留,并且当使用了数据校验rules时,触发了显示错误提示再关闭,点开还是会保留错误提示,所以直接用使用el-form的resetFields方法,在关闭窗口时既重置了form,又清除了错误提示。

为什么将templateData转换成readonly?

只是表明展示组件拿到的关于展示的数据是不应该被修改的,是一种含义上的声明,不这样功能也没什么区别。

其他的就是按照使用的方式,将form,templateData等绑定给插槽,让展示组件可以拿到数据和操作表单的方法。

useFormModal

之前说过,我希望的useFormModal的作用就是,帮助简化创建容器组件,所以我觉得它也许应该是这个样子:

import { ref, h, defineComponent, reactive, toRaw } from "vue";
import { ElMessage } from "element-plus";

export default function (Template, TemplateDatas = []) {

  return TemplateDatas.map(({
    title = 'Title',
    finish = () => { },
    templateData,
    submitText,
    cancelText,
    successMsg = '操作成功',
    successShowMsg = true,
    showFooter = true,
    showSubmit = true,
    showCancel = true,
  }) => {
    const visible = ref(false);
    const form = reactive({})
    const loading = ref(false);

    return {
      component: defineComponent({
        name: Template.__name + 'Container',
        setup() {
          return () => h(Template, {
            visible: visible.value,
            loading: loading.value,
            title,
            form,
            templateData,
            submitText,
            cancelText,
            successMsg,
            showFooter,
            showSubmit,
            showCancel,
            'onUpdate:visible': value => visible.value = value,
            'onUpdate:form': value => Object.assign(form, value),
            'onFinish': () => {
              loading.value = true;
              // 解构原始对象浅拷贝给finish作参数
              const { ...formData } = toRaw(form);
              finish(formData, (isSuccess, successCallback) => {
                if (isSuccess) {
                    loading.value = false;
                    if (successShowMsg) ElMessage.success(successMsg)
                    visible.value = false;
                    successCallback && successCallback();
                  } else {
                    loading.value = false;
                  }
              })
            }
          })
        },
      }),
      open: (initForm = {}) => {
        Object.assign(form, initForm);
        visible.value = true;
      }
    }
  })
}

根据传入的模板组件(展示组件)和容器组件属性数组(第二个参数),返回一个可以直接挂载在template中的组件,和打开弹窗的方法。

因为vue的模板本质上就是渲染函数的语法糖,用js本来也能创建组件,所以可以使用vue中的 h 函数去创建虚拟dom, 这里做的事情,就是之前单独在容器组件中所做的,由容器组件维护visible、form、loading,将展示逻辑和数据传给展示组件。

Template就是之前写的展示组件,h 的第二个参数可以将props传入给Template,这里需要说明的是,为什么明明Template组件中并没有定义这些props,也没有手动的将props传递给引用的FormModal,在创建容器组件时却可以将这些props传给FormModal?因为vue3中默认会有 Attributes 继承,它指的是当一个组件以单个元素为根作渲染时,透传的 attribute 会自动被添加到根元素上,这里展示组件Template里,根组件就是FormModal, 所以FormModal能直接得到props。

<template>
  <FormModal>
    ...
  </FormModal>
</template>

onFinish里,会调用容器组件属性中的finish函数,并且将form的原始对象返回用于调用提交的网络请求, 还返回了一个用于关闭窗口的方法,当点击FormModal的提交按钮时,会自动让form进入loading,在页面处理完提交请求调用关闭窗口的方法时,自动弹出成功提示(可以配置是否弹出,成功的文字),关闭loading并且关闭弹窗.

返回给页面使用的open方法,在调用时可以传入一个初始化form去改变容器组件的form,通常的使用方式就是:

<el-table>
  <el-table-column label="操作">
    <template #default="{ scope }">
      <el-button type="primary" @click="openEdit(scope)">编辑</el-button>
    </template>
  </el-table-column>
</el-table>

注意:因为是在open中传入初始化form,所以在<el-button @click="openAdd">新增</el-button>中不能直接这样写,会将event传入,而是要写成@click="openAdd()",第二如果某个属性是数组类型,需要在open时为其初始化openAdd({ ids: [] })

思考

到这里为止封装过程就结束了,这套写法也许没有完全的遵守了展示组件和容器组件的理论,对于我而言却是最能偷懒的方式,既然页面中无论如何都要去处理提交的逻辑,那索性就全写在页面中,而把重复的处理loading等部分接管起来,所以在写提交的方法时,代码也不是很多:

const handleAdd = async (formData, close) => {
  const [err] = await request(url, formData)
  // close的第一个参数是 boolean,true 的话会弹出成功提示、取消 loading、关闭弹窗,false 的话只会取消 loading
  // 第二个参数 refresh 是关闭回调,可以传刷新数据(例如刷新 table)的方法,如果第一个参数是 true 的话会执行
  close(!err, refresh)
}

同时页面中也并不需要去处理各种弹窗显示的逻辑,挂载的Modal直接放在那里就行,不需要给它传入什么属性。其中的form部分新建一个模板组件,不用重复的写el-dialog和el-form,直接写form的组件即可,并且这个模板组件可以简单也可以处理一些复杂的逻辑。下面我将一些常用的业务需求写法展示一下。

用例

校验

// 1
<FormModal :rules="rules">
// 2 这种方式还可以根据templateData来进行rules的细分
<FormModal>
  <el-form-item label="name" prop="name" :rules="[{ required: true, message: 'Please input name', trigger: 'blur' }]"></el-form-item>
</FormModal>

表单联动

<template #default="{ form, resetFields }">
  <el-form-item label="a" prop="a">
    <el-select v-model="form.a" @change="$event => handleChange($event, resetFields)">
      <el-option label="1" :value="1" />
      <el-option label="2" :value="2" />
    </el-select>
  </el-form-item>
  <el-form-item v-if="form.a === 2" label="b" prop="b">
    <el-input v-model="form.b" />
  </el-form-item>
</template>

<script setup>
// ...
const handleChange = (value, resetFields) => {
  if (value === 1) {
    resetFields('b')
  }
}
</script>

如果b没有校验,可以简单的在handleChange中将form.b = '',如果有的话就要用resetFields

加载数据

const options = ref([])
onMounted(() => {
  // 模拟数据请求
  setTimeout(() => {
    options.value = [
      { value: 1, label: 'a' },
      { value: 2, label: 'b' },
      { value: 3, label: 'c' },
    ]
  }, 1000);
})

const [
  { component: AddModal, open: openAdd },
] = useFormModal(TemplateForm, [
  { title: '新增', finish: handleAdd, templateData: { options } }, // options是响应式数据
])

// template
<el-form-item label="select" prop="id">
  <el-select v-model="form.id">
    <el-option v-for="item in templateData.options" :key="item.value" :label="item.label" :value="item.value" />
  </el-select>
</el-form-item>

远程搜索

// template
<el-form-item label="remote" prop="remoteId">
  <el-select v-model="form.remoteId" placeholder="Select" filterable remote :remote-method="remoteMethod" :loading="loading">
    <el-option v-for="item in options" :key="item.value" :label="item.label" :value="item.value" />
  </el-select>
</el-form-item>

<script setup>
// ...
const loading = ref(false)
const options = ref([])
// maybe debounce
const remoteMethod = async query => {
  loading.value = true
  const res = await request(url, query)
  options.value = res.data
  loading.value = false
}
</script>

动态表单

// template
<el-form-item v-for="(domain, index) in form.domains" :key="domain.key" :label="'Domain' + index" :prop="'domains.' + index + '.value'">
  <el-input v-model="domain.value" />
   <el-button @click.prevent="removeDomain(domain, form)">Delete</el-button>
</el-form-item>
<el-button @click="addDomain(form)">New domain</el-button>
// script
const removeDomain = (item, form) => {
  const index = form.domains.indexOf(item)
  if (index !== -1) {
    form.domains.splice(index, 1)
  }
}
const addDomain = (form) => {
  if (!form.domains) {
    form.domains = []
  }
  form.domains.push({
    key: Date.now(),
    value: '',
  })
}

PS: 这里需要注意,上面也说过了,useVModel无法覆盖到form属性是数组的情况,所以会出现添加了动态表单项,关闭窗口却无法重置这种现象,这时候可以在打开弹窗的open方法中添加初始化form来解决openAdd({ domains: [] })

携带隐藏数据

// template
<el-form-item label="id" prop="id">
  <el-select v-model="form.id" placeholder="Select" @change="$event => handleChange($event, form)">
    <el-option v-for="item in options" :key="item.value" :label="item.label" :value="item.value" />
  </el-select>
</el-form-item>
<el-form-item label="id2" prop="id2">
  <el-select v-model="form.id2" placeholder="Select" @change="$event => handleChange2($event, form, templateData)">
    <el-option v-for="item in templateData.options" :key="item.value" :label="item.label" :value="item.value" />
  </el-select>
</el-form-item>

// script
const options = [
  { value: 1, label: 'a' },
  { value: 2, label: 'b' },
  { value: 3, label: 'c' },
]

const handleChange = (value, form) => {
  form.name = options.find(item => item.value === value).label
}
const handleChange2 = (value, form, templateData) => {
  form.name2 = templateData.options.find(item => item.value === value).label
}