跳转至

13 动态表单组件:怎么优雅地动态渲染表单?

你好,我是杨文坚。

上节课,我们学习Vue.js 3.x自研组件库的“受控表单组件”,开发了“表单字段组件”来辅助提高开发表单的效率。但是,在实际的企业级项目中,业务需求总是“紧急”且“多变”的,表单类的开发需求,不能只靠一个“受控表单组件”的能力来提效。我们看个常见例子。

假设你接到一个业务需求,要开发一个“用户信息设置”的页面,让注册用户可以编辑自己的个人信息,常规开发步骤,我们一般是用表单组件来封装这个用户信息配置的功能。

但接下来业务需求变了,业务方要做用户类型的区分,不同类型的用户显示“不同个人信息配置”,比如这里就有“普通用户”、“多种等级会员用户”的信息配置,后续可能会新增其他维度类型的用户信息配置。这时候,你还能用常规的表单开发思路,来开发这个需求吗?

我们简单分析一下,如果按常规开发思路,每新增一个用户类型,就要重新开发一个用户信息表单来支持业务需求,工作量就是无底洞。所以问题就来了,是否有一种表单方案,能够通过简单的自定义配置,快速生成对应的表单功能呢?

答案是有的,就是“动态表单组件”。这节课,我们就来学习如何基于Vue.js 3.x,开发自研组件库里的“动态表单组件”。

什么是动态表单?

“动态表单”,顾名思义,就是能“动态”生产想要功能的“表单”。

在前端技术视角里,动态表单概念在十几年的jQuery时代就有了,可以用“简单配置”方式来“动态生成表单”,通过一个JSON的数据形式来描述表单格式,再通过JavaScript代码,根据数据描述渲染出表单。

可以看出,动态表单,核心就是通过自定义数据来动态生成自定义表单。也就是说,面对实际的开发需求时,每当新增一个表单类型的需求,我们只需要配置一下“数据”就能生成表单,不需要从零开始来开发。

这类技术方案在前端开发中经常用到,例如,开发后台页面场景时,不同类型用户身份,信息录入需要渲染多种表单;搭建页面场景时,动态生成调查问卷页面,让用户可以配置不同数据来生成不同问题内容的表单,这个过程无需投入额外的前端开发工作,用户可以自助配置数据生成问卷的表单。

那么,“动态表单”到底怎么实现呢?一步到位肯定不怎么现实,所以我们先从一个最简单的动态表单开始,先来看如何用Vue.js3.x实现一个最简单的动态表单。

如何用Vue.js3.x实现最简单的动态表单?

在动手实现之前,我们先分析一下,表单哪些内容可以统一进行动态管理。

上节课我们讲过,表单核心是由一个个表单字段组成,每个表单字段背后都是一个个表单数据组件。那么,动态表单,其实就是按照动态数据的内容,生成对应“表单数据组件”的各种“组合“的“结果”。实现一个最简单的表单组件,其实就是根据自定义数据,来自定义生成表单数据组件的各种组合。

实现步骤现在就很清晰了:

  • 第一步,列举要用到的表单数据组件,例如 input,radio等;
  • 第二步,定义描述动态表单的数据格式;
  • 第三步,根据数据格式来渲染动态表单。

来看代码:

<template>
  <form class="dynamic-form" @submit="onSubmit">
    <div class="dynamic-form-title">{{ schema.title }}</div>
    <div
      class="dynamic-form-field"
      v-for="(field, index) in schema.fieldList"
      v-bind:key="index"
    >
      <div class="dynamic-form-label">{{ field.label }}:</div>
      <div v-if="field.fieldType === 'input'" class="dynamic-form-item">
        <input v-model="model[field.name]" />
      </div>
      <div v-else-if="field.fieldType === 'radio'" class="dynamic-form-item">
        <span
          v-for="(option, index) in field.options"
          v-bind:key="index"
          class="dynamic-form-option"
        >
          <input
            type="radio"
            :id="option.value"
            :name="field.name"
            :value="option.value"
            :checked="model[field.name] === option.value"
            @change="
              onRadioChange({ fieldName: field.name, value: option.value })
            "
          />
          <label :for="option.value">{{ option.name }}</label>
        </span>
      </div>
      <div v-else class="dynamic-form-item"></div>
    </div>
    <div>
      <button class="dynamic-form-btn" type="submit">提交</button>
    </div>
  </form>
