Appearance
L'héritage des composants dans Angular
L'héritage des composants est une technique puissante qui permet de réutiliser du code entre différents composants qui partagent des fonctionnalités communes. Voyons comment l'utiliser efficacement dans Angular.
Comprendre l'héritage avec un exemple concret
Imaginons que vous gérez un restaurant. Vous avez différents types de serveurs : des serveurs en salle, des serveurs au bar, des serveurs en terrasse. Tous partagent des caractéristiques communes (prendre une commande, servir un client) mais ont aussi leurs spécificités. Au lieu de réécrire les fonctionnalités communes pour chaque type de serveur, vous créez un "serveur de base" dont les autres héritent.
Cas d'usage
- Composants de formulaire partageant une logique de validation
- Composants de liste avec pagination
- Composants nécessitant une gestion d'état commune
- Composants avec des fonctionnalités de CRUD similaires
Implémentation dans Angular
Dans notre application de gestion d'utilisateurs, créons un composant de base pour l'affichage des utilisateurs avec des fonctionnalités communes.
ts
import { Component, Input } from '@angular/core';
import { User } from './user.interface';
interface UserConfig {
type: string;
canEdit: boolean;
maxUsers: number;
}
@Component({
standalone: true,
template: ''
})
export class BaseUserComponent {
@Input() userType = 'user';
protected getUserFullName(user: User): string {
return `${user.firstName} ${user.lastName}`;
}
}
ts
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { BaseUserComponent } from './base-user.component';
import { inject } from '@angular/core';
import { UserService } from '../../core/services/user.service';
@Component({
standalone: true,
selector: 'app-user-list',
template: `
<div>{{ userType }}</div>
@for (user of users; track user.id) {
<div>{{ getUserFullName(user) }}</div>
}
`
})
export class UserListComponent extends BaseUserComponent {
@Input() users: User[] = [];
}
Lorsqu'un composant hérite d'un autre composant, il hérite également de toutes les métadonnées définies dans le décorateur de la classe de base et de ses membres décorés. Dans notre exemple ci-dessus :
UserListComponent
hérite de BaseUserComponent
et obtient automatiquement :
- L'input
userType
avec sa valeur par défaut 'user' - La méthode protégée
getUserFullName()
- Le mode
standalone: true
défini dans le décorateur@Component
Transmission des dépendances injectées
Lorsqu'on utilise l'héritage avec des composants qui ont des dépendances injectées, il faut faire attention à la façon dont on transmet ces dépendances à la classe parente. Voyons comment gérer cela correctement.
Voici l'approche classique utilisant le constructeur :
ts
@Component({
standalone: true,
template: ''
})
export class BaseListComponent {
constructor(protected element: ElementRef) {}
protected getElementWidth(): number {
return this.element.nativeElement.offsetWidth;
}
}
ts
@Component({
standalone: true,
selector: 'app-custom-list',
template: `
<div>Width: {{ getElementWidth() }}px</div>
`
})
export class CustomListComponent extends BaseListComponent {
constructor(element: ElementRef) {
super(element);
}
}
ATTENTION
Cette approche présente plusieurs inconvénients :
- Il faut redéclarer le constructeur dans chaque classe fille
- Si la classe parente ajoute de nouvelles dépendances, toutes les classes filles devront être modifiées
- Le code est plus verbeux et plus sujet aux erreurs
Approche moderne avec inject()
La meilleure approche consiste à utiliser la fonction inject()
:
ts
@Component({
standalone: true,
template: ''
})
export class BaseListComponent {
protected element = inject(ElementRef);
protected getElementWidth(): number {
return this.element.nativeElement.offsetWidth;
}
}
ts
@Component({
standalone: true,
selector: 'app-custom-list',
template: `
<div>Width: {{ getElementWidth() }}px</div>
`
})
export class CustomListComponent extends BaseListComponent {
// Pas besoin de constructeur !
// Les injections sont héritées automatiquement
}
AVANTAGES DE L'INJECTION AVEC INJECT()
- Code plus concis et plus lisible
- Pas besoin de gérer manuellement la transmission des dépendances
- Plus facile à maintenir quand on ajoute de nouvelles dépendances
- Moins de risques d'erreurs
Limitation du constructeur avec l'héritage
Imaginons que nous voulions créer des composants de liste avec des fonctionnalités communes comme la pagination, le tri et le filtrage. Voici comment structurer cela correctement :
ts
import { InjectionToken } from '@angular/core';
export interface ListConfig {
pageSize: number;
sortable: boolean;
filterable: boolean;
columns: {
key: string;
label: string;
sortable?: boolean;
filterable?: boolean;
width?: string;
}[];
actions?: {
edit?: boolean;
delete?: boolean;
view?: boolean;
};
}
export const LIST_CONFIG = new InjectionToken<ListConfig>('LIST_CONFIG');
ts
@Component({
standalone: true,
selector: 'app-product-list',
template: `
<!-- Même template que user-list mais avec des colonnes différentes -->
`,
providers: [
{
provide: LIST_CONFIG,
useValue: {
pageSize: 5,
sortable: true,
filterable: false,
columns: [
{ key: 'reference', label: 'Réf.', width: '80px' },
{ key: 'name', label: 'Produit', sortable: true },
{ key: 'price', label: 'Prix', sortable: true, width: '100px' },
{ key: 'stock', label: 'Stock', width: '80px' }
],
actions: {
edit: true,
view: true
}
}
}
]
})
export class ProductListComponent extends BaseListComponent<Product> {
// Implémentation spécifique aux produits
}
ts
import { Component, inject } from '@angular/core';
import { ListConfig, LIST_CONFIG } from './list.config';
@Component({
standalone: true,
template: ''
})
export class BaseListComponent<T> {
protected items: T[] = [];
protected currentPage = 1;
protected config = inject(LIST_CONFIG);
protected get totalPages(): number {
return Math.ceil(this.items.length / this.config.pageSize);
}
protected get visibleItems(): T[] {
const start = (this.currentPage - 1) * this.config.pageSize;
return this.items.slice(start, start + this.config.pageSize);
}
protected sortBy(column: string): void {
if (!this.config.sortable) return;
// Logique de tri commune
}
protected filter(column: string, value: string): void {
if (!this.config.filterable) return;
// Logique de filtrage commune
}
}
ts
@Component({
standalone: true,
selector: 'app-user-list',
template: `
<table>
<thead>
<tr>
@for (column of config.columns; track column.key) {
<th [style.width]="column.width">
{{ column.label }}
@if (column.sortable && config.sortable) {
<button (click)="sortBy(column.key)">↕️</button>
}
</th>
}
@if (config.actions) {
<th>Actions</th>
}
</tr>
</thead>
<tbody>
@for (user of visibleItems; track user.id) {
<tr>
@for (column of config.columns; track column.key) {
<td>{{ user[column.key] }}</td>
}
@if (config.actions) {
<td>
@if (config.actions.edit) {
<button>✏️</button>
}
@if (config.actions.delete) {
<button>🗑️</button>
}
@if (config.actions.view) {
<button>👁️</button>
}
</td>
}
</tr>
}
</tbody>
</table>
@if (totalPages > 1) {
<div class="pagination">
<button [disabled]="currentPage === 1"
(click)="currentPage--">Précédent</button>
<span>Page {{ currentPage }} / {{ totalPages }}</span>
<button [disabled]="currentPage === totalPages"
(click)="currentPage++">Suivant</button>
</div>
}
`,
providers: [
{
provide: LIST_CONFIG,
useValue: {
pageSize: 10,
sortable: true,
filterable: true,
columns: [
{ key: 'id', label: 'ID', width: '50px' },
{ key: 'name', label: 'Nom', sortable: true, filterable: true },
{ key: 'email', label: 'Email', sortable: true, filterable: true },
{ key: 'username', label: 'Pseudo', sortable: true }
],
actions: {
edit: true,
delete: true,
view: true
}
}
}
]
})
export class UserListComponent extends BaseListComponent<User> {
private userService = inject(UserService);
ngOnInit() {
this.loadUsers();
}
private loadUsers(): void {
this.userService.getUsers().subscribe({
next: (users) => this.items = users
});
}
}
AVANTAGES DE CETTE CONFIGURATION
- Configuration déclarative et typée
- Réutilisation maximale du code
- Personnalisation facile pour chaque type de liste
- Contrôle fin des fonctionnalités (tri, filtrage, actions)
- Gestion commune de la pagination
1. L'usage de InjectionToken
InjectionToken
est un mécanisme d'Angular qui permet d'injecter des valeurs non-classe dans le système d'injection de dépendances.
ts
import { InjectionToken } from '@angular/core';
export interface ListConfig {
pageSize: number;
sortable: boolean;
// ... autres propriétés
}
// Création du token
export const LIST_CONFIG = new InjectionToken<ListConfig>('LIST_CONFIG');
POURQUOI UTILISER INJECTIONTOKEN ?
- Pour éviter les collisions de noms (le token est unique)
- Pour typer fortement nos configurations
- Pour intégrer des valeurs simples dans le système d'injection de dépendances
En savoir plus sur les InjectionToken : Angular InjectionToken
2. Les providers dans le composant
Les providers au niveau du composant permettent de définir une configuration spécifique pour chaque instance du composant :
ts
@Component({
// ...
providers: [
{
provide: LIST_CONFIG,
useValue: {
pageSize: 10,
sortable: true,
columns: [
{ key: 'name', label: 'Nom', sortable: true },
{ key: 'email', label: 'Email', sortable: true }
]
}
}
]
})
AVANTAGES DES PROVIDERS AU NIVEAU COMPOSANT
- Isolation : Chaque composant a sa propre configuration
- Flexibilité : Facile à modifier pour chaque instance
- Encapsulation : La configuration est liée au cycle de vie du composant
Vous pouvez aussi utiliser useFactory
pour une configuration dynamique :
ts
@Component({
providers: [
{
provide: LIST_CONFIG,
useFactory: () => ({
pageSize: window.innerWidth < 768 ? 5 : 10,
sortable: true,
// ... autres propriétés
})
}
]
})
3. Injection dans BaseListComponent
L'injection de la configuration dans le composant de base se fait avec inject()
:
ts
export class BaseListComponent<T> {
protected config = inject(LIST_CONFIG);
protected get visibleItems(): T[] {
const start = (this.currentPage - 1) * this.config.pageSize;
return this.items.slice(start, start + this.config.pageSize);
}
}
Surcharge des méthodes de cycle de vie
Lorsqu'on utilise l'héritage, il est important de comprendre comment gérer correctement les méthodes de cycle de vie d'Angular (lifecycle hooks) entre la classe parente et les classes filles.
Principe de base
Quand une classe fille définit une méthode de cycle de vie qui existe déjà dans la classe parente, elle surcharge (override) le comportement de la classe parente. Pour conserver le comportement de la classe parente tout en ajoutant des fonctionnalités supplémentaires, il faut explicitement appeler la méthode parente avec super
.
ts
@Component({
standalone: true,
template: ''
})
export class BaseUserComponent {
protected isInitialized = false;
protected loadingState = 'idle';
ngOnInit() {
this.isInitialized = true;
this.loadingState = 'loading';
}
ngOnDestroy() {
// Nettoyage des ressources
this.loadingState = 'destroyed';
}
}
ts
@Component({
standalone: true,
selector: 'app-user-list',
template: `
<div>État: {{ loadingState }}</div>
@if (users.length > 0) {
<!-- Affichage des utilisateurs -->
}
`
})
export class UserListComponent extends BaseUserComponent {
private userService = inject(UserService);
users: User[] = [];
override ngOnInit() {
// Appel de la méthode parente d'abord
super.ngOnInit();
// Logique spécifique au composant enfant
this.loadUsers();
}
override ngOnDestroy() {
// Nettoyage spécifique au composant enfant
this.users = [];
// Appel de la méthode parente ensuite
super.ngOnDestroy();
}
private loadUsers(): void {
this.userService.getUsers().subscribe({
next: (users) => {
this.users = users;
this.loadingState = 'loaded';
},
error: () => {
this.loadingState = 'error';
}
});
}
}
ATTENTION
Si vous ne appelez pas super.ngOnInit()
, le code d'initialisation de la classe parente ne sera jamais exécuté. Cela peut conduire à des bugs subtils et difficiles à déboguer.
Bonnes pratiques pour la surcharge des cycles de vie
- Utiliser le mot-clé
override
POURQUOI UTILISER OVERRIDE ?
- Meilleure lisibilité du code
- Détection des erreurs à la compilation
- Documentation implicite du code
ts
// ❌ Sans override - Moins sûr
ngOnInit() {
super.ngOnInit();
// ...
}
// ✅ Avec override - Recommandé
override ngOnInit() {
super.ngOnInit();
// ...
}
- Ordre d'exécution cohérent
Pour ngOnInit
et les hooks d'initialisation :
ts
override ngOnInit() {
// 1. D'abord appeler la méthode parente
super.ngOnInit();
// 2. Ensuite exécuter la logique spécifique
this.initializeSpecificLogic();
}
Pour ngOnDestroy
et les hooks de nettoyage :
ts
override ngOnDestroy() {
// 1. D'abord nettoyer les ressources spécifiques
this.cleanupSpecificResources();
// 2. Ensuite appeler le nettoyage parent
super.ngOnDestroy();
}
- Gestion des hooks asynchrones
ts
@Component({
standalone: true,
template: ''
})
export class BaseAsyncComponent implements OnInit {
protected subscription = new Subscription();
ngOnInit() {
this.subscription.add(
interval(1000).subscribe(() => {
// Logique de base
})
);
}
ngOnDestroy() {
this.subscription.unsubscribe();
}
}
ts
@Component({
standalone: true,
template: `...`
})
export class CustomAsyncComponent extends BaseAsyncComponent {
override ngOnInit() {
super.ngOnInit();
// Ajouter des souscriptions supplémentaires
this.subscription.add(
this.userService.getUsers().subscribe(/* ... */)
);
}
// Pas besoin de surcharger ngOnDestroy
// La classe parente gère déjà le désabonnement
}
- Gestion des hooks avec des décorateurs
Si votre composant de base utilise des décorateurs comme @Input()
ou @HostListener()
, assurez-vous de gérer correctement leurs cycles de vie :
ts
@Component({
standalone: true,
template: ''
})
export class BaseInputComponent {
@Input() set value(val: string) {
this.handleValueChange(val);
}
protected handleValueChange(val: string): void {
// Logique de base
}
}
@Component({
standalone: true,
template: `...`
})
export class CustomInputComponent extends BaseInputComponent {
override protected handleValueChange(val: string): void {
super.handleValueChange(val);
// Logique supplémentaire
}
}
CONSEIL
Pour une meilleure maintenabilité :
- Gardez les méthodes de cycle de vie de la classe parente simples et focalisées
- Documentez clairement ce que fait chaque méthode de cycle de vie
- Évitez les effets de bord dans les méthodes de cycle de vie
- Utilisez des méthodes protégées pour la logique que les classes filles pourraient vouloir surcharger