手把手实现一个动态表单

948 阅读4分钟

说到动态表单,常见的业务场景,比如固定表单场景和固定UI场景,比如问卷调查的表单业务,比如线上考试的题库等,有大量相似表单需求的场景。


实现一个如下所示简单的动态表单,所谓的动态,基本都可以封装在配置里面,然后基于这份配置渲染出表单。


得到的结论: 动态表单 = 动态的配置 + 渲染表单;

动态配置

为了配置动态表单,就要将表单描述变得序列化,如何实现表单结果可序列化,目前业内比较火的数据序列化json-schema, 除了有数据层面的基础描述,还包括各种校验属性。 但是动态表单,除了数据层面的描述,还包括UI上的描述;所以json-schema某种程度上还不能覆盖动态表单的描述; 为了增强json-schema;可有以下几种方式

  1. json-schema做数据层面的描述,自定义ui-schema做UI层面的描述;
  2. json-schema做数据层面的描述,扩展其上的属性,xx-aaa用作UI层面的属性表述;
  3. DSL 自定义一套标准,来用描述动态表单

方式一 ———json-schema + ui-schema

比如react-jsonschema-form, JSONSchema用来描述数据层面的,UISchema用来描述UI上面的不同; 可以完美的复用JSONSchema, 但是需要维护两份配置,对于哪些描述属于哪个配置文件,给使用者有一定心智负担,同时维护两份也带来维护成本;

方式二————对JSON-Schema做扩展

比如formilyjs, 基于JSON-Schema上做了扩展,只需维护一份配置。

方式三*————DSL

  • DSL(Domain Specific Language )领域特定语法,为动态表单打造一套描述其数据结构的语法;定制化程度会更高,但是理解一套新的语法来描述,也有一定的上手成本。
{
  type: '',
  label: '',
  defaultValue: '',
  placeholder?: string;
  options?:[],
  rule: ["all", ["gte", 18], ["lte", 55]],
  errorMsg: "xxxx",
  when: []
}

目前我们采用基于DSL来实现一套简要的动态表单,因为自定义程度比较高,也不存在复杂的语法,更偏向于类似json,描述一些键值对来做解析。

const fieldList= [{
  label: '用户名称',
  name: 'username',
  type: 'Input',
  rule: {
    validator: (val: unknown) => {
      const hasError = /^[a-z]{1,}$/gi.test(`${val || ''}`) !== true;
      return {
        hasError,
        message: hasError ? '仅支持a-z的大小写字母' : ''
      };
    }
  }
}, {
  label: '手机号码',
  name: 'phone',
  type: 'Input',
  rule: {
      validator: (val: unknown) => {
        const hasError = /^[0-9]{1,}$/gi.test(`${val || ''}`) !== true;
        return {
          hasError,
          message: hasError ? '仅支持0-9的数字' : ''
        };
      }
    }
}, {
  label: '是否参加活动',
  name: 'sex',
  type: 'Radio',
  options: [{
    name: '参加', value: '1'
  }, {
    name: '不参加(不参加需要备注原因)', value: '2'
  }],
  rule: {
    validator: (val: unknown) => {
        const hasError = `${val}`?.length === 0;
        return {
          hasError,
          message: hasError ? '性别不能为空' : ''
        };
    }
  }
}, {
  label: '原因',
  name: 'summmer',
  type: 'Input',
  binVisible: {
    sign: '',
    name: 'sex',
    value: '2'
  }
}]

动态渲染

统一基础组件的输入和输出

结合配置,可以初始化我们定义的一些基础组件,input, radio,并且我们统一所有组件的输入和输出API上的封装,这样可以在渲染的时候可以统一接入,不需要额外的处理不一致的行为。

// radio组件
const props = defineProps<{
    value: string;
    options: Array<{name: string; value: string}>;
}>()

const emits = defineEmits<{
    (e: 'change', value: string): void
}>()
const props = defineProps<{
    value: string;
    options: Array<{name: string; value: string}>
}>()

const emits = defineEmits<{
    (e: 'change', value: string): void;
}>();

渲染表单

说到vue的动态渲染,官网里面提供的方式有以下几种方式

  1. h() render函数;
  2. jsx——使用@vue/babel-plugin-jsx插件来支持对jsx的解析;
  3. 动态组件

目前我们选择动态组件结合type属性来实现

<template>
  <div>
      <Form ref="formRef" :model="internalModel" @finish="onFinish" @finishFail="onFinishFail">
        <FormItem v-for="(filed, index) in fieldList" :key="index" :label="filed.label" :rule="filed.rule" :name="filed.name" :binVisible="filed.binVisible">
            <component :is="registerComponentMap[filed.type]" :value="internalModel[filed.name]" :options="filed.options || []" @change="(value: unknown) => { onFieldChange({name: filed.name, value})}"></component>
        </FormItem>
        <Row v-if="$slots.default">
          <slot></slot>
        </Row>
      </Form>
  </div>
</template>
<template>
    <form @submit="handleSubmit">
        <slot />
    </form>
</template>
<template>
    <Row>
        <Row v-if="showVisible">
            <Col :span="labelCol">
                <span>{{  props.label }}</span>
            </Col>
            <Col :span="wrapperCol">
                <slot />
            </Col>
        </Row>
        <Row v-if="props.name">
            <Col :span="labelCol"></Col>
            <Col :span="wrapperCol">
                <span v-if="errorTip">{{  errorTip }}</span>
            </Col>
        </Row>
    </Row>
</template>

表单统一校验

Form提供一个formContext供FormItem来将每一行的表单项的校验逻辑注入,并且统一在Form组件中触发;

FormItem中根据rule字段来解析每一行表单项的校验工作。

async function validateFieldValue(val) {
    if(props.rule?.validator) {
        const result = await props.rule?.validator?.(val);
        if (result.hasError && result.message) {
            errorTip.value = result.message;
        } else {
            errorTip.value = '';
        }
        return { ...result, ...{ name:props.name, value:toRaw(val)}}
    }
    return {
        hasError: false
    }
}

联动

利用formContext中共享表单所有数据,FormItem中获取到binVisible配置,结合formContext共享的model数据,可以实现表单控件间的联动。

const genVisiable = () => {
    if(props.binVisible) {
        showVisible.value = false;
        if((formContext.model[props.binVisible?.name] === props.binVisible?.value)) {
            showVisible.value = true;
        } else {
            showVisible.value = false;
        }
    } 
}

总结

实际业务中,动态表单的场景更多适合为简单固定的表单的业务场景,这样减少一定的重复性工作。但是也有一些开放性的三方表单生成器,提供了自定义扩展,更有与低代码结合的拖拽式表单生成器,可以实现复杂场景的表单生成,

很多重复性的表单可以用动态表单生成,节约一定的时间成本,如果是复杂性的表单,需要有很多的自定义的扩展,生成的同时,其中的维护成本,扩展性也需要考虑。

本文结合例子,实现了一个简易的动态表单,从动态配置的三种方式,

  1. json-schema做数据层面的描述,自定义ui-schema做UI层面的描述;
  2. json-schema做数据层面的描述,扩展其上的属性,xx-aaa用作UI层面的属性表述;
  3. DSL 自定义一套标准,来用描述动态表单;

表单渲染的流程,对基础组件统一输入和输出,动态渲染的方式,用provider,inject来实现表单数据共享和通信,同时也借此实现了表单校验,表单联动。

参考

vue-渲染函数

formily