发布于 2026-01-06 1 阅读
0

高级 Angular 动态组件

高级 Angular 动态组件

在本文中,我将向您展示如何在创建动态组件的同时,仍然使用输入和输出并支持 OnChanges 生命周期。

如果你还不了解动态组件,我建议你在继续学习之前先阅读这篇文章《使用 Angular 动态创建组件》 。

为了更清楚地说明我接下来要讲的内容,您可以通过 Github 浏览该项目,或者如果您愿意,也可以查看演示。

更新 - 2023年5月8日:
组件检查 API 已从 Angular V13 开始弃用。如果您使用的是 Angular V14 及更高版本,请更新您的实现以遵循“ https://github.com/ezzabuzaid/dynamic-component-article/blob/main/src/app/dynamic-component.directive.v14.ts ”。

问题

要创建动态组件,必须使用ngComponentOutlet指令或ComponentFactoryResolver对象,但两者都无法绑定输入和输出。

此外,ngOnChanges这样做行不通。这是因为执行输入检查的函数是由编译器在编译期间生成的

解决方案

为了解决这个问题,我们将使用一个自定义指令,该指令对绑定过程的帮助要尽可能小。

我们将使用它ComponentFactoryResolver来创建一个组件工厂,该工厂保存有关组件输入和输出的元数据。这些元数据将用于确保输入和输出的属性名称正确无误。

const factory = componentFactoryResolver.resolveComponentFactory(ComponentType);
Enter fullscreen mode Exit fullscreen mode

factory它有两个 getter,分别代表组件的输入和输出。

/**
 * The inputs of the component.
 */
abstract get inputs(): {
    propName: string;
    templateName: string;
}[];
/**
 * The outputs of the component.
 */
abstract get outputs(): {
    propName: string;
    templateName: string;
}[];
Enter fullscreen mode Exit fullscreen mode

其中每一部分都有propName并且templateName对应于

@Input(templateName) propName;
@Output(templateName) propName;
Enter fullscreen mode Exit fullscreen mode

templateNamepropName如果不指定,则使用默认值。

设置

我们的指令将这样使用。

<ng-template [dynamic-component]="component" [inputs]="{}" [outputs]="{}"> </ng-template>
Enter fullscreen mode Exit fullscreen mode

代码中将使用的类型

type UserOutputs = Record<string, (event: any) => void>;
type UserInputs = Record<string, any>;
type ComponentInputs = ComponentFactory<any>['inputs'];
type ComponentOutputs = ComponentFactory<any>['outputs'];
type Color = 'red' | 'blue' | 'green';
Enter fullscreen mode Exit fullscreen mode

为严格模式用户提供的实用功能😅

function assertNotNullOrUndefined<T>(value: T): asserts value is NonNullable<T> {
    if (value === null || value === undefined) {
        throw new Error(`cannot be undefined or null.`);
    }
}
Enter fullscreen mode Exit fullscreen mode

该指令

@Directive({
    selector: '[dynamic-component]',
})
export class DynamicComponentDirective implements OnDestroy, OnChanges {
  @Input('dynamic-component') component!: Type<any>;
  @Input() outputs?: UserOutputs = {};
  @Input() inputs?: UserInputs = {};
  ngOnChanges(changes: SimpleChanges) { }
  ngOnDestroy() { }
}
Enter fullscreen mode Exit fullscreen mode

为了完成设置,我们需要确保:

  1. outputs/inputs对象对应于组件的输出/输入,未使用错误名称。
  2. component ngOnChange根据输入变化运行。
  3. 输出内容EventEmitter将自动取消订阅。

我将展示几个函数的实现,以便更好地说明其工作原理。阅读下一节时,您可能需要查看完整的代码。

验证

由于这不是 Angular 的开箱即用解决方案,我们无法确保使用正确的输入/输出名称,因此需要手动验证以避免隐藏的问题。

