Les composants enfant (Angular)

Cet article fait partie de la série d’articles Angular from Scratch.

@jplenio

Une fonctionnalité importante des composants est qu’ils peuvent contenir des directives. Par suite sachant que les composants sont des directives, il est possible d’imbriquer des composants les uns dans les autres. Un composant se trouvant dans un autre composant est appelé composant enfant (i.e. child component).

Dans la suite de cet article, on appellera:

  • “Composant parent”: le composant contenant d’autres composants,
  • “Composant enfant”: le composant imbriqué dans un autre composant.

Dans cet article, on ne parlera que de la composition des composants pour faciliter les explications toutefois il faut garder en tête que dans le cas général, les composants peuvent contenir des directives (pour davantage de détails, voir “Les directives Angular”).

Dans un premier temps, on indiquera comment placer un composant enfant dans un composant parent. Puis on explicitera la méthode pour injecter des paramètres dans un composant et comment être notifié par le déclenchement d’un évènement dans un composant enfant. Enfin, on expliquera la fonctionnalité de projection de contenu.

Pour imbriquer un composant dans un autre, il suffit d’indiquer le paramètre selector dans le template du composant parent. Pour que la résolution du template puisse réussir dans le composant parent, il faut que le composant enfant soit dans le même module que le composant parent.

Pour illustrer l’exemple d’un composant enfant, on va créer 2 composants:

  • ChildComponent qui servira de composant enfant,
  • ParentComponent qui servira de composant parent et qui contiendra ChildComponent.

Dans le répertoire du module dans lequel on veut créer les 2 composants, on exécute la commande suivante en utilisant le CLI Angular:

ng generate component Child
ng generate component Parent

Ou

ng g c Child
ng g c Parent

On modifie le paramètre selector du composant enfant:

Template
<h3>Child component</h3>
Classe du composant
@Component({
    selector: 'app-child',
    templateUrl: './child.component.html'
})
export class ChildComponent {}

Dans le composant parent, on modifie l’implémentation du template pour afficher le contenu du composant enfant:

<h1>Parent component</h1>
<app-child></app-child>

En affichant le composant parent, on verra aussi le composant enfant:

De façon très facultative, on peut rajouter un style CSS pour que l’affichage permette de faire la différence entre le composant parent et le composant enfant.

On modifie le style du composant parent dans le fichier CSS correspondant (i.e. parent.component.css):

:host {
    border: 1px solid black;
    display: block;
    background-color: lightgrey;
    width: 500px;
}

On modifie le style du composant enfant dans le fichier CSS child.component.css:

:host {
    border: 1px solid black;
    display: block;
    background-color: lightgreen;
    margin: 5px;
}

L’affichage devient:

Interactions avec les composants enfant

Les interactions possibles entre le composant parent et les composants enfant peuvent être de différente nature suivant l’origine de l’interaction, par exemple:

  • Du composant parent vers l’enfant:
    • En injectant le composant parent dans le composant enfant: utiliser l’injection de dépendances d’Angular pour injecter le composant parent dans le composant enfant.
    • En utilisant des paramètres d’entrée dans le composant enfant avec le décorateur @Input(): un property binding peut lier ce paramètre d’entrée avec une propriété du composant parent.
  • Du composant enfant vers le parent:
    • En utilisant le décorateur @ViewChild() pour accéder à l’instance du composant enfant dans la classe du composant parent.
    • En utilisant le décorateur @Output() pour émettre un évènement dans le composant enfant et notifier le composant parent.

On va expliciter chacune de ces interactions par la suite.

Injection du composant parent

L’interaction la plus directe entre un composant parent et un enfant est d’injecter le composant parent directement dans le composant enfant en utilisant l’injection de dépendances. Angular comporte un moteur d’injection de dépendances que l’on peut utiliser pour injecter dans un composant enfant une instance d’un de ses parents. L’injection ne se limite pas au composant parent direct, il est possible d’effectuer une injection de n’importe quel parent dans l’arbre de dépendances.

Par exemple, si l’implémentation du composant parent est:

Template
<h1>Parent component</h1> 
<app-child></app-child> 
Classe du composant
import { Component } from '@angular/core'; 

@Component({
    templateUrl: './parent.component.html' 
}) 
 
export class ParentComponent { 
    innerValue = 'Value defined in parent'; 
} 

Et si l’implémentation du composant enfant est:

Template
<h3>Child component</h3> 
Classe du composant
import { Component } from '@angular/core'; 
import { ParentComponent } from '../parent/parent.component'; 

@Component({ 
    selector: 'app-child', 
    templateUrl: './child.component.html' 
}) 
export class ChildComponent { 
    constructor(parent: ParentComponent) { 
        console.log(parent.innerValue); 
    }
} 

Dans le composant enfant, on peut ainsi accèder à l’instance de son parent.

Double dépendance = couplage fort

Même si l’injection du composant parent est une solution facile à implémenter, d’un point de vue de l’architecture, elle introduit une double dépendance entre le composant parent et le composant enfant puisque:

  • Le composant parent référence le composant enfant dans l’implémentation du template: c’est le sens conventionnel de dépendance puisque le template doit indiquer quel est le composant enfant à afficher.
  • Le composant enfant référence le composant parent avec:
    import { ParentComponent } from '../parent/parent.component'; 
    

Il y a, ainsi, une double dépendance puisque le composant parent doit avoir une connaissance de son enfant et inversement. Cette double dépendance amène le composant enfant à avoir une implémentation dépendante de celle du composant parent, ce qui renforce le couplage entre ces 2 composants.

Pour éviter cette double dépendance, il est préférable d’exposer des propriétés dans le composant enfant par l’intermédiaire des décorateurs @Input() et @Output() de façon à ce que la dépendance se fasse dans un seul sens, du parent vers l’enfant. Ainsi seul le composant parent a une connaissance du composant enfant. L’implémentation du composant enfant reste, alors, indépendante de celle du composant parent.

Exposer le composant parent avec une classe abstraite

