vue3 + antd 封装动态表单组件(一)
vue3 + antd 封装动态表单组件(二)
vue3 + antd 封装动态表单组件(三)
vue版本 v3.3.11
ant-design-vue版本 v4.1.1
这篇文章主要解决表单联动
的问题。
现在模拟表单联动业务场景:
动态组件配置文件config.js
import { Input, Textarea, InputNumber, Select, RadioGroup, CheckboxGroup, DatePicker } from 'ant-design-vue';
// 表单域组件类型
export const componentsMap = {
Text: Input,
Textarea,
Number: InputNumber,
Select,
Radio: RadioGroup,
Checkbox: CheckboxGroup,
DatePicker,
}
// 配置各组件属性默认值,相关配置项请查看ant-design官网各组件api属性配置
export const defaultComponentProps = {
Text: {
allowClear: true,
bordered: true,
disabled: false,
showCount: true,
maxlength: 20,
},
Textarea: {
allowClear: true,
autoSize: { minRows: 4, maxRows: 4 },
showCount: true,
maxlength: 200,
style: {
width: '100%'
}
},
Select: {
allowClear: true,
bordered: true,
disabled: false,
showArrow: true,
optionFilterProp: 'label',
optionLabelProp: 'label',
showSearch: true,
},
DatePicker: {
allowClear: true,
bordered: true,
disabled: false,
format: 'YYYY-MM-DD',
picker: 'date',
style: {
width: '100%'
}
},
}
动态表单组件dynamic-form.vue
<template>
<div>
<a-form ref="formRef" :model="formModel" v-bind="$attrs">
<a-form-item
:name="item.field"
:label="item.label"
v-for="item in realFormSchema"
:key="item.field"
v-bind="item.formItemProps"
>
<!-- 表单form-item插槽, 注意优先级:组件formItemProps.slots > formItemPropsSlots-->
<template
v-for="slot in formItemPropsSlots"
#[slot.name]="slotProps"
:key="slot.key"
>
<template v-if="slot.field === item.field">
<slot :name="slot.key" v-bind="slotProps"></slot>
</template>
</template>
<template
v-for="(slot, name) in item.formItemProps?.slots || {}"
#[name]="slotProps"
:key="`${item.field}_${name}`"
>
<component :is="slot" v-bind="slotProps"></component>
</template>
<template v-if="item.slot">
<slot :name="item.slot" v-bind="formModel"></slot>
</template>
<template v-else>
<span v-if="item.loading"
><LoadingOutlined style="margin-right: 4px" />数据加载中...</span
>
<component
v-else
:is="componentsMap[item.component]"
v-bind="item.componentProps"
v-model:value="formModel[item.field]"
>
<!-- 表单项组件插槽, 注意优先级:组件componentProps.slots > componentPropsSlots-->
<template
v-for="slot in componentPropsSlots"
#[slot.name]="slotProps"
:key="slot.key"
>
<template v-if="slot.field === item.field">
<slot :name="slot.key" v-bind="slotProps"></slot>
</template>
</template>
<template
v-for="(slot, name) in item.componentProps?.slots || {}"
#[name]="slotProps"
:key="`${item.field}_componentProps_${name}`"
>
<!-- 这里是关键, 渲染slot -->
<component :is="slot" v-bind="slotProps"></component>
</template>
</component>
</template>
</a-form-item>
</a-form>
</div>
</template>
<script setup>
import { ref, watch, onMounted, computed, useSlots, nextTick } from "vue";
import { componentsMap, defaultComponentProps } from "./config.js";
import { LoadingOutlined } from "@ant-design/icons-vue";
import dayjs from "dayjs";
import { set as setValue } from "@/common/utils";
const props = defineProps({
// 表单项配置
schema: {
type: Array,
default: () => [],
},
// 表单model配置,一般用于默认值、回显数据
model: {
type: Object,
default: () => ({}),
},
// 组件属性配置
componentProps: {
type: Object,
default: () => ({}),
},
});
const slots = useSlots();
// 表单formItem slots
const formItemPropsSlots = ref([]);
// 表单项组件slots
const componentPropsSlots = ref([]);
// 用于获取componentProps、formItemProps插槽
const createPropsSlots = (type) => {
// 对象转数组, 这里表单项slots规则为 对应的filed + '-type-' + slot名称,可自行定义规则,对应字段匹配上即可
const slotsArr = Object.entries(slots);
return slotsArr
.filter((x) => x[0].indexOf(type) !== -1)
.map((x) => {
const slotParams = x[0].split("-");
return {
key: x[0],
value: x[1],
name: slotParams[2],
field: slotParams[0],
};
});
};
const createSlots = () => {
formItemPropsSlots.value = createPropsSlots("formItemProps");
componentPropsSlots.value = createPropsSlots("componentProps");
};
const formRef = ref(null);
const formSchema = ref([]);
const formModel = ref({});
// 组件placeholder
const getPlaceholder = (x) => {
let placeholder = "";
switch (x.component) {
case "Text":
case "Textarea":
placeholder = `请输入${x.label}`;
break;
case "RangePicker":
placeholder = ["开始时间", "结束时间"];
break;
default:
placeholder = `请选择${x.label}`;
break;
}
return placeholder;
};
// 组件属性componentProps, 注意优先级:组件自己配置的componentProps > props.componentProps > config.js中的componentProps
const getComponentProps = (x) => {
if (!x?.componentProps) x.componentProps = {};
// 使得外层可以直接配置options
if (x.hasOwnProperty("options") && x.options) {
x.componentProps.options = [];
const isFunction = typeof x.options === "function";
const isArray = Array.isArray(x.options);
if (isFunction || isArray) {
// 函数时先赋值空数组
x.componentProps.options = isFunction ? [] : x.options;
}
}
// 监听onChange表单项,并触发onChange事件-一般用于表单联动
if (x?.onChange && typeof x.onChange === "function") {
watch(
() => formModel.value[x.field],
(newVal) => {
x.onChange({
value: newVal,
formModel: formModel.value,
formSchema: formSchema.value,
formRef: { ...formRef.value, ...formRef.value?.$parent },
});
}
);
}
return {
placeholder: x?.componentProps?.placeholder ?? getPlaceholder(x),
...(defaultComponentProps[x.component] || {}), // config.js带过来的基础componentProps默认配置
...(props.componentProps[x.component] || {}), // props传进来的组件componentProps配置
...x.componentProps, // 组件自身的componentProps
};
};
// 表单属性formItemProps
const getFormItemProps = (x) => {
let result = { ...(x.formItemProps || {}) };
// 使得外层可以直接配置required必填项
if (x.hasOwnProperty("required") && x.required) {
result.rules = [
...(x?.formItemProps?.rules || []),
{
required: true,
message: getPlaceholder(x),
trigger: "blur",
},
];
}
return result;
};
// 各组件为空时的默认值
const getDefaultEmptyValue = (x) => {
let defaultEmptyValue = "";
switch (x.component) {
case "Text":
case "Textarea":
defaultEmptyValue = "";
break;
case "Select":
defaultEmptyValue = ["tag", "multiple"].includes(x?.componentProps?.mode)
? []
: undefined;
case "Cascader":
defaultEmptyValue = x?.value?.length ? x.value : [];
default:
defaultEmptyValue = undefined;
break;
}
return defaultEmptyValue;
};
// 格式化各组件值
const getValue = (x) => {
let formatValue = x.value;
if (!!x.value) {
switch (x.component) {
case "DatePicker":
formatValue = dayjs(x.value, "YYYY-MM-DD");
break;
}
}
return formatValue;
};
// 隐藏属性hidden
const getHidden = (x) => (x.hasOwnProperty("hidden") ? x.hidden : false);
// 标签label
const getLabel = (x) =>
x.formItemProps?.slots?.label ||
formItemPropsSlots.value.find((y) => y.field === x.field)?.field
? undefined
: x.label;
const getSchemaConfig = (x) => {
return {
...x,
componentProps: getComponentProps(x),
formItemProps: getFormItemProps(x),
value: x.value ?? getDefaultEmptyValue(x),
label: getLabel(x),
hidden: getHidden(x),
};
};
// 新增schema
const addSchema = ({
field,
schema = { label: "label", component: "Text" },
location = "after",
}) => {
if (!["before", "after"].includes(location)) {
console.warn(
`location的值只能取 before 或 after ,默认为 before,当前取值为${location}`
);
}
const index = formSchema.value.findIndex((x) => x.field === field);
if (index !== -1) {
//判断插入位置
const insertIndex = location === "before" ? index : index + 1;
schema = {
...getSchemaConfig(schema),
...schema,
};
formSchema.value.splice(insertIndex, 0, schema);
}
};
// 删除schema
const deleteSchema = (field) => {
const index = formSchema.value.findIndex((x) => x.field === field);
if (index !== -1) {
formSchema.value.splice(index, 1);
}
};
const composePath = (path) => {
const wrapProp = {
componentProps: ["options", "disabled", "value"],
formItemProps: ["required"],
};
for (const key in wrapProp) {
if (wrapProp[key].includes(path)) {
return `${key}.${path}`;
}
}
return path;
};
// 设置schema
const setSchema = ({ field, path, value }) => {
const formatPath = composePath(path);
const target = formSchema.value.find((x) => x.field === field);
// 设置required,注意移除之前设置为true的校验特性
if (["required"].includes(path)) {
const index = target?.formItemProps?.rules?.length
? target.formItemProps.rules.findIndex((y) =>
y.hasOwnProperty("required")
)
: -1;
if (index !== -1) {
target.formItemProps.rules.splice(index, 1);
}
target.formItemProps = {
...(target.formItemProps || {}),
rules: [
...(target?.formItemProps?.rules || []),
{
required: value,
message: getPlaceholder(target),
trigger: ["change", "blur"],
},
],
};
}
setValue(target, formatPath, value);
};
const setFormModel = () => {
formModel.value = formSchema.value.reduce((pre, cur) => {
if (!pre[cur.field]) {
// 表单初始数据(默认值)
pre[cur.field] = getValue(cur);
return pre;
}
}, {});
};
const realFormSchema = computed(() =>
formSchema.value.filter((x) => !x.hidden)
);
// 表单初始化
const initForm = () => {
formSchema.value = props.schema.map((x) => getSchemaConfig(x));
// model初始数据
setFormModel();
// options-获取异步数据
formSchema.value.forEach(async (x) => {
if (x.options && typeof x.options === "function") {
x.loading = true;
x.componentProps.options = await x.options(formModel.value);
x.loading = false;
}
});
};
onMounted(() => {
createSlots();
initForm();
watch(
() => props.model,
(newVal) => {
// 重新赋值给formSchema
formSchema.value.forEach((x) => {
for (const key in newVal) {
if (x.field === key) {
x.value = newVal[key];
}
}
});
setFormModel();
},
{
immediate: true,
deep: true,
}
);
});
const hasLoadingSchema = computed(() =>
formSchema.value.some((x) => x.loading)
);
// 表单验证
const validateFields = () => {
if (hasLoadingSchema.value) {
console.log("正在加载表单项数据...");
return;
}
return new Promise((resolve, reject) => {
formRef.value
.validateFields()
.then((formData) => {
resolve(formData);
})
.catch((err) => reject(err));
});
};
// 表单重置
const resetFields = (isInit = true) => {
// 是否清空默认值
if (isInit) {
formModel.value = {};
}
formRef.value.resetFields();
};
// 暴露方法
defineExpose({
validateFields,
resetFields,
setSchema,
addSchema,
deleteSchema,
});
</script>
utils.js
文件
// 用于模拟接口请求
export const getRemoteData = (data = '获取数据', time = 2000) => {
return new Promise((resolve) => {
setTimeout(() => {
console.log(`模拟获取接口数据`, data)
resolve(data)
}, time)
})
}
// lodash "set"方法
export const set = (obj, path, value) => {
if (Object(obj) !== obj) return obj;
if (!Array.isArray(path)) path = path.toString().match(/[^.[\]]+/g) || [];
path.slice(0, -1).reduce((a, c, i) =>
Object(a[c]) === a[c]
? a[c]
: a[c] = Math.abs(path[i + 1]) >> 0 === +path[i + 1]
? []
: {},
obj)[path[path.length - 1]] = value;
return obj;
}
使用动态表单组件
<template>
<div style="padding: 200px">
<DynamicForm
ref="formRef"
:schema="schema"
:model="model"
:labelCol="{ span: 4 }"
:wrapperCol="{ span: 20 }"
>
<template #country-formItemProps-label>
<span style="color: green">国家span>
template>
<template #name-componentProps-addonAfter>
<span>我是slotspan>
template>
<template #country-componentProps-suffixIcon>
<span>我也是slotspan>
template>
<template #someComponentX="formModel">
<div><BellFilled style="color: red" />我是特殊的某某组件div>
<div>表单信息:{{ formModel }}div>
template>
DynamicForm>
<div style="display: flex; justify-content: center">
<a-button @click="handleReset(true)">重置(全部清空)a-button>
<a-button style="margin-left: 50px" @click="handleReset(false)"
>重置a-button
>
<a-button type="primary" style="margin-left: 50px" @click="handleSubmit"
>提交a-button
>
div>
div>
template>
<script lang="jsx" setup>
import DynamicForm from "@/components/form/dynamic-form.vue";
import { ref, reactive } from "vue";
import dayjs from "dayjs";
import { getRemoteData } from "@/common/utils";
import { UserOutlined, BellFilled } from "@ant-design/icons-vue";
const formRef = ref(null);
const schema = ref([
{
label: "姓名",
field: "name",
component: "Text",
required: true,
componentProps: {
slots: {
addonAfter: () => <UserOutlined />,
},
},
},
{
label: '性别',
field: "sex",
component: "Radio",
options: [
{ value: 1, label: "男" },
{ value: 2, label: "女" },
{ value: 3, label: "保密" },
],
value: 1,
required: true,
formItemProps: {
slots: {
label: () => <div style="color: blue">性别</div>
}
},
onChange: async ({formRef, value}) => {
// <性别>为“女”时<兴趣>必填
formRef.setSchema({
field: 'hobby',
path: 'required',
value: value === 2
})
const boyList = [
{ value: 1, label: "足球" },
{ value: 2, label: "篮球" },
{ value: 3, label: "排球" },
]
const girlList = [
{ value: 1, label: "音乐" },
{ value: 2, label: "美术" },
{ value: 3, label: "瑜伽" },
]
const list = value === 2 ? girlList : boyList
formRef.setSchema({
field: 'hobby',
path: 'loading',
value: true
})
formRef.setSchema({
field: 'hobby',
path: 'options',
value: await getRemoteData(list, 300)
})
formRef.setSchema({
field: 'hobby',
path: 'loading',
value: false
})
// <性别>为“保密”时<生日>显示
formRef.setSchema({
field: 'birthday',
path: 'hidden',
value: value === 3
})
}
},
{
label: "生日",
field: "birthday",
component: "DatePicker",
required: true,
},
{
label: "兴趣",
field: "hobby",
component: "Checkbox",
options: async () => {
// 后台返回的数据list
const list = [
{ value: 1, label: "足球" },
{ value: 2, label: "篮球" },
{ value: 3, label: "排球" },
];
return await getRemoteData(list);
},
},
{
label: "国家",
field: "country",
component: "Select",
options: [
{ value: 1, label: "中国" },
{ value: 2, label: "美国" },
{ value: 3, label: "俄罗斯" },
],
onChange: ({formRef, value}) => {
// <国家>为“美国”时禁用<简介>
formRef.setSchema({
field: 'desc',
path: 'componentProps.disabled',
value: value === 2
})
}
},
{
label: "简介",
field: "desc",
component: "Textarea",
},
{
label: "插槽组件X",
field: "someComponentX",
slot: "someComponentX",
hidden: true
},
]);
const model = reactive({ name: "百里守约", someComponentB: 'ok' });
// 提交
const handleSubmit = async () => {
const formData = await formRef.value.validateFields();
if (formData.birthday) {
formData.birthday = dayjs(formData.birthday).format("YYYY-MM-DD");
}
console.log("提交信息:", formData);
};
// 重置
const handleReset = (isInit) => {
formRef.value.resetFields(isInit);
};
script>