Construire dynamiquement un composant Angular depuis une Directive

Il existe pas mal de contextes où la création “à la volée” d’un composant dans une application peut être nécessaire. Les boîtes de dialogue, notamment, ont souvent besoin d’afficher différentes informations (provenant de différents composants) à l’intérieur de la même vue.

Angular Material par exemple utilise ce mécanisme pour son propre service MdDialog:

let dialogRef = this.dialog.open(DialogResultExampleDialog);

Le code ci-dessus est extrait de la documentation et montre bien que le service reçoit un composant directement. Le service est ensuite capable de le construire et de l’insérer dans l’application à l’endroit voulu pour obtenir une belle dialog.

Pour faire le parallèle avec AngularJS (aka Angular 1.x), il était déjà possible d’avoir ce comportement avec $compile(). Le principe était différent toutefois car il fallait se baser sur du HTML passé en chaîne de caractères. Le compilateur était donc capable d’interpréter cette nouvelle chaîne et il fallait ensuite insérer ce HTML interprété dans notre DOM. Mais ça, c’était avant !

La solution d’Angular : les ComponentFactory

Pour gérer ce type de comportement, Angular a introduit les ComponentFactory. Comme leur nom l’indique, le rôle est de gérer la création de Components pour directement les passer à notre vue. Ça a l’air trop simple dit comme ça et pourtant c’est tout ce qu’il y a à faire !

Je vais prendre ici l’exemple d’un sous-menu dynamique (j’évite volontairement la boîte de dialogue car on voit l’exemple trop souvent). Mon point de départ est donc une Directive SubMenu qui générerait un composant de menu appelé SubMenuComponent lorsque l’on click sur son host. La Directive doit donc ressembler à quelque chose comme ça :

import { Directive, HostListener } from '@angular/core';
@Directive({
selector: ‘[appSubMenu]’,
})
export class SubMenuDirective {
open: boolean = false;
  @HostListener('click') toggleSubMenu() {
if (open) {
closeSubMenu();
} else {
openSubMenu();
}
}
  constructor() {}
  private openSubMenu() {
// Do you magic here
}
  private closeSubMenu() {
// Do you magic here
}
}

Son comportement est assez basique pour le moment puisque l’on a simplement ajouté un évènement de click sur le host de notre directive qui déclenchera la construction de notre SubMenuComponent.

Sur la base de notre directive, on va demander à Angular de créer une Factory pour notre composant. Une Factory est la classe qui prépare l’instanciation de notre composant dynamiquement dans le contexte de l’application. A noter que le composant n’est pas instancié par la Factory mais fait le pont entre notre définition du composant que l’on souhaite insérer dynamiquement, et l’application. Angular appelle ce processus la “résolution” d’un composant.

Pour créer notre Factory, Angular met à disposition le helper ComponentFactoryResolver qui va retourner un object de type ComponentFactory. La première étape est donc d’importer ces deux dépendances dans la directive.

Suivant la logique de la Factory d’Angular, il faut donc que la directive utilise le Resolver pour avoir à disposition un ComponentFactory , qui pourra être ajouté à notre vue. Ce ComponentFactory doit être typé et dans notre cas, ce sera avec le composant SubMenu, car c’est ce composant qui sera ajouté à chaque fois.

import { Directive, HostListener, ComponentFactoryResolver, ComponentFactory } from '@angular/core';
import { SubMenuComponent } from './path/to/sub-menu.component';
@Directive({
selector: ‘[appSubMenu]’,
})
export class SubMenuDirective {
open: boolean = false;
subMenuCpntFactory: ComponentFactory<SubMenuComponent>;
  @HostListener('click') toggleSubMenu() {
if (open) {
closeSubMenu();
} else {
openSubMenu();
}
}
  constructor(
private resolver: ComponentFactoryResolver
) {
this.subMenuCpntFactory =
this.resolver.resolveComponentFactory(SubMenuComponent);

}
  private openSubMenu() {
// Do you magic here
}
  private closeSubMenu() {
// Do you magic here
}
}

La Directive est maintenant prête à générer des SubMenuComponent. L’étape suivante est donc de générer un composant grâce à notre Factory, et de l’ajouter au DOM pour qu’il soit visible par les utilisateurs. Bien que ce soit logique, il faut garder à l’esprit que Angular est “platform-agnostic” (qui peut être exécuté sans contrainte de plateforme, d’environnement). Cette conception nous empêche de toucher au DOM directement. Si on utilise directement le DOM, cela implique que nous avons un DOM dans tous les contextes de notre application, donc elle ne serait plus “platform-agnostic”. Pour accéder à notre vue sans passer par le DOM, nous devons donc utiliser l’outil que nous fournit Angular, à savoir ViewContainerRef. Cette classe représente le conteneur où une ou plusieurs vues sont attachées. C’est un utilitaire qui ne sert donc pas uniquement à ajouter des composants dynamiquement mais à aussi bien d’autres choses ! Dans notre cas, il va servir de point d’ancrage à notre composant fraîchement créé avec notre Factory. Ce composant généré par la Factory sera d’ailleurs de ComponentRef car suivant la même logique d’agnosticité, nous ne manipulerons que les références fournies par Angular et non le DOM directement.

Comme notre Directive va potentiellement créer des composants plusieurs fois (rien n’empêche l’utilisateur d’afficher un sous-menu plusieurs fois), il faut faire attention à bien vider notre point d’ancrage avant chaque nouvelle création. Même si nous allons également le faire lors de la destruction du composant, ça ne coûte rien de s’assurer que l’on parte d’une base vierge.