Une méthode pour éviter la double dépendance entre le composant parent et le composant enfant est de passer par une classe abstraite pour limiter les éléments exposés par le composant parent au composant enfant.

Ainsi:

  • La classe abstraite définit les propriétés ou fonctions utilisables par le composant enfant,
  • Le composant parent implémente la classe abstraite et
  • L’instance du composant parent est injectée dans le composant enfant sous la forme de la classe abstraite. Le couplage entre le composant parent et enfant est, ainsi, davantage limité.

Par exemple, si on considère la classe abstraite suivante implémentée dans un fichier séparée ValueHandler.ts:

export abstract class ValueHandler { 
    abstract get innerValue(): string; 
} 

Pour injecter ValueHandler dans le composant enfant plutôt que ParentComponent, on configure l’injecteur de cette façon au niveau du composant parent:

  • Avec l’option provide pour indiquer le type de la classe abstraite
  • L’option useExisting pour indiquer une instance particulière de ParentComponent et
  • forwardRef() pour faire suivre la référence vers le type du composant parent.

L’implémentation du composant parent devient:

Template
<h1>Parent component</h1> 
<app-child></app-child> 
Classe du composant
import { Component, forwardRef } from '@angular/core'; 
import { ValueHandler } from '../ValueHandler'; 

@Component({ 
    templateUrl: './parent.component.html', 
    providers: [{ provide: ValueHandler, useExisting: forwardRef(() => ParentComponent) }]
}) 
export class ParentComponent extends ValueHandler { 
    get innerValue(): string { 
        return 'Value defined in Parent' 
    } 

    constructor() { 
        super(); 
    } 
} 

L’implémentation du composant enfant devient:

Template
<h3>Child component</h3> 
Classe du composant
import { Component } from '@angular/core'; 
import { ValueHandler } from '../ValueHandler'; 

@Component({ 
    selector: 'app-child', 
    templateUrl: './child.component.html' 
}) 
export class ChildComponent { 
    constructor(valueHandler: ValueHandler) { 
        console.log(valueHandler.innerValue); 
    }
} 

Dans le composant enfant, la référence vers le composant parent a disparu.

On ne peut pas utiliser d’interfaces

Il n’est pas possible d’utiliser une interface à la place de la classe abstraite pour exposer le composant parent.

Trouver un parent sur plusieurs niveaux

L’injection du composant parent dans le composant enfant n’est pas limitée au parent direct. Le parent peut se trouver à plusieurs niveaux du composant enfant.

Par exemple, si on considère 3 composants tels que:

  • ChildComponent est un composant enfant de MiddleComponent et
  • MiddleComponent est un composant enfant de ParentComponent.

Les implémentations des 3 composants sont:

  • Pour ParentComponent:
    Template
    <h1>Parent component</h1> 
    <app-middle></app-middle> 
    Classe du composant
    import { Component } from '@angular/core'; 
    
    @Component({ 
        templateUrl: './parent.component.html' 
    }) 
    export class ParentComponent { 
        innerValue = 'Value defined in parent'; 
    } 
  • Pour MiddleComponent:
    Template
    <h2>Middle component</h2> 
    <app-child></app-child> 
    Classe du composant
    import { Component } from '@angular/core'; 
    
    @Component({ 
        selector: 'app-middle', 
        templateUrl: './middle.component.html' 
    }) 
    export class MiddleComponent {} 
  • Et pour ChildComponent:
    Template
    <h3>Child component</h3> 
    Classe du composant
    import { Component} from '@angular/core'; 
    import { ParentComponent } from '../parent/parent.component'; 
    
    @Component({ 
        selector: 'app-child', 
        templateUrl: './child.component.html' 
    }) 
    export class ChildComponent { 
        constructor(parent: ParentComponent) { 
            console.log(parent.innerValue); 
        } 
    } 

L’injection réussit même si ParentComponent n’est pas le parent direct de ChildComponent.

@Optional()

On peut utiliser le décorateur @Optional() au niveau du paramètre du constructeur du composant enfant pour indiquer au moteur d’injection de dépendances de fournir null si la dépendance n’est pas trouvée.

Par exemple:

import { Component, Optional } from '@angular/core'; 

@Component({ 
    selector: 'app-child', 
    templateUrl: './child.component.html' 
}) 
export class ChildComponent { 
    constructor(@Optional() parent: ParentComponent) { 
        console.log(parent.innerValue); 
    } 
} 
Paramètre optionel

On peut rajouter ? dans le paramètre parent du constructeur pour indiquer que le paramètre est optionel:

constructor(@Optional() parent?: ParentComponent) { 
    console.log(parent.innerValue); 
} 

On peut, ainsi, appelé le constructeur de ChildComponent sans préciser de paramètre:

let child = new ChildComponent(); 

@SkipSelf()

Dans le cas où un composant configure l’injecteur de dépendances en faisant suivre la référence d’un type vers lui-même avec forwardRef() et qu’il injecte ce même type, @SkipSelf() permet d’éviter que l’instance injectée soit celle de ce composant. @SkipSelf() force le moteur d’injection de dépendances à aller chercher une autre instance à injecter.

Par exemple, si on considère 2 composants dans lesquels on fait suivre la référence d’un type vers eux-même avec forwardRef():

  • ParentComponent est le composant parent et
  • ChildComponent est le composant enfant de ParentComponent.

Les implémentations sont:

  • Pour ParentComponent:
    Template
    <h1>Parent component</h1> 
    <app-child></app-child> 
    Classe du composant
    import { Component, forwardRef } from '@angular/core'; 
    import { ValueHandler } from '../ValueHandler'; 
    
    @Component({ 
        templateUrl: './parent.component.html', 
        providers: [{ provide: ValueHandler, useExisting: forwardRef(() => ParentComponent) }] 
    }) 
    export class ParentComponent extends ValueHandler { 
        get innerValue(): string { 
            return 'Value defined in Parent'; 
        } 
    
        constructor() { 
            super(); 
        } 
    } 
  • Pour ChildComponent:
    Template
    <h3>Child component</h3> 
    Classe du composant
    import { Component, forwardRef } from '@angular/core'; 
    import { ValueHandler } from '../ValueHandler'; 
    
    @Component({ 
        selector: 'app-child', 
        templateUrl: './child.component.html', 
        providers: [{ provide: ValueHandler, useExisting: forwardRef(() => ChildComponent) }]
    }) 
    export class ChildComponent extends ValueHandler { 
        get innerValue(): string { 
            return 'Value defined in Child'; 
        } 
    
        constructor(valueHandler: ValueHandler) { 
            super(); 
            console.log(valueHandler.innerValue); 
        } 
    } 

