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

Angular 是否支持通用组件类型?

Angular 是否支持通用组件类型?

你可能熟悉TypeScript 泛型的编写,Grid<IRowData>但这对于 Angular 组件是如何工作的呢?我们无法为模板选择器提供泛型类型,那么 Angular 是如何支持泛型组件的呢?

在本文中,我们将解释 Angular 是如何实现的,以及如何帮助 Angular 编译器在复杂组件中更加准确。

TypeScript 泛型

TypeScript泛型是一项非常强大的特性。通过泛型,我们可以根据特定的使用场景自定义现有接口。例如,您可能有一个用于显示数据行的网格组件。通过将网格组件泛化,用户可以提供自己的接口来表示行数据,并在网格组件的所有属性中使用该接口。

为什么我们需要通用组件?

在深入探讨通用组件的技术细节之前,我认为我们首先需要了解为什么我们希望组件具有通用支持。

假设我们有一个网格组件,用于显示项目,并在用户选择行时触发事件。如果没有泛型,我们就必须指定行数据类型,any因为我们希望将此网格组件用于不同的数据集。

@Component({
    selector: 'app-grid',
})
export class GridComponent {

    @Input() rowData?: any[];
    @Output() onSelection: EventEmitter<any> = new EventEmitter<any>();
}
Enter fullscreen mode Exit fullscreen mode

GridComponent我们在应用程序中会这样使用它。

@Component({
    selector: 'app-car-data',
    template: `<app-grid [rowData]="rowData" (onSelection)="onRowSelected($event)"></app-grid>`
})
export class CarDataComponent {
    // Row data of cars to provide to the grid
    rowData: ICar[];
    // Callback when a row is selected
    function onRowSelected(event: ICar){
    }
}
Enter fullscreen mode Exit fullscreen mode

这一切都按预期运行,但如果我们的代码中出现错误,将选择回调定义为事件类型而IPerson不是类型,会发生什么情况呢ICar

function onRowSelected(event: IPerson){
  // ERROR: Function definition expects IPerson but it will be called with ICar!
}
Enter fullscreen mode Exit fullscreen mode

rowData我们的组件不会报错,因为`a` 和 `b`的类型之间没有关联onRowSelected。`a`IPerson和`b`ICar都可以赋值给 `b` any,所以不会出错,但我们有一个 bug,可能只有在运行时才会被发现。

但是,如果我们的组件使用了泛型类型,那么 Angular 可以警告我们这两个属性不匹配,我们可以立即修复该问题。

创建通用组件

您可以通过在类声明中使用尖括号提供泛型类型参数来使组件泛型: 。然后,您可以在类中任何需要使用该类型的地方GridComponent<TData>使用该泛型类型。TData

看起来GridComponent像这样的。

@Component({...})
export class GridComponent<TData> {

    @Input() rowData?: TData[];
    @Output() onSelection: EventEmitter<TData> = new EventEmitter<TData>();
}
Enter fullscreen mode Exit fullscreen mode

注意我们如何定义TData通用类型,然后用它any来替换之前用于rowData选择回调的类型。这样做可以达到预期的效果,将类型rowDataonSelectionEventEmitter 关联起来。

使用通用组件

如果我们用纯 TypeScript 编写代码,我们会创建GridComponent组件并应用泛型类型ICaras new GridComponent<ICar>。然后所有泛型属性都会使用该ICar类型代替 as TData

const myGrid = new GridComponent<ICar>()

// type is ICar[]
myGrid.rowData;
// type is EventEmitter<ICar>
myGrid.onSelection;
Enter fullscreen mode Exit fullscreen mode

然而,这并非我们在 Angular 中创建组件的常用方法。我们通常会将选择器放在模板中,并设置输入和输出。

<app-grid 
  [rowData]="carData" 
  (onSelection)="carSelected($event)" >
</app-grid>
Enter fullscreen mode Exit fullscreen mode

关键在于:与不同JSX,你不能显式地向组件选择器提供泛型类型!

例如,以下代码不是有效的 HTML 代码。

<app-grid<ICar> [rowData]="carData" ></app-grid>
Enter fullscreen mode Exit fullscreen mode

你不能显式地为 Angular 组件提供泛型类型,但这并不妨碍 Angular 通过其他机制支持泛型组件。

那么,通用组件是如何工作的呢?

由于我们无法显式指定泛型类型,Angular 必须根据我们绑定到组件的输入和输出的类型来推断。让我们逐步解释一下它是如何工作的。

首先,组件作者指定通用类型,TData并将其用于一个或多个属性。

