angular - 响应式表单实现动态表单

200 阅读5分钟

背景

开发过程中一般情况下表单项确定的时候表单是写死的,但是还有一些情况相同的页面需要根据服务端返回的不同业务类型,展示不同的表单项。

问题:

当业务类型较少时,可以在组件中写死几个表单根据不同类型展示不同的表单,或者新增表单组件,展示不同的表单组件。但是当后续需要新增业务类型,这时候又需要修改客户端代码,增加新的业务表单,表单代码越来越多不利于维护。

解决办法:

考虑一种生成动态表单办法,后续如果新增业务表单,表单组件也不需要修改,只需要在服务端增加配置,不同的业务返回不同的表单,前端根据服务端返回的配置解析并展示对应表单项。

示例:

1、基本实现

import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, of } from 'rxjs';
import { delay } from 'rxjs/operators';

@Injectable({
  providedIn: 'root'
})
export class FormService {
  constructor(private http: HttpClient) {}

  getFormConfig(): Observable<any> {
    return of({
      fields: [
        {
          name: 'selection',
          type: 'select',
          options: [
            { value: 'A', label: 'Option A' },
            { value: 'B', label: 'Option B' }
          ]
        },
        {
          name: 'email',
          type: 'text',
          condition: { field: 'selection', value: 'A' }
        },
        {
          name: 'phone',
          type: 'text',
          condition: { field: 'selection', value: 'B' }
        }
      ]
    }).pipe(delay(500)); // 模拟网络延迟
  }
}
import { Component, OnInit } from '@angular/core';
import { FormBuilder, FormGroup, FormControl } from '@angular/forms';
import { FormService } from './form.service';

@Component({
  selector: 'app-dynamic-form',
  templateUrl: './dynamic-form.component.html'
})
export class DynamicFormComponent implements OnInit {
  formGroup: FormGroup;
  formConfig: any;

  constructor(private fb: FormBuilder, private formService: FormService) {}

  ngOnInit() {
    this.loadFormConfig();
  }

  loadFormConfig() {
    this.formService.getFormConfig().subscribe(config => {
      this.formConfig = config;
      this.initializeForm();
      this.setupConditionalFields();
    });
  }

  initializeForm() {
    const formControls = {};
    this.formConfig.fields.forEach(field => {
      formControls[field.name] = new FormControl('');
    });
    this.formGroup = this.fb.group(formControls);
  }

  setupConditionalFields() {
    this.formConfig.fields.forEach(field => {
      if (field.condition) {
        this.formGroup.get(field.condition.field)?.valueChanges.subscribe(value => {
          if (value === field.condition.value) {
            this.formGroup.addControl(field.name, new FormControl(''));
          } else {
            this.formGroup.removeControl(field.name);
          }
        });
      }
    });
  }

  onSubmit() {
    if (this.formGroup.valid) {
      console.log('Form submitted:', this.formGroup.value);
    } else {
      console.log('Form is invalid');
    }
  }
}
<form [formGroup]="formGroup" (ngSubmit)="onSubmit()">
  <div *ngFor="let field of formConfig.fields">
    <div *ngIf="!field.condition || (field.condition && formGroup.contains(field.name))">
      <label [for]="field.name">{{ field.name }}:</label>
      <ng-container [ngSwitch]="field.type">
        <select *ngSwitchCase="'select'" [id]="field.name" [formControlName]="field.name">
          <option *ngFor="let option of field.options" [value]="option.value">{{ option.label }}</option>
        </select>
        <input *ngSwitchCase="'text'" [id]="field.name" [formControlName]="field.name" type="text">
      </ng-container>
    </div>
  </div>

  <button type="submit" [disabled]="formGroup.invalid">Submit</button>
</form>

2、可移动表单项(使用FormArray 管理表单项,并提供方法来调整表单项顺序)

import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, of } from 'rxjs';
import { delay } from 'rxjs/operators';