</template>

<script setup lang="ts">
import { reactive, toRaw } from 'vue';

const schema = {
  title: '普通用户信息',
  filedList: [
    {
      label: '用户名称',
      name: 'usename',
      fieldType: 'input'
    },
    {
      label: '手机号码',
      name: 'phone',
      fieldType: 'input'
    },
    {
      label: '收货地址',
      name: 'address',
      fieldType: 'input'
    }
  ]
};

const model = reactive<{ [key: string]: unknown }>({});
schema.fieldList.forEach((field) => {
  model[field.name] = '';
});

const onRadioChange = (data: { fieldName: string; value: string }) => {
  model[data.fieldName] = data.value;
};

const onSubmit = (e: Event) => {
  e.preventDefault();
  window.alert(JSON.stringify(data));
};
</script>
<style> /* 样式代码省略,请看代码案例 */ </style>

我先定义了描述动态表单的数据格式 schema,然后,把动态表单的描述数据,按照数组形式进行管理,根据描述数据在数组里的排列形式,按需渲染出表单内容。

这里,schema定义的动态表单数据,是一个“普通用户”的数据,表单的动态渲染结果如下图所示:

图片

这个简单的动态表单,内部支持了两种表单数据组件,input输入框和radio单项选择,现在我们把schema修改一下,改成“会员用户信息”的数据,代码如下:

const schema = {
  title: '会员用户信息',
  fieldList: [
    {
      label: '用户名称',
      name: 'usename',
      fieldType: 'input'
    },
    {
      label: '手机号码',
      name: 'phone',
      fieldType: 'input'
    },
    {
      label: '优惠服务',
      name: 'service',
      fieldType: 'radio',
      options: [
        { name: '免运费', value: 'service001' },
        { name: '9折优惠', value: 'service002' },
        { name: '满80减10', value: 'service003' }
      ]
    },
    {
      label: '收货地址',
      name: 'address',
      fieldType: 'input'
    }
  ]
}

这里的“会员用户”表单描述数据,比“普通用户”的多了一个“优惠服务”的表单描述字段,动态表单渲染效果如下:
图片

这两个表单功能,其实出于同一个动态表单的代码,我们只是将表单描述数据schema做对应差异修改,就能直接渲染出对应不同的表单功能。

通过,这样一个简单的Vue.js 3.x动态表单实现,我们可以总结出动态表单实现的三个核心要素

  • 第一点,梳理要用到的表单数据组件;
  • 第二点,根据表单数据组件种类制定数据格式;
  • 第三点,根据数据格式的内容,来渲染表单数据组件的自定义组合,这个自定义组合就是所需要的表单结果。

我们用Vue.js实现了一个简单的动态表单,实际业务需要的动态表单功能可不只这些,我们还要考虑表单校验逻辑、扩展新的表单数据组件等等,这需要一个更完善的动态表单组件。

如何设计和实现完善的动态表单组件?

在具体实现之前,我们先设计动态表单组件的规范。这个规范除了能满足现有的功能需要,还需要有前瞻性的设计,保证能扩展“新的表单动态组件”,不能局限于一开始约定的表单数据组件。

按照要求,再结合实现最简单的动态表单的核心要素,我重新梳理了四点实现要素:

  • 定义表单数据组件的统一API;
  • 定义默认支持的数据表单组件;
  • 支持自定义表单字段的校验规则;
  • 支持根据统一API扩展自定义表单数据组件。

我们来具体分析一下每一点要素。

第一点,定义表单数据组件的统一API。由于动态表单核心是由各种“表单数据组件”的组成,但是,每个表单数据组件,都有各自原生的API使用方式,这些API的差异会降低兼容性。所以,我们需要约定好统一的表单数据组件的API,对不同表单数据组件做API统一封装。