L’implémentation de ValueHandler est la même que précédemment:

export abstract class ValueHandler { 
    abstract get innerValue(): string; 
} 

Si on essaie d’exécuter ce code, une erreur apparaîtra dans la console du browser indiquant qu’une référence circulaire existe dans le composant ChildComponent:

ERROR error: "Uncaught (in promise): Error: Circular dep for ChildComponent 
[...]" 

Cette erreur survient car l’injecteur de dépendance essaie d’injecter ChildComponent dans lui-même pour le paramètre valueHandler à cause de la configuration:

providers: [{ provide: ValueHandler, useExisting: forwardRef(() => ChildComponent) }] 

Pour injecter dans le paramètre valueHandler une instance de ParentComponent au lieu de ChildComponent, il faut utiliser le décorateur @SkipSelf() dans le constructeur de ChildComponent:

import { Component, SkipSelf } from '@angular/core'; 
import { ValueHandler } from '../ValueHandler'; 

@Component({ 
    selector: 'app-child', 
    templateUrl: './child.component.html', 
    providers: [{ provide: ValueHandler, useExisting: forwardRef(() => ChildComponent) }] 
}) 
export class ChildComponent extends ValueHandler { 
    get innerValue(): string { 
        return 'Value defined in Child 
    } 

    constructor(@SkipSelf() valueHandler: ValueHandler) { 
        super(); 
        console.log(valueHandler.innerValue); 
    } 
} 

A l’exécution, le résultat affiché dans la console du browser sera 'Value defined in Parent' puisque l’instance injectée pour le paramètre valueHandler est celle de ParentComponent.

@ViewChild() et @ViewChildren()

Une autre méthode d’interaction entre un composant parent et un composant enfant est d’utiliser les décorateurs @ViewChild() ou @ViewChildren(). Dans la classe du composant parent, ils permettent d’effectuer une requête sur la vue pour récupérer l’instance du ou des composants enfant qui y sont déclarées.

Ces décorateurs seront abordés plus en détails ultérieurement dans un article consacré aux requêtes effectuées dans la vue d’un composant.

@Input()

Le décorateur @Input() permet d’implémenter des paramètres d’entrée dans le composant enfant qu’on pourra initialiser à partir du composant parent.

Par exemple, pour implémenter le paramètre identifier dans le composant enfant, on modifie l’implémentation de la façon suivante:

Template
<p>Child component with identifier: {{identifier}}</p>
Classe du composant
import { Component, Input } from '@angular/core';

@Component({
    selector: 'app-child',
    templateUrl: './child.component.html'
})
export class ChildComponent {
    @Input() identifier: number;
}

On peut injecter le paramètre identifier à partir du composant parent en modifiant l’implémentation du template:

<h1>Parent component</h1>
<app-child identifier='1'></app-child>

Ainsi la valeur du paramètre identifier peut être initialisée à partir du composant parent.

Pour afficher plusieurs fois le composant enfant avec un identifiant différent:

<h1>Parent component</h1>
<app-child identifier='1'></app-child>
<app-child identifier='2'></app-child>

L’affichage devient:

Property binding

Pour effectuer un property binding entre une propriété de la classe du composant parent et le paramètre d’entrée du composant enfant, il suffit de modifier l’implémentation du parent:

Template
<h1>Parent component</h1>
<app-child [identifier]='childIdentifier'></app-child>
Classe du composant
import { Component } from '@angular/core';

@Component({
    templateUrl: './parent.component.html'
})
export class ParentComponent {
    childIdentifier = 1;
}

Ainsi:

  • On rajoute la propriété childIdentifier dans le classe du composant ParentComponent et
  • On effectue un property binding dans le template avec le paramètre d’entrée identifier du composant enfant: [identifier]='childIdentifier'.

Utiliser un alias pour identifier le paramètre injecté

On peut utiliser un alias pour identifier le paramètre injecté dans le template du composant parent. Il suffit d’indiquer l’alias en tant que paramètre du décorateur @Input('') dans le composant enfant.

Par exemple, si on utilise 'childComponentIdentifier' à la place de 'identifier' pour désigner le paramètre injecté dans le composant enfant, l’implémentation du composant enfant devient:

Template
<p>Child component with identifier: {{identifier}}</p>
Classe du composant
import { Component, Input } from '@angular/core';

@Component({
    selector: 'app-child',
    templateUrl: './child.component.html'
})
export class ChildComponent {
    @Input('childComponentIdentifier') 
    identifier: number;
}

On utilise l’alias dans le template du composant parent (dans parent.component.ts):

<h1>Parent component</h1>
<app-child childComponentIdentifier='1'></app-child>
<app-child childComponentIdentifier='2'></app-child>

Ce changement d’implémentation ne modifie pas le comportement.

@Output() + EventEmitter

@Input() permet d’effectuer un binding de paramètres du composant parent vers le composant enfant. A l’opposé, on peut notifier le composant parent à partir d’évènements survenus dans le composant enfant en utilisant l’event binding. Il n’est pas possible d’effectuer le binding d’une propriété du composant enfant vers le composant parent toutefois, l’event binding permet de déclencher l’exécution d’une méthode à chaque modification de cette propriété.

Dans un composant enfant, pour déclarer un évènement auquel pourra s’abonner le composant parent, il faut utiliser le décorateur @Output().