@Component({...})
export class GridComponent<TData> {

    @Input() rowData?: TData[];
    @Output() onSelection: EventEmitter<TData> = new EventEmitter<TData>();
}
Enter fullscreen mode Exit fullscreen mode

组件用户在应用程序组件中输入其属性。

carData: ICar[];
Enter fullscreen mode Exit fullscreen mode

然后,它们通过输入将这些绑定到组件,在本例中rowData

<app-grid [rowData]="carData">
Enter fullscreen mode Exit fullscreen mode

此时,Angular 编译器知道该属性carData具有类型ICar[],并且该类型已分配给rowData具有该类型的输入对象TData[]。然后,它推断泛型类型TData应该设置为ICar

利用这些信息,编译器会将正确的类型应用于输出onSelection。然后,该类型会变为`Output`,编译器可以验证 `Output`和 `Output`EventEmitter<ICar>的类型是否一致。rowDataonSelection

现在,如果我们配置错误,为多个泛型属性提供了不兼容的类型,就会出现构建错误。这太棒了,正是我们想要的!

你现在可能想知道为什么我们说Angular并不完全支持泛型类型。从这个例子来看,它似乎确实支持。

推理的影响

大多数情况下,在 Angular 中使用泛型组件时,一切都会按预期运行。但是,如果组件变得更加复杂,尤其是在使用泛型类型参数时,类型推断可能会出现故障或范围扩大。

这是因为 TypeScript 中的类型推断必须确保在所有代码路径中都准确无误。当 TypeScript 无法在所有代码路径中缩小类型范围时,您可能会看到泛型类型被推断为 `Any` any。此时,您将不再收到模板类型错误,因为any`Any` 可以匹配任何类型。

在上面的例子里,这意味着关于IPerson向选择事件提供参数的错误将会消失。

在这种情况下,我们需要开始应用一些新技术来帮助改善开发者在 Angular 中使用泛型的体验。我们将通过调整组件泛型类型的定义方式来实现这一点。

推理分解示例

为了具体说明推理过程中出现的问题,我们可以看一下AG Gridag-grid-angular中的组件。该组件是针对行数据的通用组件。为了简洁起见,省略了许多属性,其定义如下。

@Component({
    selector: 'ag-grid-angular',
})
export class AgGridAngular<TData = any> {

    @Input() rowData?: TData[];
    @Input() columnDefs?: ColDef<TData>[];
    @Input() defaultColDef?: ColDef<TData>;
    @Output() rowSelected: EventEmitter<RowSelectedEvent<TData>> = new EventEmitter<RowSelectedEvent<TData>>();
}
Enter fullscreen mode Exit fullscreen mode

如果您使用该组件编写以下代码,ag-grid-angular您可能会期望泛型类型被正确推断为ICar

carData: ICar[];
defaultColDef: ColDef;
Enter fullscreen mode Exit fullscreen mode
<ag-grid-angular
 [rowData]="carData"
 [defaultColDef]="defaultColDef" >
</ag-grid-angular>
Enter fullscreen mode Exit fullscreen mode

然而,事实并非如此。你会看到泛型类型为 ` any.`。这意味着属性之间不会进行模板类型检查。

经过一番调查,可以找到问题的原因。在定义类型时,defaultColDef没有ColDef指定泛型类型,而是使用了默认的泛型类型ColDef<ICar>。这意味着any使用了默认的泛型类型。这是组件中泛型类型any的一个可能类型,因此无法将类型进一步缩小到TDataTDataICar

强制执行通用参数

确保正确推断泛型类型的一个解决方案是强制用户向每个接口提供泛型类型。

可以通过不提供默认类型来强制执行此操作。因此,不应是:

interface ColDef<TData = any>{}
Enter fullscreen mode Exit fullscreen mode

您可以将接口定义为:

interface ColDef<TData>{}
Enter fullscreen mode Exit fullscreen mode

不过,这种方法也有其弊端。对于 AG Grid 来说,这将导致所有 TypeScript 用户面临重大变更。他们将被迫更新每个类型声明,以包含泛型属性。我们认为这不会被用户接受。

如果你处于从头开始创建一个新组件的不同位置,那么或许可以考虑不提供默认类型,因为从长远来看,这将带来更准确、更一致的类型推断。

但如果您需要提供默认any类型呢?

理解 Angular 中的代码推断

现在我们需要深入了解一下Angular组件的推理机制。它与以下静态代码结构类似。

  static typeCtor<TData>(inputs: Partial<Pick<GridComponent<TData>, 'rowData' | 'onSelection'>>): GridComponent<TData> {
    return null!;
  }