第二点,定义默认支持的数据表单组件。常用的表单数据组件要在动态表单内默认支持,所以我们要用统一的API进行二次封装,并内置到动态表单组件内。这里表单数据组件的API统一代码如下所示:

const props = defineProps<{
  // 传入的数据值
  value: string | any;
  // 组件内部选项参数,例如多选框,单选框,下拉框的选项数据
  options: Array<{ name: string; value: string }>;
}>();

const emits = defineEmits<{
  // 监听组件内部数据变化事件
  (e: 'change', value: string): void;
}>();

第三点,支持自定义表单字段和校验规则。上节课我们说了,抽象表单里中最重要的复用逻辑就是“表单校验”,当时我演示了如何封装一个“表单字段组件”作为“表单数据组件”的外壳,统一管理字段校验规则。所以,自定义表单校验规则,对动态表单来讲也很重要,我们可以把上节课的表单字段组件,沿用到我们的动态表单组件里,统一管理表单校验。

第四点,支持根据统一API扩展自定义表单数据组件。

既然要实现动态表单组件,我们就不能只支持默认表单数据组件的表单生成。毕竟,如果不能扩展新的表单数据组件,以后有新的表单需求,要用到自定义的表单数据组件,动态表单组件就不能快速配置生成表单了,需要我们从零开发一个支持自定义数据组件的表单。这就失去动态表单原本提效的意义了。

所以,我们这里需要支持可扩展自定义表单数据组件,但有个前提,就是自定义表单数据组件要按照第一点要素的内容,封装统一的API

好了,那么我们现在来根据四点要素,实现动态表单组件,看代码:

<template>
  <div :class="{ [baseClassName]: true }">
    <Form>
      <Form
        ref="formRef"
        :model="internalModel"
        @finish="onFinish"
        @finishFail="onFinishFail"
      >
        <FormItem
          v-for="(field, index) in fieldList"
          :key="index"
          :label="field.label"
          :name="field.name"
          :rule="field.rule"
        >
          <component
            :is="registerComponentMap[field.fieldType]"
            :value="internalModel[field.name]"
            :options="field.options || []"
            @change="(value: unknown) => { onFieldChange({ name: field.name, value }) }"
          />
        </FormItem>
        <Row v-if="$slots.default">
          <slot></slot>
        </Row>
      </Form>
    </Form>
  </div>
</template>

<script setup lang="ts">
import { reactive } from 'vue';
import { prefixName } from '../theme';
import Row from '../row';
import Form from '../form';
import Input from '../input';
import RadioList from '../radio-list';
import type { Component } from 'vue';
import type { DynamicFormField } from './common';

// 内置支持的表单数据组件
const registerComponentMap: { [key: string]: Component } = {
  Input: Input,
  RadioList: RadioList
};

const props = withDefaults(
  defineProps<{
    fieldList?: DynamicFormField[];
    model?: { [name: string]: unknown };
  }>(),
  {}
);

const internalModel = reactive<{ [name: string]: unknown }>(props?.model || {});
const FormItem = Form.FormItem;
const baseClassName = `${prefixName}-dynamic-form`;

const onFieldChange = (event: { name: string; value: string | unknown }) => {
  internalModel[event.name] = event.value;
};

const emits = defineEmits<{
  (event: 'finish', e: unknown): void;
  (event: 'finishFail', e: unknown): void;
}>();

const onFinish = (e: unknown) => {
  emits('finish', e);
};

const onFinishFail = (e: unknown) => {
  emits('finishFail', e);
};

</script>

上述代码中,我设计了动态表单的数据格式,通过数组一一对应来描述表单字段内容的。每个表单字段的数据描述有:表单的标签名称、字段名称、字段类型、字段使用数据组件的类型和校验规则。

我们根据实现的动态表单组件,来生成一个可校验的“普通用户”信息编辑表单,代码如下所示:

<template>
  <div class="example">
    <DynamicForm
      :model="model"
      :fieldList="fieldList"
      @finish="onFinish"
      @finishFail="onFinishFail"
    >
      <Button type="primary">提交信息</Button>
    </DynamicForm>
  </div>
</template>

<script setup lang="ts">
import { Button, DynamicForm } from '../src';
import type { DynamicFormField } from '../src';

