Structurez vos components

Les components sont la partie visible de l'iceberg de votre application. Ce sont les éléments avec lesquels vos utilisateurs interagissent, et qui doivent à leur tour interagir avec la partie submergée pour envoyer, récupérer, et partager les données et événements. Une architecture claire et bien conçue est essentielle pour un codebase compréhensible et plaisant à maintenir et à étendre.

Dans ce chapitre, comme dans toute la partie précédente, je ne vous fournis pas "la" bonne réponse : je vous propose simplement des principes et des patterns à garder en tête lorsque vous créez vos propres solutions.

Smart or… not so smart?

L'un des patterns structuraux les plus répandus est le pattern smart/dumb, aussi connu sous le nom container/presenter.

Cette architecture isole les responsabilités et donc facilite la maintenance et l'extension. Elle facilite également les tests, comme on le verra un peu plus bas.

Smart - container

Ce sont les components "parents" de vos applications. Ce sont eux qui communiquent avec la partie submergée de l'iceberg (façades, services) et qui délèguent l'affichage et les interactions à des components dumb - presenter. En général, un component smart :

  • injecte les dépendances nécessaires pour sa zone de responsabilité

  • n'a pas de input/output/model

  • n'embarque pas de logique d'affichage en dehors de "quels components enfants à afficher"

  • peut avoir des enfants smart et/ou dumb

Dumb - presenter

Ces components "enfants" sont la vraie partie visible de l'iceberg. Ils ne communiquent pas directement avec les services ou façades :

  • ils dépendent seulement de leurs inputs

  • lors d'un événement (comme un clic de l'utilisateur), ils le transmettent par output à leur parent smart

  • ils n'injectent aucune dépendance

  • ils peuvent contenir d'autres components dumb (et très exceptionnellement des components smarts)

Selon moi il manque un type de component dans cette description : le component de layout, de mise en page. Un exemple très courant ressemble à cela :

<app-header/>
<div class="container">
    <aside>
	    <app-menu/>
	</aside>
	<main>
	    <router-outlet/>
	</main>
</div>
<app-footer/>

Ce component n'est pas vraiment smart, car il ne communique pas avec les services ou façades, mais il n'est pas réellement dumb non plus, car il n'a aucun input ou output. C'est pour cela que je différencie ce troisième type de component.

Structurez vos components

Voici un exemple de structure de components :

Diagramme hiérarchique d’une architecture front-end avec des composants : App en racine, suivi de Layout, puis des composants intelligents (Smart) et enfin des composants présentiels (Dumb).
Une architecture simple

Cet exemple est plutôt sain, avec une hiérarchie propre et sans endroit problématique.

Il existe, cependant, quelques anti-patterns auxquels il faut faire attention.

1. The everything bagel

De loin l'anti-pattern le plus courant :

Rectangle vert pâle représentant un composant Smart, centré au milieu de l’image. Il symbolise un composant contenant de la logique métier dans une architecture front-end.
The everything bagel

Le problème : tout tout tout dans le même component. Cet anti-pattern est malheureusement plus répandu que ce qu'on voudrait croire. Des components de 500, 1000, 2000 lignes. L'horreur.

Quelle est la solution ? 

Commencez par essayer de créer quelques components dumb que vous pouvez séparer de la grosse masse. Au bout d'un moment, des failles apparaîtront qui permettent de diviser en plusieurs components smart. Ça peut être beaucoup de travail, mais la réduction du coût du changement en vaut le coup.

2. Too smart for your own good

Voici à quoi ressemble cet anti-pattern :

Organigramme composé uniquement de composants Smart (verts), représentant une architecture où toute la logique est centralisée dans des composants intelligents, sans séparation avec des composants présentiels.
Too smart

Le problème : ici, tous les components injectent des dépendances et gèrent leurs affichages et interactions. À petite échelle, ce genre de structure peut être justifiée, mais lorsqu'elle se généralise, la dispersion de la logique métier réduit la cohésion et donc la compréhensibilité du codebase.

Et la solution pour ceci ? 

