🍇 如何优雅地设计一套通用表单配置方案?【附源码】

884 阅读4分钟

大家好,我是前端架构师,关注微信公众号【程序员大卫】:

  • 回复 [面试] :免费领取“前端面试大全2025(Vue,React等)”
  • 回复 [架构师] :免费领取“前端精品架构师资料”
  • 回复 [书] :免费领取“前端精品电子书”
  • 回复 [软件] :免费领取“Window和Mac精品安装软件”

背景

表单是每一个前端开发者都无法回避的一个“小难题”。如果你是团队的组长,那么在日常的项目开发中,如何设计一个高效的表单配置,将直接决定我们的开发效率是否能达到事半功倍的效果。

设计思路

表单的核心由四大配置组成:

  • 字段配置:FormField
  • 表单行为配置:FormProps
  • 表单状态:FormState
  • 表单实例:FormRef

1. FormField

定义每个字段的类型(type)、字段名称(field)、标签(label)等信息。

其 TypeScript 类型定义如下:

import type { Rule } from "ant-design-vue/es/form";

type BaseField<T extends string, F extends string> = {
  type: T;
  label?: string;
  field: F;
  col?: number;
  props?: { placeholder: string };
  slots?: Record<string, string>;
  rules?: Rule[];
};

type Input<F extends string> = BaseField<"input", F>;

下面是一个 Input 字段的简单配置示例:

  {
    type: "input",
    col: 12,
    props: { placeholder: "请输入用户名" },
    label: "用户名",
    field: "username",
    slots: { prefix: "input_prefix" },
    rules: [{ required: true, message: "用户名不能为空" }],
  }

2. FormProps

主要用于控制表单的提交方式、重置逻辑以及底部按钮的渲染方式。

  • FormConfig 行为逻辑层,提供表单事件和交互方式;
  • FormFooter 表现层,描述底部 UI 的结构和交互行为;
  • 属于逻辑与交互的桥梁层。

TS 类型定义如下:

// 事件行为
type FormConfig<T> = {
  onSubmit?: (values: T) => void;
  onReset?: () => void;
};

// UI 行为
export type FormFooter = {
  type?: string;
  content: string;
  props?: {
    type?: string;
    class?: string;
    onClick?: () => void;
  };
};

export type FormProps<T> = {
  config: FormConfig<T>;
  footer: FormFooter[];
};

3. FormState

FormState 是用户自定义的,它用于描述每一个表单字段的状态。TS 类型定义如下:


interface FormState {
  username: string;
  password: string;
  city: string;
  customContent: string;
}

const formState: FormState = {
  username: "David",
  password: "123456",
  city: "ShangHai",
  customContent: "hello",
};

它的每一个 key 值和 formField 中的 field 字段一一对应:

const formFields = [
  {
    type: "input",
    label: "用户名",
    field: "username", // 一一对应
    // ...
  },
  {
    type: "inputPassword",
    label: "密码",
    field: "password", // 一一对应
    // ...
  },
  {
    type: "custom",
    component: CustomComponent,
    label: "自定义组件",
    field: "customContent", // 一一对应
    // ...
  },
  {
    type: "select",
    label: "选择城市",
    field: "city", // 一一对应
    // ...
  },
];

4. FormRef

控制表单组件对外暴露的操作,比如重置、校验和获取表单值。

由于基于 ant-design-vue 进行二次封装,因此 FormRef 实际上就是 ant-design-vue 表单的实例类型:

import type { FormInstance } from "ant-design-vue/es/form";

export { type FormInstance };

四个关键问题的思考

定义好类型之后,我们还需要思考以下几个核心问题:

1.表单布局

可以通过 FormField 中的 col 字段结合 <a-col :span="fieldItem.col"></a-col> 控制布局。注意:如果两个 FormFieldcol 值相加大于 24,后一个表单项需要自动换行。

提示: <a-col></a-col>ant-design-vue 的布局组件。

为了解决这个问题,我们可以将字段配置转为一个二维数组,如果两个字段的 col 之和小于等于 24,则放在同一数组中,否则分开。例如:[[a, b], [c]][[a], [b], [c]]

核心代码如下:

// 自动分组逻辑
const groupedFields = computed(() => {
  const rows: FormField<T>[][] = [[]];
  let currentSpan = 0;

  props.fields.forEach((item) => {
    const itemSpan = item.col || 24;
    if (currentSpan + itemSpan > 24) {
      rows.push([]);
      currentSpan = 0;
    }
    rows[rows.length - 1].push(item);
    currentSpan += itemSpan;
  });

  return rows;
});

2. 插槽名称重复

由于使用的是第三方组件库进行封装,部分组件的插槽名称存在重复问题,比如 InputInputNumber 都使用了 prefix 插槽。

<script>
import { Input, InputNumber } from "ant-design-vue";
</script>
  
<template>
  <Input>
    <template #prefix>
        <UserOutlined />
    </template>
  </Input>
  <InputNumber>
    <template #prefix>
        <UserOutlined />
    </template>
  </InputNumber>
</template>

为避免插槽冲突,我们在 FormField 中为插槽提供了一个映射:

<script setup lang="ts">
const fields = [
  {
    type: "input",
    slots: { prefix: "input_prefix" },
    // ...
  },
  {
    type: "inputNumber",
    slots: { prefix: "inputNumber_prefix" },
    // ...
  },
}
</script>

<template>
  <FormCustom v-bind="{ fields }">
    <template #input_prefix>
      <UserOutlined />
    </template>
    <template #inputNumber_prefix>
      <LockOutlined />
    </template>
  </FormCustom>
</template>

3. 动态生成组件

我们使用 <component :is="xxx"></component> 实现组件的动态生成,并通过遍历 Field 的每一个 slots 项来渲染动态插槽。

关键代码如下:

  <!-- 动态组件生成 -->
  <component
    :is="getComponent(item)"
    v-bind="item.props"
    v-model:value="modelValue[item.field]"
  >
    <!-- 动态插槽 -->
    <template v-for="(slotName, slotKey) in item.slots" #[slotKey]>
      <slot :name="slotName" />
    </template>
  </component>

其中 getComponent 用于将字段类型映射为实际的组件名:

<script>
import { Input, Select, TimePicker } from "ant-design-vue";
</script>

// 组件映射
const componentMap = {
  input: Input,
  inputPassword: Input.Password,
  select: Select,
  timePicker: TimePicker,
};

const getComponent = (item) => {
  return item.type === "custom"
    ? item.component
    : componentMap[item.type] || Input;
};

4. 表单联动

由于我们是基于 ant-design-vue 的二次开发,所以联动校验可以直接利用其内置的校验机制。我们只需要在 rules 中传入 validator 函数即可实现联动逻辑。

关键代码如下:

const fields = [
  {
    type: "inputPassword",
    label: "密码",
    field: "password",
    rules: [{ required: true, validator: validatePass, trigger: "change" }],
  },
  {
    type: "inputPassword",
    label: "确认密码",
    field: "confirmPassword",
    rules: [
      { required: true, validator: validatePassConfirm, trigger: "change" },
    ],
  },
];

总结

本方案解决了以下关键问题:

  • 表单布局通过 col 字段自动分组,避免超出一行宽度导致布局错乱。
  • 插槽支持使用命名映射,解决组件之间插槽名重复的问题。
  • 动态组件渲染配合动态插槽绑定,实现灵活的 UI 拼装。
  • 利用 ant-design-vue 的校验机制,通过 validator 函数轻松实现表单联动。

源码

github.com/zm8/wechat-…