log
码中赤兔

[Vue3] 动态表单设计:链表结构的实现与优化

发布于 2025年1月29日
更新于 2025年1月30日
36 分钟阅读
Vue

在复杂业务场景中,表单项通常并非固定,而是需要根据条件动态显示或隐藏。比如,一个表单中 OtherFavorite 需要根据 Favorite 的值来决定是否显示。这类场景下,常规的实现方法是通过 if 条件语句控制显示逻辑,但当动态表单项较多时,代码复杂度会显著增加,维护成本也随之上升。

本文将分享一种更灵活的链表结构设计,帮助解决动态表单显示及校验的复杂问题。


问题背景

以下是一个简单的表单需求示例:

顺序 类型 显示条件
1 Name text 字符串 无条件
2 Address text 字符串 无条件
3 Favorite checkbox [“Apple”, “Banana”, “Cherry”, “Other”] 无条件
4 OtherFavorite text 字符串 favorite的选项里含有Other的时候显示
5 Comment textarea 字符串 无条件

普通实现中,我们可能会用如下代码通过 v-if 动态显示 OtherFavorite;同时,利用 watch 动态调整校验规则:

<template>
  <el-form
    ref="ruleFormRef"
    style="max-width: 600px"
    :model="ruleForm"
    :rules="rules"
    label-width="auto"
    :validate-on-rule-change="false"
    status-icon
  >
    <el-form-item label="Name" prop="name">
      <el-input v-model="ruleForm.name" />
    </el-form-item>
    <el-form-item label="Address" prop="address">
      <el-input v-model="ruleForm.address" />
    </el-form-item>
    <el-form-item label="Favorite" prop="favorite">
      <el-checkbox-group v-model="ruleForm.favorite">
        <el-checkbox label="Apple" value="Apple" />
        <el-checkbox label="Banana" value="Banana" />
        <el-checkbox label="Cherry" value="Cherry" />
        <el-checkbox label="Other" value="Other" />
      </el-checkbox-group>
    </el-form-item>
    <!-- 通过判断favorite的值判断other是否显示 -->
    <el-form-item v-if="ruleForm.favorite.includes('Other')" label="OtherFavorite" prop="other">
      <el-input v-model="ruleForm.other" />
    </el-form-item>
    <el-form-item label="Comment" prop="comment">
      <el-input v-model="ruleForm.comment" />
    </el-form-item>
    <el-form-item>
      <el-button type="primary" @click="submitForm(ruleFormRef)"> Create </el-button>
      <el-button @click="resetForm(ruleFormRef)">Reset</el-button>
    </el-form-item>
  </el-form>
</template>

<script lang="ts" setup>
import { ref, watch } from 'vue';
import type { FormInstance, FormRules } from 'element-plus';
import type { FormInputs } from '@/models/form.model';

const ruleFormRef = ref<FormInstance>();

const ruleForm = ref<FormInputs>({
  name: '',
  address: '',
  favorite: [],
  other: '',
  comment: '',
});

const rules = ref<FormRules<FormInputs>>({
  name: [{ required: true, message: 'Please input name', trigger: 'blur' }],
  address: [{ required: true, message: 'Please input address', trigger: 'blur' }],
  favorite: [{ required: true, message: 'Please select favorite', trigger: 'change' }],
  comment: [{ required: true, message: 'Please input comment', trigger: 'blur' }],
});

// 监听favorite的值,动态设置other的校验规则
watch(
  () => ruleForm.value.favorite,
  (value) => {
    if (value.includes('Other')) {
      rules.value.other = [
        { required: true, message: 'Please input other favorite', trigger: 'blur' },
      ];
    } else {
      rules.value.other = [];
    }
  },
);

const submitForm = async (formEl: FormInstance | undefined) => {
  if (!formEl) return;
  await formEl.validate((valid, fields) => {
    if (valid) {
      console.log('submit!');
    } else {
      console.log('error submit!', fields);
    }
  });
};

const resetForm = (formEl: FormInstance | undefined) => {
  if (!formEl) return;
  formEl.resetFields();
};
</script>

这种方法在简单场景下可以胜任,但当动态项增多或逻辑更复杂时,代码将难以维护。


解决方案:链表结构

我们可以将表单项抽象为一个链表节点,每个节点包含以下信息:

  • 表单项的基本信息(如 id、label、type 等)。
  • 动态显示的条件逻辑。
  • 下一个节点的指向。