如上所述,ComponentFactory该对象将用于检查组件的输入和输出。

输入

遍历用户提供的输入,检查每个提供的输入是否在组件中声明为Input
组件输入是一个用@Input.

private validateInputs(componentInputs: ComponentInputs, userInputs: UserInputs) {
  const userInputsKeys = Object.keys(userInputs);
  userInputsKeys.forEach(userInputKey => {
      const componentHaveThatInput = componentInputs.some(componentInput => componentInput.templateName === userInputKey);
      if (!componentHaveThatInput) {
          throw new Error(`Input ${ userInputKey } is not ${ this.component.name } input.`);
      }
  });
}
Enter fullscreen mode Exit fullscreen mode

输出

遍历组件输出,检查每个输出是否包含一个实例EventEmitter
组件输出是一个带有 `@Component` 装饰器的字段@Output,其EventEmitter值为实例。

在另一部分,我们遍历用户提供的输出,检查每个提供的输出是否在组件中声明为Output,以及用户提供的输出是否为函数。如果是函数,则该函数将用作EventEmitter处理程序。

private validateOutputs(componentOutputs: ComponentOutputs, userOutputs: UserOutputs, componentInstance: any) {
  componentOutputs.forEach((output) => {
      if (!(componentInstance[output.propName] instanceof EventEmitter)) {
          throw new Error(`Output ${ output.propName } must be a typeof EventEmitter`);
      }
  });

  const outputsKeys = Object.keys(userOutputs);
  outputsKeys.forEach(key => {
      const componentHaveThatOutput = componentOutputs.some(output => output.templateName === key);
      if (!componentHaveThatOutput) {
          throw new Error(`Output ${ key } is not ${ this.component.name } output.`);
      }
      if (!(userOutputs[key] instanceof Function)) {
          throw new Error(`Output ${ key } must be a function`);
      }
  });
}
Enter fullscreen mode Exit fullscreen mode

结合

现在绑定非常简单,因为我们不会再有错误的输入/输出名称了。

输入

private bindInputs(componentInputs: ComponentInputs, userInputs: UserInputs, componentInstance: any) {
  componentInputs.forEach((input) => {
      const inputValue = userInputs[input.templateName];
      componentInstance[input.propName] = inputValue;
  });
}
Enter fullscreen mode Exit fullscreen mode

输出

takeUntil用于EventEmitter稍后取消订阅实例的操作符。
this.subscription是 的一个实例Subject,将在下一节中声明。

private bindOutputs(componentOutputs: ComponentInputs, userOutputs: UserInputs, componentInstance: any) {
  componentOutputs.forEach((output) => {
      (componentInstance[output.propName] as EventEmitter<any>)
          .pipe(takeUntil(this.subscription))
          .subscribe((event) => {
              const handler = userOutputs[output.templateName];
              if (handler) { // in case the output has not been provided at all
                  handler(event);
              }
          });
  });
}
Enter fullscreen mode Exit fullscreen mode

创建组件

创建动态组件是使用 `create_dynamic_components`ComponentFactoryResolver和 `create_dynamic_components`完成的ViewContainerRef
首先,我们使用 `create_dynamic_components` 创建一个工厂ComponentFactoryResolver,该工厂包含用于执行输入/输出验证的元数据。

其次,我们使用该工厂来创建组件ViewContainerRef,它还接受注入器,该注入器将在稍后声明。

private createComponent() {
  this.componentFactory = this.componentFactoryResolver.resolveComponentFactory(this.component);
  this.componentRef = this.viewContainerRef.createComponent<any>(this.componentFactory, 0, this.injector);
}
Enter fullscreen mode Exit fullscreen mode

清理

要销毁一个组件,我们调用destroy在 中定义的方法ComponentRef,然后清除ViewContainerRef持有实际组件的 ,这样做也会将其从 UI 中移除。

