高级 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);
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;
}[];
其中每一部分都有propName并且templateName对应于
@Input(templateName) propName;
@Output(templateName) propName;
templateNamepropName如果不指定,则使用默认值。
设置
我们的指令将这样使用。
<ng-template [dynamic-component]="component" [inputs]="{}" [outputs]="{}"> </ng-template>
代码中将使用的类型
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';
为严格模式用户提供的实用功能😅
function assertNotNullOrUndefined<T>(value: T): asserts value is NonNullable<T> {
if (value === null || value === undefined) {
throw new Error(`cannot be undefined or null.`);
}
}
该指令
@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() { }
}
为了完成设置,我们需要确保:
outputs/inputs对象对应于组件的输出/输入,未使用错误名称。componentngOnChange根据输入变化运行。- 输出内容
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.`);
}
});
}
输出
遍历组件输出,检查每个输出是否包含一个实例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`);
}
});
}
结合
现在绑定非常简单,因为我们不会再有错误的输入/输出名称了。
输入
private bindInputs(componentInputs: ComponentInputs, userInputs: UserInputs, componentInstance: any) {
componentInputs.forEach((input) => {
const inputValue = userInputs[input.templateName];
componentInstance[input.propName] = inputValue;
});
}
输出
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);
}
});
});
}
创建组件
创建动态组件是使用 `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);
}
清理
要销毁一个组件,我们调用destroy在 中定义的方法ComponentRef,然后清除ViewContainerRef持有实际组件的 ,这样做也会将其从 UI 中移除。
private destroyComponent() {
this.componentRef?.destroy();
this.viewContainerRef.clear();
}
清理工作将在生命周期中执行ngOnDestroy,subscription正如之前提到的,这是Subject我们用来取消EventEmitter订阅的一个实例。
ngOnDestroy(): void {
this.destroyComponent();
this.subscription.next();
this.subscription.complete();
}
合并函数
我们来调用这些函数,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);
}
有了这些,我们就拥有了 [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>);
}
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);
}
}
firstChange在创建组件后,创建变更对象并将其设置为 true。- 如果组件没有改变,则意味着只有输入或输出发生了改变,因此我们创建更改对象,并将该值
firstChange设为 false。 - 仅当输入发生变化时才重新绑定输入。
- 仅当输出发生更改时才重新绑定输出。
- 调用组件
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);
}
}
宿主组件声明了<ng-template>输入和输出。 点击“更改颜色”按钮将调用,这正是预期行为。ColorBoxComponentdynamic-componentngOnChangesColorBoxComponent
尝试更改输入名称,您将在控制台中看到抛出的异常。
关于输出,你需要使用箭头函数语法来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';
}
}
结论
动态组件几乎是每个项目都需要的,因此能够轻松处理动态组件非常重要。
最后,已经有一个包可以完成所有这些操作以及更多功能,即 ng-dynamic-component。