数据结构设计

首先定义表单项元数据类型:

import type { FormItemRule } from 'element-plus';

/**
 * 表单项类型
 */
export type FormInputs = {
  name: string;
  address: string;
  favorite: string[];
  other?: string;
  comment: string;
};

/**
 * 表单项元数据
 */
export type FormItemMeta = {
  /** 表单项ID,必须是表单项类型的key */
  id: keyof FormInputs;
  /** 表单项标签 */
  label: string;
  /** 表单项类型 */
  type: 'text' | 'checkbox' | 'textarea';
  /** 表单项校验规则 */
  rules: Array<FormItemRule> | undefined;
  /** 当表单项是checkbox的时候,显示的选项 */
  options?: Array<{ label: string; value: string }>;
  /** 下一个表单项 */
  next?: FormItemMeta | Array<{ condition: string; item: FormItemMeta } | FormItemMeta>;
};

基于上述结构,我们可以用链表形式组织表单项:

export const formItemMeta: FormItemMeta = {
  id: 'name',
  label: 'Name',
  type: 'text',
  rules: [{ required: true, message: 'Please input name', trigger: 'blur' }],
  // name的下一个表单项是address
  next: {
    id: 'address',
    label: 'address',
    type: 'text',
    rules: [{ required: true, message: 'Please input address', trigger: 'blur' }],
    // address的下一个表单项是favorite
    next: {
      id: 'favorite',
      label: 'Favorite',
      type: 'checkbox',
      rules: [{ required: true, message: 'Please select favorite', trigger: 'change' }],
      options: [
        { label: 'Apple', value: 'apple' },
        { label: 'Banana', value: 'banana' },
        { label: 'Cherry', value: 'cherry' },
        { label: 'Other', value: 'other' },
      ],
      // favorite的下一个表单项是other和comment
      next: [
        // other表单项的显示条件是favorite中选择了Other
        {
          condition: 'other', // 显示条件
          item: {
            id: 'other',
            label: 'Other',
            type: 'text',
            rules: [{ required: true, message: 'Please input other', trigger: 'blur' }],
          },
        },
        // comment表单项是无条件显示的
        {
          id: 'comment',
          label: 'Comment',
          type: 'textarea',
          rules: [{ required: true, message: 'Please input Comment', trigger: 'blur' }],
        },
      ],
    },
  },
};

渲染逻辑:递归实现

定义一个递归组件 FormItemRenderer.vue,根据元数据动态渲染表单项:

<!-- FormItemRenderer.vue -->
<template>
  <el-form-item :label="meta.label" :prop="meta.id">
    <!-- text类型的表单项 -->
    <el-input v-if="meta.type === 'text'" :id="meta.id" v-model="value" />
    <!-- textarea类型的表单项 -->
    <el-input v-if="meta.type === 'textarea'" type="textarea" :id="meta.id" v-model="value" />
    <!-- checkboxt类型的表单项 -->
    <el-checkbox-group v-if="meta.type === 'checkbox'" v-model="value">
      <el-checkbox
        v-for="option in meta.options"
        :key="option.value"
        :label="option.label"
        :value="option.value"
      />
    </el-checkbox-group>
  </el-form-item>

  <!-- 渲染下一个表单项 -->
  <FormItemRenderer
    v-for="nextFormItem in nextFormItemList"
    :key="nextFormItem.id"
    :meta="nextFormItem"
    :values="values"
    v-on:value-changed="onValueChanged"
    v-on:rule-changed="onRuleChanged"
  />
</template>

<script setup lang="ts">
import type { FormInputs, FormItemMeta } from '@/models/form.model';
import type { FormItemRule } from 'element-plus';

import { computed, onMounted, onUnmounted, type PropType } from 'vue';

const props = defineProps({
  meta: {
    type: Object as PropType<FormItemMeta>,
    required: true,
  },
  values: {
    type: Object as PropType<FormInputs>,
    required: true,
  },
  onValueChanged: {
    type: Function as PropType<(id: string, value: string | string[] | undefined) => void>,
    required: true,
  },
  onRuleChanged: {
    type: Function as PropType<(id: string, rules: Array<FormItemRule> | undefined) => void>,
    required: true,
  },
});

const value = computed({
  get: () => props.values[props.meta.id],
  set: (newValue: string | string[]) => props.onValueChanged(props.meta.id, newValue),
});

/**
 * 获取下一个表单项
 */