Commencer en bas de l'arbre et trouver des components qui peuvent être transformés en dumb : il faudra parfois les éclater en plusieurs pour que cela ait du sens.

3. Dumb and dumber

Le problème inverse du "too smart for your own good", ça donne cela :

Arborescence illustrant un composant Smart racine contrôlant une hiérarchie complexe de composants Dumb, représentant une architecture très découplée et centrée sur la présentation.
Dumb and dumber

Le problème : Dans cette situation, on tombe dans ce qu'on appelle le prop drilling : les données sont injectées par le smart component tout en haut, et pour qu'elles arrivent en bas, elles doivent transiter par tous les components intermédiaires. Pour les événements, on a une chaîne de bubble up très longue : un événement déclenché tout en bas doit transiter par toutes les couches au-dessus jusqu'à être traité par le parent smart.

Est-ce qu'il y a une solution à ceci aussi ? 

Oui, il y a plusieurs chemins possibles :

  • regrouper deux couches en une seule : à faire seulement si le résultat ne génère pas un component trop énorme

  • chercher un niveau où insérer un component smart : avec parcimonie et à bon escient ! -> très souvent dans ces cas de figure, on peut trouver un component qui peut être transformé de manière logique, mais attention à ne pas virer en "too smart for your own good"

Dans une application Angular moderne, les Signals sont l'outil principal pour la réactivité. Utilisez-les sans modération ! Voici quelques principes à garder en tête toutefois :

  • évitez le plus possible les effect : ils sont là pour des cas exceptionnels ; si vous en avez absolument besoin, placez-les uniquement dans les components smart

  • utilisez les computed dans vos components dumb pour maintenir une réactivité parfaite dans l'affichage

  • les model peuvent être un excellent choix pour une communication entre deux components dumb, mais préférez les output pour faire remonter une modification dont l'application doit être prévenue

  • utilisez la stratégie OnPush pour le change detection de vos components… sans exception !

To split or not to split ?

Dès la première version d'Angular (la version 2, pour la séparer d'AngularJS qui avait une structure totalement différente), les components sont séparés en trois fichiers : le template HTML, les styles CSS ou SCSS, et la logique TS.

Avec le temps, une préférence s'est développée pour les SFC : single-file components. Au lieu d'avoir :

@Component({
    selector: 'app-product-list',
    templateUrl: './product-list.html',
    styleUrls: ['./product-list.scss']
})
export class ProductList {
    
}

Vous avez :

@Component({
    selector: 'app-product-list',
    template: `
        <div class="product-list-item">
            @for (product of products(); track product.id) {
                <app-product-list-item [product]="product"/>
            }
        </div>
    `,
    styles: `
        .product-list-item {
            display: flex;
            padding: 1rem;
            background-color: white;
        }
    `
})
export class ProductList {
    
}

Pourquoi cette préférence ?

  • moins de fichiers, moins de charge cognitive, moins besoin de switcher sans cesse entre onglets dans votre IDE

  • ça encourage très fortement à créer des petits components, améliorant la structure générale

Aujourd'hui, je préfère démarrer avec des SFC, et lorsque le component grossit et que je ne peux pas le découper en components plus petits (par exemple avec une table Material, où les colonnes, rangées, et toute la configuration doivent se trouver à la racine de l'élément mat-table), je sépare en plusieurs fichiers.

Testez vos components

Dans une structure smart/dumb, ce qui se révèle être le plus cohérent côté components est d'écrire la majorité de vos tests contre les components smart en injectant des fake ou des stub pour ses dépendances. Si on développe un peu l'exemple précédent :

@Component({
    //...
    template: `
        @for (product of products(); track product.id) {
            <app-product-list-item [product]="product"/>
        } @empty {
            <app-empty-list-message/>
        }
    `,
})
export class ProductList {
    private productFacade = inject(ProductFacade);
    
    products: Signal<Product[]> = this.productFacade.allProducts();
}

On a un component smart très simple qui récupère une liste de produits et l'affiche ; si jamais cette liste est vide, il affiche un component de message de liste vide.

Ce sont deux comportements qui peuvent être facilement testés en créant un fake pour  ProductFacade  :