private destroyComponent() {
  this.componentRef?.destroy();
  this.viewContainerRef.clear();
}
Enter fullscreen mode Exit fullscreen mode

清理工作将在生命周期中执行ngOnDestroysubscription正如之前提到的,这是Subject我们用来取消EventEmitter订阅的一个实例。

ngOnDestroy(): void {
  this.destroyComponent();
  this.subscription.next();
  this.subscription.complete();
}
Enter fullscreen mode Exit fullscreen mode

合并函数

我们来调用这些函数,ngOnChanges生命周期将用于在component输入或injector输入发生变化时创建组件,在这种情况下,我们首先销毁先前的组件,然后创建新组件。

之后,我们进行验证,然后绑定输入和输出。

private subscription = new Subject();
@Input('dynamic-component') component!: Type<any>;
@Input() outputs?: UserOutputs = {};
@Input() inputs?: UserInputs = {};
@Input() injector?: Injector;

ngOnChanges(changes: SimpleChanges): void {
  // ensure component is defined
  assertNotNullOrUndefined(this.component);

  const shouldCreateNewComponent =
      changes.component?.previousValue !== changes.component?.currentValue
      ||
      changes.injector?.previousValue !== changes.injector?.currentValue;

  if (shouldCreateNewComponent) {
      this.destroyComponent();
      this.createComponent();
  }

  // to make eslint happy ^^
  assertNotNullOrUndefined(this.componentFactory);
  assertNotNullOrUndefined(this.componentRef);

  this.subscription.next(); // to remove old subscription
  this.validateOutputs(this.componentFactory.outputs, this.outputs ?? {}, this.componentRef.instance);
  this.validateInputs(this.componentFactory.inputs, this.inputs ?? {});
  this.bindInputs(this.componentFactory.inputs, this.inputs ?? {}, this.componentRef.instance);
  this.bindOutputs(this.componentFactory.outputs, this.outputs ?? {}, this.componentRef.instance);
}
Enter fullscreen mode Exit fullscreen mode

有了这些,我们就拥有了 [ngComponentOutlet] 无法实现的所有必要功能。

ngOnChanges

目前我们可以完全创建动态组件,但是我们不能使用ngOnChanges生命周期,因为它不会对@Input变化做出反应,所以我们必须手动完成这项工作。

另一种方法是将你关注的字段更改@Input为具有 getter 和 setter,这样你就可以知道何时发生更改,但这并不是一个理想的选择,所以我们还是坚持使用ngOnChanges

我们先来为组件创建changes
对象。 简单来说,就是遍历新的输入(currentInputs),并将每个输入与前一个输入进行比较,如果发生变化,则将其作为已更改的输入添加到 changes 对象中。

private makeComponentChanges(inputsChange: SimpleChange, firstChange: boolean): Record<string, SimpleChange> {
  const previuosInputs = inputsChange?.previousValue ?? {};
  const currentInputs = inputsChange?.currentValue ?? {};
  return Object.keys(currentInputs).reduce((changes, inputName) => {
  const currentInputValue = currentInputs[inputName];
  const previuosInputValue = previuosInputs[inputName];
  if (currentInputValue !== previuosInputValue) {
      changes[inputName] = new SimpleChange(firstChange ? undefined : previuosInputValue, currentInputValue, firstChange);
  }
  return changes;
  }, {} as Record<string, SimpleChange>);
}
Enter fullscreen mode Exit fullscreen mode

ngOnChanges现在,如果组件声明了该方法并将更改作为参数传递,则我们需要手动从组件实例调用该方法。

让我们修改指令ngOnChanges以实现以下功能