const nextFormItemList = computed(() => {
  // 如果没有下一个表单项,则返回空数组
  if (props.meta.next === undefined) {
    return [];
  }
  // 如果下一个表单项是一个FormItemMeta类型的数据,则直接返回
  if ('id' in props.meta.next) {
    return [props.meta.next] as Array<FormItemMeta>;
  }
  // 如果下一个表单项是一个数组,则遍历数组,根据条件返回下一个表单项
  const next = props.meta.next as Array<{ condition: string; item: FormItemMeta } | FormItemMeta>;
  let nextItems: Array<FormItemMeta> = [];
  for (const nextItem of next) {
    if ('condition' in nextItem && 'item' in nextItem) {
      // 如果下一个表单项有条件
      const nextItemWithCondition = nextItem as { condition: string; item: FormItemMeta };

      // 如果当前表单项的值是一个stirng类型,并且等于下一个表单项的条件,则将下一个表单项添加到nextItems数组中
      if (typeof value.value === 'string' && value.value === nextItemWithCondition.condition) {
        nextItems.push(nextItemWithCondition.item);
      }
      // 如果当前表单项的值是一个数组,并且包含下一个表单项的条件,则将下一个表单项添加到nextItems数组中
      if (Array.isArray(value.value) && value.value.includes(nextItemWithCondition.condition)) {
        nextItems.push(nextItemWithCondition.item);
      }
    } else {
      // 如果下一个表单项没有条件,则直接将下一个表单项添加到nextItems数组中
      nextItems.push(nextItem);
    }
  }

  return nextItems;
});

/**
 * 当组件挂载时,如果表单项有规则,则将规则传递给父组件
 */
onMounted(() => {
  if (props.meta.rules) {
    props.onRuleChanged(props.meta.id, props.meta.rules);
  }
});

/**
 * 当组件卸载时,将表单项的值和规则清空
 */
onUnmounted(() => {
  props.onRuleChanged(props.meta.id, undefined);
  props.onValueChanged(props.meta.id, undefined);
});
</script>

主组件渲染

在主组件中,通过 FormItemRenderer.vue 渲染整个表单链表:

<template>
  <el-form
    ref="ruleFormRef"
    style="max-width: 600px"
    :model="form"
    :rules="rules"
    label-width="auto"
    :validate-on-rule-change="false"
    status-icon
  >
    <FormItemRenderer
      :meta="formItemMeta"
      :values="form"
      v-on:value-changed="onValueChanged"
      v-on:rule-changed="onRuleChanged"
    />
    <el-form-item>
      <el-button type="primary" @click="submitForm(ruleFormRef)"> Create </el-button>
      <el-button @click="resetForm(ruleFormRef)">Reset</el-button>
    </el-form-item>
  </el-form>
</template>

<script lang="ts" setup>
import { ref } from 'vue';
import type { FormInstance, FormItemRule, FormRules } from 'element-plus';
import type { FormInputs } from '@/models/form.model';
import FormItemRenderer from './FormItemRenderer.vue';
import { formItemMeta } from '@/models/form.model';

const ruleFormRef = ref<FormInstance>();

const form = ref<FormInputs>({
  name: '',
  address: '',
  favorite: [],
  other: '',
  comment: '',
});

const rules = ref<FormRules<FormInputs>>({});

const onValueChanged = (id: string, value: string | string[] | undefined) => {
  form.value = {
    ...form.value,
    [id]: value,
  };
};

const onRuleChanged = (id: string, itemRules: Array<FormItemRule> | undefined) => {
  rules.value = {
    ...rules.value,
    [id]: itemRules,
  };
};

const submitForm = async (formEl: FormInstance | undefined) => {
  if (!formEl) return;
  await formEl.validate((valid, fields) => {
    if (valid) {
      console.log('submit!');
    } else {
      console.log('error submit!', fields);
    }
  });
};

const resetForm = (formEl: FormInstance | undefined) => {
  if (!formEl) return;
  formEl.resetFields();
};
</script>

总结

通过链表结构设计动态表单,我们不仅可以简化代码逻辑,还能提升动态表单的扩展性与可维护性。无论是条件复杂的动态显示,还是规则变更,都能通过元数据定义轻松实现。

关于

分享技术见解、经验和思考的个人博客

联系方式

  • Email: hushukang_blog@proton.me
  • GitHub

© 2025 码中赤兔. 版权所有