Par exemple, on modifie l’implémentation du composant enfant ChildComponent:

  • Dans le template:
    • on ajoute un élément input contenant une valeur qu’on pourra incrémenter. La valeur dans l’input sera bindée avec la propriété internalCount dans la classe du composant enfant.
    • On ajoute un bouton pour incrémenter la valeur dans l’élément input.
    • On ajoute la méthode incrementValue() qui permettra d’incrémenter la valeur de internalCount.
  • Dans la classe du composant enfant (child.component.ts): on ajoute la propriété internalCount et on déclare l’évènement countUpdated avec la décorateur @Output() pour notifier le composant parent quand le valeur de la propriété internalCount est modifiée.

L’implémentation du composant enfant devient:

  • Template:
    <p>Child component with identifier: {{identifier}}</p>
    <p>
        <input readonly='true' [(ngModel)]='internalCount' />
        <button (click)='incrementValue()'>Increment</button>
    </p>
    
  • Classe du composant:
    import { Component, Input, Output, EventEmitter } from '@angular/core';
    
    @Component({
        selector: 'app-child',
        templateUrl: './child.component.html'
    })
    export class ChildComponent {
        internalCount = 0;
    
        @Input('childComponentIdentifier') identifier: number;
    
        @Ouput() countUpdated: EventEmitter<number>= new EventEmitter<number>();
    
        incrementValue(): void {
            this.internalCount++;
            this.countUpdated.emit(this.identifier);
        }
    }

Dans le code du composant:

  • countUpdated: EventEmitter<number> = new EventEmitter<number>() permet d’initialiser un objet de type EventEmitter qui permettra de notifier le composant parent.
  • Dans incrementValue(), this.countUpdated.emit(this.internalIdentifier) déclenche l’évènement en incluant l’identifiant du composant enfant.

On modifie le composant parent:

  • Dans le template:
    • On effectue un event binding avec l’évènement countUpdated du composant enfant.
    • On ajoute un total des incrémentations des compteurs des composants enfant.
  • Dans la classe du composant: on implémente la méthode updateTotalCount() qui sera appelée à chaque déclenchement de l’évènement.

L’implémentation du composant parent devient:

  • Template:
    <h1>Parent component</h1>
    <app-child childComponentIdentifier='1' (countUpdated)='updateTotalCount($event)'></app-child>
    <app-child childComponentIdentifier='2' (countUpdated)='updateTotalCount($event)'></app-child>
    <p>Total: {{totalCount}}</p>
    
  • Classe du composant:
    import { Component } from '@angular/core';
    
    @Component({
        templateUrl: './parent.component.html'
    })
    export class AppComponent {
        totalCount = 0;
        lastUpdateIdentifier: number;
    
        updateTotalCount(childIdentifier: number): void {
            this.totalCount++;
            this.lastUpdateIdentifier = childIdentifier;
        }
    }

Ainsi, l’event binding permet de déclencher l’exécution de la méthode updateTotalCount() pour incrémenter totalCount et récupérer l’identifiant du dernier composant enfant pour lequel une incrémentation a été effectuée.

Le résultat devient:

Utiliser un alias pour identifier l’évènement

Comme pour le décorateur @Input(), on peut utiliser un alias pour identifier l’évènement auquel on s’abonne du coté du composant parent. Il suffit d’indiquer l’alias en tant que paramètre du décorateur @Output('') dans le composant enfant.

Par exemple, si on utilise 'childCountUpdated' à la place de 'countUpdated' pour désigner l’évènement déclaré dans le composant enfant, l’implémentation du composant enfant ChildComponent (dans child.component.ts) devient:

import { Component, Input, Output } from '@angular/core';

@Component({
    selector: 'app-child',
    templateUrl: './child.component.html'
})
export class ChildComponent {
    internalCount = 0;

    @Input('childComponentIdentifier') identifier: number;

    @Ouput('childCountUpdated') countUpdated: EventEmitter = new EventEmitter();

    incrementValue(): void {
        this.internalCount++;
        this.countUpdated.emit(this.internalIdentifier);
    }
}

Le template du composant enfant n’est pas modifié.

On utilise l’alias dans le template du composant parent (dans parent.component.ts):

<h1>Parent component</h1>
<app-child childComponentIdentifier='1' (childCountUpdated)='updateTotalCount($event)'></app-child>
<app-child childComponentIdentifier='2'  (childCountUpdated)='updateTotalCount($event)'></app-child>
<p>Total: {{totalCount}}</p>

Ce changement d’implémentation ne modifie pas le comportement.

Paramètres inputs et outputs de @Component()

Au lieu d’utiliser les décorateurs @Input() et @Output(), on peut utiliser les paramètres inputs et outputs du décorateur @Component(). A l’origine, ces paramètres sont utilisables dans le décorateur @Directive(), par suite le décorateur @Component() en hérite puisque les composants sont des directives. Pour plus de détails, voir le détail de ces paramètres pour les directives:

Content projection

La fonctionnalité content projection (i.e. projection de contenu) permet de réserver dans un composant enfant des espaces dont le contenu sera implémenté dans le template du composant parent. Le contenu est, ainsi, projeté du composant parent vers le composant enfant:

L’intérêt de cette fonctionnalité est de prévoir un composant enfant dont l’implémentation est générique pour permettre d’indiquer des éléments de la vue à partir du composant parent.

Pour indiquer dans le template du composant enfant où doit être placé le contenu projeté, il faut utiliser <ng-content></ng-content>.

Par exemple, si on considère le composant parent suivant:

Template
<h1>Parent component</h1>
<app-child identifier='1'></app-child>
Classe du composant
import { Component } from '@angular/core';

@Component({
    templateUrl: './parent.component.html',
    styleUrls: ['./parent.component.css']
})
export class ParentComponent {}

Dans cet exemple, on utilise les mêmes styles CSS que ceux définis plus haut pour avoir un affichage plus clair.

On considère le composant enfant ChildComponent suivant:

Template
<h3>Child component {{identifier}}</h3>
Classe du composant
import { Component } from '@angular/core';

@Component({
    templateUrl: './child.component.html',
    styleUrls: ['./child.component.css']
})
export class ChildComponent {
    @Input() identifier: number;
}