export class FakeProductsFacade implements ProductsFacade {
    
    private _products: WritableSignal<Product[]> = signal([]);
    
    allProducts(): Signal<Product[]> {
        return this._products.asReadonly();
    }
    
    setProductsForTesting(products: Product[]): void {
        this._products.set(products);
    }
}

Là, vous avez un fake qui vous permet de dicter quels produits doivent être retournés.

Si vous ajoutez des attributs  data-testid  dans le template du component :

@for (product of products(); track product.name) {
  <app-product-list-item data-testid="product-list-item" [product]="product" />
} @empty {
  <app-empty-list-message data-testid="empty-list-message" />
}

Vous pouvez avoir une suite de tests comme celle-ci :

import { ProductList } from './product-list';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { DebugElement, provideZonelessChangeDetection } from '@angular/core';
import { FakeProductsFacade, ProductsFacade } from '../../facades/products.facade';
import { By } from '@angular/platform-browser';

describe('ProductList', () => {
  let fixture: ComponentFixture<ProductList>;
  let debugElement: DebugElement;
  let productsFacade: FakeProductsFacade;

  beforeEach(async () => {
    TestBed.configureTestingModule({
      imports: [
        ProductList
      ],
      providers: [
        provideZonelessChangeDetection(),
        { provide: ProductsFacade, useClass: FakeProductsFacade },
      ],
    });
    fixture = TestBed.createComponent(ProductList);
    debugElement = fixture.debugElement;
    productsFacade = TestBed.inject(ProductsFacade) as FakeProductsFacade;
  });

  it('should display the empty message', async () => {
    productsFacade.setProductsForTesting([]);
    await fixture.whenStable();
    const emptyMessage = debugElement.query(By.css('[data-testid="empty-list-message"]'));
    expect(emptyMessage).toBeTruthy();
  });

  it('should display the product list', async () => {
    productsFacade.setProductsForTesting([
      { name: 'Product 1' },
      { name: 'Product 2' },
    ]);
    await fixture.whenStable();
    const productListItems = debugElement.queryAll(By.css('[data-testid="product-list-item"]'));
    expect(productListItems.length).toBe(2);
  });
});

La possibilité de manipuler le fake de cette manière vous laisse valider tous les comportements d'affichage de votre application. Depuis ces tests, vous pouvez également vérifier le comportement des components enfants.

Il y a deux cas de figure principaux où tester directement des components dumb devient intéressant :

  • ses comportements deviennent complexes

  • il est réutilisé à plusieurs endroits de l'application

Dans les deux cas de figure, il est possible que ça vous fasse tester le même comportement à plusieurs niveaux : ce n'est pas un problème en soi, il faut juste faire attention à ne pas dupliquer s'il n'y a pas de réel intérêt.

À vous de jouer

Contexte

Vous héritez d'un produit "Liste de tâches" qui présente un anti-pattern : un bon gros everything bagel. Afin de rendre le projet maintenable, vous décidez de le refactoriser vers une structure smart/dumb.

Consigne

Corrigé

En résumé

  • Séparez clairement les responsabilités avec des components smart qui gèrent la logique métier, des components dumb focalisés sur l'affichage, et des components de layout pour la mise en page

  • Evitez les anti-patterns courants : méfiez-vous du everything bagel, du too smart for your own good , et du dumb and dumber

  • Utilisez les Signals, mais évitez les effect le plus possible, privilégiez les computed dans les components dumb, et maintenez OnPush pour optimiser les performances

  • Concentrez vos tests sur les components smart en utilisant des fake pour les dépendances, et ne testez les components dumb que s'ils sont complexes ou réutilisés

Vos components sont bien structurés et testables ! Il est temps de nous pencher sur la partie submergée de l'iceberg : comment organiser et architecturer les façades et services qui alimentent vos components en données et gèrent la logique métier de votre application.

 

Et si vous obteniez un diplôme OpenClassrooms ?
  • Formations jusqu’à 100 % financées
  • Date de début flexible
  • Projets professionnalisants
  • Mentorat individuel
Trouvez la formation et le financement faits pour vous