如何优雅的实现动态表单

2,836 阅读11分钟

背景

在最近对公司某项目的维护中,我们发现存在大量的表单提交模块。由于团队交接和多人维护,这些模块中充斥着重复代码和冗余逻辑,增加了维护的难度。为了解决这些问题,我们对项目进行了重构。

通过分析代码发现,原项目中的表单是通过普通表单的形式开发的,其开发流程如下:

在需求量少或场景不复杂的情况下,这种开发模式尚可接受。然而,当项目中包含大量表单时,普通表单的开发方式因UI与业务逻辑过于耦合,导致后续的代码维护困难,影响可扩展性和可读性。此外,这种方式通常需要编写大量冗长且重复的代码,显著延长开发周期并增加维护成本。面对大量相似表单的构建,手动编写和维护的工作量和时间消耗尤为巨大。那么,有没有一种方法可以有效地分离业务逻辑与UI渲染逻辑,让我们专注于数据配置,避免繁琐的DOM样式编写与调整呢?基于此,我们想到了动态表单的设计思路。

动态表单是什么

动态表单是一种通过 Schema 描述自动完成表单布局、数据管理、校验、交互和项联动的开发方式。简单来说就是:

给我一份数据,我帮你渲染出你想要的表单,并实现相关的功能。

开发流程如下图:

可以看到,动态表单对开发者而言,有效地将业务逻辑与 UI 渲染逻辑分离,开发者可以专注于数据配置,免去繁琐的 DOM 样式编写与调整。这大大缩短了开发时间,提升了开发体验。对于动态表单,只需设定一套统一的配置模板,后续新增表单或输入项时,只需简单修改配置文件,过程便捷高效。与传统的组合方式相比,动态表单显著减少了重复的 HTML 模板代码,使得开发更加顺畅。

技术方案与技术选型

通过以上分析,我们首先明确下动态表单应用场景

  • 动态表单特别适合需求量大且相似的场景,其中输入控件类型相对固定,UI 和布局保持一致。这使得 Schema 的定义变得高效,通过简单的循环即可快速生成表单。

以及设计原则

  • 决策考虑:选择动态表单时,不仅要从技术角度出发,还需充分考虑业务需求和 UI 设计。避免因为几行重复的 HTML 代码而选择动态表单,某些重复是“必要的”;错误的选择可能会降低开发效率。
  • 灵活性妥协:一旦决定采用动态表单方案,需在灵活性上做出适度妥协,不再追求千变万化的 UI 设计。这需要开发、业务和设计团队之间的良好沟通与协作。
  • 样式配置:避免在配置文件中设定过于细致的 CSS 样式。可以封装一些固定的样式,例如两列或三列布局,通过配置项进行切换,以简化操作。

接下来,我们来分析动态表单实现过程。其实就是如何设计 schema ,以及如何将 schema 转换为表单

Schame设计

表单 Schema 设计的重点是三个方面,如何描述表单的数据结构?如何描述表单项之间的关系?如何描述表单的 UI 样式?

描述数据

image.png

对于上述表单,我们通过Json Schema来描述。

[
  {
    key: 'username',
  },
  {
    key: 'password',
  },
]

我们通过上面的Json描述了一个数据,其包含 usernamepassword 属性。

描述样式

设计动态表单的 Schema 除了要描述表单的数据结构之外,还需要描述表单的 UI 样式。如输入框类型 typeplaceholder 文案;禁用状态 disabled 等。另外,我们通过 props 来承载底层组件库的参数。

[
  {
    key: 'username',
    type: 'input',
    props: {
      label: '用户名',
      placeholder: '请输入用户名',
    },
  },
  {
    key: 'password',
    type: 'input',
    props: {
      label: '密码',
      placeholder: '请输入密码',
    },
  },
]

描述关系 - 联动

之所以要描述表单项之间的关系,则是因为在一个表单中,表单项之间的联动操作实在太过于常见了。

表单的联动操作简单理解就是某个表单项的变化会引起另外表单项的变化。比如表单项的某个值会控制另外一个表单项的显隐。如下图当勾选 单选框1时 时,展示 步进器,勾选 单选框2时,展示 评分

image.png

[
    {
      key: 'select',
      type: 'radio',
      rules: 'required',
      value: select,
      hidden: !showVirtualForm.value,
      props: {
        label: '单选框',
      },
    },
    {
      key: 'stepper',
      type: 'stepper',
      value: stepper,
      hidden: select.value === 2,
      props: {
        label: '步进器',
      },
    },
    {
      key: 'rate',
      type: 'rate',
      rules: 'required',
      value: rate,
      hidden: select.value === 1,
      props: {
        label: '评分',
      },
    },
]

我们不在渲染器内部做处理,通过在外部修改Json参数来控制。设置一个 hidden 参数,来控制该项是否隐藏。这样通过响应式的设计,当监听到数据变化时,重新渲染组件。