const model = {
  username: 'Hello',
  phone: '123456',
  address: '',
  service: ''
};

const fieldList: DynamicFormField[] = [
  {
    label: '用户名称',
    name: 'username',
    fieldType: 'Input',
    rule: {
      validator: (val: unknown) => {
        const hasError = /^[a-z]{1,}$/gi.test(`${val || ''}`) !== true;
        return {
          hasError,
          message: hasError ? '仅支持a-z的大小写字母' : ''
        };
      }
    }
  },
  {
    label: '手机号码',
    name: 'phone',
    fieldType: 'Input',
    rule: {
      validator: (val: unknown) => {
        const hasError = /^[0-9]{1,}$/gi.test(`${val || ''}`) !== true;
        return {
          hasError,
          message: hasError ? '仅支持0-9的数字' : ''
        };
      }
    }
  },
  {
    label: '快递地址',
    name: 'address',
    fieldType: 'Input',
    rule: {
      validator: (val: unknown) => {
        const hasError = `${val}`?.length === 0;
        return {
          hasError,
          message: hasError ? '地址不能为空' : ''
        };
      }
    }
  }
];

const onFinish = (e: any) => {
  // eslint-disable-next-line no-console
  console.log('success =', e);
};

const onFinishFail = (e: any) => {
  // eslint-disable-next-line no-console
  console.log('fail =', e);
};
</script>

<style>
html,
body {
  height: 100%;
  width: 100%;
}
.example {
  width: 400px;
  padding: 16px;
  margin: 20px auto;
  box-sizing: border-box;
  border-radius: 4px;
  border: 1px solid #999999;
  font-size: 14px;
}

.btn {
  height: 32px;
  padding: 0 20px;
  min-width: 100px;
}
</style>

代码在浏览器里的演示效果如图:

图片

我再改变一下自定义数据,生成一个可校验的“会员用户”信息编辑表单,代码如下所示:

const fieldList: DynamicFormField[] = [
  {
    label: '用户名称',
    name: 'username',
    fieldType: 'Input',
    rule: {
      validator: (val: unknown) => {
        const hasError = /^[a-z]{1,}$/gi.test(`${val || ''}`) !== true;
        return {
          hasError,
          message: hasError ? '仅支持a-z的大小写字母' : ''
        };
      }
    }
  },
  {
    label: '手机号码',
    name: 'phone',
    fieldType: 'Input',
    rule: {
      validator: (val: unknown) => {
        const hasError = /^[0-9]{1,}$/gi.test(`${val || ''}`) !== true;
        return {
          hasError,
          message: hasError ? '仅支持0-9的数字' : ''
        };
      }
    }
  },
  {
    label: '快递地址',
    name: 'address',
    fieldType: 'Input',
    rule: {
      validator: (val: unknown) => {
        const hasError = `${val}`?.length === 0;
        return {
          hasError,
          message: hasError ? '地址不能为空' : ''
        };
      }
    }
  },
  {
    label: '会员服务',
    name: 'service',
    fieldType: 'RadioList',
    options: [
      { name: '免运费', value: 'service001' },
      { name: '9折优惠', value: 'service002' },
      { name: '满80减10', value: 'service003' }
    ],
    rule: {
      validator: (val: unknown) => {
        const hasError = `${val}`?.length === 0;
        return {
          hasError,
          message: hasError ? '优惠不能为空' : ''
        };
      }
    }
  }
]; 

上述使用代码的动态表单渲染如下图所示:

图片

这两个不同的表单内容,是通过输入不同的表单配置得来的。

第一个表单是在动态表单组件里输入“普通用户”的表单配置数据(用户名称、手机号码和快递地址),渲染了普通用户的表单,也实现了对应表单的校验功能。

第二个表单,输入“会员用户”的表单配置数据(用户名称、手机号码、快递地址和会员服务),其中“会员服务”的配置数据是一个“单选表单数据组件”,附带了可选择的数据,渲染了一个与输入框不一样的表单数据组件。同时,所有表单的字段也配置了校验数据,自动地实现动态校验功能。

好,到这里,我们已经通过动态数据,大致实现了动态渲染表单和动态校验的功能。