Si on exécute l’application, l’affichage est:

On modifie le composant enfant ChildComponent pour permettre la projection de contenu. Il suffit de rajouter ng-content à l’endroit où on souhaite la projection dans le template:

<h3>Child component</h3>
<ng-content></ng-content>

On modifie ensuite le template du composant parent en indiquant une implémentation du contenu à projeter:

<h1>Parent component</h1>
<app-child identifier='1'>
    <span>Content projection from parent component</span>
</app-child>

En exécutant l’application, l’affichage est:

De façon très facultative, on peut modifier le style CSS des composants pour que l’affichage puisse mettre en évidence le contenu projeté provenant du composant parent:

  • Dans child.component.css:
    :host {
        border: 1px solid black;
        display: block;
        background-color: lightgreen;
        margin: 5px;
        padding: 5px;
    }
    
  • Dans parent.component.css:
    :host {
        border: 1px solid black;
        display: block;
        background-color: lightgrey;
        width: 500px;
    }
    
    span {
        border: 1px solid black;
        background-color: lightgrey;
    }
    

L’affichage devient:

On peut choisir un contenu projeté différent directement à partir du template du composant parent:

<h1>Parent component</h1>
<app-child identifier='1'>
    <span>Content projection from parent component</span>
</app-child>
<app-child identifier='2'>
    <span>Different content projection</span>
</app-child>

L’affichage correspondant est:

Projection de contenu sur plusieurs niveaux

Dans tous les exemples précédents, on exploitait la projection de contenu sur un seul niveau c’est-à-dire la projection du contenu d’un parent vers un seul composant enfant.

Il est possible d’effectuer des projections vers des composants enfant se trouvant à plusieurs niveaux de hiérarchie du composant parent.
Par exemple, si on considère 3 composants ParentComponent, MiddleComponent et ChildComponent tels que:

  • MiddleComponent est un composant enfant de ParentComponent et
  • ChildComponent est un composant enfant de MiddleComponent.

A partir de ParentComponent, il est possible de projeter un contenu dans MiddleComponent (se trouvant au 1er niveau de ParentComponent) et dans ChildComponent (se trouvant au 2e niveau de ParentComponent).

Au niveau de la syntaxe, si:

  • Le selector de MiddleComponent est 'app-middle' et
  • Le selector de ChildComponent est 'app-child'.

Le contenu à projeter peut être implémenté de cette façon dans le template du composant ParentComponent:

<app-middle>
    // Ce contenu sera projeté dans MiddleComponent au 1er niveau
    <app-child>
        // Ce contenu sera projeté dans ChildComponent au 2e niveau
        <p>Projected content</p>
    </app-child>
</app-middle>

L’implémentation complète des composants est:

  • Pour ChildComponent:
    Template
    <h3>Child component</h3>
    <ng-content></ng-content>
    Classe du composant
    @Component({
        selector: 'app-child',
        templateUrl: './child.component.html'
    })
    export class ChildComponent {}
  • Pour MiddleComponent:
    Template
    <h2>Middle component</h2>
    <ng-content></ng-content>
    Classe du composant
    @Component({
        selector: 'app-middle',
        templateUrl: './middle.component.html'
    })
    export class MiddleComponent {}
  • Et pour ParentComponent:
    Template
    <h1>Parent component</h1>
    <app-middle>
        <app-child>
            <span>Projected content</span>
        </app-child>
    </app-middle>
    Classe du composant
    @Component({
        templateUrl: './parent.component.html'
    })
    export class ParentComponent {}

Le résultat permet de se rendre compte que le contenu est projeté sur tous les composants enfant sur plusieurs niveaux:

Multi-content projection

Il est possible d’effectuer des projections de plusieurs contenus dans le composant enfant en utilisant la syntaxe suivante quand on utilise <ng-content>:

<ng-content select='<type de l'élément à projeter>'></ng-content>

Par exemple, si on modifie le template du composant enfant ChildComponent pour autoriser plusieurs contenus à projeter:

<h3>Child component</h3>
<ng-content select='h4'></ng-content>
<ng-content select='span'></ng-content>

On implémente, ensuite, 2 projections dans le template du composant parent:

<h1>Parent component</h1>
<app-child identifier='1'>
     <span>Content projection from parent component</span>
     <h4>The content is:</h4>
</app-child>

Ainsi:

  • Le contenu de <span></span> est projeté dans la partie <ng-content select='span'></ng-content> du template de ChildComponent.
  • Le contenu de <h4></h4> est projeté dans la partie <ng-content select='h4'></ng-content> du template de ChildComponent.

L’affichage correspondant est:

Si on utilise plusieurs indications <ng-content> mais qu’une de ces indications ne possède pas d’attribut select, alors le contenu projeté sera le contenu qui ne correspond à aucune des autres clauses select.

Par exemple, si on considère l’implémentation suivante dans le template du composant enfant:

<h3>Child component</h3>
<ng-content select='h4'></ng-content>
<ng-content></ng-content>
<ng-content select='span'></ng-content>

On modifie l’implémentation dans le template du composant parent:

<h1>Parent component</h1>
<app-child [identifier]='1'>
     <span>Input value is: {{inputElement.value}}</span>
     <h4>The content is:</h4>
     <p><input #inputElement ngModel /></p>
</app-child>

Ainsi:

  • Le contenu de <span></span> est projeté dans la partie <ng-content select='span'></ng-content>.
  • Le contenu de <h4></h4> est projeté dans la partie <ng-content select='h4'></ng-content>.
  • Le contenu de <p></p> est projeté dans <ng-content></ng-content> car il ne correspond à aucunes autres clauses select.

L’affichage correspondant est:

Paramètre providers vs viewProviders de @Component()

La paramètre providers du décorateur @Component() est utilisé dans le cadre de l’injection de dépendances pour configurer les injecteurs au niveau du composant. Ce paramètre peut être utilisé pour les décorateurs:

Ainsi, si on utilise le paramètre providers dans le décorateur @Component(), l’instance injectée sera la même pour le composant et pour tous ses composants enfant.

Contenu projeté vs contenu non projeté

Avant d’expliquer le paramètre viewProviders, il faut distinguer 2 types de contenu présent dans la vue d’un composant:

  • Le contenu non projeté: il contient les éléments implémentés dans la vue de ce composant:
    • Directement si ces éléments sont implémentés dans le template ou
    • Indirectement si ces éléments sont implémentés dans un composant enfant ou une directive.
  • Le contenu projeté: il contient des éléments provenant d’un autre composant et qui sont projetés avec <ng-content></ng-content>.

Paramètre viewProviders de @Component()

A l’opposé du paramètre providers, le paramètre viewProviders va configurer les injecteurs pour que l’injection soit limitée exclusivement aux composants se trouvant dans le contenu non projeté d’une vue d’un composant.

L’intérêt de viewProviders par rapport au paramètre providers est de limiter l’injection d’une classe dans un cadre plus restreint. Par exemple dans le cas d’une bibliothèque à l’intérieur de laquelle on souhaite effectuer l’injection d’une classe. Les injections ne seront limitées aux composants ayant un contenu non projeté c’est-à-dire les contenus se trouvant à l’intérieur de la bibliothèque. Les contenus projetés provenant de l’extérieur de la bibliothèque ne seront pas concernés par ces injections.

Par exemple, si on considère 3 composants:

  • CallerComponent qui sera le composant parent se trouvant à l’extérieur de la bibliothèque.
  • LibraryComponent qui sera le composant se trouvant à l’intérieur de la bibliothèque.
  • ProjectedComponent qui sera le composant qui sera injecté dans la bibliothèque.

On va effectuer une projection de contenu dans LibraryComponent et ProjectedComponent à partir de CallerComponent sur plusieurs niveaux. On va utiliser successivement le paramètre providers et viewProviders de façon à voir les différences.

On considère la classe suivante qui sera injectée dans le composant:

export class Dependency {
    message: string;

    constructor(caller: string) {
        this.message = 'Instance from: ' + caller;
    }
}

Voici le code correspondant aux 3 composants:

  • Pour ProjectedComponent:
    Template
    <p><b>ProjectedComponent</b></p>
    <p>{{dependency.message}}</p>
    <ng-content></ng-content>
    Classe du composant
    import { Component } from '@angular/core';
    import { Dependency } from '../dependency';
    
    @Component({
      selector: 'app-projected',
      templateUrl: './projected.component.html',
      styleUrls: ['./projected.component.css']
    })
    export class ProjectedComponent {
      constructor(public dependency: Dependency) {}
    }
  • Pour LibraryComponent:
    Template
    <p><b>LibraryComponent</b></p>
    <p>{{dependency.message}}</p>
    
    Projected content from outside:
    <ng-content></ng-content>
    Classe du composant
    import { Component } from '@angular/core';
    import { Dependency } from '../dependency';
    
    const dependency = new Dependency('library component');
    
    @Component({
      selector: 'lib-library',
      templateUrl: './library.component.html',
      styleUrls: ['./library.component.css']
    })
    export class LibraryComponent {
      constructor(public dependency: Dependency) {}
    }
  • Et pour CallerComponent:
    Template
    <p><b>CallerComponent</b></p>
    <p>{{dependency.message}}</p>
    <lib-library>
        <app-projected>
            <span>Projected content</span>
        </app-projected>
    </lib-library>
    Classe du composant
    import { Component } from '@angular/core';
    import { Dependency } from '../dependency';
    
    @Component({
      templateUrl: './caller.component.html',
      styleUrls: ['./caller.component.css']
    })
    export class CallerComponent {
      constructor(public dependency: Dependency) {}
    }

De façon très facultative, on modifie les fichiers de style des composants CallerComponent et LibraryComponent pour que l’affichage permette de faire la différence entre les différents composants:

  • Dans le fichier CSS de CallerComponent (dans caller.component.css):
    :host {
        border: 1px solid black;
        display: block;
        background-color: lightgrey;
        width: 500px;
    }
    
    app-projected {
        border: 2px dashed black;
        display: block;
        background-color: lightgreen;
        margin: 5px;
        padding: 5px;
    }
    
    span {
        border: 2px dashed black;
        background-color: white;
    }
    
  • Dans le fichier CSS de LibraryComponent (dans library.component.css):
    :host {
        border: 1px solid black;
        display: block;
        background-color:  #bb8fce;
        margin: 5px;
        padding: 5px;
    }
    
    app-projected {
        border: 1px solid black;
        display: block;
        background-color: lightblue;
        margin: 5px;
        padding: 5px;
    }
    
    span {
        border: 2px dashed black;
        background-color: white;
    }
    

Paramètre providers au niveau du module uniquement

Si on place le paramètre providers au niveau du module dans lequel se trouve tous les composants, la même instance sera injectée et utilisée dans tous les composants:

Le code du module devient:

import { Dependency } from '../Dependency';

const dependency = new Dependency('module');

@NgModule({
    ...
    providers: [ { provide: Dependency, useValue: dependency} ]
})

Le paramètre useValue permet de configurer les injecteurs pour injecter une instance précise de Dependency.

A l’exécution, le résultat permet de montrer que l’instance provient du module:

Paramètre providers au niveau du composant

Si on ajoute le paramètre providers au niveau du composant LibraryComponent, l’instance utilisée sera:

  • Dans CallerComponent, l’instance provenant du module.
  • Dans LibraryComponent, l’instance provenant dans LibraryComponent et
  • Dans ProjectedComponent, l’instance provenant de LibraryComponent puisque ProjectedComponent est projeté dans LibraryComponent.

On modifie LibraryComponent pour instancier une nouvelle classe Dependency et ajouter le paramètre providers à ce niveau:

Template
<p><b>LibraryComponent</b></p>
<p>{{dependency.message}}</p>

Projected content from outside:
<ng-content></ng-content>
Classe du composant
import { Component } from '@angular/core';
import { Dependency } from '../dependency';