Enter fullscreen mode Exit fullscreen mode

这为我们提供了一种有效的机制,无需在模板中对组件运行 Angular 编译器,即可尝试不同的通用配置。这意味着您可以在像 TS Playground 这样易于共享的环境中验证组件的类型推断。

你可以使用这种方法来试验你的通用类型,以根据常见用例找到能够提供最佳开发者体验的类型。

影响类型推断

下一节将介绍为改进 AG Grid 组件的泛型类型支持而进行的具体更改。我们试图防止因使用ColDef不带类型参数的接口而导致泛型类型推断失败TData

我们通过引入第二个通用参数来实现这一点,TColDef该参数源自第一个参数:TColDef extends ColDef<TData>。这完全是为了改变推理的工作方式,没有其他任何影响。

我们的组件定义更改如下:

- class AgGridAngular<TData = any>
+ class AgGridAngular<TData = any, TColDef extends ColDef<TData> = ColDef<any>>
Enter fullscreen mode Exit fullscreen mode

然后我们更新columnDefs输入以使用这个新的派生通用参数。

- @Input() columnDefs?: ColDef<TData>[];
+ @Input() columnDefs?: TColDef[];
Enter fullscreen mode Exit fullscreen mode

ColDef<any>我们提供了一个默认值,这样,如果组件在 ViewChild 选择器中使用,TColDef我们就不会改变接口要求。AgGridAngular

然而,由于大多数用户只会在模板中定义组件,因此ag-grid-angular这项更改对他们来说很可能是不可见的。他们无需担心额外的泛型类型参数,因为他们一开始就没有指定过这些参数。

表面上看,你可能不会认为这会改变什么,因为你只是columnDefs在泛型参数中重新定义了类型。我们实际上并没有改变任何类型。然而,这种重新定位的效果是,TypeScript 尝试推断哪些属性为同一类型。对于组件而言,结果AgGridAngular是该columnDefs属性对推断类型的影响更大TData

现在让我们通过实际应用程序代码来展示这一变化的影响。

改进的推理结果

为了展示这一改变带来的结果,我们将使用两个不同版本的 AG Grid 来展示完全相同的代码。(由于本文主要关注类型定义,因此我省略了实际的实现,仅展示类型定义。)

我们定义了属性,并将ICar接口作为泛型参数提供给 `and`columnDefs和 ` rowData.`。我们没有向 `.` 提供泛型参数defaultColDef

IPerson最后,我们通过将回调函数的通用参数设置为 来模拟错误onRowSelected

columnDefs: ColDef<ICar>[];
defaultColDef: ColDef;
rowData$: Observable<ICar[]>;
//This should result in an error as IPerson != ICar
onRowSelected(e: RowSelectedEvent<IPerson>): void {}
Enter fullscreen mode Exit fullscreen mode

然后,我们将这些值按如下方式分配给组件:

<ag-grid-angular
  [columnDefs]="columnDefs" 
  [defaultColDef]="defaultColDef"
  [rowData]="rowData$ | async"
  (rowSelected)="onRowSelected($event)">
</ag-grid-angular>
Enter fullscreen mode Exit fullscreen mode

如果您使用 AG Grid v28.0,则此代码将编译,因为通用参数回退到any此屏幕截图中可以看到。

IDE 代码显示推断出的泛型类型为 any

但是,由于我们在最新版本的 AG Grid 中更新了类型定义,因此相同的代码会如预期那样导致编译错误。

构建错误:ICar 和 IPerson 不兼容

我们现在还可以看到通用参数是如何被正确推断的,以及改进的 IDE 错误高亮显示。

IDE 代码显示了 TData 泛型类型的正确推断

这是个好消息,因为这意味着我们能够在不强迫 AG Grid 用户在其所有现有代码中添加通用参数的情况下,改善他们的输入体验。

结论

虽然在很多情况下 Angular 都能正确推断组件类型,但当它无法正确推断时,我希望通过分享这些知识,您能更好地向 Angular 编译器提供提示,使其恢复正常。

鸣谢

感谢 Angular 核心团队的 Alex Rickabaugh (@synalx)在 NG Conf 的 Hallway 环节向我展示了这种方法。原来它也被用于改进类型定义ngFor。详情请见此处。非常感谢 Alex!


Stephen Cooper - AG Grid高级开发人员
,Twitter @ScooperDev转发此帖子。

文章来源:https://dev.to/angular/does-angular-support-generic-component-types-4fkm