Angular 高级技巧-表单复用艺术

在日常的应用程序开发中,经常会遇到一些表单场景,它们具有相似但又不完全相同的特征,例如,可能只有一部分字段在不同表单之间共享相似性。在本文中,我们将以联系人管理为例,逐步展示如何运用高级技巧来优化我们的代码,实现表单复用的艺术。通过深入研究这一实例,您将能够更好地理解如何在Angular应用中巧妙地应对类似的表单需求。

本文中的技巧依赖于Angular的依赖注入技巧,思路不仅适用本文的表单复用中还可以应用很多地方,尤其是组件库封装。

场景

在上述场景中可以看到联系人这块的字段是一样的,如果在一个项目中只在这个表单中出现还好,如果在一个项目中只在这个表单中出现, 我们可以使用循环来渲染, 但一般来说会在多个表单中出现,所以封装就有必要了。

改造前的代码如下:

ts 复制代码
@Component({
  selector: 'app-root',
  standalone: true,
  imports: [CommonModule, RouterOutlet, NzFormModule, ReactiveFormsModule, NzInputModule, NzButtonModule, NzSelectModule],
  template: `
    <form nz-form [formGroup]="formGroup" class="login-form" (ngSubmit)="submitForm()">
      <nz-form-item>
        <nz-form-control nzErrorTip="Please input your username!">
          <nz-form-label>姓名</nz-form-label>
          <input type="text" nz-input formControlName="username" placeholder="Please input your username" />
        </nz-form-control>
      </nz-form-item>
      <nz-form-item>
        <nz-form-control nzErrorTip="Please input your phone!">
          <nz-form-label>手机号码</nz-form-label>
          <input type="text" nz-input formControlName="phone" placeholder="Please input your phone" />
        </nz-form-control>
      </nz-form-item>
      <fieldset formGroupName="firstContact">
        <legend>第一联系人</legend>
        <nz-form-item>
          <nz-form-control nzErrorTip="Please input your username!">
            <nz-form-label>与本人关系</nz-form-label>
            <nz-select formControlName="relation" class="relation-select">
              <nz-option nzLabel="母子" nzValue="母子"></nz-option>
              <nz-option nzLabel="父子" nzValue="父子"></nz-option>
              <nz-option nzLabel="朋友" nzValue="朋友"></nz-option>
              <nz-option nzLabel="同事" nzValue="同事"></nz-option>
            </nz-select>
          </nz-form-control>
        </nz-form-item>
        <nz-form-item>
          <nz-form-control nzErrorTip="Please input your username!">
            <nz-form-label>姓名</nz-form-label>
            <input type="text" nz-input formControlName="username" placeholder="Please input username" />
          </nz-form-control>
        </nz-form-item>
        <nz-form-item>
          <nz-form-control nzErrorTip="Please input your username!">
            <nz-form-label>手机号码</nz-form-label>
            <input type="text" nz-input formControlName="phone" placeholder="Please input phone" />
          </nz-form-control>
        </nz-form-item>
      </fieldset>
      <fieldset formGroupName="secondContact">
        <legend>第二联系人</legend>
        <nz-form-item>
          <nz-form-control nzErrorTip="Please input your username!">
            <nz-form-label>与本人关系</nz-form-label>
            <nz-select formControlName="relation" class="relation-select">
              <nz-option nzLabel="母子" nzValue="母子"></nz-option>
              <nz-option nzLabel="父子" nzValue="父子"></nz-option>
              <nz-option nzLabel="朋友" nzValue="朋友"></nz-option>
              <nz-option nzLabel="同事" nzValue="同事"></nz-option>
            </nz-select>
          </nz-form-control>
        </nz-form-item>
        <nz-form-item>
          <nz-form-control nzErrorTip="Please input your username!">
            <nz-form-label>姓名</nz-form-label>
            <input type="text" nz-input formControlName="username" placeholder="Please input username" />
          </nz-form-control>
        </nz-form-item>
        <nz-form-item>
          <nz-form-control nzErrorTip="Please input your username!">
            <nz-form-label>手机号码</nz-form-label>
            <input type="text" nz-input formControlName="phone" placeholder="Please input phone" />
          </nz-form-control>
        </nz-form-item>
      </fieldset>
      <fieldset formGroupName="thirdContact">
        <legend>第三联系人</legend>
        <nz-form-item>
          <nz-form-control nzErrorTip="Please input your username!">
            <nz-form-label>与本人关系</nz-form-label>
            <nz-select formControlName="relation" class="relation-select">
              <nz-option nzLabel="母子" nzValue="母子"></nz-option>
              <nz-option nzLabel="父子" nzValue="父子"></nz-option>
              <nz-option nzLabel="朋友" nzValue="朋友"></nz-option>
              <nz-option nzLabel="同事" nzValue="同事"></nz-option>
            </nz-select>
          </nz-form-control>
        </nz-form-item>
        <nz-form-item>
          <nz-form-control nzErrorTip="Please input your username!">
            <nz-form-label>姓名</nz-form-label>
            <input type="text" nz-input formControlName="username" placeholder="Please input username" />
          </nz-form-control>
        </nz-form-item>
        <nz-form-item>
          <nz-form-control nzErrorTip="Please input your username!">
            <nz-form-label>手机号码</nz-form-label>
            <input type="text" nz-input formControlName="phone" placeholder="Please input phone" />
          </nz-form-control>
        </nz-form-item>
      </fieldset>
      <button nz-button class="form-button form-margin" [nzType]="'primary'">submit</button>
    </form>
  `,
  styles: [`
    :host {
      display: flex;
      justify-content: center;

      form {
        min-width: 600px;

        .form-button {
          margin-top: 8px;
          width: 100%;
        }
      }
    }
  `]
})
export class AppComponent {
  fb = inject(FormBuilder);