@Injectable({
  providedIn: 'root'
})
export class FormService {
  constructor(private http: HttpClient) {}

  getFormConfig(): Observable<any> {
    return of({
      fields: [
        {
          name: 'selection',
          type: 'select',
          options: [
            { value: 'A', label: 'Option A' },
            { value: 'B', label: 'Option B' }
          ]
        },
        {
          name: 'email',
          type: 'text',
          condition: { field: 'selection', value: 'A' }
        },
        {
          name: 'phone',
          type: 'text',
          condition: { field: 'selection', value: 'B' }
        }
      ]
    }).pipe(delay(500)); // 模拟网络延迟
  }
}
import { Component, OnInit } from '@angular/core';
import { FormBuilder, FormGroup, FormArray, FormControl } from '@angular/forms';
import { FormService } from './form.service';

@Component({
  selector: 'app-dynamic-form',
  templateUrl: './dynamic-form.component.html'
})
export class DynamicFormComponent implements OnInit {
  formGroup: FormGroup;
  formConfig: any;

  constructor(private fb: FormBuilder, private formService: FormService) {}

  ngOnInit() {
    this.loadFormConfig();
  }

  loadFormConfig() {
    this.formService.getFormConfig().subscribe(config => {
      this.formConfig = config;
      this.initializeForm();
      this.setupConditionalFields();
    });
  }

  initializeForm() {
    this.formGroup = this.fb.group({
      fields: this.fb.array([])
    });

    this.formConfig.fields.forEach(field => {
      this.addField(field);
    });
  }

  addField(field: any) {
    const formArray = this.formGroup.get('fields') as FormArray;
    formArray.push(this.createFormGroupForField(field));
  }

  createFormGroupForField(field: any) {
    return this.fb.group({
      name: [field.name],
      type: [field.type],
      value: [''],
      condition: [field.condition]
    });
  }

  setupConditionalFields() {
    this.formConfig.fields.forEach(field => {
      if (field.condition) {
        this.formGroup.get(field.condition.field)?.valueChanges.subscribe(value => {
          if (value === field.condition.value) {
            this.addField(field);
          } else {
            this.removeField(field.name);
          }
        });
      }
    });
  }

  removeField(fieldName: string) {
    const formArray = this.formGroup.get('fields') as FormArray;
    const index = formArray.controls.findIndex(control => control.get('name').value === fieldName);
    if (index !== -1) {
      formArray.removeAt(index);
    }
  }

  moveFieldUp(fieldName: string) {
    const formArray = this.formGroup.get('fields') as FormArray;
    const index = formArray.controls.findIndex(control => control.get('name').value === fieldName);
    if (index > 0) {
      const temp = formArray.at(index);
      formArray.setControl(index, formArray.at(index - 1));
      formArray.setControl(index - 1, temp);
    }
  }

  moveFieldDown(fieldName: string) {
    const formArray = this.formGroup.get('fields') as FormArray;
    const index = formArray.controls.findIndex(control => control.get('name').value === fieldName);
    if (index < formArray.length - 1) {
      const temp = formArray.at(index);
      formArray.setControl(index, formArray.at(index + 1));
      formArray.setControl(index + 1, temp);
    }
  }

  onSubmit() {
    if (this.formGroup.valid) {
      console.log('Form submitted:', this.formGroup.value);
    } else {
      console.log('Form is invalid');
    }
  }
}
<form [formGroup]="formGroup" (ngSubmit)="onSubmit()">
  <div formArrayName="fields">
    <div *ngFor="let field of formGroup.get('fields').controls; let i = index" [formGroupName]="i">
      <div *ngIf="!field.get('condition').value || (field.get('condition').value && formGroup.contains(field.get('condition').value.field))">
        <label [for]="field.get('name').value">{{ field.get('name').value }}:</label>
        <ng-container [ngSwitch]="field.get('type').value">
          <select *ngSwitchCase="'select'" [id]="field.get('name').value" formControlName="value">
            <option *ngFor="let option of formConfig.fields[i].options" [value]="option.value">{{ option.label }}</option>
          </select>
          <input *ngSwitchCase="'text'" [id]="field.get('name').value" formControlName="value" type="text">
        </ng-container>
        <button (click)="moveFieldUp(field.get('name').value)">Move Up</button>
        <button (click)="moveFieldDown(field.get('name').value)">Move Down</button>
      </div>
    </div>
  </div>

  <button type="submit" [disabled]="formGroup.invalid">Submit</button>