const dependency = new Dependency('library component');

@Component({
  selector: 'lib-library',
  templateUrl: './library.component.html',
  styleUrls: ['./library.component.css'],
  providers: [ { provide: Dependency, useValue: dependency} ]
})
export class LibraryComponent {
  constructor(public dependency: Dependency) {}
}

Le résultat permet de montrer que l’instance Dependency utilisée dans LibraryComponent et ProjectedComponent est celle instanciée dans LibraryComponent:

Paramètre viewProviders au niveau de LibraryComponent

Si on modifie LibraryComponent pour utiliser viewProviders au lieu de providers, l’instance injectée sera:

  • Dans CallerComponent, l’instance provenant du module (pas de changement).
  • Dans LibraryComponent, l’instance provenant dans LibraryComponent (pas de changement) et
  • Dans ProjectedComponent, l’instance provenant du module car viewProviders permet de limiter l’injection au contenu non projeté. ProjectedComponent étant projeté dans LibraryComponent, l’instance de Dependency injectée sera celle du module.

On modifie LibraryComponent pour utiliser viewProviders au lieu de providers:

Template
<p><b>LibraryComponent</b></p>
<p>{{dependency.message}}</p>

Projected content from outside:
<ng-content></ng-content>
Classe du composant
import { Component } from '@angular/core';
import { Dependency } from '../dependency';

const dependency = new Dependency('library component');

@Component({
  selector: 'lib-library',
  templateUrl: './library.component.html',
  styleUrls: ['./library.component.css'],
  viewProviders: [ { provide: Dependency, useValue: dependency} ]
})
export class LibraryComponent {
  constructor(public dependency: Dependency) {}
}

Le résultat permet de montrer que l’instance Dependency utilisée dans ProjectedComponent est celle instanciée dans le module c’est-à-dire à l’extérieur de la bibliothèque:

Ajout d’un contenu directement dans LibraryComponent

On va rajouter le composant enfant ProjectedComponent directement dans LibraryComponent. Cette instance de ProjectedComponent ne sera pas projetée mais fera partie directement du contenu de LibraryComponent. Cet exemple permet de montrer que les instances de Dependency seront différentes dans le cas où ProjectedComponent fait partie du contenu projeté ou non projeté. Ainsi:

  • Dans l’instance de ProjectedComponent qui fait partie du contenu projeté, l’instance injectée de Dependency est celle du module à cause du paramètre viewProviders au niveau de LibraryComponent.
  • Dans l’instance de ProjectedComponent qui fait partie du contenu non projeté, l’instance injectée de Dependency est celle de LibraryComponent.

On modifie seulement le fichier template de LibraryComponent pour ajouter ProjectedComponent en tant composant enfant:

Template
<p><b>LibraryComponent</b></p>
<p>{{dependency.message}}</p>

Projected content from outside:
<ng-content></ng-content>

Child:
<app-projected>
    <span>Projected content from LibraryComponent</span>
</app-projected>
Classe du composant
import { Component } from '@angular/core';
import { Dependency } from '../dependency';

const dependency = new Dependency('library component');

@Component({
  selector: 'lib-library',
  templateUrl: './library.component.html',
  styleUrls: ['./library.component.css'],
  viewProviders: 
    [ { provide: Dependency, useValue: dependency} ]
})
export class LibraryComponent {
  constructor(public dependency: Dependency) {}
}

Dans le résultat, on peut voir que la 2e instance de ProjectedComponent faisant partie du contenu non projeté de LibraryComponent comporte une instance de Dependency provenant de LibraryComponent. Le reste des injections n’est pas modifié:

Ordre d’exécution des callbacks du cycle de vie d’un composant

Lors de la création et du rendu de la vue d’un composant, certaines callbacks peuvent être déclenchées de façon successive dans la classe du composant dans le cas où elles sont implémentées. Dans la documentation Angular ces callbacks sont appelées “lifecycle hooks”.

Pour rappel, à l’initialisation d’un composant, les callbacks de ce cycle de vie (i.e. Lifecycle hooks) sont, dans l’ordre de déclenchement:

  1. ngOnChanges(): cette callback est exécutée si le composant contient des propriétés en entrée (notamment avec le décorateur @Input()).
  2. ngOnInit(): déclenchée après l’exécution du constructeur. Il permet d’initialiser le composant avec le 1er affichage des données de la vue ayant un binding avec des propriétés de la classe du composant. Le cas échéant, il permet d’affecter les paramètres en entrée du composant. Cette callback est déclenchée une seule fois à l’initialisation du composant même si ngOnChanges() n’est pas déclenchée.
  3. ngDoCheck() permet d’indiquer des changements si Angular ne les a pas détecté.
  4. ngAfterContentInit() est déclenchée à l’initialisation après la projection de contenu. Elle est déclenchée même s’il n’y a pas de contenu à projeter.
  5. ngAfterContentChecked(): déclenchée après la détection de changement dans le contenu projeté. Cette callback est déclenchée même s’il n’y a pas de projection de contenu.
  6. ngAfterViewInit(): déclenchée après l’initialisation de la vue du composant et après l’initialisation de la vue des composants enfant.
  7. ngAfterViewChecked() est déclenchée après détection d’un changement dans la vue du composant et dans la vue des composants enfant.
  8. ngOnDestroy() est déclenchée avant la destruction du composant.

A chaque détection de changements, les callbacks déclanchées sont, dans l’ordre:

  1. ngOnChanges() si les paramètres en entrée du composant sont modifiés.
  2. ngDoCheck()
  3. ngAfterContentChecked() est déclenchée même s’il n’y a pas de contenu projeté.
  4. ngAfterViewChecked().

Ainsi quand le composant comporte des composants enfant, l’ordre d’exécution est le suivant à l’initialisation:

Exécution du constructeur du composant parent
Exécution du constructeur du composant enfant
Exécution des callbacks du composant parent jusqu’à ngAfterContentChecked()
  1. ParentComponent.ngOnChanges()
  2. ParentComponent.ngOnInit()
  3. ParentComponent.ngDoCheck()
  4. ParentComponent.ngAfterContentInit()
  5. ParentComponent.ngAfterContentChecked()