  formGroup = this.fb.group({
    username: [],
    phone: [],
    firstContact: this.fb.group({
      username: [],
      relation: ['朋友'],
      phone: []
    }),
    secondContact: this.fb.group({
      username: [],
      relation: ['朋友'],
      phone: []
    }),
    thirdContact: this.fb.group({
      username: [],
      relation: ['朋友'],
      phone: []
    })
  })

  submitForm() {
    console.log(this.formGroup);
  }
}

改造

重复部分抽取成一个组件

定义 ContactGroup 组件:

ts 复制代码
@Component({
  selector: 'app-contact-group',
  standalone: true,
  imports: [
    NzFormModule,
    NzSelectModule,
    NzInputModule,
    ReactiveFormsModule
  ],
  template: `
    <fieldset formGroupName="{{groupName}}">
      <legend>{{ legend }}</legend>
      <nz-form-item>
        <nz-form-control nzErrorTip="Please input your username!">
          <nz-form-label>与本人关系</nz-form-label>
          <nz-select formControlName="relation" class="relation-select">
            <nz-option nzLabel="母子" nzValue="母子"></nz-option>
            <nz-option nzLabel="父子" nzValue="父子"></nz-option>
            <nz-option nzLabel="朋友" nzValue="朋友"></nz-option>
            <nz-option nzLabel="同事" nzValue="同事"></nz-option>
          </nz-select>
        </nz-form-control>
      </nz-form-item>
      <nz-form-item>
        <nz-form-control nzErrorTip="Please input your username!">
          <nz-form-label>姓名</nz-form-label>
          <input type="text" nz-input formControlName="username" placeholder="Please input username" />
        </nz-form-control>
      </nz-form-item>
      <nz-form-item>
        <nz-form-control nzErrorTip="Please input your username!">
          <nz-form-label>手机号码</nz-form-label>
          <input type="text" nz-input formControlName="phone" placeholder="Please input phone" />
        </nz-form-control>
      </nz-form-item>
    </fieldset>
  `,
  styles: [``]
})
export class ContactGroup {
  @Input({ required: true }) legend!: string;
  @Input({ required: true }) groupName!: string;
}

接下来,我们在业务代码中使用了这个组件:

ts 复制代码
@Component({
  selector: 'app-root',
  standalone: true,
  imports: [CommonModule, NzFormModule, ReactiveFormsModule, NzInputModule, NzButtonModule, ContactGroup],
  template: `
    <form nz-form [formGroup]="formGroup" class="login-form" (ngSubmit)="submitForm()">
      <nz-form-item>
        <nz-form-control nzErrorTip="Please input your username!">
          <nz-form-label>姓名</nz-form-label>
          <input type="text" nz-input formControlName="username" placeholder="Please input your username" />
        </nz-form-control>
      </nz-form-item>
      <nz-form-item>
        <nz-form-control nzErrorTip="Please input your phone!">
          <nz-form-label>手机号码</nz-form-label>
          <input type="text" nz-input formControlName="phone" placeholder="Please input your phone" />
        </nz-form-control>
      </nz-form-item>
      <app-contact-group legend="第一联系人" groupName="firstContact"></app-contact-group>
      <app-contact-group legend="第二联系人" groupName="secondContact"></app-contact-group>
      <app-contact-group legend="第三联系人" groupName="thirdContact"></app-contact-group>
      <button nz-button class="form-button form-margin" [nzType]="'primary'">submit</button>
    </form>
  `,
  styles: [`
    // 一些代码...
  `]
})
export class AppComponent {
  /*一些代码...*/
}