</form>

关键点说明

  1. 使用 FormArray 管理表单项

    • initializeForm 方法初始化 FormArray,并将表单项添加到 FormArray 中。
    • addField 方法将表单项添加到 FormArray 中。
    • createFormGroupForField 方法创建一个 FormGroup 对象,表示一个表单项。
  2. 调整 FormArray 中的 controls 数组

    • moveFieldUp 和 moveFieldDown 方法通过交换 controls 数组中的元素位置来实现表单项的上下移动。
  3. 模板

    • 使用 *ngFor 指令遍历 FormArray 中的表单项。
    • 使用 *ngIf 指令根据条件决定是否显示表单项。
    • 使用 ngSwitch 指令根据字段类型生成不同的输入元素。
    • 添加按钮来调整表单项的顺序,调用 moveFieldUp 和 moveFieldDown 方法

3、添加校验信息

配置文件

const formConfig = {
  fields: [
    {
      name: 'category',
      type: 'select',
      label: '类别',
      options: [
        { value: 'option1', label: '选项1' },
        { value: 'option2', label: '选项2' }
      ],
      validators: ['required']
    },
    {
      name: 'subField1',
      type: 'text',
      label: '子字段1',
      visible: 'category === "option1"',
      dependsOn: ['category'],
      validators: ['required', 'minLength:5', 'pattern:/^[a-zA-Z0-9]+$/']
    },
    {
      name: 'subField2',
      type: 'text',
      label: '子字段2',
      visible: 'category === "option2"',
      dependsOn: ['category'],
      validators: ['required', 'maxLength:10', 'pattern:/^[a-zA-Z]+$/']
    }
  ]
};
import { Component, OnInit } from '@angular/core';
import { FormBuilder, FormGroup, FormControl, Validators } from '@angular/forms';

@Component({
  selector: 'app-dynamic-form',
  template: `
    <form [formGroup]="form">
      <div *ngFor="let field of formConfig.fields">
        <ng-container *ngIf="isFieldVisible(field)">
          <label [for]="field.name">{{ field.label }}</label>
          <ng-container [ngSwitch]="field.type">
            <select *ngSwitchCase="'select'" [formControlName]="field.name">
              <option *ngFor="let option of field.options" [value]="option.value">{{ option.label }}</option>
            </select>
            <input *ngSwitchCase="'text'" [formControlName]="field.name" type="text" />
          </ng-container>
          <div *ngIf="form.get(field.name)?.invalid && form.get(field.name)?.touched" class="error">
            <div *ngIf="form.get(field.name)?.errors?.['required']">此字段是必填的。</div>
            <div *ngIf="form.get(field.name)?.errors?.['minlength']">最小长度为 {{ form.get(field.name)?.errors?.['minlength'].requiredLength }}。</div>
            <div *ngIf="form.get(field.name)?.errors?.['maxlength']">最大长度为 {{ form.get(field.name)?.errors?.['maxlength'].requiredLength }}。</div>
            <div *ngIf="form.get(field.name)?.errors?.['pattern']">格式不正确。</div>
          </div>
        </ng-container>
      </div>
      <button (click)="onSubmit()">提交</button>
    </form>
  `,
  styles: [`
    .error { color: red; }
  `]
})
export class DynamicFormComponent implements OnInit {
  form: FormGroup;
  formConfig = formConfig;

  constructor(private fb: FormBuilder) {}

