Créer un composant dynamiquement

Qu’est-ce qu’un composant dynamique ?

Dans cette recette, nous appelons “composant dynamique” un composant qui peut être créé programmatiquement à partir de données fournies par le backend.

L’idée est d’assembler à la volée un template contenant un mélange de texte, de HTML et de syntaxes Angular, et une classe contenant les données et les méthodes éventuellement requises par le template, et enfin de compiler le tout comme un composant Angular statique.

Cela permet d’utiliser des syntaxes riches dans des templates générés dynamiquement, et notamment :

  • D’afficher d’autres composants Angular dans ces templates. Indispensable si vous utilisez une librairie de composants.
  • D’ajouter de la logique d’affichage (*ngIf, *ngFor…).
  • D’une façon générale, d’utiliser toutes les syntaxes Angular dans ses templates.

Démarche générale

La démarche générale pour créer un composant dynamiquement est la suivante :

  • Créer un composant programmatiquement, en utilisant le décorateur Component comme une fonction.
  • Créer un NgModule programmatiquement, en utilisant le décorateur NgModule comme une fonction, tout en déclarant le composant créé à l’étape précédente dans ce NgModule.
  • Compiler le NgModule avec le compilateur Angular (méthode Compiler#compileModuleAndAllComponentsAsync) et récupérer la factory du composant.
  • Utiliser la factory du composant ainsi généré pour l’insérer dans le DOM avec ViewContainerRef#createComponent.

Exemple d’implémentation

Le code suivant représente un composant hôte, HostDynamicComponent, à l’intérieur duquel on vient afficher un composant généré dynamiquement.

import {
  AfterViewInit, Compiler, Component, ComponentRef, Inject, Injector,
  NgModule, OnDestroy, ViewChild, ViewContainerRef
} from '@angular/core';
import {RouterModule} from '@angular/router';
import {CommonModule} from '@angular/common';

@Component({
  selector: 'app-dynamic',
  template: '<ng-container #vc></ng-container>'
})
export class HostDynamicComponent implements AfterViewInit, OnDestroy {
  // Template et contexte du composant dynamique.
  // Dans un vrai projet, ces infos viendraient plutôt du backend.
  template = '<p>Mon nom est {{ name }}.</p>';
  context = {name: 'James Bond'};
  
  // Récupère l'emplacement du template où le composant dynamique sera inséré.
  @ViewChild('vc', {read: ViewContainerRef}) vc: ViewContainerRef;

  cmpRef: ComponentRef<any>;

  constructor(private _compiler: Compiler,
              private _injector: Injector) { }

  ngAfterViewInit() {
    // Crée un composant programmatiquement.
    const tmpCmp = Component({template: this.template})(class { });
    
    // Crée un NgModule programmatiquement, dans lequel on déclare le composant.
    const tmpModule = NgModule({imports: [CommonModule], declarations: [tmpCmp]})(class { });

    // Compile le NgModule et récupère la factory du composant créé dynamiquement.
    this._compiler.compileModuleAndAllComponentsAsync(tmpModule)
      .then((factories) => {
        const f = factories.componentFactories[0];
        // Insère le composant dynamique dans le DOM du composant hôte.
        this.cmpRef = this.vc.createComponent(f);
        // Copie le contexte (propriétés et méthodes) dans l'instance du composant dynamique.
        for (const key in this.context) {
          this.cmpRef.instance[key] = this.context[key];
        };
      });
  }

  // Détruit le composant dynamique quand le composant hôte est détruit.
  ngOnDestroy() {
    if (this.cmpRef) {
      this.cmpRef.destroy();
    }
  }
}

Template et contexte dynamiques

Le template du composant dynamique est ici une simple propriété statique (template), mais dans un vrai projet il serait généré dynamiquement ou récupéré depuis le backend. La propriété context est un objet littéral contenant les propriétés et les méthodes requises par le template ; elle aussi pourrait être générée dynamiquement.

Fonctions Component() et NgModule() pour créer le composant

Component et NgModule ne sont pas utilisés avec la syntaxe habituelle de décorateur mais sous forme de fonctions. C’est possible car syntaxiquement, les décorateurs sont en fait des fonctions, et ces deux fonctions renvoient une instance de composant et une instance de NgModule.

const tmpCmp = Component({template: this.template})(class { });
// ...
const tmpModule = NgModule({imports: [CommonModule], declarations: [tmpCmp]})(class { });

Notez qu’on a importé CommonModule dans notre NgModule, afin de pouvoir utiliser les syntaxes *ngIf, *ngFor… dans notre composant dynamique. C’est le même principe qu’un NgModule classique : vous devez importer dans le module toutes les fonctionnalités requises par votre composant.

Insertion du composant dynamique dans le DOM

Le template du composant hôte doit contenir une balise avec une template reference variable. Cette balise sert à marquer l’emplacement où le composant dynamique sera inséré. Ici on a utilisé la balise <ng-container> (neutre au niveau de l’affichage) avec la variable #vc.

<ng-container #vc></ng-container>

Et comme on doit attendre que le template du composant hôte ait été initialisé pour faire l’insertion, on a placé tout le code dans le hook ngAfterViewInit().

Destruction du composant dynamique

Enfin, ne pas oublier de détruire le composant dynamique quand le composant hôte est détruit, grâce au hook ngOnDestroy().

Limitations

La technique présentée ci-dessus présente plusieurs limitations.

Pas compatible avec la compilation AOT

La compilation AOT, recommandée pour les applis en production, consiste à précompiler les templates de composant lors du build et à ne pas inclure le compilateur Angular dans le build final. Or, pour générer des composants dynamiquement, vous avez besoin du compilateur. Vous devrez donc soit renoncer à la compilation AOT (et garder la compilation JIT), soit trouver un moyen d’inclure ou charger à la volée le compilateur Angular.

Pas très performant

Dans l’implémentation présentée, le composant dynamique est recompilé à chaque fois qu’il est affiché. Même si le temps que ça prend n’est pas énorme, ce n’est pas idéal.

De plus, la nécessité d’inclure (ou de télécharger à la volée) le compilateur Angular, augmente la taille de l’application finale de plusieurs centaines de kb (le compilateur fait environ 1MB, ou 320kb une fois minifié).

Pas très robuste

On voit dans l’implémentation présentée qu’on se retrouve à utiliser des APIs Angular de bas niveau comme Compiler#compileModuleAndAllComponentsAsync. Qui nous dit que ces API ne vont pas changer et faire planter notre code ? Et que se passerait-il si nos templates dynamiques contenaient des erreurs de syntaxe ? Ces erreurs sont normalement détectées en amont lors du build (avec la pré-compilation AOT), mais dans notre cas elles ne se produiront qu’à l’utilisation… Enfin, qu’en est-il de la sécurité ? Si un utilisateur malveillant parvenait à injecter du code dans notre template compilé dynamiquement, nous aurions clairement un problème…

Conclusion

Il y aurait un réel bénéfice à pouvoir générer des composants Angular dynamiquement, et c’est une fonctionnalité très réclamée par la communauté, mais à l’heure où nous écrivons ces lignes (mars 2018), on ne peut pas dire qu’on dispose d’une solution robuste pour cela.

Espérons qu’une future version d’Angular apportera une API claire permettant de gérer cette problématique de manière performante et sécurisée.



Ressources supplémentaires

Informations

Tags : components

Dernière mise à jour :

Auteur : AngularChef

Qualité : Bonne

Recettes liées :