现在可以看到,通过使用 ContactGroup 组件,我们能够将表单的重复部分抽取出来并进行复用了,看上去很好,可理想有多丰满现实就有多骨感,Angular抛出一个异常并说不认识它。

txt 复制代码
ERROR Error: NG01053: formGroupName must be used with a parent formGroup directive.  You'll want to add a formGroup
    directive and pass it an existing FormGroup instance (you can create one in your class).

    Example:

    
  <div [formGroup]="myGroup">
      <div formGroupName="person">
        <input formControlName="firstName">
      </div>
  </div>

  In your class:

  this.myGroup = new FormGroup({
      person: new FormGroup({ firstName: new FormControl() })
  });
    at groupParentException (forms.mjs:1443:12)
    at FormGroupName._checkParentType (forms.mjs:5069:19)
    at FormGroupName.ngOnInit (forms.mjs:3669:14)
    at callHookInternal (core.mjs:3920:14)
    at callHook (core.mjs:3947:13)
    at callHooks (core.mjs:3902:17)
    at executeInitAndCheckHooks (core.mjs:3852:9)
    at selectIndexInternal (core.mjs:11763:17)
    at Module.ɵɵadvance (core.mjs:11746:5)
    at ContactGroup_Template (app.component.ts:21:15)

从异常信息可以看出,formGroupName 指令必须与父级的 formGroup 指令一起使用,但在我们的代码中,似乎已经使用了 formGroup 指令。为什么 Angular 无法识别呢?

从源码角度分析问题

解决问题的思路:

要解决这个问题,我们需要深入分析 FormGroupName 指令的源码。下面是 FormGroupName 指令的源码片段:

ts 复制代码
const formGroupNameProvider: Provider = {
  provide: ControlContainer,
  useExisting: forwardRef(() => FormGroupName)
};
@Directive({selector: '[formGroupName]', providers: [formGroupNameProvider]})
export class FormGroupName extends AbstractFormGroupDirective implements OnInit, OnDestroy {
  @Input('formGroupName') override name: string|number|null = null;

  constructor(
      // ...
      @Optional() @Host() @SkipSelf() parent: ControlContainer,
      // ...
  ) {
    super();
    this._parent = parent;
    // ...
  }

  /** @internal */
  override _checkParentType(): void {
    if (_hasInvalidParent(this._parent) && (typeof ngDevMode === 'undefined' || ngDevMode)) {
      throw groupParentException();
    }
  }
}

从源码来看 如果_parent无法获取就会触发上述异常

尽管FormGroupName指令也注册了ControlContainer提供商,但是使用了依赖限制查找符@SkipSelf 并不会获取自身的ControlContainer 也不合理

那到底是什么影响FormGroupName指令获取ControlContainer呢?在依赖中除了使用了依赖限制查找符@SkipSelf还使用了@Host() 该装饰器会禁止在宿主组件以上的搜索。

根据思路,看上去示例中的宿主组件是ContactGroupContactGroup组件中确实没有注册ControlContainer提供商,现在我们注册ControlContainer提供商 如下:

ts 复制代码
@Component({
  selector: 'app-contact-group',
  standalone: true,
  providers: [
    { 
        provide: ControlContainer, 
        useFactory: () => inject(ControlContainer, { skipSelf: true })
    }
  ],
  template: `
    <!-- 一些代码 -->
  `,
  styles: [``]
})
export class ContactGroup {
  /*一些代码...*/
}

不出意外还是同样的异常

解决问题的关键

在前面的部分,我们提到了一个关键问题,即宿主组件的定义。在 Angular 中,宿主组件通常是请求某个依赖的组件。然而,当该组件被投影到某个父组件中时,那个父组件就变成了宿主。

在我们的代码中,ContactGroup 组件并不是 FormGroupName 指令的宿主组件,因此,尽管我们在 ContactGroup 组件中注册了 ControlContainer 提供商,但无法获取正确的父级 ControlContainer

所以ContactGroup并不是FormGroupName指令的宿主组件,即便注册了ControlContainer提供商,也无法获取。

解决问题

上一个小节中我们遇到了依赖无法正确解析问题,陷入了困境,为了解决这个问题,我们需要使用 viewProviders 特性来注册 ControlContainer 提供商。下面是修改后的 ContactGroup 组件的代码:

ts 复制代码
@Component({
  selector: 'app-contact-group',
  standalone: true,
  viewProviders: [
    { 
        provide: ControlContainer, 
        useFactory: () => inject(ControlContainer, { skipSelf: true })
    }
  ],
  template: `
    <!-- 一些代码 -->
  `,
  styles: [``]
})
export class ContactGroup {
  /*一些代码...*/
}

viewProviders特性:定义对其视图 DOM 子级注册提供商集

通过添加 viewProviders 配置项,我们成功地解决了问题,异常不再出现了。现在,ContactGroup 组件能够正确地获取父级 ControlContainer,使表单工作如预期。

在实际应用中,理解 Angular 的依赖注入机制以及正确配置提供商非常重要,尤其在处理嵌套表单和组件复用时。通过掌握这些高级技巧,您可以更灵活地构建强大的 Angular 应用程序。

进一步优化代码

前文我们已经将重复部分单独封装成了ContactGroup组件,并在业务组件中如期工作,但还不够,既然获取到了ControlContainer示例,那么我们还可以做到自动注册控件,看如下代码:

ts 复制代码
@Component({
  selector: 'app-contact-group',
  standalone: true,
  viewProviders: [
    { provide: ControlContainer, useFactory: () => inject(ControlContainer, { skipSelf: true })}
  ],
  template: `
    <!-- 一些代码 -->
  `,
  styles: [``]
})
export class ContactGroup implements OnInit {
  @Input({ required: true }) legend!: string;
  @Input({ required: true }) groupName!: string;
  controlContainer = inject(ControlContainer)

  ngOnInit() {
    const parentFormGroup = this.controlContainer.control as FormGroup;

    parentFormGroup.addControl(this.groupName, new FormGroup({
      username: new FormControl(''),
      relation: new FormControl('朋友'),
      phone: new FormControl(''),
    }))
  }
}

@Component({
  selector: 'app-root',
  standalone: true,
  template: `<!-- 一些代码 -->`,
  styles: [``]
})
export class AppComponent {
  fb = inject(FormBuilder);

  formGroup = this.fb.group({
    username: [],
    phone: [],
  })

  submitForm() {
    console.log(this.formGroup.value);
  }
}

输入的表单结构跟之前一致

总结

本文介绍了在 Angular 应用程序开发中一种强大的技巧,即表单复用艺术,通过将表单的重复部分抽取成一个可重用的组件,提高了代码的可维护性和可读性。我们以联系人管理为例,逐步展示了如何运用这一技巧,同时也解决了可能出现的问题。

通过创建名为 ContactGroup 的组件,我们成功地将表单部分抽象出来,使业务代码更加简洁。然而,在实践中,我们遇到了一个挑战,即 Angular 抛出了一个异常,表示无法识别 formGroupName 指令。我们深入分析了问题,并解释了它的根本原因。

解决问题的关键在于理解 Angular 的依赖注入机制以及正确配置提供商。通过使用 viewProviders 配置项,我们成功地注册了 ControlContainer 提供商,确保 ContactGroup 组件能够正确地获取父级 ControlContainer,从而解决了异常问题。

本文中的技巧依赖于 Angular 的依赖注入技巧,思路不仅适用于本文的表单复用中,还可以应用于很多地方,尤其是组件库封装。 在实际应用中,这些高级技巧将帮助您更灵活地构建强大的 Angular 应用程序,提高开发效率和代码质量。

深入理解 Angular 的依赖注入机制,并善用这些技巧,将使您成为更出色的 Angular 开发者。继续探索 Angular 的世界,并不断提升自己的技能!


源码链接: 在这里查看完整的源码示例

通过源码示例,您可以更详细地了解如何实现表单复用以及解决相关问题。

相关推荐
滔滔不绝tao2 分钟前
自动化测试常用函数
前端·css·html5
canonical_entropy15 分钟前
金蝶云苍穹的Extension与Nop平台的Delta的区别
后端·低代码·架构
码爸30 分钟前
flink doris批量sink
java·前端·flink
深情废杨杨31 分钟前
前端vue-父传子
前端·javascript·vue.js
沛沛老爹1 小时前
服务监控插件全览:提升微服务可观测性的利器
微服务·云原生·架构·datadog·influx·graphite
J不A秃V头A2 小时前
Vue3:编写一个插件(进阶)
前端·vue.js
司篂篂2 小时前
axios二次封装
前端·javascript·vue.js
姚*鸿的博客2 小时前
pinia在vue3中的使用
前端·javascript·vue.js
huaqianzkh2 小时前
了解华为云容器引擎(Cloud Container Engine)
云原生·架构·华为云
宇文仲竹3 小时前
edge 插件 iframe 读取
前端·edge