Angular 可复用动态表单组件封装

在金融风控系统(下文简称Nova系统)的开发中,经常会遇到包含数百个字段的复杂表单页面。这些表单不仅字段类型多样,还存在复杂的联动关系和校验规则,传统的硬编码方式不仅开发效率低下,还会导致代码冗余难以维护。本文将基于Nova系统的真实需求分享公共表单组件封装方案,通过配置化思路解决复杂表单的开发难题。

项目背景与痛点分析

Nova系统基于 Angular 框架开发,采用 ng-zorro 组件库构建 UI 界面。系统中存在大量包含数百个字段的表单,这些表单具有以下特点:

  • 字段类型多样:包含 input、日期选择框、select、单选框、多选框等基础组件,还有 input group 组合组件及自定义封装的业务组件;

  • 事件需求复杂 :需要支持 ngModelChangeblur 等基础事件,部分组件还需特殊事件如nzOpenChange

  • 校验规则严格:每个字段都有特定的校验规则,需实时提示错误信息;

  • 联动关系紧密:字段间存在复杂的依赖关系,如 A 字段的状态由 B 字段的值决定,多个字段值相互计算等;

  • 动态性要求高 :存在可动态增减的列表项FormArray,部分字段需根据外部变量动态变化;

  • 附加功能多:部分字段需要详情查看按钮,支持跳转或弹窗展示;

如果按ng-zorro逐个编写表单控件的方式会导致代码量激增,维护成本高,且难以保证表单行为的一致性。因此,需要设计一套可配置、可复用的表单组件方案。

核心实现思路

解决方案是封装一个公共表单组件,通过配置数组动态生成表单项。核心思路包括:

  1. 定义统一的表单项配置接口,涵盖各类组件的属性和行为
  2. 通过遍历配置数组,动态生成对应的表单控件
  3. 集中处理表单验证、事件响应和字段联动逻辑
  4. 支持自定义模板和扩展功能,满足特殊业务需求

这种配置化组件可以将表单的结构与逻辑分离,大幅减少重复代码,提高开发效率。

表单项配置设计

表单项配置是整个方案的核心,配置接口包含以下关键属性:

js 复制代码
    interface FormItemConfig {
      // 绑定的表单控件名称
      formName: string;
      // 表单项类型
      type: 'input' | 'select' | 'datepicker' | 'radio' | 'checkbox' | 'number' | 'select-tree' | 'custom';
      // 标签文本
      label?: string;
      // 是否隐藏标签
      hideLabel?: boolean;
      // 是否必填
      required?: boolean | ((formValue: any) => boolean);
      // 禁用状态
      disabled?: boolean | ((formValue: any, externalData: any) => boolean);
      // 隐藏状态
      hidden?: boolean | ((formValue: any, externalData: any) => boolean);
      // 文本颜色
      textColor?: string | ((formValue: any) => string);
      // 校验规则
      validators?: ValidatorFn | ValidatorFn[];
      [key: string]: any;
    }

这个配置接口的设计遵循了 "最小知识原则",每个配置项只包含该类型组件必要的信息,同时通过扩展属性支持特殊需求。特别值得注意的是:

  • 使用函数类型支持动态计算 required、disabled 等状态,实现字段间的联动
  • 通过 events 属性统一管理各类事件,保持接口一致性
  • 针对 FormArray 类型单独设计了嵌套配置,支持动态增减项
  • 提供 customTemplate 选项,允许在配置化框架中嵌入自定义内容

表单组件实现要点

1. 动态生成表单控件

在组件内部,根据配置数组构建 FormGroup:

js 复制代码
ngOnInit() {
      this.formGroup = new FormGroup({});
      // 遍历配置数组,为每个表单项创建FormControl
      this.config.forEach(item => {
        const validators = this.buildValidators(item);
        if (item.type === 'formarray') {
          this.formGroup.addControl(
            item.formName,
            new FormArray([this.createFormGroup(item.formArrayConfig.itemConfig)], validators)
          );
        } else {
          this.formGroup.addControl(
            item.formName,
            new FormControl('', validators)
          );
        }
      });
      
      // 监听表单值变化,处理联动逻辑
      this.formGroup.valueChanges.subscribe(values => {
        this.handleFormChanges(values);
      });
    }

在模板中,使用 ngSwitch 根据不同的 type 渲染对应的组件:

html 复制代码
<div *ngFor="let item of config" class="form-item">
      <nz-form-label [nzSpan]="item.labelSpan || 6" *ngIf="!item.hideLabel">
        {{ item.label }}<span *ngIf="isRequired(item)">*</span>
      </nz-form-label>
      <nz-form-control [nzSpan]="item.controlSpan || 14">
        <ng-container [ngSwitch]="item.type">
          <!-- Input 组件 -->
          <input nz-input 
                 *ngSwitchCase="'input'"
                 [(ngModel)]="formGroup.get(item.formName).value"
                 (ngModelChange)="handleEvent(item, 'ngModelChange', $event)"
                 (blur)="handleEvent(item, 'blur', $event)"
                 [disabled]="isDisabled(item)">
          
          <!-- Select 组件 -->
          <nz-select *ngSwitchCase="'select'"
                    [(ngModel)]="formGroup.get(item.formName).value"
                    (ngModelChange)="handleEvent(item, 'ngModelChange', $event)"
                    (nzOpenChange)="handleEvent(item, 'nzOpenChange', $event)"
                    [disabled]="isDisabled(item)">
            <nz-option *ngFor="let opt of item.options" [nzLabel]="opt.label" [nzValue]="opt.value"></nz-option>
          </nz-select>
          
          <!-- 其他类型组件 -->
          <!-- ... -->
          
          <!-- 自定义模板 -->
          <ng-container *ngSwitchCase="'custom'">
            <ng-container *ngTemplateOutlet="item.customTemplate; context: { $implicit: item, form: formGroup }"></ng-container>
          </ng-container>
        </ng-container>
        
        <!-- 错误提示 -->
        <nz-form-explain *ngIf="formGroup.get(item.formName).dirty && formGroup.get(item.formName).invalid">
          {{ getErrorMessage(item, formGroup.get(item.formName)) }}
        </nz-form-explain>
        
        <!-- 详情按钮 -->
        <button *ngIf="item.detail?.visible" nz-button nzType="text" (click)="openDetail(item)">
          详情
        </button>
      </nz-form-control>
    </div>

2. 表单验证实现

通过配置项中的 validators 和 errorMessages 实现灵活的表单验证:

js 复制代码
   buildValidators(item: FormItemConfig): ValidatorFn[] {
      const validators: ValidatorFn[] = [];
      
      // 处理必填验证
      if (item.required) {
        validators.push(Validators.required);
      }
      
      // 处理自定义验证规则
      if (item.validator) {
        if (Array.isArray(item.validator)) {
          validators.push(...item.validator);
        } else {
          validators.push(item.validator);
        }
      }
      
      return validators;
    }

    // 获取错误提示信息
    getErrorMessage(item: FormItemConfig, control: AbstractControl): string {
      if (control.hasError('required')) {
        return `${item.label || ''}为必填项`;
      }
      
      // 处理自定义错误信息
      for (const key in control.errors) {
        if (item.errorMessages && item.errorMessages[key]) {
          return item.errorMessages[key];
        }
      }
      
      return '';
    }

3. 事件处理机制

为了统一处理各类事件,实现一个事件分发器:

js 复制代码
  handleEvent(item: FormItemConfig, eventName: string, event: any) {
      // 执行配置中定义的事件处理函数
      if (item.events && item.events[eventName]) {
        item.events[eventName](event, this.formGroup);
      }
      
      // 触发组件输出的统一事件
      this.formEvent.emit({
        formName: item.formName,
        eventName,
        event,
        formGroup: this.formGroup
      });
    }

这种机制既支持在配置中定义事件处理逻辑,也允许父组件通过输出事件统一处理,兼顾了灵活性和统一性。

4. 字段联动处理

字段联动是金融表单的核心需求,通过监听表单值变化实现这一功能:

js 复制代码
handleFormChanges(values: any) {
      this.config.forEach(item => {
        // 处理必填状态联动
        if (typeof item.required === 'function') {
          const isRequired = item.required(values);
          this.updateValidator(item.formName, 'required', isRequired);
        }
        
        // 处理禁用状态联动
        if (typeof item.disabled === 'function') {
          const isDisabled = item.disabled(values, this.externalData);
          const control = this.formGroup.get(item.formName);
          if (isDisabled && !control.disabled) {
            control.disable();
          } else if (!isDisabled && control.disabled) {
            control.enable();
          }
        }
        
        // 处理其他联动逻辑
        if (item) {
          // 执行配置的联动函数
          it(values, this.formGroup);
        }
      });
      
      // 执行全局联动计算
      this.calculateFields(values);
    }

    // 处理值计算类联动(如A = B + C + D)
    calculateFields(values: any) {
      // 遍历所有需要计算的字段配置
      this.config.filter(item => item.calculator).forEach(item => {
        const calculator = item.calculator;
        const result = calculator(values);
        this.formGroup.get(item.formName).setValue(result, { emitEvent: false });
      });
    }

对于复杂的联动场景(如 fx swap 交易详情中的多字段计算),支持配置专门的 calculator 函数,实现自定义计算逻辑。

5. FormArray 支持

对于需要动态增减的列表项,通过 FormArray 实现:

js 复制代码
   // 添加新项
    addFormArrayItem(formName: string) {
      const formArray = this.formGroup.get(formName) as FormArray;
      const config = this.config.find(item => item.formName === formName);
      if (config && config.formArrayConfig) {
        formArray.push(this.createFormGroup(config.formArrayConfig.itemConfig));
      }
    }

    // 删除指定项
    removeFormArrayItem(formName: string, index: number) {
      const formArray = this.formGroup.get(formName) as FormArray;
      formArray.removeAt(index);
    }

    // 创建FormArray中的表单组
    createFormGroup(itemConfigs: FormItemConfig[]): FormGroup {
      const group = new FormGroup({});
      itemConfigs.forEach(item => {
        const validators = this.buildValidators(item);
        group.addControl(item.formName, new FormControl('', validators));
      });
      return group;
    }

在模板中,为 FormArray 类型提供了新增和删除按钮:

html 复制代码
    <!-- FormArray 项渲染 -->
    <div *ngSwitchCase="'formarray'">
      <div *ngFor="let group of formGroup.get(item.formName).controls; let i = index" class="formarray-item">
        <div *ngFor="let subItem of item.formArrayConfig.itemConfig">
          <!-- 渲染子表单项 -->
          <form-item [config]="[subItem]" [formGroup]="group"></form-item>
        </div>
        <button nz-button nzType="text" (click)="removeFormArrayItem(item.formName, i)">删除</button>
      </div>
      <button nz-button nzType="dashed" (click)="addFormArrayItem(item.formName)">
        <i nz-icon nzType="plus"></i> 新增
      </button>
    </div>

使用示例

使用封装好的表单组件只需定义配置数组并传入:

html 复制代码
   <app-common-form 
      [config]="formConfig" 
      [externalData]="externalData"
      (formEvent)="handleFormEvent($event)"
      (formSubmit)="handleSubmit($event)">
      <ng-template #customTemplate let-item let-form="formGroup">
        <!-- 自定义内容 -->
        <div class="custom-component">
          {{ form.get(item.formName).value | somePipe }}
        </div>
      </ng-template>
    </app-common-form>

对应的配置数组示例:

js 复制代码
formConfig: FormItemConfig[] = [
      {
        formName: 'amount',
        type: 'number',
        label: '交易金额',
        required: true,
        validator: [Validators.min(0)],
        errorMessages: { min: '交易金额不能为负数' },
        events: {
          ngModelChange: (value, form) => {
            // 金额变化时的处理逻辑
          }
        }
      },
      {
        formName: 'currency',
        type: 'select',
        label: '币种',
        required: (values) => values.amount > 0, // 金额大于0时才必填
        options: [
          { label: '人民币', value: 'CNY' },
          { label: '美元', value: 'USD' }
        ]
      },
      {
        formName: 'term',
        type: 'select',
        label: '期限',
        disabled: (values, external) => external.isDisabled, // 由外部变量控制禁用状态
        options: (values) => {
          // 根据币种动态生成期限选项
          if (values.currency === 'CNY') {
            return [{ label: '1个月', value: 1 }, { label: '3个月', value: 3 }];
          } else {
            return [{ label: '1个月', value: 1 }, { label: '6个月', value: 6 }];
          }
        },
        calculator: (values) => {
          // 根据其他字段计算默认值
          return values.currency === 'CNY' ? 3 : 6;
        }
      },
      // 更多表单项配置...
    ];

总结

通过这套表单组件封装方案,成功解决了Nova系统中复杂表单的开发难题,主要收益包括:

  1. 开发效率提升:新表单开发时间减少 60% 以上;
  2. 代码质量改善:统一的表单行为和验证逻辑,减少了重复代码;
  3. 维护成本降低:集中管理表单逻辑,修改一处即可影响所有相关表单;
  4. 扩展性增强:支持自定义组件和逻辑,满足特殊业务需求。
相关推荐
aha-凯心36 分钟前
前端学习 vben 之 axios interceptors
前端·学习
熊出没1 小时前
Vue前端导出页面为PDF文件
前端·vue.js·pdf
VOLUN1 小时前
Vue3项目中优雅封装API基础接口:getBaseApi设计解析
前端·vue.js·api
用户99045017780091 小时前
告别广告干扰,体验极简 JSON 格式化——这款工具让你专注代码本身
前端
前端极客探险家1 小时前
告别卡顿与慢响应!现代 Web 应用性能优化:从前端渲染到后端算法的全面提速指南
前端·算法·性能优化
袁煦丞2 小时前
【局域网秒传神器】LocalSend:cpolar内网穿透实验室第418个成功挑战
前端·程序员·远程工作
江城开朗的豌豆2 小时前
Vuex数据突然消失?六招教你轻松找回来!
前端·javascript·vue.js
好奇心笔记2 小时前
ai写代码随机拉大的,所以我准备给AI出一个设计规范
前端·javascript
江城开朗的豌豆2 小时前
Vue状态管理进阶:数据到底是怎么"跑"的?
前端·javascript·vue.js
用户21411832636022 小时前
dify案例分享-Dify v1.6.0 重磅升级:双向 MCP 协议引爆 AI 生态互联革命
前端