Il faut donc ajouter tout ce comportement dans la méthode openSubMenu() de la Directive. Après avoir importé les 2 nouvelles dépendances (ViewContainerRef et ComponentRef), nous allons pouvoir demander à notre vue de créer notre composant. Nous utilisons pour cela directement une méthode de la référence à notre vue en lui passant la Factory. Cette méthode createComponent() va retourner un ComponentRef que nous assignerons à notre donnée membre subMenuRef pour éviter toute fuite mémoire.

import { Directive, HostListener, ComponentFactoryResolver, ComponentFactory, ViewContainerRef, ComponentRef
} from '@angular/core';
import { SubMenuComponent } from './path/to/sub-menu.component';
@Directive({
selector: ‘[appSubMenu]’,
})
export class SubMenuDirective {
  open: boolean = false;
subMenuRef: ComponentRef<SubMenuComponent>;
subMenuCpntFactory: ComponentFactory<SubMenuComponent>;
  @HostListener('click') toggleSubMenu() {
if (open) {
closeSubMenu();
} else {
openSubMenu();
}
}
  constructor(
private resolver: ComponentFactoryResolver,
private viewContainerRef: ViewContainerRef
) {
this.subMenuCpntFactory =
this.resolver.resolveComponentFactory(SubMenuComponent);
}
  private openSubMenu() {
this.viewContainerRef.clear();
this.subMenuRef =
this.viewContainerRef.createComponent(this.subMenuCpntFactory);
}
  private closeSubMenu() {
// Do you magic here
}
}

Et voilà, notre composant est maintenant visible par l’utilisateur ! Rien de bien compliqué en définitive. Une fois la logique bien appréhendée, tout semble cohérent et bien plus stable que la version d’AngularJS qui se basait uniquement sur le DOM, et tenait plus du bricolage finalement que d’une vraie structure pour nos composants dynamiques.

Maintenant qu’il est créé, comment supprimer un composant ?

La création d’un composant est quelque chose d’assez simple à déclencher, en effet, notre application aura un bouton ou un évènement qui va déclencher la création (comme dans notre exemple de directive où c’est un click qui est le point de départ). Pour la destruction d’un composant dynamiquement créé, il faut donner la possibilité de détruire ce composant depuis lui-même, mais aussi depuis l’endroit où il a été créé. Pour le moment, la destruction ne peut se faire correctement que là où le composant a été créé. Dans notre exemple, on veut donc pouvoir détruire le composant depuis lui-même ou la Directive, mais c’est toujours cette dernière qui devra le détruire.

Pour résoudre cette problématique, j’ai trouvé une méthode assez facile à mettre en place: c’est d’utiliser un Subject dans le composant. Pourquoi est-ce pratique ? Car nous allons pouvoir souscrire à ce Subject depuis la Directive, mais émettre à la fois depuis le composant ou la Directive.

Pour que cela fonctionne bien, il faut être sûr que le composant possède bien une donnée membre de type Subject appelé close. Pour cela, le mieux est de prévoir une interface:

import { Subject } from 'rxjs';
export interface DynamicComposant {
close: Subject<null>;
}

Le composant devra donc ressembler à ça:

import { Subject } from 'rxjs';
// Imports
@Component({
// Decorator
})
export class SubMenuComponent implements OnInit, DynamicComposant {
close = new Subject<boolean>();
  // Defintion
}

Ainsi, le composant qui implémentera l’interface sera assuré d’avoir la possibilité d’être détruit. Qu’il déclenche lui-même le close ou que ce soit la Directive, vue que cette dernière souscrit au Subject close, elle déclenchera la destruction quoi qu’il arrive.

import { Directive, HostListener, ComponentFactoryResolver, ComponentFactory, ViewContainerRef, ComponentRef
} from '@angular/core';
import { SubMenuComponent } from './path/to/sub-menu.component';
@Directive({
selector: ‘[appSubMenu]’,
})
export class SubMenuDirective {
  open: boolean = false;
subMenuRef: ComponentRef<SubMenuComponent>;
subMenuCpntFactory: ComponentFactory<SubMenuComponent>;
  @HostListener('click') toggleSubMenu() {
if (open) {
closeSubMenu();
} else {
openSubMenu();
}
}
  constructor(
private resolver: ComponentFactoryResolver,
private viewContainerRef: ViewContainerRef
) {
this.subMenuCpntFactory =
this.resolver.resolveComponentFactory(SubMenuComponent);
}
  private openSubMenu() {
this.viewContainerRef.clear();
this.subMenuRef =
this.viewContainerRef.createComponent(this.subMenuCpntFactory);
this.open = true;
    this.subMenuRef.instance.close.subscribe(() => {
this.subMenuRef.instance.close.unsubscribe();
this.subMenuRef.destroy();
this.viewContainerRef.clear();
this.open = false;
});
  }
  private closeSubMenu() {
this.subMenuRef.instance.close.next();
}
}

A noter que lors de la destruction, il est important de détruire tout ce qui est intervenu lors de la création de notre composant ainsi que de unsubscribe() le Subject close. J’ai également ajouté un flag open pour gérer l’ouverture et la fermeture sous forme d’un Toggle, il peut également être utilisé dans le cas où des animations CSS devraient être ajoutées.

Pour simplifier l’exemple, je n’ai montré que la création d’un SubMenuComponent. Il est bien sûr possible de créer n’importe quel composant dans n’importe quelle directive ou même dans un autre composant. Pour donner plus de liberté, on pourrait ici mettre le composant en paramètre de la directive ou plus logiquement, passer par un service.

Retrouvez le code dans son intégralité sur Gist. Et pour aller plus loin dans l’utilisation des composants dynamiques, le composant Dialog d’Angular Material est très intéressant.