Exécution des callbacks du composant enfant y compris ngAfterViewInit() et ngAfterViewChecked()
  1. ChildComponent.ngOnChanges()
  2. ChildComponent.ngOnInit()
  3. ChildComponent.ngDoCheck()
  4. ChildComponent.ngAfterContentInit()
  5. ChildComponent.ngAfterContentChecked()
  6. ChildComponent.ngAfterViewInit()
  7. ChildComponent.ngAfterViewChecked()
Exécution des callbacks restantes du parent
  1. ParentComponent.ngAfterViewInit()
  2. ParentComponent.ngAfterViewChecked().

Lors de la détection d’un changement, les callbacks sont exécutées dans cet ordre:

Exécution des callbacks du composant parent sauf ngAfterViewChecked()
  1. ParentComponent.ngOnChanges()
  2. ParentComponent.ngDoCheck()
  3. ParentComponent.ngAfterContentChecked()
Exécution des callbacks du composant enfant
  1. ChildComponent.ngOnChanges()
  2. ChildComponent.ngDoCheck()
  3. ChildComponent.ngAfterContentChecked()
  4. ChildComponent.ngAfterViewChecked()
Exécution de ParentComponent.ngAfterViewChecked()

Pour résumer…

Pour imbriquer un composant dans un autre:

  • D’abord il faut que le composant enfant soit déclaré dans le module du composant parent pour que la résolution réussisse.
  • Ensuite le template du composant parent doit contenir le contenu du paramètre selector du composant enfant:
    Par exemple si le paramètre selector du composant est:

    @Component({
        selector: 'app-child',
        templateUrl: './child.component.html'
    })
    export class ChildComponent {}
    
  • Le template du composant doit contenir:
    <app-child></app-child>
    

Pour injecter un composant parent dans un composant enfant:
Pour injecter un des composants parent dans un composant enfant, il suffit d’indiquer un paramètre du type du parent dans le constructeur du composant enfant:

export class ChildComponent { 
    constructor(parent: ParentComponent) { 
        console.log(parent.innerValue); 
    }
}

Pour éviter un couplage trop fort entre un composant parent et un composant enfant, on peut utiliser une classe abstraite au lieu d’injecter directement le parent. Le composant parent doit satisfaire la classe abstraite et l’injecteur de dépendances doit être configuré avec l’option useExisting:

@Component({ 
    [...]
    providers: [{ provide: AbstractComponent, useExisting: forwardRef(() => ParentComponent) }]
}) 
export class ParentComponent extends AbstractComponent { 
    constructor() { 
        super(); 
    } 
} 

L’injection est effectuée dans le composant enfant avec la classe abstraite:

export class ChildComponent { 
    constructor(parent: AbstractComponent) { 
    }
}

Pour effectuer un binding avec le composant enfant:

  • Property binding:
    Le composant enfant doit déclarer les propriétés exposées en tant que paramètre d’entrée avec le décorateur @Input(), par exemple:

    @Input() identifier: number;
    

    Le composant parent peut effectuer un property binding avec la propriété du composant enfant à partir du template:

    <app-child [identifier]='1'></app-child>
    

    Avec un alias, la propriété doit être de cette façon:

    @Input('identifier_alias') identifier: number;
    

    On utilise l’alias dans le template du composant parent:

    <app-child [identifier_alias]='1'></app-child>
    
  • Event binding:
    Le composant enfant doit déclarer l’évènement à exposer avec le décorateur @Output() et EventEmitter:

    @Ouput() updated: EventEmitter<number>= new EventEmitter<number>();
    

    Le composant parent peut effectuer un event binding avec la propriété du composant enfant à partir du template:

    <app-child (updated)='triggerFunction($event)'></app-child>
    

    triggerFunction($event) est la fonction du composant parent qui sera exécutée à chaque déclenchement de l’évènement.
    Il est aussi possible d’utiliser un alias avec @Output().

Content projection
La fonctionnalité content projection permet de projeter un contenu à partir du composant parent dans un composant enfant.

Si 'app-child' est le selector du composant enfant, pour qu’un contenu soit projeté à partir du composant parent la syntaxe dans le template doit être:

<app-child>
  <!-- Contenu projeté -->
</app-child>

L’emplacement du contenu à projeter doit être indiqué dans le composant enfant avec:

<ng-content></ng-content>

Si <ng-content> est omis dans le composant enfant, il n’y aura pas d’erreur.

On peut projeter plusieurs contenus en les nommant avec l’attribut select. Par exemple:

<ng-content select='h4'></ng-content>
<ng-content></ng-content>
<ng-content select='span'></ng-content>

Si le template du composant parent est:

<app-child [identifier]='1'>
     <span>Input value is: {{inputElement.value}}</span>
     <h4>The content is:</h4>
     <p><input #inputElement ngModel /></p>
</app-child>

Alors:

  • Le contenu de <span></span> est projeté dans la partie <ng-content select='span'></ng-content>.
  • Le contenu de <h4></h4> est projeté dans la partie <ng-content select='h4'></ng-content>.
  • Le contenu de <p></p> est projeté dans <ng-content></ng-content> car il ne correspond à aucuns autres attributs select.

Paramètres providers et viewProviders de @Component()
Les paramètres providers et viewProviders du décorateur @Component() permettent de paramètrer les injecteurs pour effectuer de l’injection de dépendances:

  • providers configure les injecteurs d’un composant pour que la même instance d’un objet soit injectée dans ce composant et dans tous les composants et directives dont il est le parent.
  • viewProviders limite l’injection d’une instance d’objet aux composants et directives faisant partie du contenu non projeté d’un composant.

Lifecycle hooks
Dans le cas où un composant comporte un composant enfant, l’ordre d’exécution des lifecycle hooks est plus complexe:

  • A l’initialisation:
  • A chaque détection d’un changement:

Leave a Reply