但是,在完善动态表单的要素中,我们说的最后一点就是,要支持自定义表单数据组件的扩展,那么要怎么基于现在完善的动态表单组件,实现可扩展的动态表单组件呢?

如何实现可扩展的动态表单

从前面完善的表单组件可以看出,默认支持的表单数据组件,都是内部定义好的,存放在内部的一个对象里,这就意味着,要扩展其他自定义表单数据组件,把相应的组件配置进去就可以了。

这时候,我们需要一个“配置”的过程,一般称为“注册”,首先就是自定义表单数据组件的注册。而表单数据组件需要统一使用的API,也就是说,我们还需要先封装好自定义组件,再把自定义表单数据组件,给注册到统一的动态表单里。

实现的代码如下:

<template>
  <div :class="{ [baseClassName]: true }">
    <Form>
      <Form
        ref="formRef"
        :model="internalModel"
        @finish="onFinish"
        @finishFail="onFinishFail"
      >
        <FormItem
          v-for="(field, index) in fieldList"
          :key="index"
          :label="field.label"
          :name="field.name"
          :rule="field.rule"
        >
          <component
            :is="registerComponentMap[field.fieldType]"
            :value="internalModel[field.name]"
            :options="field.options || []"
            @change="(value: unknown) => { onFieldChange({ name: field.name, value }) }"
          />
        </FormItem>
        <Row v-if="$slots.default">
          <slot></slot>
        </Row>
      </Form>
    </Form>
  </div>
</template>

<script setup lang="ts"> 

// 中间省略上述演示过的代码 ....

// 内置支持的表单数据组件
const registerComponentMap: { [key: string]: Component } = {
  Input: Input,
  RadioList: RadioList
};

// 中间省略上述演示过的代码 ....

// 注册自定义表单数据组件方法
const registerFieldComponent = (name: string, component: Component) => {
  registerComponentMap[name] = component;
};

// 暴露可以注册自定义表单数据组件
defineExpose<{
  registerFieldComponent: typeof registerFieldComponent;
}>({
  registerFieldComponent
});
</script>

代码中,我给动态表单组件添加了一个“外用方法”registerFieldComponent,把子自定义表单组件,注册到动态表单里。你可以从代码的注释中看出,registerFieldComponent 注册组件方法和内置组件缓存变量registerComponentMap的关系。

通过registerFieldComponent方法,我们可以把自定义组件缓存到registerComponentMap变量里,等待后续渲染表单时候使用。也就是说,外部组件,可以直接通过这个方法来操控动态表单,将自定义组件注入到表单中。后续,只要动态的配置数据里用到了这个自定义组件,就会自动渲染出来。

至此,我们就从API设计到组件扩展层面,实现了一个完整的动态表单组件。

总结

通过这节课的学习,相信你应该已经理解了什么是动态表单,以及如何基于Vue.js 3.x开发自研组件库里的动态表单组件。我们总结几个重要的概念和技术实现。

动态表单的要素:

  • 能通过自定义数据来配置生成表单渲染;
  • 能支持扩展其他表单数据组件来扩展表单能力;

“动态表单组件”的核心技术实现就是,通过数据来动态渲染所需的表单数据组件,以及可以自定义其他数据组件。具体的实现步骤:

  • 第一步,需要定义好统一的表单数据组件的API,封装好默认支持的表单数据组件;
  • 第二步,定义动态表单的配置数据格式,并且做好可以扩展的数据格式设计;
  • 第三步,根据配置数据来渲染描述的表单数据组件,以及用表单字段组件进行统一管理;
  • 第四步,开发自定义表单数据组件的的注册能力;

思考题

动态表单能实现多种表单数据组件的渲染,那么不同表单数据组件能否做联动操作的功能配置?

欢迎积极参与讨论,如果有疑问,也欢迎在留言区留言,我会尽快回复。下一讲见。

完整的代码在这里

精选留言(2)
  • Akili 👍(2) 💬(1)

    动态表单 联动怎么做呢

    2023-02-17

  • ifelse 👍(0) 💬(0)

    学习打卡

    2024-09-10