ngOnChanges(changes: SimpleChanges): void {
    // ensure component is defined
  assertNotNullOrUndefined(this.component);

  let componentChanges: Record<string, SimpleChange>;
  const shouldCreateNewComponent =
      changes.component?.previousValue !== changes.component?.currentValue
      ||
      changes.injector?.previousValue !== changes.injector?.currentValue;

  if (shouldCreateNewComponent) {
      this.destroyComponent();
      this.createComponent();
      // (1) 
      componentChanges = this.makeComponentChanges(changes.inputs, true);
  }
  // (2)
  componentChanges ??= this.makeComponentChanges(changes.inputs, false);

  assertNotNullOrUndefined(this.componentFactory);
  assertNotNullOrUndefined(this.componentRef);

  this.validateOutputs(this.componentFactory.outputs, this.outputs ?? {}, this.componentRef.instance);
  this.validateInputs(this.componentFactory.inputs, this.inputs ?? {});

  // (3)
  if (changes.inputs) {
      this.bindInputs(this.componentFactory.inputs, this.inputs ?? {}, this.componentRef.instance);
  }

  // (4)
  if (changes.outputs) {
      this.subscription.next(); // to remove old subscription
      this.bindOutputs(this.componentFactory.outputs, this.outputs ?? {}, this.componentRef.instance);
  }

  // (5)
  if ((this.componentRef.instance as OnChanges).ngOnChanges) {
      this.componentRef.instance.ngOnChanges(componentChanges);
  }
}
Enter fullscreen mode Exit fullscreen mode
  1. firstChange在创建组件后,创建变更对象并将其设置为 true。
  2. 如果组件没有改变,则意味着只有输入或输出发生了改变,因此我们创建更改对象,并将该值firstChange设为 false。
  3. 仅当输入发生变化时才重新绑定输入。
  4. 仅当输出发生更改时才重新绑定输出。
  5. 调用组件ngOnChanges生命周期,并考虑可能的输入变更。

例子

是时候试一试了。演示

这是一个简单的组件,它根据输入显示颜色,并在颜色改变时发出事件。

import { Component, EventEmitter, Input, OnChanges, Output, SimpleChanges } from '@angular/core';

@Component({
  selector: 'app-color-box',
  template: `<div style="height: 250px; width: 250px;" [style.background-color]="backgroundColor"></div>`,
})
export class ColorBoxComponent implements OnChanges {
  @Input() backgroundColor: Color = 'red';
  @Output() backgroundColorChanges = new EventEmitter<Color>();

  ngOnChanges(changes: SimpleChanges): void {
    this.backgroundColorChanges.next(changes.backgroundColor);
  }
}
Enter fullscreen mode Exit fullscreen mode

宿主组件声明<ng-template>输入和输出。 点击“更改颜色”按钮将调用这正是预期行为。ColorBoxComponentdynamic-component
ngOnChangesColorBoxComponent

尝试更改输入名称,您将在控制台中看到抛出的异常。

关于输出,你需要使用箭头函数语法来this引用AppComponent实例。

import { Component } from '@angular/core';
import { ColorBoxComponent } from './color-box.component';

@Component({
  selector: 'app-root',
  template: `
  <ng-template
   [dynamic-component]="component"
   [inputs]="{backgroundColor: backgroundColor}"
   [outputs]="{backgroundColorChanges: onColorChange}">
  </ng-template>
  <button (click)="changeColor()">Change Color</button>
`,
  styleUrls: ['./app.component.css']
})
export class AppComponent {
  component = ColorBoxComponent;
  backgroundColor: Color = 'green';

  onColorChange = (value: Color) => {
    console.log(value, this.backgroundColor);
  }

  changeColor() {
    this.backgroundColor = 'blue';
  }
}
Enter fullscreen mode Exit fullscreen mode

结论

动态组件几乎是每个项目都需要的,因此能够轻松处理动态组件非常重要。

最后,已经有一个包可以完成所有这些操作以及更多功能,即 ng-dynamic-component

资源

  1. 以下是您需要了解的关于 Angular 中动态组件的内容
  2. NgComponentOutlet
  3. 使用 Angular 动态创建组件
文章来源:https://dev.to/this-is-angular/advance-angular-dynamic-component-12e