  ngOnInit() {
    this.form = this.fb.group(
      this.formConfig.fields.reduce((acc, field) => {
        acc[field.name] = this.createFormControl(field);
        return acc;
      }, {})
    );

    // 监听表单值的变化,更新可见性
    this.form.valueChanges.subscribe(() => {
      this.updateVisibility();
    });
  }

  createFormControl(field: any): FormControl {
    const validators = field.validators?.map(validator => {
      if (validator.startsWith('required')) {
        return Validators.required;
      } else if (validator.startsWith('minLength:')) {
        const length = parseInt(validator.split(':')[1], 10);
        return Validators.minLength(length);
      } else if (validator.startsWith('maxLength:')) {
        const length = parseInt(validator.split(':')[1], 10);
        return Validators.maxLength(length);
      } else if (validator.startsWith('pattern:')) {
        const pattern = validator.split(':')[1];
        return Validators.pattern(pattern);
      }
      return null;
    }).filter(Boolean);

    return new FormControl('', validators);
  }

  isFieldVisible(field: any): boolean {
    if (!field.visible) {
      return true;
    }

    const expression = field.visible;
    const values = this.form.value;
    return new Function('values', `return ${expression};`)(values);
  }

  updateVisibility() {
    this.formConfig.fields.forEach(field => {
      if (field.dependsOn) {
        const control = this.form.get(field.name);
        if (this.isFieldVisible(field)) {
          control.enable();
        } else {
          control.disable();
        }
      }
    });
  }

  onSubmit() {
    if (this.form.valid) {
      console.log('表单提交:', this.form.value);
    } else {
      this.form.markAllAsTouched();
    }
  }
}

解释

  1. 配置文件

    • validators 属性用于定义每个字段的校验规则,支持内置校验器(如 requiredminLengthmaxLength)和自定义正则表达式校验(如 pattern:/^[a-zA-Z0-9]+$/)。
  2. 动态表单组件

    • createFormControl 方法根据配置文件中的 validators 属性创建 FormControl,并应用相应的校验器。
    • isFieldVisible 方法使用 new Function 动态解析 visible 表达式,判断字段是否可见。
    • updateVisibility 方法在表单值变化时调用 isFieldVisible 方法,根据依赖字段的值更新字段的可见性和启用状态。
    • onSubmit 方法在表单提交时检查表单的有效性,并在无效时标记所有字段为已触摸状态。

校验规则解析

  • required:必填校验。
  • minLength:5:最小长度为 5。
  • maxLength:10:最大长度为 10。
  • pattern:/^[a-zA-Z0-9]+$/ :正则表达式校验,确保输入只包含字母和数字。

通过这种方式,你可以在配置文件中定义字段的校验规则,包括自定义的正则表达式校验,并在动态表单组件中处理这些校验规则。

自定义校验器器

export function myCustomValidator(): ValidatorFn {
  return (control: AbstractControl): ValidationErrors | null => {
    const value = control.value;

    // 校验逻辑
    if (value && value.length < 5) {
      return { 'tooShort': true }; // 返回错误信息
    }

    return null; // 校验成功
  };
}

使用示例:

组件

import { Component } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { myCustomValidator } from './path-to-validator';

@Component({
  selector: 'app-my-form',
  templateUrl: './my-form.component.html'
})
export class MyFormComponent {
  form: FormGroup;

  constructor(private fb: FormBuilder) {
    this.form = this.fb.group({
      username: ['', [Validators.required, myCustomValidator()]]
    });
  }
}

模板

<form [formGroup]="form" (ngSubmit)="onSubmit()">
  <div>
    <label for="username">Username:</label>
    <input id="username" formControlName="username" type="text">
    <div *ngIf="form.controls.username.touched && form.controls.username.invalid">
      <small *ngIf="form.controls.username.errors?.required">Username is required.</small>
      <small *ngIf="form.controls.username.errors?.tooShort">Username must be at least 5 characters long.</small>
    </div>
  </div>
  <button type="submit" [disabled]="form.invalid">Submit</button>
</form>