另外我们通过 value 参数来传入默认值,通过 rules 参数来设置校验规则

表单渲染 - 从 Schema 到表单

在这一阶段重点考虑将 Schema 转换为表单页面展示的问题,因为表单 Schema 主要由 JSON Schema 构成,因为它是一套递归的数据结构,所以 Schema 转换成真实渲染的表单实际上是一个组件逐级递归渲染的过程。

渲染器设计

解析流程从遍历 Schema JSON 开始,先读取顶级的属性 model。利用 model 生成模型。接下来,就是利用渲染器对节点进行渲染,如在vue中,通过读取单个表单类型,即读取 JSONtype 对应的真实组件,通过 vue 动态组件来完成该组件的渲染,并将 props 信息传入该组件中,由此,就可以渲染出单个表单组件。children 的渲染亦是如此,将 children 渲染完之后,作为上一层组件的子组件完成渲染。

<template>
    <ValidationProvider class="dynamic-form-item">
        <component
            :is="component(item.type)"
            v-model="formInfo[item.key]"
            v-bind="item.props"
            :error="errors[0]"
        >
            <template v-if="children.length" v-slot:children>
                <form-deep
                    v-for="child in children"
                    :key="child.key"
                    :formInfo="formInfo"
                    :item="child"
                ></form-deep>
            </template>
        </component>
    </ValidationProvider>
</template>
<script lang="ts">
import { defineComponent, computed, ref, PropType } from '@vue/composition-api';
import BInput from './b-input.vue';
import BSelector from './b-selector.vue';
import BImage from './b-image.vue';
import { FormItemType } from './form-item-type';

const componentMap = {
    input: BInput,
    selector: BSelector,
    image: BImage,
};

export default defineComponent({
    name: 'form-deep',
    components: {
        BInput,
        BSelector,
        BImage,
    },
    props: {
        formInfo: {
            type: Object as PropType<any>,
            default: () => ({} as any),
        },
        item: {
            type: Object as PropType<FormItemType>,
            default: () => ({} as FormItemType),
        },
    },
    emits: ['submit'],
    setup(props) {
        const component = computed(() => (type: keyof typeof componentMap) => componentMap[type]);

        const children = computed(() => {
            return props.item?.children?.filter(item => !item.hidden) || [];
        });

        return {
            children,
            component,
        };
    },
});
</script>
<template>
    <ValidationObserver slim ref="form">
        <form-deep
            v-for="item in list.filter(item => !item.hidden)"
            :key="item.key"
            :formInfo="formInfo"
            :item="item"
        ></form-deep>
    </ValidationObserver>
</template>
<script lang="ts">
import { defineComponent, ref, Ref, PropType, watch } from '@vue/composition-api';
import FormDeep from './form-deep.vue';
import { FormItemType } from './form-item-type';

const getFormInfo = (list: FormItemType[]) => {
    const form: any = {};
    const stack = [...list];
    while (stack.length) {
        // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
        const item = stack.pop()!;
        form[item.key] = item.value;
        if (item?.children) {
            stack.push(...item.children);
        }
    }

    return form;
};

export default defineComponent({
    components: {
        FormDeep,
    },
    emits: ['update-form', 'submit'],
    props: {
        list: {
            type: Array as PropType<FormItemType[]>,
            default: () => [],
        },
    },
    setup(props, { emit }) {
        const form = ref<HTMLElement | null>();
        const formInfo = ref<any>(getFormInfo(props.list));
    },
});
</script>

此外,我们采用插件化设计,实现了组件层与渲染层的解耦。渲染器能够根据类型动态渲染相应的组件。基于这一理念,我们可以利用 Antd、Vant 等框架,为我们的业务定制丰富的组件库。这些组件可以是单纯的展示组件,也可以是复杂的业务组件。在 Schema 中,这些组件都通过 type 属性进行标识。

aaaa.png

表单校验

目前市面上有多种表单校验库可供选择,包括 VuelidateVeeValidateBuefyVueformulate 等。 经过调研,各工具的优缺点如下:

优点缺点
VeeValidate功能强大:提供丰富的内置校验规则,几乎涵盖了所有常见的校验需求。
可扩展性:支持自定义校验规则,并可以轻松集成到复杂的表单中。
异步支持:支持异步校验,适合处理需要服务器验证的场景。
Vue 3 兼容:支持 Vue 3 和 Composition API。
体积较大:相比其他工具,VeeValidate 的体积较大,可能会影响性能。
复杂性:功能强大也意味着更复杂的配置,可能不适合小型项目。
Vuelidate轻量级:Vuelidate 是一个轻量级的库,适合需要简单校验的项目。
灵活性:提供基于模型的校验规则,可以轻松地自定义和组合校验规则。
异步支持:支持异步校验,适合处理需要服务器验证的场景。
Vue 3 兼容:支持 Vue 3,适合现代 Vue 项目。
学习曲线:对于初学者来说,理解其基于模型的校验方式可能需要一些时间。
功能有限:功能相对简单,可能不适合需要复杂表单逻辑的大型项目。
Vueformulate表单生成:提供强大的表单生成功能,可以快速构建复杂表单。
多功能:支持动态表单、自定义输入类型和丰富的校验规则。
易于集成:易于与现有项目集成,减少开发时间。
复杂性:功能多样可能导致配置复杂,学习曲线较陡。
社区支持:相对于 VeeValidate 和 Vuelidate,社区支持和资源可能较少。
Buefy集成 Bulma:基于 Bulma 框架,适合使用 Bulma 进行样式设计的项目。
简单易用:提供基础的表单校验功能,适合简单项目。
组件丰富:除了表单校验,还提供了其他丰富的 UI 组件。
功能有限:校验功能相对简单,可能不适合需要复杂校验逻辑的项目。
Vue 3 支持有限:Buefy 主要是为 Vue 2 设计的,对 Vue 3 的支持可能不如其他工具。

考虑到功能丰富度、扩展性和社区支持度,我们选择使用VeeValidate作为我们的表单校验工具,下面是一个VeeValidate的使用用例:

首先我们需要注册 ValidationProvider 作为字段验证器的组件,它通过插槽形式来为我们提供验证错误。

import { ValidationProvider } from 'vee-validate';
Vue.component('ValidationProvider', ValidationProvider);

然后我们以验证邮箱为例,来添加邮箱验证规则 required, email 。通过 extend 函数来注册规则。以上代码我们可以新建一个 validation.js 文件,在模板中使用规则之前引入,如 main.js 中。

import { extend, ValidationProvider } from 'vee-validate';
import { required, email } from 'vee-validate/dist/rules';


Vue.component('ValidationProvider', ValidationProvider);

// No message specified.
extend('email', email);

// Override the default message.
extend('required', {
  ...required,
  message: 'This field is required'
});

最后我们通过 ValidationProvider  来包裹我们的输入框,此时我们就可以实现规则校验和错误输出。多个规则通过 | 管道字符分隔规则。

<template>
  <ValidationProvider name="email" rules="required|email" v-slot="{ errors }">
    <input v-model="email" type="text">
    <span>{{ errors[0] }}</span>
  </ValidationProvider>
</template>

联动

数据联动的核心在于有效的“数据监听”和“事件管理”。当表单中的某个数据发生变化时,我们需要检查是否触发相应的事件以实现联动管理。

首先,我们需要将组件的入参 JSON 设计为响应式,以确保外部响应式变量变化时,能够自动更新界面。接着,在渲染器内部对表单数据进行监听,一旦检测到数据变化,就将变化信息派发给外部系统,由外部决定是否执行联动操作。

提交表单

这里我们考虑了两种实现方案:

派发事件

为了适应各业务的不同提交逻辑,我们将提交逻辑放在组件外部进行处理。通过设置 skip 参数,当检测到 skip 发生变化时,触发表单校验,并将表单内容信息传递给外部,由外部进行相应处理。

const onSubmit = () => {
  (form.value as any)?.validate().then((res: boolean) => {
    emit('submit', res, formInfo.value);
  }).catch((err: any) => {
    console.log('表单校验 err', err);
  });
};

watch(() => props.skip, (skip: number) => {
  if (skip) {
    onSubmit();
  }
});
events 事件

将提交事件放入 events 中进行处理,如下所示。

[
    {
      key: 'rate',
      type: 'rate',
      rules: 'required',
      value: rate,
      hidden: select.value === 1,
      props: {
        label: '评分',
      },
    },
    events: {
        'on-change': (formInfo) => {
            // submit()
        }
    },
]

鉴于实现成本的考量,我们决定采用第一种方案。然而,第二种方案也具有潜在的价值,值得在未来进一步探讨和研究。

探索与展望

  1. 代码导出

供配置预览页面,支持通过配置导出代码包,便于第三方业务引入。

为什么不采用编辑器或自动发布页面的模式?

因为纯表单页面的需求较少,通常表单只是页面中的一个模块。开发完整的编辑器和自动发布系统成本高昂,投入产出比不理想。通过代码包或组件引入的方式,开发者能更灵活地适应不同场景,提升开发效率。

  1. 丰富UI组件库,自定义组件库异步加载

在 JSON 配置中,使用动态语法动态表达式来实现表单的动态联动功能

文章参考

动态表单引擎,向低代码迈出最关键的一步
建模与表单的动态化设计
动态表单的设计思想及实现策略

优秀案例

formily
form-create
H5-Dooring
vue-dynamic-form