Native ints (C# 9.0)

Cet article fait partie d’une série d’articles sur les apports fonctionnels de C# 9.0.

Cette fonctionnalité consiste à permettre d’utiliser les types “native int” et “native unsigned int” dans du code C#. Avant cette fonctionnalité, ces types n’existaient que dans le code MSIL, ils étaient générés quand on utilisait les types System.IntPtr et System.UIntPtr.
L’inconvénient est que les types IntPtr et UIntPtr ne sont pas trop très flexibles et ne permettent pas d’effectuer des opérations arithmétiques.

En préambule, on va expliquer l’intérêt des types IntPtr et UIntPtr; ensuite on indiquera l’intérêt des nouveaux types nint et nuint et la différence avec IntPtr et UIntPtr.

IntPtr et UIntPtr

Historiquement avant C# 9, IntPtr était le type privilégié pour contenir une adresse mémoire sans être dans un contexte unsafe. Dans cette partie, on va détailler l’utilisation de ce type.

Adressage de la mémoire

La mémoire est composée de blocs d’octets (i.e. bytes). Chaque octet est lui-même composé de 8 bits. Ensuite chaque octet est identifié par une adresse unique qui correspond au décalage par rapport au début de la mémoire. Le CPU identifie le bloc avec une adresse dont la taille est fixe, cette taille est appelée mot (i.e. word). Actuellement le plus souvent, la taille des mots est:

  • 4 octets pour les processeurs ou systèmes 32 bits.
  • 8 octets pour les processeurs ou systèmes 64 bits.

Pour un système 32 bits, en théorie il est possible d’adresser au maximum 232-1 = 4294967295 blocs (car la 1ère adresse commence à 0). Pour un système 64 bits, on peut adresser 264-1 blocs. 232-1 correspond à un peu près à 4 x 230 octets = 4 GiB (i.e. gibibyte) = 4 x 1073741824 octets.

Dans la pratique, dans un système Windows 32 bits, un processus ne peut pas adresser plus de 3 GiB. Dans un système Windows 64 bits, un processus 32 bits ne peut pas adresser plus de 4GiB.

Dans un système d’exploitation, il faut distinguer les adresses physiques de la mémoire utilisées par le CPU et les adresses virtuelles utilisées par les processus. Le lien entre les adresses physiques et les adresses virtuelles est effectué par le système d’exploitation.

Utilisation de IntPtr et UIntPtr

Comme on l’a vu précédemment, l’adresse d’un bloc mémoire est identifiée par un mot de 4 ou 8 octets suivant l’architecture du système sur lequel est exécuté un processus. Dans le cadre de Windows, il est possible d’exécuter des processus dont l’architecture d’exécution ne correspond pas forcément à l’architecture du système:

Architecture du système Architecture d’exécution du processus
32 bits 64 bits
32 bits OK Impossible
64 bits Possible avec WoW64(*) OK

*: Windows 32-bit on Windows 64-bit.

Ainsi au delà de l’architecture du système, suivant l’architecture d’exécution d’un processus l’adressage de la mémoire pourrait se faire avec des mots dont la taille est différente. Le type System.IntPtr permet de proposer une solution dans le code pour stocker une adresse mémoire en permettant de s’adapter à l’architecture d’exécution du processus:

  • Dans un processus 32 bits sizeof(IntPtr) est 4
  • Dans un processus 64 bits sizeof(IntPtr) est 8

Dans la pratique, System.IntPtr est un entier dont la taille est la même qu’un pointeur pour une architecture d’exécution donnée. L’intérêt de ce type est de pouvoir stocker des adresses mémoire comme des pointeurs sans forcément être dans un contexte unsafe et sans se préoccuper de la taille du pointeur qui peut varier suivant l’architecture d’exécution.

Le nom du type IntPtr peut prêter à confusion à cause du terme Ptr pour “pointer”. Il laisse penser qu’une variable de type IntPtr est un pointeur. Ce n’est pas le cas, un IntPtr correspond seulement à un entier dans lequel peut être stocké une adresse. Il n’y a pas de garantie ni de vérification que l’adresse correspond effectivement à un objet en mémoire ou à un emplacement utilisable par le processus. Ce type d’objet est similaire au type void* en C++. L’intérêt d’utiliser IntPtr est d’avoir un entier dont la taille varie suivant l’architecture d’exécution du processus.

Le plus souvent IntPtr sert dans le cadre d’appels à du code natif pour stocker des pointeurs non typés.

Par exemple si on considère une fonction native dont la signature est:

void* NativeFunctionExample(void* ptr);

Cette fonction utilise et retourne des pointeurs non typés. On peut effectuer un appel à cette fonction à partir de code C# en utilisant Platform/Invoke avec IntPtr:

[DllImport(...)]
public extern static IntPtr NativeFunctionExample(IntPtr ptr);

Ainsi les pointeurs pourront être stockés dans des variables de type IntPtr, par exemple:

int valueExample = 7;
int* valuePtr = &valueExample;
IntPtr valueIntPtr = new IntPtr(valuePtr);

Console.WriteLine(valueIntPtr.ToString("X")); // Afficher l'adresse du pointeur

IntPtr result = NativeFunctionExample(valueIntPtr);

Autre exemple:

int[] arrayValues = { 1, 2, 3, 4 };
unsafe
{
  // fixed Permet d'extraire le pointeur et empêche au 
  // Garbage Collector de déplacer l'objet dans un scope donné
  fixed (int* arrayPtr = arrayValues)
  {
    IntPtr arrayIntPtr = new IntPtr(arrayPtr);
    Console.WriteLine(arrayIntPtr.ToString("X")); // Affichage du pointeur
  }
}

Intervalle de valeur

Sachant que la taille de IntPtr s’adapte en fonction de l’architecture d’exécution du processus, sur un processus 32 bits, IntPtr est un entier sur 4 octets Int32. Ainsi l’intervalle de valeur de IntPtr est celui de System.Int32 c’est-à-dire:

  • Int32.MinValue = -2147483648
  • Int32.MaxValue = 2147483647

Ainsi une exception System.OverflowException survient si on initialise un objet IntPtr avec une valeur supérieure à Int32.MaxValue dans un processus 32 bits:

IntPtr value1 = new IntPtr((long)Int32.MaxValue); // OK 
IntPtr value2 = new IntPtr((long)Int32.MaxValue + 1L); // ⚠ ERREUR ⚠

On peut se poser la question de l’intérêt d’utiliser des valeurs négatives pour représenter des adresses mémoires. De ce point de vue System.UIntPtr serait plus adapté.

Code MSIL

Les objets IntPtr et UIntPtr sont convertis respectivement en native int et native uint dans le code MSIL. Les types MSIL native int et native uint ne sont pas directement accessibles dans le code C#. L’arithmétique mathématique et les conversions applicables aux objets Int32 sont aussi applicables aux native ints.

Avant C# 9, seuls IntPtr et UIntPtr permettent de générer des objets native int et native uint. Bien que les opérations d’arithmétiques classiques peuvent s’appliquer aux types MSIL native int et native uint, il n’est bas possible d’effectuer ces opérations avec du code C#. IntPtr n’autorise que les additions ou soustraction avec:

Par exemple quelques opérations qui sont possibles si on manipule IntPtr:

IntPtr intPtrValue1 = new IntPtr(5);
// Utilisation de IntPtr.Add()
IntPtr result = IntPtr.Add(4); // OK

// Addition impossible
IntPtr offset = new IntPtr(4);
IntPtr addResult = intPtrValue1 + offset; // ⚠ ERREUR ⚠

// Cast possible
int castValue = (int)intPtrValue1; // OK

// Boxing possible
object boxedValue = intPtrValue1; // OK

D’un point du code MSIL, on peut voir que la type MSIL utilisé est native int Si on compile le code suivant:

IntPtr initialValue = new IntPtr(5);
IntPtr withOffset = IntPtr.Add(initialValue, 4);
Console.WriteLine(withOffset);

Le code MSIL (compile en mode Release) est:

.method public hidebysig instance void  Example() cil managed
{
  // Code size       23 (0x17)
  .maxstack  8
  IL_0000:  ldc.i4.5
  IL_0001:  newobj     instance void [System.Runtime]System.IntPtr::.ctor(int32)
  IL_0006:  ldc.i4.4
  // Le type MSIL utilisé est native int
  IL_0007:  call       native int [System.Runtime]System.IntPtr::Add(native int,int32)
  // Le boxing est effectué implicitement pour utiliser object.ToString()
  IL_000c:  box        [System.Runtime]System.IntPtr
  IL_0011:  call       void [System.Console]System.Console::WriteLine(object)
  IL_0016:  ret
} 

UIntPtr

System.UIntPtr est l’équivalent non signé de System.IntPtr. De la même façon il s’agit d’un type d’entier dont l’intervalle de valeurs varie suivant l’architecture d’exécution du processus:

UIntPtr IntPtr
Processus 32 bits Minimum 0 -232/2 = -2147483648
Maximum 232-1 = 4294967296 232/2-1 = 2147483647
Processus 64 bits Minimum 0 -264/2 = -9,22 x 1018
Maximum 264-1 = 18,45 x 1018 264/2-1 = 9,22 x 1018

Etant donné que les adresses mémoires sont forcément positives, on pourrait se demander pourquoi ne pas utiliser UIntPtr plutôt que IntPtr pour stocker des adresses mémoire. En effet dans un processus 64 bits, l’intervalle de valeurs de IntPtr dépasse largement le nombre d’adresses possibles pour un processus. En revanche pour un processus 32 bits et si on ne considère que des valeurs supérieures à 0, l’intervalle de valeur de IntPtr (de 0 à 232/2-1) ne permet pas de traiter toutes les adresses mémoire possibles. En effet, un processus 32 bits peut adresser au maximum:

  • 2 GB sans ajouter IMAGE_FILE_LARGE_ADDRESS_AWARE dans l’entête de l’assembly (voir Plateforme cible en .NET).
  • 3 GB sur un système 32 bits si on ajoute IMAGE_FILE_LARGE_ADDRESS_AWARE dans l’entête de l’assembly.
  • 4 GB sur un système 64 bits si on ajoute IMAGE_FILE_LARGE_ADDRESS_AWARE dans l’entête de l’assembly.

Par exemple si on exécute le code suivant dans un processus 32 bits, il se produit une exception System.OverflowException:

// 0x7FFFFFFF est la valeur hexadecimale de Int32.MaxValue
IntPtr val1 = new IntPtr(0x7FFFFFFF); // OK
// 0x80000000 est la valeur hexadecimale de Int32.MaxValue + 1
IntPtr val2 = new IntPtr(0x80000000); // Exception dans un processus 32 bits.

Dans un processus 32 bits, l’intervalle de valeur de UIntPtr comprend Int32.MaxValue + 1 donc le code précédent ne produit pas d’erreur:

UIntPtr val3 = new UIntPtr(0x80000000); // OK dans un processus 32 bits.

L’autre différence significative entre IntPtr et UIntPtr est que UIntPtr n’est pas conforme au CLS (i.e. Common Language Specification) (voir CLS-compliant). Cette caractéristique de non-conformité est partagée par tous les entiers non signés à part byte.

D’un point du vue du code MSIL, si on reprend le code précédent dans le cas de UIntPtr:

UIntPtr initialValue = new UIntPtr(5);
UIntPtr withOffset = UIntPtr.Add(initialValue, 4);
Console.WriteLine(withOffset);

Le code MSIL est:

.method public hidebysig instance void  Example() cil managed
{
  // Code size       23 (0x17)
  .maxstack  8
  IL_0000:  ldc.i4.5
  IL_0001:  newobj     instance void [System.Runtime]System.UIntPtr::.ctor(uint32)
  IL_0006:  ldc.i4.4
  // Le type MSIL correspondant à UIntrPtr est native uint
  IL_0007:  call       native uint [System.Runtime]System.UIntPtr::Add(native uint,
                                                                       int32)
  IL_000c:  box        [System.Runtime]System.UIntPtr
  IL_0011:  call       void [System.Console]System.Console::WriteLine(object)
  IL_0016:  ret
} 

nint et nuint

Comme on l’a évoqué précédemment IntPtr et UIntPtr sont limités pour effectuer des opérations arithmétiques ou des comparaisons. Les nouveaux types nint et nuint disponibles à partir de C# 9.0 proposent les mêmes fonctionnalités que IntPtr et UIntPtr mais ils permettent d’effectuer des opérations arithmétiques et des comparaisons. Après compilation, les types MSIL utilisés sont les mêmes que pour IntPtr et UIntPtr:

  • nint permet de générer le type native int (comme pour IntPtr) pour stocker un entier sur 32 bits dans un processus 32 bits et sur 64 bits dans un processus 64 bits et
  • nuint permet de générer le type native uint (comme pour UIntPtr) pour stocker un entier non signé sur 32 bits dans un processus 32 bits et sur 64 bits dans un processus 64 bits.

Dans le code C#, les types équivalents sont interchangeables, par exemple si on écrit le code suivant:

IntPtr initialValue = new IntPtr(5);
nint nintValue = initialValue;
IntPtr otherValue = nintValue;

Console.WriteLine(otherValue); 

Le code MSIL généré n’effectue pas de conversion entre IntPtr et nint, c’est le même type MSIL qui est utilisé. Pour cet exemple, le code MSIL généré en mode release n’effectue pas d’affectations mis à part l’initialisation car elles sont inutiles:

.maxstack  8
IL_0000:  ldc.i4.5
IL_0001:  newobj     instance void [System.Runtime]System.IntPtr::.ctor(int32)
IL_0006:  box        [System.Runtime]System.IntPtr
IL_000b:  call       void [System.Console]System.Console::WriteLine(object)
IL_0010:  ret

Opérations arithmétiques

nint et nuint permettent d’effectuer des opérations arithmétiques en plus de l’addition et de la soustraction:

nint val1 = 4;
nint val2 = 5;

nint addResult = val1 + val2;
nint subResult = val1 - val2;
nint divResult = val1 / val2;
nint mulResult = val1 * val2; 

Des conversions implicites peuvent être effectuées:

nint result = val1 + 5;
float floatResult = val1 + 5f;
long doubleResult = val1 + 5L;

Comparaisons

nint et nuint autorisent les comparaisons:

Console.WriteLine(val1 > val2);   // False
Console.WriteLine(val1 >= val2);  // False
Console.WriteLine(val1 == val2);  // False
Console.WriteLine(val1 == val3);  // True
Console.WriteLine(val1.Equals(val3));   // True

typeof() et GetType()

typeof() et GetType() retournent IntPtr au lieu de nint:

Console.WriteLine(typeof(result)); // System.IntPtr
Console.WriteLine(val1.GetType());  // System.IntPtr

De même pour UIntPtr et nuint, typeof() et GetType() retournent UIntPtr au lieu de nuint:

nuint nuintValue = 5;
Console.WriteLine(typeof(nuintValue)); // System.UIntPtr
Console.WriteLine(nuintValue.GetType());  // System.UIntPtr

Utilisation de nint et nuint pour des index de tableau

Les types nint et nuint peuvent être utilisés pour les index d’un tableau:

int[] array = new int[] { 2, 3, 4, 5 };
nint index = 2;
nuint unsignedIndex = 3;
Console.WriteLine(table[index]);   // OK le résultat est 4
Console.WriteLine(table[unsignedIndex]);  // OK le résultat est 5

Il n’est pas possible d’utiliser nint ou nuint en tant qu’index dans le cadre de List<>:

List<int> list = new List<int>{ 2, 3, 4, 5 };
Console.WriteLine(list[index]);   // ⚠ ERREUR ⚠
Console.WriteLine(list[unsignedIndex]);  // ⚠ ERREUR ⚠

NativeIntegerAttribute

Quelque soit le type d’objet utilisé dans le code C#, le même type d’objet est utilisé dans le code MSIL:

  • native int dans le cas où on utilise IntPtr ou nint
  • native uint dans le cas où on utilise UIntPtr ou nuint

La différence est que le compilateur ajoute l’attribut System.Runtime.CompilerServices.NativeIntegerAttribute sur les objets utilisant nint ou nuint. Si ces objets utilisent IntPtr ou UIntPtr, cet attribut n’est pas ajouté. NativeIntegerAttribute est une classe utilisée seulement par le compilateur, elle ne peut être utilisée dans du code C#.

Par exemple si considère l’objet suivant:

public class NintExample
{
  public IntPtr A;
  public UIntPtr B;
  public nint C;
  public nuint D; 
}

Le code MSIL correspondant aux membres est:

.field public native int A

.field public native uint B

.field public native int C
.custom instance void System.Runtime.CompilerServices.NativeIntegerAttribute::.ctor() = ( 01 00 00 00 ) 

.field public native uint D
.custom instance void System.Runtime.CompilerServices.NativeIntegerAttribute::.ctor() = ( 01 00 00 00 ) 

L’initialisation de l’attribut NativeIntegerAttribute se fait avec un tableau de booléens. Chaque booléen du tableau permet d’indiquer quelle partie du type référence utilise nint ou nuint

  • true indique que le type native int utilisé provient de nint ou nuint; ou
  • false pour indiquer que le type native int utilisé provient de IntPtr ou UIntPtr.

Dans le code MSIL, l’attribut est initialisé suivant les spécifications d’initialisation des custom attributes (i.e. attributs personnalisés) (cf. ECMA):
La syntaxe de cette initialisation est définie par le diagramme général suivant:

source: ECMA-335

Ainsi les éléments sont indiqués sous forme d’octet en hexadecimal en little-endian (i.e. octet de poids faible en premier):

  • Prolog est un entier sur 2 octets permetant d’indiquer qu’il s’agit d’un custom attribute, sa valeur est toujours 01 00.
  • FixedArg indique les paramètres fixes du constructeur sous forme de tableau. Cet élément respecte une syntaxe particulière, dans le cas d’un tableau de booléen, un entier sur 4 octets indique la taille du tableau. Chaque octet suivant contient un booléen: 01 pour true et 00 pour false.
  • NumNamed est un entier sur 2 octets indiquant le nombre de propriétés nommés. Dans notre cas, il n’y en a pas de propriété nommé donc la valeur est 00 00.
  • NamedArg indique les propriétés nommées suivant une syntaxe particulière. Dans notre cas, il n’y a pas de propriété nommé donc NamedArg ne contient pas de valeurs.

Par exemple, si considère l’objet suivant:

private (IntPtr, nint) D;

Le code MSIL correspond est:

.field private valuetype [System.Runtime]System.ValueTuple`2<native int,native int> D
.custom instance void System.Runtime.CompilerServices.NativeIntegerAttribute::.ctor(bool[]) = ( 01 00 02 00 00 00 00 01 00 00 ) 

La valeur d’initialisation est sur 10 octets:

01 00 02 00 00 00 00 01 00 00

Le tuple contient 2 objets native ints de type IntPtr et nint. 2 booléens seront nécessaires pour initialiser l’objet NativeIntegerAttribute, ainsi:

  • Le Prolog: 01 00
  • FixedArg: 02 00 00 00 00 01 avec:
    • 02 00 00 00 permettant d’indiquer le nombre d’arguments qui est 2.
    • 00 01 qui sont 2 booléens false true.
  • NumNamed: 00 00 car pas d’arguments nommés.
  • NamedArg qui ne contient rien.

De la même façon, si on considère le tuple:

private (IntPtr, IntPtr, nint) E;

Le code MSIL correspondant est:

.field private valuetype [System.Runtime]System.ValueTuple`3<native int,native int,native int> E
.custom instance void System.Runtime.CompilerServices.NativeIntegerAttribute::.ctor(bool[]) = ( 01 00 03 00 00 00 00 00 01 00 00 ) 

3 booléens seront nécessaires pour initialiser l’objet NativeIntegerAttribute

  • Le Prolog: 01 00
  • FixedArg: 03 00 00 00 00 00 01 avec:
    • 03 00 00 00 permettant d’indiquer le nombre d’arguments qui est 3.
    • 00 00 01 qui sont 3 booléens false false true.
  • NumNamed: 00 00 car pas d’arguments nommés.
  • NamedArg qui ne contient rien.
Références
Share on RedditTweet about this on TwitterShare on LinkedInEmail this to someonePrint this page

Les pointeurs de fonction (C# 9.0)

Cet article fait partie d’une série d’articles sur les apports fonctionnels de C# 9.0.

Le but de la fonctionnalité des pointeurs de fonction en C# est de proposer une syntaxe pour facilement manipuler ce type de pointeurs. La manipulation de pointeurs est possible en C# toutefois avant C# 9, manipuler des pointeurs de fonction n’était pas direct, cela nécessitait de passer par l’émission directe d’instructions en MSIL ce qui complique l’écriture de code et éventuellement le débugage.

Plus techniquement, l’intérêt principal de cette manipulation de pointeurs de fonction directement à travers le code est de permettre d’accéder aux instructions IL (i.e. Intermediate Language) ldftn et calli. Ces instructions servent respectivement, à pousser dans la pile un pointeur de fonction non managé et à appeler cette méthode.

Le but de cet article est de rentrer dans les détails de cette nouvelle fonctionnalité pour en comprendre le fonctionnement et l’intérêt. Dans un 1er temps, quelques explications seront apportées sur des sujets autour de la fonctionnalité comme:

  • Les instructions CIL/MSIL,
  • La manipulation de pointeurs en C# et en C++,
  • Les fonctions intrinsèques du compilateur.

Dans un 2e temps, on apportera plus de précisions sur cette nouvelle fonctionnalité.

Quelques explications en préambule

Code MSIL

Compilation

En .NET, le code n’est pas directement compilé en code machine comme cela est le cas pour du code C++ natif. Le code .NET est compilé dans des assemblies contenant des instructions MSIL (pour MicroSoft Intermediate Language). Ces instructions sont exécutables par le CLR (i.e. Common Language Runtime).

Compilation avec Roslyn vs Compilation avec le JIT

A l’exécution et suivant les besoins du CLR, les instructions MSIL sont de nouveau compilées en code machine par le compilateur JIT (i.e. Just In Time). Le code machine généré est ensuite exécuté par la machine. Les instructions MSIL sont compilées à la demande, en fonction des appels qui sont effectués. Si des instructions correspondant à une fonction ne sont pas appelées alors ces instructions ne seront pas compilées par le compilateur JIT. D’autre part, le compilateur JIT effectue des optimisations dans le code généré suivant la façon dont les fonctions sont appelées. Ainsi les performances d’exécution d’un programmation peuvent s’améliorer au fur et à mesure de son exécution.

MSIL vs CIL

Le code MSIL (pour MicroSoft Intermediate Language) correspond à un ensemble d’instructions exécutables par le CLR .NET. Le code CIL (pour Common Intermediate Language) correspond aux mêmes jeux d’instructions toutefois ce terme est utilisé dans le cadre du standard CLI (i.e. Common Language Infrastructure).

Fonctionnement générale du code IL

Le code IL généré après la compilation est un code lisible. Ce code se trouvant dans les assemblies peut facilement être décompilé avec ILDasm (i.e. Intermediate Language Dissambler) ou par DotPeek.
ILDasm est fourni avec le SDK du framework .NET accessible, par exemple, avec des chemins du type: C:\Program Files (x86)\Microsoft SDKs\Windows\<version>\bin\NETFX 4.8 Tools\ildasm.exe.
Avec .NET Core, il est possible de l’utiliser avec le package NuGet Microsoft.NETCore.ILDasm.

L’exécution d’instructions MSIL consiste d’une façon générale à effectuer 3 types d’opérations:

  1. Pousser les opérandes des commandes ou les paramètres de fonction dans la pile
  2. Exécuter la commande ou la fonction MSIL. Cette exécution récupère les opérandes et les paramètres dans la pile pour effectuer son traitement puis éventuellement pousse le ou les résultats dans la pile.
  3. Lire et récupérer le résultat dans la pile.

D’une façon générale, on distingue 2 catégories d’objets en .NET: les objets de type valeur et les objets de type référence:

  • Les objets de type valeur sont manipulés par valeur et sont généralement stockés dans la pile. Dans certains cas, ces objets peuvent être stockés dans le tas (par exemple dans le cas du boxing, d’objets statiques etc…)
  • Les objets de type référence sont manipulés par référence et sont stockés dans le tas managé. Les références des objets de type référence sont des objets de type valeur qui sont stockés dans la pile.

Les manipulations de ces objets correspondent à les stocker dans une variable ou à les passer en argument de fonction.
Pour davantage de détails, voir Type valeur vs type référence.

Ainsi dans la pile, on peut retrouver:

  • Les variables locales d’une fonction
  • Les arguments d’une fonction

Une pile fonctionne en mode LIFO (i.e. Last In First Out). Les opérations effectuées sur la pile sont:

  • Pousser une objet sur la pile c’est-à-dire ajouter une valeur. L’objet est rajouté au sommet de la pile. Cette opération est effectuée par des commandes MSIL avec le préfixe ld... pour load.
  • Enlever un objet de la pile. L’objet enlevé est celui se trouvant au sommet de la pile. Cette opération est effectuée par des commandes MSIL avec le préfixe st... pour store. Généralement l’objet est enlevé de la pile pour être stocké dans une variable.

Pour comprendre comment fonctionne le code MSIL, on propose quelques exemples:

Exemple simple d’une fonction

Code C# Code MSIL
namespace Cs9
{
  public class SimpleFunctionTests
  {
    public int AddNumbers(int startNumber)
    {
      int result = startNumber;

      {
        Console.WriteLine("Enter number: ");
        string numberAsString = 
          Console.ReadLine();
        if (int.TryParse(numberAsString, 
          out int number))
        {
          result += number;
        }

        Console.WriteLine
          ($"Result is: {result}");
      }

      return result;
    }
  }
}
.class public auto ansi beforefieldinit 
  Cs9.SimpleFunctionTests extends [System.Runtime]System.Object
{
  .method public hidebysig instance default 
  int32 AddNumbers(int32 startNumber) cil managed
  {
  // Method begins at Relative Virtual Address (RVA) 0x2194
  // Code size 70 (0x46)
  .maxstack 2
  .locals init(int32 V_0, string V_1, int32 V_2, bool V_3, int32 V_4)
  IL_0000: nop
  IL_0001: ldarg.1
  IL_0002: stloc.0
  IL_0003: nop
  IL_0004: ldstr "Enter number: "
  IL_0009: call void class 
    [System.Console]System.Console::WriteLine(string)
  IL_000e: nop
  IL_000f: call string class 
    [System.Console]System.Console::ReadLine()
  IL_0014: stloc.1
  IL_0015: ldloc.1
  IL_0016: ldloca.s class V_2
  IL_0018: call bool class 
    int32::TryParse(string, byreference)
  IL_001d: stloc.3
  IL_001e: ldloc.3
  IL_001f: brfalse.s   IL_0027
  IL_0021: nop
  IL_0022: ldloc.0
  IL_0023: ldloc.2
  IL_0024: add
  IL_0025: stloc.0
  IL_0026: nop
  IL_0027: ldstr "Result is: {0}"
  IL_002c: ldloc.0
  IL_002d: box class System.Int32
  IL_0032: call string class 
    string::Format(string, [System.Runtime]System.Object)
  IL_0037: call void class 
    [System.Console]System.Console::WriteLine(string)
  IL_003c: nop
  IL_003d: nop
  IL_003e: ldloc.0
  IL_003f: stloc.s class V_4
  IL_0041: br.s   IL_0043
  IL_0043: ldloc.s class V_4
  IL_0045: ret
  } 
  
  .method public hidebysig specialname rtspecialname instance default 
  void .ctor() cil managed
  {
  // Method begins at Relative Virtual Address (RVA) 0x21E6
  // Code size 8 (0x8)
  .maxstack 8
  IL_0000: ldarg.0
  IL_0001: call instance void class 
    [System.Runtime]System.Object::.ctor()
  IL_0006: nop
  IL_0007: ret
  } 
} 

Dans le code MSIL, d’une façon générale les méthodes et fonctions appelées récupèrent la valeur de leur argument dans la pile. Lorsqu’une valeur est récupérée, elle est supprimée de la pile. Le résultat d’une fonction est ajoutée dans la pile.

Explication du code MSIL:

// Un objet de type référence dérive toujours de System.Object
.class public auto ansi beforefieldinit Cs9.SimpleFunctionTests 
  extends [System.Runtime]System.Object
{
  // Signature de la méthode AddNumbers() avec son argument
  // hidebysig signifie "hide by name-and-signature" pour 
  // indiquer que les fonctions doivent être identifiées en 
  // utilisant le nom et la signature (et non seulement le nom). 
  .method public hidebysig instance default int32 AddNumbers(int32 startNumber) cil managed
  {
  // Method begins at Relative Virtual Address (RVA) 0x2194
  // Code size 70 (0x46)
  // Indique la profondeur maximale de la pile nécessaire à 
  // l’exécution de la fonction. 
  .maxstack 2
  // Indique les variables locales
  .locals init(int32 V_0, string V_1, int32 V_2, bool V_3, int32 V_4)
  // Signifie "No OPeration". Cette instruction indique au compilateur JIT 
  // les emplacements où le code machine peut être associé à une instruction MSIL. 
  IL_0000: nop
  // Ajoute la valeur de l’argument 1 c’est-à-dire startNumber à la pile (load argument 1) 
  IL_0001: ldarg.1
  // Récupère la 1ère valeur de la pile pour la stocker dans la variable locale 
  // loc.0 (store local 0).
  IL_0002: stloc.0
  IL_0003: nop
  // Ajoute la chaine de caractère "Enter number: " dans la pile
  IL_0004: ldstr "Enter number: "
  // Appelle la méthode statique Console.WriteLine. 
  // Cette méthode va récupérer la 1ère valeur de la pile
  IL_0009: call void class [System.Console]System.Console::WriteLine(string)
  IL_000e: nop
  // Appelle la méthode Console.ReadLine. 
  // Cette méthode va placer son résultat dans la pile
  IL_000f: call string class [System.Console]System.Console::ReadLine()
  // Récupère la 1ère valeur de la pile pour la stocker dans la variable locale loc.1
  IL_0014: stloc.1
  // Ajoute la valeur de la variable locale loc.1 dans la pile
  IL_0015: ldloc.1
  // Ajoute l’adresse de la variable V_2 dans la pile (load local short form)
  IL_0016: ldloca.s class V_2
  // Appelle de la fonction int32.TryParse(). Cette fonction va récupérer 
  // la valeur de ses arguments dans la pile. Elle ajoute son résultat dans la pile. 
  IL_0018: call bool class int32::TryParse(string, byreference)
  IL_001d: stloc.3
  IL_001e: ldloc.3
  // Va à l’instruction IL_0027 si la 1ère valeur dans la pile est false (branch false short).
  IL_001f: brfalse.s   IL_0027
  IL_0021: nop
  IL_0022: ldloc.0
  IL_0023: ldloc.2
  // Ajoute les 2 premières valeurs de la pile (ces valeurs sont supprimées de la pile). 
  // La fonction ajoute le résultat de l’addition dans la pile. 
  IL_0024: add
  IL_0025: stloc.0
  IL_0026: nop
  IL_0027: ldstr "Result is: {0}"
  IL_002c: ldloc.0
  // Effectue une opération de boxing (conversion d’un objet de type valeur en un objet 
  // de type référence dérivant de System.Object). Cette opération est nécessaire pour 
  // exécuter ToString() sur un objet dérivant de System.Object. Le résultat de ToString() 
  // est utilisé pour "Result is: {0}".
  IL_002d: box class System.Int32
  IL_0032: call string class string::Format(string, [System.Runtime]System.Object)
  IL_0037: call void class [System.Console]System.Console::WriteLine(string)
  IL_003c: nop
  IL_003d: nop
  IL_003e: ldloc.0
  IL_003f: stloc.s class V_4
  // Va à l’instruction IL_0043 (branch short)
  IL_0041: br.s   IL_0043
  IL_0043: ldloc.s class V_4
  // Retour de la méthode ou de la fonction. Dans le cas d’une fonction, 
  // le résultat se trouve dans la pile.
  IL_0045: ret
  } 

  // Un constructeur par défaut est rajouté par le compilateur
  .method public hidebysig specialname rtspecialname instance default void .ctor() cil managed
  {
  // Method begins at Relative Virtual Address (RVA) 0x21E6
  // Code size 8 (0x8)
  .maxstack 8
  IL_0000: ldarg.0
  IL_0001: call instance void class [System.Runtime]System.Object::.ctor()
  IL_0006: nop
  IL_0007: ret
  } 
}

Le code indiqué précédemment est un code généré en mode debug, on peut voir que de nombreuses instructions peuvent sembler inutile comme par exemple:

  • Les instructions nop
    • IL_0000: nop
    • IL_0003: nop
    • etc…
  • Des instructions où on stocke la 1ère valeur de la pile dans une variable alors que l’instruction suivante repousse la valeur de la variable dans la pile:
    • IL_0014: stloc.1
    • IL_0015: ldloc.1
  • Des instructions inutiles indiquant de passer à la ligne suivante:
    • IL_0041: br.s IL_0043
    • IL_0043: ldloc.s class V_4

La raison est que le compilateur effectue peu d’optimisation, les instructions du code C# sont directement traduites en instructions MSIL. Si on compile le même code en mode release, on peut voir que les instructions inutiles ne sont plus présentes, par exemple pour la fonction AddNumbers():

.method public hidebysig instance default int32 AddNumbers(int32 startNumber) cil managed
{
  // Method begins at Relative Virtual Address (RVA) 0x216C
  // Code size 53 (0x35)
  .maxstack 2
  .locals init(int32 V_0, int32 V_1)
  IL_0000: ldarg.1
  IL_0001: stloc.0
  IL_0002: ldstr "Enter number: "
  IL_0007: call void class [System.Console]System
    .Console::WriteLine(string)
  IL_000c: call string class [System.Console]System
    .Console::ReadLine()
  IL_0011: ldloca.s class V_1
  IL_0013: call bool class int32::TryParse(string, byreference)
  IL_0018: brfalse.s   IL_001e
  IL_001a: ldloc.0
  IL_001b: ldloc.1
  IL_001c: add
  IL_001d: stloc.0
  IL_001e: ldstr "Result is: {0}"
  IL_0023: ldloc.0
  IL_0024: box class System.Int32
  IL_0029: call string class string::Format(string, 
    [System.Runtime]System.Object)
  IL_002e: call void class [System.Console]System
    .Console::WriteLine(string)
  IL_0033: ldloc.0
  IL_0034: ret
}

Dans la suite, on présentera le code MSIL en mode release.

Exemple d’un appel de fonction

Si on considère le code suivant:

Code C#
public class SimpleClass
{
  public void ExecuteMe()
  {
    Console.WriteLine("OK");
  }
}

class Program
{
  static void Main(string[] args)
  {
    var simpleClass = new SimpleClass();
    simpleClass.ExecuteMe();
  }
}
    
Code MSIL du
Main()
.method private hidebysig static void  Main(string[] args) cil managed
{
  .entrypoint
  // Code size       11 (0xb)
  .maxstack  8
  IL_0000:  newobj     instance void FunctionPointerTests
    .SimpleClass::.ctor()
  IL_0005:  callvirt   instance void FunctionPointerTests
    .SimpleClass::ExecuteMe()
  IL_000a:  ret
} // end of method Program::Main
        

Dans ce code, 2 instructions sont importantes:

  • newobj permettant d’instancier un objet de type référence et d’ajouter la référence à la pile.
  • callvirt permettant d’appeler dans un objet une méthode correspondant à une signature particulière en utilisant la référence de cet objet dans la pile. D’autres explications sont apportées sur la fonction callvirt par la suite.

Exemple d’un appel Platform/Invoke

Si on considère le code suivant permettant d’appeler la fonction native Multiply() dans la DLL appelée NativeDll.dll:

Code C#
class Program
{
    static void Main(string[] args)
    {
        Multiply(2, 4);
    }

    [DllImport("NativeDll.dll", 
      CallingConvention = CallingConvention.StdCall, 
      CharSet = CharSet.Unicode)]
    public extern static int Multiply(int arg1, int arg2);
}
Code MSIL du
Main()
.method private hidebysig static void  Main(string[] args) cil managed
{
  .entrypoint
  // Code size       9 (0x9)
  .maxstack  8
  IL_0000:  ldc.i4.2
  IL_0001:  ldc.i4.4
  IL_0002:  call       int32 FunctionPointerTests.Program
    ::Multiply(int32,int32)
  IL_0007:  pop
  IL_0008:  ret
} // end of method Program::Main

.method public hidebysig static 
  pinvokeimpl("NativeDll.dll" unicode stdcall) 
  int32  Multiply(int32 arg1,int32 arg2) cil managed preservesig
{
}

L’instruction importante est call qui permet d’appeler une méthode particulière suivant sa signature. Dans le cas de l’appel Platform/Invoke, la méthode Multiply() est statique.

Delegates

Les delegates en C# sont des références vers une méthode comportant une signature particulière. Un delegate définit le type de la référence et non la référence elle-même. Par exemple, un delegate peut se définir de cette façon:

public delegate int ArithmeticOperation(int a, int b);

La fonction suivante possède une signature compatible avec le delegate:

public static int MultiplyIntegers(int a, int b)
{
  return a * b;
}

On peut instancier le delegate et l’exécuter de cette façon:

ArithmeticOperation operation = MultiplyIntegers;
int result = operation(2, 6);

Dans cet exemple, le delegate operation contient une référence vers la méthode statique MultiplyIntegers().

On peut aussi utiliser des méthodes d’instance plutôt que des méthodes statiques, par exemple si on considère la classe:

public class DelegateExample
{
  public int ExecuteOperation(int arg1, int arg2, ArithmeticOperation operation)
  {
    return operation(arg1, arg2);
  }

  public int AddIntegers(int arg1, int arg2)
  {
    return arg1 + arg2;
  }

  public int MultiplyIntegers(int arg1, int arg2)
  {
    return arg1 * arg2;
  }
}

On peut instancier un delegate avec une méthode d’instance:

var delegateExampleInstance = new DelegateExample();
ArithmeticOperation operation = new ArithmeticOperation(delegateExampleInstance.MultiplyIntegers);
int result = delegateExampleInstance.ExecuteOperation(3, 4, operation);

Ou plus directement:

int result = delegateExampleInstance.ExecuteOperation(3, 4, delegateExampleInstance.AddIntegers);

Par rapport à des pointeurs de fonction classiques, l’intérêt des delegates est qu’ils sont sûrs, vérifiables par le compilateur et le type est déterminé à la compilation. Etant donné qu’il s’agit de références managées, ils sont compatibles avec les traitements du Garbage Collector. Enfin lors d’appels Platform/Invoke, les delegates peuvent être convertis en pointeurs de fonctions et vice-versa, des pointeurs peuvent être convertis en delegates.

Du point de vue du code MSIL, les delegates sont compilés en classe dans laquelle se trouve les membres suivants:

  • Un constructeur avec pour arguments l’instance de la classe de la méthode déléguée et un entier contenant un pointeur vers la méthode déléguée.
  • Une méthode Invoke() utilisée pour exécuter la méthode déléguée de façon synchrone. La signature de Invoke() est la même que celle de la méthode déléguée.
  • Des méthodes BeginInvoke() et EndInvoke() utilisées pour exécuter la méthode déléguée de façon asynchrone.

Pointeur de fonction C++

Les pointeurs de fonction sont représentés en C++ par une déclaration du type <type de retour> (*<nom du pointeur>)(<type arguments en entrée>). Par exemple, pour déclarer un pointeur de fonction nommé fcnPtr permettant de pointer vers une fonction dont la signature est int(double, bool):

int (*fcnPtr)(double, bool);

La déclaration de la fonction peut être du type:

int PointedFunction(double arg1, bool arg2) 
{ 
  // ...
}

Si la signature de la fonction cible est int* (double, bool) c’est-à-dire que l’argument de retour est un pointeur d’un entier, par exemple:

int* PointedFunction(double arg1, bool arg2) 
{ 
  // ... 
}

Le pointeur de fonction doit être déclaré de cette façon:

int* (*fcnPtr)(double, bool);

Initialisation et affectation

A ce stade le pointeur de fonction plus haut, est juste déclaré et non initialisé. Pour l’initialiser, on peut écrire:

int (*fcnPtr)(double, bool) { &PointedFunction };

Dans cet exemple, la fonction PointedFunction peut être une fonction d’instance ou une fonction statique.
Ce pointeur est instancié sur la pile, il est donc perdu à la sortie de la méthode dans laquelle il a été instancié.

Pour affecter une méthode à un pointeur de fonction:

int (*fcnPtr)(double, bool); // Déclaration
fcnPtr = &PointedFunction; // Affectation

Appel en utilisant un pointeur de fonction

On peut appeler une méthode en utilisant un pointeur de fonction avec 2 syntaxes:

  • Par référencement explicite en utilisant la forme (*<nom du pointeur>), par exemple:
    int (*fcnPtr)(double, bool) { &PointedFunction };
    int result = (*fcnPtr)(5, false);
    
  • Par déférencement implicite en utilisant directement la forme <nom du pointeur>, par exemple:
    int (*fcnPtr)(double, bool) { &PointedFunction };
    int result = fcnPtr(5, false);

Passage de pointeur de fonction en argument

Un pointeur de fonction en tant qu’argument doit être indiqué de la même façon que les autres arguments, en utilisant sa déclaration. Par exemple:

int ExecuteOperation(int arg1, int arg2, int (*operationToExecute)(int, int))
{
  return operationToExecute(arg1, arg2);
}

On peut appeler cette méthode en indiquant directement les fonctions, par exemple si on déclare la fonction:

int MultiplyIntegers(int arg1, int arg2)
{
  return arg1 * arg2;
}

// ...
int result = ExecuteOperation(2, 3, MultiplyIntegers);

Cast void*

Comme pour tous les pointeurs, un pointeur de fonction peut être casté en pointeur void*

  • Conversion implicite en void*, par exemple:
    int (*fcnPtr)(double, bool){ &PointedFunction };
    void* voidFcnPtr = fcnPtr; // Conversion implicite
    
  • Conversion explicite de void* vers un pointeur de fonction avec reinterpret_cast, par exemple:
    void* voidFcnPtr = ... 
    int (*otherFcnPtr)(double, bool) = reinterpret_cast<int(*)(double, bool)>(voidFcnPtr);

Type alias

La déclaration d’un pointeur de fonction peut être simplifiée en utilisant un type alias, par exemple:

using AliasName = int(*)(double, bool);

Cet alias peut être utilisé directement pour remplacer la déclaration du pointeur:

AliasName fcnPtr; // déclaration du pointeur
// ...
AliasName fcnPtr { &PointedFunction }; // déclaration + affectation
fcnPtr = &PointedFunction; // Affectation

L’alias peut être utilisé aussi pour les arguments:

using OperationAlias = int(*)(int, int);

// ...
int ExecuteOperation(int arg1, int arg2, OperationAlias operationToExecute)
{
  return operationToExexute(arg1, arg2);
}

Utiliser des pointeurs de fonction avant C# 9

Avant C# 9, dans certaines conditions, il était possible de manipuler des pointeurs de fonctions toutefois ces différentes approches ne permettant pas d’utiliser l’instruction MSIL calli. D’autre part, ces approches ne sont possibles qu’entre des appels entre du code managé et du code natif. Par exemple, on peut utiliser:

Manipuler des pointeurs de fonction

Dans cet exemple, l’appel à la méthode peut se faire en utilisant le delegate. Si on considère une méthode externe fournissant un pointeur de fonction sous la forme void* dont la signature est int(int, int):

using unsafe class CallFunctionPointer
{
  public delegate int MultiplyDelegate(int arg1, int arg2);

  [DllImport(...)]
  public extern static void* GetFunctionPointer();

  public int Multiply(int a, int b)
  {
    void* nativePtr = GetFunctionPointer();
    IntPtr ptr = new IntPtr(nativePtr);
    MultiplyDelegate multiplyDelegate = Marshal.GetDelegateForFunctionPointer<MultiplyDelegate>(ptr);
    return multiplyDelegate(a, b);
  }
}

Pour que ces méthodes soient exécutables, il faut que le code unsafe soit autorisé.

Comment compiler du code unsafe ?

Pour compiler du code unsafe et autoriser le compilateur à utliser le mot-clé unsafe, il faut l’autoriser dans les propriétés du projet:

  • Dans les propriétés du projet dans Visual Studio, il faut cocher la propriété “Allow unsafe code” dans l’onglet Build.
  • En éditant directement le fichier .csproj, il faut rajouter le nœud AllowUnsafeBlocks dans PropertyGroup:
    <Project Sdk="Microsoft.NET.Sdk"> 
        <PropertyGroup> 
          <!—- ... -—> 
          <AllowUnsafeBlocks>true</AllowUnsafeBlocks> 
        </PropertyGroup> 
      </Project> 
      

Une autre syntaxe plus directe permet d’éviter d’utiliser du code unsafe:

Code C#
public class CallFunctionPointer
{
  public delegate int MultiplyDelegate(int arg1, 
    int arg2);

  [DllImport(...)]
  public extern static void* GetFunctionPointer();  
  
  public int Multiply(int a, int b)
  {
    IntPtr ptr = GetFunctionPointer();
    MultiplyDelegate multiplyDelegate = Marshal
      .GetDelegateForFunctionPointer<MultiplyDelegate>(ptr);
    return multiplyDelegate(a, b);
  }
}
Code MSIL de
Multiply()
.method public hidebysig instance 
  int32  Multiply(int32 a, int32 b) cil managed
{
  // Code size     18 (0x12)
  .maxstack  8
  // Appel Platform/Invoke pour récupérer 
  // un pointeur de fonction
  IL_0000: call   native int FunctionPointerTests
    .CallFunctionPointer::GetFunctionPointer()
  // "Conversion" en délégué managé
  IL_0005: call   !!0 [System.Runtime.InteropServices]
    System.Runtime.InteropServices.Marshal
    ::GetDelegateForFunctionPointer
      <class FunctionPointerTests
      .CallFunctionPointer/MultiplyDelegate>(native int)
  IL_000a: ldarg.1
  IL_000b: ldarg.2
  // Appel du delegate
  IL_000c: callvirt  instance int32 FunctionPointerTests
    .CallFunctionPointer/MultiplyDelegate
    ::Invoke(int32,int32)
  IL_0011:  ret
} // end of method CallFunctionPointer::Multiply

Cette méthode génère un appel à callvirt car l’appel se fait une utilisant un délégué managé.

Fournir un pointeur de fonction

La conversion d’un delegate en pointeur de fonction est aussi possible en utilisant les capacités de marshalling de Platform/Invoke:

Code C#
public unsafe class FunctionPointerProvider
{
  [UnmanagedFunctionPointer(CallingConvention.StdCall)]
  public delegate int MultiplyDelegate(int arg1, 
    int arg2);

  [DllImport(...)]
  public extern static int MultiplyWithFunctionPointer(
    int arg1, 
    int arg2, 
    [MarshalAs(UnmanagedType.FunctionPtr)]MultiplyDelegate 
      functionDelegate);

  private int Multiply(int arg1, int arg2)
  {
    return arg1 * arg2;
  }

  public int MultiplyIntegers(int a, int b)
  {
    MultiplyDelegate functionDelegate = Multiply;
    int result = MultiplyWithFunctionPointer(a, b, 
      functionDelegate);
  }
}
Code MSIL de
MultiplyIntegers()
.method public hidebysig instance 
  int32  MultiplyIntegers(int32 a, int32 b) cil managed
{
  // Code size     22 (0x16)
  .maxstack  3
  .locals init (class FunctionPointerTests
  .FunctionPointerProvider/MultiplyDelegate V_0)
  IL_0000:  ldarg.0
  // Ajout dans la pile du pointeur de fonction
  // natif vers la function Multiply()
  IL_0001:  ldftn  instance int32 FunctionPointerTests
    .FunctionPointerProvider::Multiply(int32,int32)
  // Instanciation d'un délégué managé 
  // avec le pointeur natif
  IL_0007:  newobj instance void FunctionPointerTests
    .FunctionPointerProvider/MultiplyDelegate::
    .ctor(object,native int)
  IL_000c:  stloc.0
  IL_000d:  ldarg.1
  IL_000e:  ldarg.2
  IL_000f:  ldloc.0
  // Appel Platform/Invoke 
  IL_0010:  call   int32 FunctionPointerTests
    .FunctionPointerProvider
    ::MultiplyWithFunctionPointer(int32,int32,
      class FunctionPointerTests
      .FunctionPointerProvider/MultiplyDelegate)
  IL_0015:  ret
} 

Dans cet exemple, durant le marshalling, le delegate est directement converti en pointeur de fonction. L’attribut UnmanagedFunctionPointerAttribute permet d’indiquer que le delegate peut être utilisé par du code natif.

Utiliser les pointeurs de fonction delegate* à partir de C# 9

Le but de cette partie est d’expliquer la fonctionnalité des pointeurs de fonction en C# 9 en justifiant son intérêt par rapport aux autres solutions existantes. On explicitera quelques cas d’utilisation de cette fonctionnalité.

call vs callvirt vs calli

Comme on a pu le voir précédemment, call et callvirt sont des instructions MSIL pour appeler des méthodes:

  • call permet d’appeler des méthodes non virtuelles, statiques ou des surcharges d’une méthode se trouvant dans une classe mère.
  • callvirt permet d’appeler une méthode virtuelle dans le cas où la méthode à exécuter se trouve dans une classe fille.

Dans la pratique le compilateur C# utilise quasi toujours callvirt pour effectuer des appels de méthode lorsqu’il s’agit d’autres méthodes managées. call sera utilisé lorsqu’il n’y a pas de doutes sur l’emplacement de la méthode à appeler (comme dans le cas de méthodes statiques puisqu’une classe statique ne peut pas hériter d’une autre classe et une méthode statique ne peut pas être overrider). Les appels Platform/Invoke avec DllImport rentre aussi dans le cadre des utilisations de call.

Ainsi:

  • call effectue une recherche dans la table de méthodes de la classe. Le résultat de cette recherche fournit un pointeur correspondant à un décalage par rapport à l’adresse de la classe.
  • callvirt effectue une recherche dans la table de méthodes virtuelles de l’instance de la classe. Le résultat fournit un pointeur correspondant à un décalage par rapport à l’adresse de l’instance de la classe.

D’un point de vue de la syntaxe MSIL, call et callvirt utilisent les objets se trouvant dans la pile en tant qu’argument de la fonction à appeler. Dans le code MSIL, les instructions call ou callvirt sont suivies d’indications sur la méthode à appeler:

  • instance pour indiquer s’il s’agit d’une méthode faisant partie d’un objet instancié:
    call  instance  void Cs9.Example::MethodName()
  • [<Assembly où se trouve la méthode à appeler>] éventuellement une indication sur l’assembly dans laquelle se trouve la méthode statique à appeler, par exemple:
    call  void [System.Console]System.Console::WriteLine(int32)

L’instruction calli est différente de call et callvirt puisqu’elle utilise un pointeur de fonction dans la pile pour effectuer l’appel. calli pour call indirect permet d’effectuer un appel indirect en utilisant un pointeur se trouvant au sommet de la pile. Le pointeur doit être poussé au préalable en utilisant les instructions ldftn ou ldvirtftn:

  • ldftn: charge le pointeur de la fonction à appeler en utilisant la table de méthodes de la classe. La fonction est reconnue à partir de sa signature. Le pointeur de fonction est poussé dans la pile.
  • ldvirtftn: cette instruction a la même fonction que ldftn La différence est que ldvirtftn effectue la recherche dans la table des fonctions virtuelles de l’instance de la classe.

ldftn et ldvirtftn permettent de pousser un pointeur dans la pile, ce pointeur peut ensuite être utilisé par calli pour appeler une méthode:

  • L’utilisation de ldftn et calli est un équivalent de call.
  • L’utlisation de ldvirtftn et calli est un équivalent de callvirt.

Il n’y a pas forcément de différences significatives de performance entre les utilisations de ldftn/ldvirtftn + calli et call/callvirt, la différence est que ldftn/ldvirtftn et calli étant des instructions séparées, elles peuvent faire l’objet d’optimisation par le compilateur au moment où elles sont appelées.

Pourquoi manipuler des pointeurs de fonction en C# ?

Une fonction comporte des arguments, cette fonction effectue un traitement et éventuellement renvoie un résultat. Les arguments sont généralement des variables contenant des valeurs utilisées lors du traitement. Ce paradigme de programmation est de type impératif: une fonction sert à appliquer un traitement comme s’il s’agissait d’une fonction mathématique.
Un autre paradigme comme la programmation fonctionnelle nécessite de pouvoir passer en paramètre d’autres fonctions (cf. “higher-order function) et de renvoyer une fonction en résultat.
Sans aller jusqu’à l’application stricte des principes de la programmation fonctionnelle, on peut avoir le besoin de passer en paramètre de fonction un comportement. Les pointeurs de fonction ou les delegates en C# permettent d’effectuer ce type de manipulation en autorisant le passage de fonction en argument d’une autre fonction. On peut, ainsi, passer en argument un comportement plutôt que simplement des valeurs. Le gain est, par exemple, de composer une suite de traitements sans avoir à réellement exécuter ce traitement.

Les delegates en C# permettent de passer en argument de fonction d’autres fonctions. Techniquement, si des appels s’effectuent seulement de code managé vers du code managé, il n’y a pas de nécessité d’utiliser autre chose que les delegates pour plusieurs raisons:

  • Ils sont supportés par le Garbage Collector
  • Ils permettent des appels rapides
  • Ils peuvent être appelés de façon asynchrone

Dans le cadre d’appels entre du code managé et du code natif, on peut aussi utiliser les delegates car ils peuvent être marshalé et transformé en pointeurs de fonction lors d’appels Platform/Invoke. Cette solution utilise les instructions call dans le code MSIL car le delegate est implémenté sous la forme d’un wrapper de méthode (voir plus haut).
A la différence, les pointeurs de fonction en C# apportent la même solution technique lors d’appels entre du code managé et du code natif toutefois ils permettent de tirer partie de l’instruction MSIL calli. Cette instruction va directement utilisée un pointeur de fonction pour appeler le code de la méthode à exécuter.

Limitations de C# concernant les pointeurs de fonctions avant C# 9

Avant C# 9, les utilisations des pointeurs de fonction sont possibles toutefois ils utilisent call lors des appels (comme on a pu le voir plus haut). L’instruction MSIL calli n’est pas utilisée alors que cette instruction est celle qui est le plus adaptée pour appeler des méthodes en utilisant un pointeur. Le choix d’utiliser call peut s’expliquer par le fait de privilégier un procédé plus sûr pour appeler la méthode via un pointeur.

Ainsi malgré l’existence de l’instruction MSIL calli, il n’existe pas de possibilité de l’utiliser en utilisant du code C# usuel. Pour des besoins d’optimisation (cf. Inline IL ASM), certains développeurs ont forcé l’utilisation de calli en passant par du code C# émettant directement l’instruction avec OpCodes.Calli et DynamicMethod.GetILGenerator().

Pour palier à cette difficulté d’utiliser calli, une nouvelle syntaxe a été introduite en C# 9 permettant réellement de générer cette instruction.

Manipuler des pointeurs de fonction en C# 9

A partir de C# 9, il est possible d’utiliser une syntaxe permettant de manipuler les pointeurs de fonction et d’autoriser des appels sans passer par du code Platform/Invoke. Les appels peuvent être fait entre du code managé ⇔ managé et du code managé ⇔ natif. L’inconvénient est que ces manipulations nécessitent toujours un contexte unsafe.

Ces pointeurs sont représentés par la syntaxe:

  • delegate* managed<int, float, long> cette syntaxe correspond à un pointeur de fonction dont la signature est long (int, float) c’est-à-dire:
    • Le type de retour est long
    • Les arguments sont de type int et float dans cet ordre.
    • Ce pointeur de fonction ne peut être utilisé que dans le code managé (à cause de la convention d’appel).
  • delegate* unmanaged<int, float, long> cette syntaxe correspond à un pointeur de fonction à utiliser dans le cadre d’appels à du code natif. Sans précision, le CLR détermine la convention d’appel suivant le contexte.
  • delegate* unmanaged[StdCall]<int, float, long> cette syntaxe permet de préciser des éléments comme la convention d’appels:
    • StdCall pour désigner la convention par défaut de l’API Win32
    • Cdecl pour la convention d’appels des programmes C et C++.
    • Fastcall pour des appels optimisés en C++.
    • Thiscall qui fournit un pointeur this à la méthode lors de l’appel.

L’intérêt le plus direct des delegate* est de pouvoir remplacer l’utilisation des delegates managés et de permettre les conversions de pointeurs de fonction en void*.
Par exemple si on reprend l’exemple précédent qui permettait de fournir et d’utiliser un pointeur de fonction en utilisant un délégué managé, l’implémentation est directe en utilisant delegate*:

  • Pour utiliser un pointeur de fonction
    Code C#
    public unsafe class CallFunctionPointer
    {
      [DllImport(...)]
      public extern static delegate* unmanaged<int, int, int> 
        GetFunctionPointer();  
      
      public int Multiply(int a, int b)
      {
        delegate* unmanaged<int, int, int> fcnPtr = 
          GetFunctionPointer();
        return fcnPtr(a, b);
      }
    }
    
    Code MSIL de
    Multiply()
    .method public hidebysig instance 
        int32  Multiply(int32 a,int32 b) cil managed
    {
      // Code size     15 (0xf)
      .maxstack  3
      .locals init (method unmanaged cdecl int32 *(int32,
        int32) V_0)
      IL_0000:  call     method unmanaged cdecl 
        int32 *(int32,int32) FunctionPointerTests
        .CallFunctionPointer::GetFunctionPointer()
      IL_0005:  stloc.0
      IL_0006:  ldarg.1
      IL_0007:  ldarg.2
      IL_0008:  ldloc.0
      // Appel de fonction en utilisant le pointeur avec 
      // calli
      IL_0009:  calli    unmanaged cdecl int32(int32,int32)
      IL_000e:  ret
    } // end of method CallFunctionPointer::Multiply
    
  • Pour fournir un pointeur de fonction:
    Code C#
    public unsafe class FunctionPointerProvider
    {
      [UnmanagedFunctionPointer(CallingConvention.StdCall)]
      public delegate int MultiplyDelegate(int arg1, int arg2);
    
      public static MultiplyDelegate MultiplyAction = Multiply;
    
      private static int Multiply(int arg1, int arg2)
      {
        return arg1 * arg2;
      }
    
      public int MultiplyIntegers(int a, int b)
      {
        delegate* unmanaged[Stdcall]<int, int, int> fcnPtr = 
          (delegate* unmanaged[Stdcall]<int, int, int>)
          Marshal.GetFunctionPointerForDelegate(MultiplyAction);
        return MultiplyWithFunctionPointer(a, b, fcnPtr);
      }
    
      [DllImport(...)]
      public extern static int MultiplyWithFunctionPointer(
        int arg1, 
        int arg2,
        delegate* unmanaged[Stdcall]<int, int, int> fcnPtr);
    }
    Code MSIL de
    MultiplyIntegers()
    .method public hidebysig instance 
        int32  MultiplyIntegers(int32 a,int32 b) cil managed
    {
      // Code size     22 (0x16)
      .maxstack  3
      .locals init (class FunctionPointerTests
        .FunctionPointerProvider/MultiplyDelegate V_0)
      IL_0000:  ldarg.0
      // Ajout dans la pile du pointeur natif
      // vers Multiply() avec ldftn
      IL_0001:  ldftn  instance int32 FunctionPointerTests
        .FunctionPointerProvider::Multiply(int32,int32)
      IL_0007:  newobj instance void FunctionPointerTests
        .FunctionPointerProvider/MultiplyDelegate::
        .ctor(object,native int)
      IL_000c:  stloc.0
      IL_000d:  ldarg.1
      IL_000e:  ldarg.2
      IL_000f:  ldloc.0
      // Appel Platform/Invoke
      IL_0010:  call   int32 FunctionPointerTests
        .FunctionPointerProvider
        ::MultiplyWithFunctionPointer(int32,int32,
          class FunctionPointerTests
          .FunctionPointerProvider/MultiplyDelegate)
      IL_0015:  ret
    } 
    

    Dans cet exemple, il n’y a pas d’utilisation de calli puisqu’on ne fait que fournir le pointeur de fonction, il n’y a pas d’appels de fonction en utilisant un pointeur.

Un delegate* ne peut pas être initialisé en C# qu’avec une fonction statique

Contrairement aux delegates managés, il n’est possible d’instancier un delegate* qu’avec une fonction statique en C#.
On peut écrire:

public unsafe class FunctionPointerProvider
{
  private static int Multiply(int arg1, int arg2) { ... }

  public void DelegateExample()
  {
    delegate* <int, int, int> fcnPtr = &Multiply; // OK
    // ...
  }
}

Mais ce code provoque une erreur à la compilation:

public unsafe class FunctionPointerProvider
{
  private int Multiply(int arg1, int arg2) { ... }

  public void DelegateExample()
  {
    delegate* <int, int, int> fcnPtr = &Multiply; // ⚠ ERREUR ⚠
    // ...
  }
}

Une solution est d’utiliser une fonction statique et de fournir une instance de la classe, par exemple:

public unsafe class FunctionPointerProvider
{
  private static int Multiply(FunctionPointerProvider instance, int arg1, int arg2) { ... }

  public void DelegateExample()
  {
    delegate* <FunctionPointerProvider, int, int, int> fcnPtr = &Multiply; // OK
    // ...
  }
}

Conversions de delegate*

Comme on peut le voir dans les exemples précédents, il est possible d’effectuer quelques manipulations sur les pointeurs de fonction comme:

  • Effectuer des conversions de delegate* vers void* et inversement:
    • La conversion est implicite dans le sens delegate*void*:
      delegate* managed<int, int, int> functionPointer = ...
      void* voidPointer = functionPointer; // Conversion implicite
      
    • La conversion doit être explicite dans le sens void*delegate*:
      void* voidPointer = ...;
      // Conversion explicite
      delegate* managed<int, int, int> functionPointer = (delegate* managed<int, int, int>)voidPointer; 
      
  • Dans le même sens, on peut convertir les delegate* en IntPtr:
    delegate* managed<int, int, int> functionPointer = ...
    IntPtr pointer = new IntPtr(functionPointer);
    

    Pour afficher l’adresse du pointeur:

    Console.WriteLine(pointer.ToString("X"));

Benchmark

De façon à comparer les performances des appels en utilisant des pointeurs de fonctions, on se propose plusieurs cas de figure d’exécution d’un algorithme. Cet algorithme effectue un traitement qui n’a pas de sens mathématique et dont la complexité est Ο(loopCount * 20)

  • loopCount est un nombre de boucles qu’on choisit suffisamment grand pour que l’exécution de l’algorithme soit significatif.
  • 20 car dans l’algorithme, un tableau de 20 entiers est parcouru. Ce nombre d’entiers est choisi arbitrairement.

Durant ce traitement une multiplication entre 2 entiers est effectuée et répétée loopCount * 20 fois. On effectue volontairement cette multiplication dans une fonction séparée de façon à modifier les appels suivant les différents cas de figure:

  • Un appel normal à une fonction managée: cet appel sert de référence.
  • Un appel en utilisant un délégué managé: cet appel s’effectue seulement dans le code managé. Techniquement cet appel est très semblable à un appel normal puisque le delegate est une fonction managée.
  • Un appel en utilisant un pointeur d’une fonction se trouvant dans du code managé: ce scénario ne s’effectue que dans du code managé. Il permet d’instancier un pointeur d’une fonction managée. Les appels sont ensuite effectués en utilisant ce pointeur de fonction managé.
  • Un appel en utilisant un pointeur d’une fonction se trouvant dans du code natif: ce scénario permet d’effectuer plusieurs appels à une fonction se trouvant dans du code natif en utilisant un pointeur de fonction. Le pointeur de fonction se trouvant dans le code natif est récupéré avec un appel Platform/Invoke.
  • Un appel en fournissant à une fonction native un pointeur d’une fonction managée: ce scénario permet d’utiliser un pointeur vers une fonction managée à partir de code natif.

L’implémentation de ce benchmark est la suivante:

public void RunBenchmark()
{
  var firstArray = new int[] { 23, 87,  51, 98, 29, 75, 93, 48, 24, 83, 47, 38, 62, 22, 97, 15, 52, 41, 74, 13 };
  var secondArray = firstArray.Reverse().ToArray();

  int arrayLength = firstArray.Length;
  int value = 0;
  int offset = 0;
  bool add = true;
  for (int i = 0; i < loopCount; i++)
  {
     for (int j = 0; j < arrayLength; j++)
     {
        int index = (offset + j) % arrayLength;
        int multiplicationResult = Multiply(firstArray[index], secondArray[index]);
        if (add)
          value += multiplicationResult;
        else
          value -= multiplicationResult;

        add = !add;
     }

     offset++;
  }
}

avec

private int Multiply(int arg1, int arg2)
{
  return arg1 * arg2;
}
Code sur GitHub

Le code de cet exemple se trouve dans le repository GitHub: github.com/msoft/Cs9_FunctionPointer

On décline ensuite cette implémentation suivant les différents types d’appels à effectuer en ne modifiant que l’appel à la fonction effectuant la multiplication.

  • Un appel normal à une fonction managée:
    On crée la classe suivante:

    public class MultiplyClass
    {
      public int Multiply(int arg1, int arg2)
      {
        return arg1 * arg2;
      }
    }
    

    On instancie cette classe pour l’utiliser dans la fonction exécutant le benchmark:

    public class Benchmark
    {
      private readonly MultiplyClass multiplyClass;
    
      public Benchmark()
      {
        this.multiplyClass = new MultiplyClass();
      }
    
      [Benchmark]
      public void InstanceFunctionCall()
      {
        // ... 
        for (int i = 0; i < loopCount; i++)
        {
          for (int j = 0; j < arrayLength; j++)
          {
            // ...      
            int multiplicationResult = this.multiplyClass.Multiply(firstArray[index], secondArray[index]);
            // ...   
          }
    
          // ...
        }
      }
    }
    
  • Un appel en utilisant un délégué managé
    On crée un delegate managé pour wrapper l’appel à la fonction MultiplyClass.Multiply()

    public class Benchmark
    {
      private readonly MultiplyClass multiplyClass;
      private delegate int multiplyDelegate(int arg1, int arg2); // Définition du delegate
      private readonly multiplyDelegate multiplyManagedDelegate;
    
      public Benchmark()
      {
        this.multiplyClass = new MultiplyClass();
        this.multiplyManagedDelegate = this.multiplyClass.Multiply;
      }
    
      [Benchmark]
      public void ManagedDelegateCall()
      {
        // ... 
        for (int i = 0; i < loopCount; i++)
        {
          for (int j = 0; j < arrayLength; j++)
          {
            // ...      
            int multiplicationResult = this.multiplyManagedDelegate(firstArray[index], secondArray[index]);
            // ...   
          }
    
          // ...
        }
      }
    }
    
  • Un appel en utilisant un pointeur d’une fonction se trouvant dans du code managé:
    On ajoute une fonction statique permettant d’effectuer la multiplication et on crée un pointeur de fonction vers cette fonction statique. On appelle ensuite le pointeur dans la méthode du benchmark:

    public unsafe class Benchmark
    {
      private readonly delegate* <int, int, int> multiplyManagedPointer;
    
      public Benchmark()
      {
        this.multiplyManagedPointer = &Multiply;
      }
    
      private static int Multiply(int arg1, int arg2)
      {
        return arg1 * arg2;
      }
    
      [Benchmark]
      public void ManagedFunctionPointerCall()
      {
        // ... 
        for (int i = 0; i < loopCount; i++)
        {
          for (int j = 0; j < arrayLength; j++)
          {
            // ...      
            int multiplicationResult = this.multiplyManagedPointer(firstArray[index], secondArray[index]);
            // ...   
          }
    
          // ...
        }
      }
    }
    
  • Un appel en utilisant un pointeur d’une fonction se trouvant dans du code natif
    On crée une fonction native permettant de renvoyer un pointeur vers une fonction dans le code natif. Ce code se trouve dans un projet permettant de générer une DLL C++:

    • Dans le fichier .cpp:
      int Multiply(int arg1, int arg2)
      {
        return arg1 * arg2;
      }
      
      void* GetMultiplyFunctionPointer()
      {
      	int (*)(int, int) fcnPtr = &Multiply;
      	return reinterpret_cast<void*>(fcnPtr);
      }
      
    • Dans le fichier .h:
      extern "C" __delspec(dllexport) void* GetMultiplyFunctionPointer();
      static int Multiply(int arg1, int arg2);
      

    Dans le code C#, on crée une indication pour effectuer un appel Platform/Invoke avec DllImport:

    public unsafe class Benchmark
    {
      private readonly delegate* unmanaged<int, int, int> multiplyUnmanagedPointer;
    
      [DllImport("NativeCallee.dll", CallingConvention = CallingConvention.StdCall, CharSet = CharSet.Unicode)]
      public extern static delegate* unmanaged<int, int, int> GetMultiplyFunctionPointer();
    
      public Benchmark()
      {
        this.multiplyUnmanagedPointer = GetMultiplyFunctionPointer();
      }
    
      [Benchmark]
      public void UnmanagedFunctionPointerCall()
      {
        // ... 
        for (int i = 0; i < loopCount; i++)
        {
          for (int j = 0; j < arrayLength; j++)
          {
              // ...      
            int multiplicationResult = multiplyUnmanagedPointer(firstArray[index], secondArray[index]);
            // ...   
          }
    
          // ...
        }
      }
    }
    
  • Un appel en fournissant à une fonction native un pointeur d’une fonction managée:
    La méthode d’exécution du benchmark est codée coté code natif. Un paramètre de cette méthode permet d’indiquer un pointeur de fonction qui va effectuer la multiplication. Dans le cadre de ce test, on fournit le pointeur d’une fonction managée.

    Coté code natif, l’implémentation est:

    • Dans le fichier .cpp:
      void PerformBenchmarkWithFunctionPointer(int loopCount, int(*multiplyFcn)(int, int))
      {
        const int arrayLength = 20;
      
        int firstArray[arrayLength] = { 23, 87, 51, 98, 29, 75, 93, 48, 24, 83, 47,
          38, 62, 22, 97, 15, 52, 41, 74, 13 };
        int secondArray[arrayLength];
      
        for (int i = 0; i < arrayLength; i++)
        {
          secondArray[i] = firstArray[arrayLength - i];
        }
      
        int value = 0;
        int offset = 0;
        bool add = true;
        for (int i = 0; i < loopCount; i++)
        {
          for (int j = 0; j < arrayLength; j++)
          {
            int index = (offset + j) % arrayLength;
            int multiplicationResult = multiplyFcn(firstArray[index], secondArray[index]);
            if (add)
              value += multiplicationResult;
            else
              value -= multiplicationResult;
      
            add = !add;
          }
      
          offset++;
        }
      }
      
    • Dans le fichier .h:
      extern "C" __declspec(dllexport) void PerformBenchmarkWithFunctionPointer(int loopCount, 
          int(*multiplyFcn)(int, int));
        

    Le code C# permettant d’appeler le code natif est:

    public unsafe class Benchmark
    {
      private readonly delegate* <int, int, int> multiplyManagedPointer;
    
      [DllImport("NativeCallee.dll", 
        CallingConvention = CallingConvention.StdCall, 
        CharSet = CharSet.Unicode)]
      public extern static int PerformBenchmarkWithFunctionPointer(int loopCount, 
        delegate* <int, int, int> multiplFcn);
    
      public Benchmark()
      {
        this.multiplyManagedPointer = &Multiply;
      }
    
      private static int Multiply(int arg1, int arg2)
      {
        return arg1 * arg2;
      }
    
      [Benchmark]
      public void UnmanagedFunctionPointerCall()
      {
        PerformBenchmarkWithFunctionPointer(loopCount, this.multiplyManagedPointer);
      }
    }
    

Les résultats de l’exécution sont:

BenchmarkDotNet=v0.13.1, OS=Windows 10.0.18363.1679 (1909/November2019Update/19H2)
Intel Xeon CPU ES-2697 v3 2,6Ghz, 2 CPU, 4 logical and physical cores
.NET SDK=5.0.302
  [Host]      : .NET 5.0.8 (5.0.821.31504), X64 RyuJIT
  DefaultJob  : .NET 5.0.8 (5.0.821.31504), X64 RyuJIT

|                                   Method	|    Mean	|   Error	|  StdDev	|
|------------------------------------------	|--------	|--------	|--------	|
|                     InstanceFunctionCall	| 49.51ms	| 1.054ms	| 3.075ms	|
|                      ManagedDelegateCall	| 65.37ms	| 1.306ms	| 3.130ms	|
|                ManagedFuntionPointerCall	| 77.87ms	| 1.549ms	| 4.266ms	|
|             UnmanagedFunctionPointerCall	| 75.76ms	| 1.494ms	| 2.281ms	|
|   ProvideFunctionPointerToNativeFunction      | 49.04ms       | 0.979ms	| 1.089ms	|

Si on exécute plusieurs fois ces tests, les résultats peuvent être sensiblement différents toutefois les différences de performances entre les différents cas de figure sont les mêmes:

  • InstanceFunctionCall() l’appel normal à une fonction managée est la référence. Le temps d’exécution est le plus court.
  • ManagedDelegateCall() l’utilisation d’un delegate managé introduit un temps de traitement plus long dans ce test bien que dans la pratique l’utilisation d’un delegate managé n’entraîne pas des performances moins bonnes.
  • ManagedFuntionPointerCall() et UnmanagedFunctionPointerCall() les appels utilisant un pointeur de fonction delegate* provoquent tous les 2 un temps de traitement plus long. Le choix des delegate* n’est pas anodin et doit se faire s’il apporte un gain par rapport à des appels à du code natif sans passer par des pointeurs de fonction.
  • ProvideFunctionPointerToNativeFunction() ce cas de figure n’est pas vraiment pertinent par rapport aux tests précédents puisque la majorité du code est exécutée par le runtime C++. Les performances semblent égalées celles d’un appel normal malgré l’utilisation d’un pointeur de fonction.

On peut juste retenir que l’utilisation de pointeurs de fonction dégrade les performances par rapport à un appel normal. L’utilisation de ces pointeurs doit se faire si le gain est avéré et permet d’éviter, par exemple, d’effectuer une succession d’appels de type Platform/Invoke.

Références

Compiler intrinsics

MSIL/CIL:

Calli

Delegates

GC Premptive vs Cooperative

Share on RedditTweet about this on TwitterShare on LinkedInEmail this to someonePrint this page

Les records (C# 9.0)

Cet article fait partie d’une série d’articles sur les apports fonctionnels de C# 9.0.

C# 9 introduit un nouveau type d’objets dont le but est de fournir une syntaxe simple pour déclarer des objets de type référence contenant des propriétés. Ces objets peuvent être définis en utilisant le mot-clé record.

Cet article a pour but de passer en revue les propriétés des objets record.

Suivant la façon dont ces objets sont instanciés, le compilateur peut rendre ces objets immutables et ajouter des méthodes à l’implémentation existante.

Il existe 2 syntaxes pour déclarer des objets records:

  • Une syntaxe condensée appelée positional record, par exemple:
    public record Car(string Brand, string Model); 
    

    Cette syntaxe permet de générer implicitement un objet immutable: les propriétés Car.Brand et Car.Model ne sont accessibles qu’en lecture seule.

  • Une syntaxe plus classique avec un constructeur:
    public record Car
    {
        public Car(string brand, string model)
        {
            this.Brand = brand;
            this.Model = model;
        }
    
        public string Brand { get; }
        public string Model { get; }
    }
    

    Les caractéristiques de l’objet record généré avec cette syntaxe sont explicites: les propriétés sont en lecture seule parce-qu’il n’existe que l’accesseur get.

De base, le compilateur rajoute des méthodes à l’implémentation des objets record de façon à faciliter leur utilisation et à étendre leurs caractéristiques:

  • La comparaison d’objets record se fait en comparant les propriétés membre des objets et non en comparant les références des objets (comme c’est le cas par défaut pour les objets de type référence).
  • Un constructeur est implicitement rajouté pour facilement instancier les objets record. Le constructeur rajouté permet d’affecter toutes les propriétés du record.
  • Ces objets peuvent être affichés directement avec ToString() sans avoir à surcharger cette méthode.
  • Ces objets supportent la déconstruction de ses propriétés sans effectuer d’implémentation particulière.

Ainsi si on définit un objet record de cette façon (syntaxe positional record):

public record Car(string Brand, string Model); 

Le compilateur va complêter l’implémentation pour produire une classe dont l’implémentation équivalente pourrait être:

public class Car 
{ 
    public string Brand { get; } // Propriétés accessibles en lecture seule
    public string Model { get; } 

    // Ajout d'un constructeur implicite
    public Car(string Brand, string Model) 
    {
        this.Brand = Brand;
        this.Model = Model;
    }   

    // Implémentation de ToString() 
    public override string ToString() 
    { 
        return $"Car {{ Brand = {this.Brand}, Model = {this.Model} }}"; 
    } 

    // Implémentation de Equals() 
    public override bool Equals(object obj) { ... } 
    public override int GetHashCode() { ... } 

    // Surcharge d'opérateurs 
    public static bool operator ==(object a, Car b) 
    { ... }; 

    public static bool operator !=(object a, Car b) 
    { ... }; 

    // Implémentation d'un déconstructeur
    public void Deconstruct(out string brand, out string model)  
        => (brand, model) = (this.Brand, this.Model); 
} 

Ainsi l’intérêt principal des objets record est de complêter l’implémentation en rajoutant des fonctions courantes sans avoir à surcharger la syntaxe.

Même si l’implémentation des objets record est complêtée par un constructeur ou des méthodes à la compilation, il reste possible d’implémenter d’autres constructeurs ou d’autres méthodes comme pour une classe:

public record Car 
{ 
    public string Brand { get; set; } 
    public string Model { get; set; } 

    public Car(string brand) 
    { 
        this.Brand = brand; 
        this.Model = string.Empty; 
    } 

    public string GetCompleteName() 
    { 
        return $"{this.Brand} {this.Model}"; 
    } 
} 

Un record est une classe

Un objet record est compilé sous forme d’une classe. Si on regarde le code MSIL (i.e. MicroSoft Intermediate Language) obtenu si on compile le code C# suivant, on remarque que les instructions sont quasiment les mêmes que pour une classe:

Code C# Instructions MSIL
public class CarAsClass
{ 
    public string Brand { get; init; }  
    public string Model { get; init; }  
} 
.class public auto ansi beforefieldinit Cs9.CarAsClass 
    extends [System.Runtime]System.Object
{ 
    //...  
} // End of class Cs9.CarAsClass 
public record CarAsRecord
{ 
    public string Brand { get; init; }  
    public string Model { get; init; }  
} 
.class public auto ansi beforefieldinit Cs9.CarAsRecord 
    extends [System.Runtime]System.Object 
    implements [System.Runtime]System.IEquatable`1<Cs9.CarAsRecord>
{ 
    // ... 
} // End of class Cs9.CarAsRecord 

La différence entre le 2 objets est que le compilateur génère implicitement davantage de fonctions pour l’objet record comme par exemple ToString(), PrintMembers(), Equals(), GetHashCode() etc…

Déclaration et construction

Comme indiqué plus haut, il existe 2 syntaxes pour déclarer un objet record:

  • Une syntaxe explicite similaire à celle des classes: avec cette syntaxe, les accès aux propriétés sont explicitement implémentés.

    Par exemple:

    public record Car 
    { 
        public string Brand { get; set; } 
        public string Model { get; set; } 
    } 
    

    Un objet record déclaré de cette façon peut être instancié en utilisant des initializers:

    var car = new Car{ Brand = "Renault", Model = "4L" }; 
    
  • Une syntaxe condensée (i.e. positional record): les accès aux propriétés sont implicitement en lecture seule.

    Par exemple:

    public record Car(string Brand, string Model); 
    

    Dans ce cas, cet objet peut être instancié en utilisant un constructeur:

    var car  = new Car("Renault", "4L"); 
    

    Ou en omettant le type après new:

    Car car  = new ("Renault", "4L"); 
    

    Avec cette construction, les propriétés sont en lecture seule:

    var brand = car.Brand; // OK 
    car.Brand = "Peugeot"; // ⚠ ERREUR ⚠
    

    D’autres constructions sont possibles pour préciser d’autres propriétés ou des méthodes en dehors du constructeur, par exemple:

    public record Car(string Brand, string Model) 
    { 
        public int Power { get; set; } 
    
        public string GetCompleteName() 
        { 
            return $"{this.Brand} {this.Model}"; 
        } 
    } 
    

    Avec cette dernière déclaration, on peut instancier en utilisant un initializer:

    var car  = new Car("Renault", "4L") { Power = 40 }; 
    

Construction en utilisant une expression avec with

On peut instancier des objets record à partir d’autres objets en utilisant with, par exemple:

public record Car(string Brand, string Model); 

// ...

var sedan = new Car("Tesla", "3"); 
var suv = sedan with { Model = "X" }; 

Héritage

Les objets record supportent l’héritage ce qui est un avantage par rapport aux objets struct, par exemple:

public record Vehicle 
{ 
    public Vehicle(int wheelCount, int doorCount) 
    { 
        this.WheelCount = wheelCount; 
        this.DoorCount = doorCount; 
    } 

    public int WheelCount { get; } 
    public int DoorCount { get; } 
} 

public record Car : Vehicle 
{ 
    public Car(string brand, string model, int wheelCount, int doorCount):  
        base(wheelCount, doorCount) 
    { 
        this.Brand = brand; 
        this.Model = model; 
    } 

    public string Brand { get; } 
    public string Model { get; } 
} 

Si on utilise la syntaxe positional record, l’implémentation équivalente est:

public record Vehicle(int WheelCount, int DoorCount); 

public record Car(string Brand, string Model, int WheelCount, int DoorCount): 
    Vehicle(WheelCount, DoorCount); 

Comparaison

Comme indiqué plus haut, la comparaison entre des objets record est facilitée puisque qu’il n’est pas nécessaire de surcharger explicitement la fonction Equals(). Implicitement, le compilateur rajoute une implémentation de la fonction Equals() permettant de comparer toutes les propriétés, par exemple:

var car1 = new Car("Tesla", "3"); 
var car2 = new Car("Tesla", "3"); 
 
Console.WriteLine(car1.Equals(car2)); // true 

Les objets sont égaux par comparaison des valeurs des propriétés toutefois ils sont bien distincts en comparant les références:

Console.WriteLine(ReferenceEquals(car1, car2)); // false 

Les opérateurs d’égalité et d’inégalité sont surchargés:

Console.WriteLine(car1 == car2); // true 
Console.WriteLine(car1 != car2); // false 

Surcharger Equals() et les opérateurs d’égalité et d’inégalités

Même si le compilateur rajoute implicitement une implémentation pour les fonctions:

  • bool Equals(Object object),
  • int GetHashCode() et
  • Des opérateurs == et =!.

Il est possible de proposer une autre implémentation pour GetHashCode() par override toutefois ce n’est pas possible pour bool Equals(Object object) et pour les opérateurs == et =!:

public record Car(string Brand, string Model) 
{ 
    public override bool Equals(Object obj) { ... } // ⚠ ERREUR ⚠
    public override int GetHashCode() {... } //OK 

    public static bool operator ==(Car car1, Car car2) => { ... } // ⚠ ERREUR ⚠
    public static bool operator !=(Car car1, Car car2) => { ... } // ⚠ ERREUR ⚠
} 

Il est possible de proposer une nouvelle implémentation Equals() si:

  • La signature n’est pas Equals(Object obj) et
  • Si la nouvelle implémentation est une fonction virtuelle ou si l’objet record est sealed (i.e. avec sealed il n’est pas possible d’hériter de l’objet record).

Par exemple, pour proposer une nouvelle implémentation de Equals() l’argument ne doit pas être de type object. Dans ce cas, on définit une nouvelle surcharge de Equals(), il n’y a plus d’override:

public record Car(string Brand, string Model) 
{ 
    public virtual bool Equals(Car car) { ... } // OK 
    public override int GetHashCode() {... }  
} 

Ou l’objet record doit être sealed:

public sealed record Car(string Brand, string Model) 
{ 
    public bool Equals(Car car) { ... } // OK 
    public override int GetHashCode() {... }  
} 

Dans ce cas, si on ne propose pas une implémentation pour GetHashCode(), un warning est généré à la compilation.

La comparaison prend en compte le type

Dans le cas de l’implémentation par défaut de Equals(), la comparaison implique la prise en compte des types des objets record. Ainsi même si les valeurs des membres sont identiques, le type des objets est pris en compte.

Par exemple si on considère des objets record héritant du même objet, possédant des propriétés identiques et dont les valeurs sont aussi identiques, les objets ne pourront être égaux puisqu’ils ne sont pas de même type.

Par exemple:

public record Vehicle(int WheelCount, int DoorCount); 

public record Car(string Brand, string Model, int WheelCount, int DoorCount): Vehicle(WheelCount, DoorCount); 

public record Truck(string Brand, string Model, int WheelCount, int DoorCount): Vehicle(WheelCount, DoorCount); 
 
// ...

var car = new Car { Brand = "Tesla",  Model = "3", WheelCount = 4, DoorCount = 4}; 
var truck = new Truck { Brand = "Tesla",  Model = "3", WheelCount = 4, DoorCount = 4}; 

Console.WriteLine(car.Equals(truck)); // False 
Console.WriteLine(car == truck); // False 

Immutabilité

L’accès en écriture des propriétés d’un objet record est le même que pour une classe. Un objet record n’est pas forcément immatuble. On peut le rendre immutable comme pour une classe en n’utilisant des accesseurs n’autorisant que l’accès en lecture après la construction.

Plusieurs possibilités:

  • En omettant l’accesseur set; la propriété ne peut être initilisée que dans le constructeur:
    public record Car 
    { 
        public Car(string brand) 
        { 
            this.Brand = brand; // OK 
        } 
    
        public string Brand { get; } 
    } 
    
    // ...
    
    var car1  = new Car("Tesla"); // OK 
    car1.Brand = "Nio"; // ⚠ ERREUR ⚠ 
    
    var car2  = new Car{ Brand = "Nio" }; // ⚠ ERREUR ⚠ 
    
  • Un accesseur init; pour ne permettre l’initialisation qu’avec un constructeur ou un initializer.
    public record Car 
    { 
        public Car(string brand) 
        { 
            this.Brand = brand; // OK 
        } 
    
        public string Brand { get; init; } 
    } 
    
    // ...
    
    var car1  = new Car("Tesla"); // OK 
    car1.Brand = "Nio"; // ⚠ ERREUR ⚠ 
    
    var car2  = new Car{ Brand = "Tesla" }; // OK 
    
  • Utiliser un champ readonly et un accesseur init, l’affectation n’est possible que dans un constructeur ou avec un initializer:
    public record Car 
    { 
        private readonly string brand; 
    
        public Car(string brand) 
        { 
            this.Brand = brand; // OK 
        } 
    
        public string Brand  
        {  
            get => this.brand; 
            init => this.brand = value; 
        } 
    } 
    
    // ...
    
    var car1  = new Car("Tesla"); // OK 
    car1.Brand = "Nio"; // ⚠ ERREUR ⚠ 
     
    var car2 = new Car { Brand = "Nio" }; //OK  
    

Implémentation implicite de ToString()

Pour les objets record, le compilateur génère une implémentation de ToString() de façon à afficher facilement les valeurs des propriétés.

Par exemple, si on considère l’objet suivant:

public record Car 
{ 
    public string Brand { get; init; } 
    public string Model { get; init; } 
} 

// ...

var car = new Car{ Brand = "Tesla", Model = "3" }; 
Console.WriteLine(car); 

On obtient:

Car { Brand = Tesla, Model = 3 } 

Il est possible de surcharger la méthode ToString():

public record Car 
{ 
    public string Brand { get; init; } 
    public string Model { get; init; } 

    public override string ToString() 
    { 
        // ... 
    } 
} 

PrintMembers()

Dans l’implémentation par défaut de ToString(), pour afficher les valeurs des propriétés, une fonction dont la signature est bool PrintMembers(StringBuilder stringBuilder) est rajoutée à la compilation et est appelée par ToString(). Cette fonction ajoute dans l’objet stringbuilder, les valeurs des propriétés.

Un exemple de l’implémentation de ToString() appelant PrintMembers() est:

public override string ToString() 
{ 
    StringBuilder stringBuilder = new StringBuilder(); 
    stringBuilder.Append("Car");  
    stringBuilder.Append(" { "); 
  
    if (PrintMembers(stringBuilder)) 
    { 
        stringBuilder.Append(" "); 
    } 
 
    stringBuilder.Append("}"); 
    
    return stringBuilder.ToString(); 
} 

On peut proposer une autre implémentation pour PrintMembers() toutefois il faut permettre que cette méthode soit surchargée dans une classe qui hériterait de la classe où se trouve PrintMembers(). Ainsi 2 solutions sont possibles pour cette implémentation:

  • Implémenter une fonction virtuelle et protected de PrintMembers():
    public record Car 
    { 
        public string Brand { get; set; } 
        public string Model { get; set; } 
    
        protected virtual bool PrintMembers(StringBuilder stringBuilder) 
        { 
            // ... 
        } 
    } 
    
  • Si l’objet record est sealed (on ne peut donc pas en hériter), il faut implémenter PrintMembers() sous forme d’une fonction privée:

    public sealed record Car 
    { 
        public string Brand { get; set; } 
        public string Model { get; set; } 
    
        private bool PrintMembers(StringBuilder stringBuilder) 
        { 
            // ... 
        } 
    }
    

PrintMembers() dans le cas d’héritage

Dans le cas où l’objet record dérive d’un autre objet, les implémentations de PrintMembers() changent suivant si l’objet record est sealed ou non:

  • Si l’objet n’est pas sealed (c’est-à-dire qu’on peut en hériter), PrintMembers() doit être protected override:
    public record Vehicle 
    { 
        public int WheelCount { get; init; } 
        public int DoorCount { get; init; } 
    
        protected virtual bool PrintMembers() 
        { 
            //...  
        } 
    } 
    
    public record Car : Vehicle 
    { 
        public string Brand { get; init; } 
        public string Model { get; init; } 
    
        protected override bool PrintMembers() 
        { 
            //...  
        } 
    } 
    
  • Si l’objet est sealed (c’est-à-dire qu’on ne peut pas en hériter), PrintMembers() doit être une fonction protected sealed override:
    public record Vehicle 
    { 
        public int WheelCount { get; init; } 
        public int DoorCount { get; init; } 
    
        protected virtual bool PrintMembers() 
        { 
            //...  
        } 
    } 
    
    public sealed record Car : Vehicle 
    { 
        public string Brand { get; init; } 
        public string Model { get; init; } 
    
        protected sealed override bool PrintMembers() 
        { 
            //...  
        } 
    } 
    

Implémentation d’un destructeur avec la syntaxe positional record

Si on utilise la syntaxe positional record pour déclarer un objet record, le compilateur rajoute une implémentation pour un constructeur et pour un deconstructeur.

Ainsi, si on considère un record défini de la façon suivante:

public record Car(string Brand, string Model); // déclaration avec la syntaxe positional record 

Un constructeur et un deconstructeur sont ajoutés à la compilation, on peut donc écrire:

var car = new Car("Tesla", "3"); // OK 

(string brand, string model) = car; // OK  
Ajout du constructeur et du deconstructeur par le compilateur si la syntaxe utilisée est positional record

L’ajout du constructeur et du deconstructeur n’est effectué par le compilateur que si on utilise la syntaxe positional record pour déclarer le record.

Si on définit le record de cette façon:

public record Car 
{ 
    public string Brand { get; set; } 
    public string Model { set; set; } 
} 

var car = new Car("Tesla", "3"); // ⚠ ERREUR ⚠, pas de constructeur 
(string brand, string model) = car; // ⚠ ERREUR ⚠, pas de destructeur 

Il est possible de rajouter un deconstructeur explicitement à un objet record (au même titre que le constructeur):

public record Car 
{ 
    public Car(string brand, string model)  
    { 
        this.Brand = brand; 
        this.Model = model; 
    } 

    public string Brand { get; set; } 
    public string Model { set; set; } 
    
    public void Deconstruct(out string brand, out string model)  
        => (brand, model) = (this.Brand, this.Model); 
} 

Pour résumer…

Un objet record est une classe dans laquelle le compilateur rajoute implicitement une implémentation pour des fonctions usuelles comme Equals(), ToString(), un déconstructeur et des surcharges pour les opérateurs d’égalité et d’inégalité.
2 syntaxes permettent de déclarer des objets record:

  • Une syntaxe classique proche de celle des classes: dans ce cas, l’objet record n’est pas forcément immutable, c’est l’implémentation qui détermine explicitement les propriétés de l’objet.
  • Une syntaxe condensée appelée positional record qui permet d’implémenter un objet immutable avec un constructeur permettant d’affecter toutes les propriétés, par exemple:
    public record Car(string Brand, string Model); 
    

    Ce record peut être instancié en utilisant le constructeur implicite:

    var car = new Car("Car brand", "Car model");
    
Classe
(class)
Record
(record)
Structure
(struct)
Type d’objet Objet de type référence Objet de type valeur
Manipulation des variables, passage de paramètre Par copie de référence Par copie de valeur
Stockage Dans le tas managé Dans la pile mais peut être stocké dans le tas managé, par exemple:

  • si la struct contient une référence
  • en cas de boxing
Peut être statique
Oui
Non
Non
Héritage
Supporté
Non
Constructeur sans paramètre Implicite en cas d’absence d’implémentation de constructeur Un constructeur sans paramètre ne peut pas être implémenté.
Constructeur de copie
Non

(A implémenter explicitement)

Oui

si on utilise with

Toute affectation est une copie
Immutable
Non
Non
Oui
Oui si on utilise la syntaxe positional record
Comportement par défaut en cas de comparaison (avec ==, != ou Equals()) Comparaison des références Comparaison des données membre
Surchage de Equals(Object obj)
Possible
Impossible
Possible
Comportement par défaut de ToString() Chaine contenant le type de la classe Chaine contenant un affichage amélioré des valeurs des membres Chaine contenant le type de la struct
Type d’objet dans le code MSIL class struct
Comportement en cas d’absence d’opérateur de portée des membres Privé par défaut Public par défaut
Durée de vie gérée par le Garbage Collector Oui Non
Share on RedditTweet about this on TwitterShare on LinkedInEmail this to someonePrint this page

Les composants Angular

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

@brodanoel

Les composants font partie des objets les plus importants d’Angular car ils permettent d’afficher des vues. Chaque vue correspond à une unité capable d’afficher une partie d’une application Angular.

Pour ordonner cette unité d’implémentation, un composant est formé de différents éléments:

  • La vue du composant appelée template: cette partie comporte du code HTML correspondant aux objets statiques. Ce squelette statique est enrichi par du code interprété par Angular pour permettre des interactions dynamiques entre les objets statiques et du code métier se trouvant dans le reste du composant.
  • La classe du composant: c’est une classe Typescript dans laquelle on peut définir:
    • des membres contenant les données affichées par la vue,
    • des méthodes et fonctions pour exécuter des traitements déclenchés par des actions sur la vue ou par des évènements divers.
  • Des métadonnées: il s’agit d’informations supplémentaires d’implémentation qui permettront à Angular d’interfacer le template avec la classe du composant.

Cet article permet de présenter les fonctionnalités les plus importantes des composants de façon à pouvoir en créer et à en comprendre les principales caractéristiques. Les détails de chaque fonctionnalité seront présentés dans d’autres articles.

Fonctionnement général d’un composant

Une vue Angular est composée d’éléments HTML avec une hiérarchie comme une page HTML classique. Angular s’interface avec le DOM correspondant à ces éléments de façon à en modifier les caractéristiques de façon dynamique. Ainsi, la vue comporte des éléments qui vont rester statiques durant l’exécution de l’application et d’autres éléments dont l’affichage pourrait être modifié suivant différents évènements. L’implémentation de ces éléments se fait dans la classe du composant et dans le fichier template.

Création d’un composant

Par exemple, après avoir créé une application Angular avec le CLI Angular en utilisant ng build (voir Créer une application Angular “from scratch” pour plus de détails), on peut créer un composant nommé Example en exécutant:

ng generate component example 

Ou de façon plus condensée:

ng g c example 

Cette étape effectue plusieurs opérations:

~% ng g c example 
CREATE src/app/example/example.component.css (0 bytes) 
CREATE src/app/example/example.component.html (22 bytes) 
CREATE src/app/example/example.component.spec.ts (635 bytes) 
CREATE src/app/example/example.component.ts (279 bytes) 
UPDATE src/app/app.module.ts (815 bytes) 

Dans un premier temps, 4 fichiers sont créés:

  • example.component.html qui est le fichier template dans lequel on peut implémenter les éléments graphiques de la vue.
  • example.component.ts qui est la classe Typescript du composant.
  • example.component.css qui contient de façon facultative le style CSS utilisé par la vue du composant.
  • example.component.spec.ts contenant les tests unitaires à implémenter pour le composant.

Dans un 2e temps, le composant sera rajouté dans le Root Module de l’application dans app.module.ts:

@NgModule({ 
  declarations: [ 
    AppComponent, 
    ExampleComponent
  ], 
  imports: [ 
    BrowserModule, 
    AppRoutingModule 
  ], 
  providers: [], 
  bootstrap: [AppComponent] 
}) 
export class AppModule { } 

Affichage d’un composant avec le paramètre selector

L’ajout du composant au module de l’application permet à Angular de configurer la factory de composants de façon à instancier le composant dans le cas où il faudrait l’afficher. Toutefois à ce stade, il manque un élément important: il n’y a pas d’éléments dans le code permettant d’indiquer où la vue du composant sera affichée.

On peut indiquer où le composant sera affiché en utilisant le paramètre selector dans les metadatas du composant. Ce paramètre est indiqué dans la classe du composant (cf. example.component.ts):

@Component({ 
  selector: 'app-example',
  templateUrl: './example.component.html', 
  styleUrls: ['./example.component.css'] 
}) 
export class ExampleComponent implements OnInit { 
  constructor() { } 

  ngOnInit(): void { 
  } 
} 

La valeur 'app-example' du paramètre selector indique que le composant Example sera affiché si le template d’un autre composant contient:

<app-example></app-example> 

Ainsi, si on indique ce code dans le template du composant principal (dans le fichier src/app/app.component.html) après en avoir supprimé tout le contenu:

<app-example></app-example> 

Si on lance l’application en exécutant la commande ng server --watch, la vue est directement affichée de cette façon:

example work! 

Afficher plusieurs vues

On peut afficher plusieurs vues en même temps suivant les besoins de l’application. Par exemple si on considère les fichiers index.html et styles.css suivants de façon à afficher 4 zones: header, content, sidebar et footer:

index.html styles.css
<html> 
  <head> 
    <title>Test</title> 
    <link rel="stylesheet" 
      type="text/css" 
      href="styles.css" /> 
  </head> 
  <body> 
    <div id="header"> 
      Header 
    </div> 
    <div id="sidebar"> 
      Sidebar 
    </div> 
    <div id="content"> 
      Content 
    </div> 
    <div id="footer"> 
      Footer 
    </div> 
  </body> 
</html> 
html, body { 
  background: lightgreen; 
  margin: 0; 
  padding: 0; 
  height: 100%; 
} 

div#header { 
  padding: 10px; 
  height: 90px; 
  background: yellow; 
} 

div#footer { 
  position: fixed; 
  height: 30px; 
  bottom: 0; 
  width: 100%; 
  background: lightsalmon; 
  padding-left: 10px; 
} 

div#content { 
  position: relative; 
  margin: 0 210 0 0; 
  bottom: 0; 
  padding-left: 10px; 
  padding-right: 10px; 
} 

div#sidebar { 
  background: lightblue; 
  float: right; 
  width: 190px; 
  position: fixed; 
  right: 0; 
  height: inherit; 
} 

Ces fichiers permettant d’afficher une page comportant 4 zones:

Dans le cadre d’une application Angular, on peut utiliser un composant pour chaque zone et ainsi afficher plusieurs vues. Par exemple, si on reprend l’exemple précédent en créant 4 nouveaux composants:

  1. On exécute les commandes suivantes avec le CLI Angular pour créer les composant header, footer, content et sidebar:
    ~% ng g c header 
    ~% ng g c footer  
    ~% ng g c content 
    ~% ng g c sidebar 
    
  2. On modifie les fichiers templates des composants créés pour que le contenu soit similaire au fichier d’exemple d’origine:
    • app/src/header/header.component.html:
      Header 
      
    • app/src/footer/footer.component.html:
      Footer 
      
    • app/src/sidebar/sidebar.component.html:
      Sidebar 
      
    • app/src/content/content.component.html:
      Content
      
  3. On copie les styles CSS de le fichier src/styles.css de façon à ce que les styles CSS soient disponibles dans toute l’application.
  4. On modifie le template du composant principal dans src/app/app.component.html pour afficher tous les composants en utilisant les paramètres selector de ces composants:
    <div id="header"> 
      <app-header></app-header> 
    </div> 
    <div id="sidebar"> 
      <app-sidebar></app-sidebar> 
    </div> 
    <div id="content"> 
      <app-content></app-content> 
    </div> 
    <div id="footer"> 
      <app-footer></app-footer> 
    </div> 
    

L’affichage obtenu est similaire à l’exemple de départ. On peut voir que chaque partie correspond à un composant différent et à une vue différente.

Liens entre le template et la classe du composant

Pour rendre dynamique l’affichage de la vue d’une composant, Angular permet d’implémenter des interactions entre un template et la classe d’un composant. Ces interactions se font par l’intermédiaire de bindings. L’implémentation de ces bindings permet d’enrichir la vue avec des données ou de déclencher l’exécution de code dans la classe du composant.

Sur le schéma suivant provenant de la documentation Angular, on peut voir 2 types de bindings:

  • Property binding permettant d’échanger des données de la classe du composant vers le template. Ce type de binding permet de modifier une propriété d’un objet dans le DOM à partir d’un membre de la classe du composant.
  • Event binding pour exécuter du code dans la classe du composant à partir d’évènements déclenchés dans le template.

Il existe d’autres types de bindings qui sont décrits de façon plus détaillée dans l’article Les vues des composants Angular:

Interpolation

Le binding le plus simple est l’interpolation. Il permet d’exécuter directement une expression Typescript contenant des membres ou des fonctions publiques dans la classe du composant, par exemple:

Template
<div>{{textToDisplay}}</div>
Classe du composant
@Component({ ... })
export class ExampleComponent {  
  textToDisplay = 'Texte à afficher'; 
} 

Dans cet exemple, à l’exécution {{textToDisplay}} dans le template sera évalué et remplacé par la valeur du membre textToDisplay de la classe.

Property binding

Ce type de binding permet de mettre à jour le contenu d’une propriété DOM d’un élément affiché avec la valeur d’un membre dans la classe du composant, par exemple:

Template
<div [innerText]="textToDisplay"></div> 
Classe du composant
@Component({ ... })  
export class ExampleComponent {  
  textToDisplay = 'Texte à afficher'; 
} 

Dans cet exemple, la propriété innerText de l’objet du DOM sera liée au membre textToDisplay de la classe.

Event binding

Ce binding déclenche l’exécution d’une fonction dans le classe du composant à partir du déclenchement d’un évènement dans un objet du DOM, par exemple:

Template
<p>{{valueToDisplay}}</p> 
<button (click)="incrementValue()">Increment</button> 
Classe du composant
@Component({ ... })
export class ExampleComponent {  
  valueToDisplay = 0; 
 
  incrementValue(): void { 
    this.valueToDisplay++; 
  } 
} 

Dans cet exemple, l’exécution de la fonction incrementValue() dans la classe du composant est lancée quand l’évènement click survient dans l’objet button.

Les autres types de bindings sont décrits dans Les vues des composants Angular.

Composants enfants

Une fonctionnalité importante des composants est qu’ils peuvent contenir d’autres composants. Ainsi, 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> 
    

Les interactions entre le composant parent et un composant enfant sont possibles en utilisant:

  • Des paramètres d’entrée du composant enfant avec @Input().
  • Des évènements de sortie du composant enfant avec @Output().
  • Injecter du contenu dans le composant enfant avec la fonctionnalité content projection.

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ètre d’entrée

Le composant parent peut injecter un paramètre dans le composant enfant en utilisant un property binding. Ainsi 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:

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

Dans cet exemple, le paramètre d’entrée est identifier.

Le composant parent peut effectuer un property binding:

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; 
} 

Le membre childIdentifier du composant parent est injecté par property binding dans le paramètre identifier du composant enfant.

Evènement de sortie

Le composant enfant peut déclencher un évènement qui exécutera une fonction dans le composant parent par event binding.

La déclaration de l’évènement dans le composant enfant se fait en utilisant @Output() et l’objet EventEmitter. EventEmitter permettra d’émettre l’évènement, par exemple:

Template
<p>Child component with identifier: {{identifier}}</p> 
<p> 
    <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; 

  @Ouput() countUpdated: EventEmitter<number>= new EventEmitter<number>();
 
  incrementValue(): void { 
    this.internalCount++; 
    this.countUpdated.emit(this.internalCount); 
  } 
}

Le composant parent peut être notifié du déclenchement de l’évènement en utilisant un event binding sur l’évènement du composant enfant, par exemple:

Template
<h1>Parent component</h1> 
<app-child (countUpdated)='updateTotalCount($event)'></app-child> 
Classe du composant
import { Component } from '@angular/core'; 
     
@Component({ 
  templateUrl: './parent.component.html' 
}) 
export class AppComponent { 
  totalCount = 0; 

  updateTotalCount(count: number): void { 
    this.totalCount = count; 
  } 
}

La fonction updateTotalCount() dans le composant parent est exécutée à chaque déclenchement de l’évènement countUpdated dans le composant enfant.

Il est possible d’implémenter d’autres types d’interactions entre un composant parent et un composant enfant, pour plus de détails voir Les composants enfant.

Cycle de vie d’un composant

Les bindings entre la vue et la classe d’un composant peuvent provoquer des changements dans l’affichage des objets dans la vue d’un composant. La répercussion de ces changements dans la vue se fait par Angular de façon transparente. Par exemple, dans le cas d’une interpolation avec le membre d’une classe, chaque nouvelle valeur du membre provoquera un changement de la valeur affichée:

Template
<div>{{textToDisplay}}</div>
Classe du composant
@Component({ ... })   

export class ExampleComponent {
  
textToDisplay = 'Texte à afficher';  

}

Pour mettre à jour un élément graphique, Angular doit s’interfacer avec le DOM pour créer ou supprimer des objets ou en modifier des propriétés.

Ces accès multiples au DOM sont coûteux en performance. Ainsi, afin d’en limiter au maximum les accès et de modifier les propriétés au minimum, la solution d’Angular est de détecter automatiquement les changements nécessitant une modification dans le DOM. Cette détection s’exécute dans la version des objets maintenue par Angular. Si Angular détecte un changement, il répercute ce changement dans le DOM de façon à ce que les éléments graphiques correspondant puissent être modifiés.

L’algorithme de détection de changements effectue les mises à jour des éléments graphiques suivant un ordre précis. Tout au long de ces mises à jour et suivant les éléments qui sont mis à jour, il va aussi exécuter les callbacks du cycle de vie des composants. La détection de changements est donc très liée au cycle de vie des composants.

Les callbacks peuvent être implémentées au niveau de la classe d’un composant en héritant de l’interface correspondante, par exemple pour OnInit():

import { OnInit } from '@angular/core';  

@Component({ ... })  
export class ExampleComponent implements OnInit {  

  ngOnInit() {  
  }  
}  

Pour implémenter plusieurs callbacks, il suffit de satisfaire plusieurs interfaces. Par exemple pour OnInit() et DoCheck():

import { OnInit, DoCheck } from '@angular/core';  

@Component({ ... })  
export class ExampleComponent implements OnInit, DoCheck {  

  ngOnInit() {  
  }  

  ngDoCheck() {  
  }  
}  

Le but des callbacks est de pouvoir interagir finement avec le framework durant les différentes phases de création d’un composant et de sa vue dans un premier temps ou lors de la détection de changement dans un 2e temps. La succession de ces différentes phases permet d’intervenir:

  • Dans le cas de l’initialisation d’un composant: avant ou après l’initialisation d’un élément particulier de ce composant (paramètres d’entrée, contenu projeté, requête sur la vue etc…)
  • Dans le cas de la détection de changement: avant ou après la vérification d’un changement sur un élément particulier de ce composant.

Ainsi, à 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()). Si cette callback est implémentée sans paramètre, elle sera déclenchée autant de fois qu’il y a de propriétés en entrée du composant.

    Si la callback est implémentée avec un argument de type SimpleChanges:

    void ngOnChanges(changes: SimpleChanges): void {}
    

    Elle sera déclenchée une seule fois.

  2. ngOnInit(): déclenchée après l’exécution du constructeur. Elle 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. 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().

Il faut avoir en tête l’ordre d’appels des callbacks du cycle de vie du composants puisque chaque appel survient avant ou après une opération particulière d’Angular concernant l’initialisation d’objets de la vue, la détection des changements sur ces objets et leur destruction.

Pour plus de détails sur le cycle de vie des composants et sur la détection de changements voir les articles:

Requêter les éléments d’un vue d’un composant

Les bindings permettent d’interfacer des objets de la classe du composant vers le template. A l’opposé si on souhaite effectuer un traitement sur un objet de la vue à partir de la classe du composant, il faut effectuer une requête sur la vue. Cette requête permet de récupérer l’instance d’un composant enfant, d’une directive ou d’un objet du DOM.

Pour effectuer une requête sur la vue, on peut s’aider des décorateurs @ViewChild(), @ViewChildren(), @ContentChild() ou @ContentCildren(). Ces décorateurs se placent devant la propriété ou le membre de la classe du composant dans lesquels l’instance doit être renseignée. Le choix du décorateur à utiliser dépend du type d’objet à requêter:

  • @ViewChild() pour effectuer une requête sur un seul objet directement dans la vue du composant. Seul le 1er objet de la vue satisfaisant la requête est renvoyé.
  • @ViewChildren() pour effectuer une requête sur une liste d’objets directement dans la vue du composant. Tous les objets satisfaisant la requête sont renvoyés.
  • @ContentChild() pour effectuer une requête sur un seul objet se trouvant dans du contenu projeté (i.e. content projection) sur la vue du composant. Seul le 1er objet de la vue satisfaisant la requête est renvoyé.
  • @ContentChildren() pour effectuer une requête sur une liste d’objets se trouvant dans du contenu projeté (i.e. content projection) sur la vue du composant. Tous les objets satisfaisant la requête sont renvoyés.

Par exemple, pour requêter un élément dans la vue d’un composant:

Template
<p>Example component</p>
<span #spanElement>Span content</span>
Classe du composant
import { Component, ViewChild, OnInit, ElementRef } from '@angular/core';

@Component({
  templateUrl: './example.component.html'
})
export class ExampleComponent implements OnInit {
  @ViewChild('spanElement', { static: true }) spanReference: ElementRef;

  ngOnInit() {
    console.log(this.spanReference.nativeElement);
  }
}

Ainsi, avec une variable référence pour désigner l’objet span, on peut utiliser le décorateur @ViewChild() pour binder l’objet du DOM avec le membre spanReference. Le binding sera effectué quand la callback ngOnInit() est déclenchée.

L’objet du DOM est wrappé dans un objet de type ElementRef. Cet objet permet de récupérer un élément du DOM en utilisant la propriété nativeElement.

Pour requêter des objets d’une vue, les critères utilisés peuvent être de nature différente.

Requêter suivant un type

2 méthodes sont possibles pour spécifier le type de l’objet à requêter:

  • Spécifier directement le type en tant qu’argument du décorateur utilisé, par exemple pour requêter une directive dont le type est TypedDirective:
    @ViewChild(TypedDirective) requestedDirective: TypedDirective;

    Dans cet exemple, on a utilisé @ViewChild() toutefois les autres décorateurs utilisent la même syntaxe.

  • Dans le cas où on effectue une requête avec le nom d’une variable référence, on peut préciser le type attendu de l’objet en l’indiquant en utilisant l’option read.

    Par exemple, pour requêter une directive avec une variable référence 'directiveRef' et dont le type est TypedDirective:

    @ViewChild('directiveRef', { read: TypedDirective }) requestedDirective: TypedDirective;

Requêter suivant une variable référence

Si l’objet est identifié dans la vue en utilisant une variable référence, on peut requêter en utilisant le nom de cette variable.

Par exemple, pour requêter une directive avec une variable référence 'directiveRef':

@ViewChild('directiveRef') requestedDirective: TypedDirective;

Indiquer si l’objet fait partie du contenu statique de la vue

Le contenu de la vue d’un composant est séparé en 2 parties:

  • Un contenu statique: ce contenu permet de mettre à jour le DOM seulement à l’initialisation de la vue. Ce contenu est initialisé juste avant le déclenchement des callbacks ngOnInit() et/ou ngDoCheck() du cycle de vie du composant.
  • Un contenu dynamique: cette partie de la vue est mise à jour à chaque détection de changement. Ce contenu est initialisé et mis à jour à des périodes différentes suivant l’objet requêté:
    • Si l’objet se trouve directement dans la vue du composant: il est requêté avec @ViewChild() ou @ViewChildren(). Le contenu dynamique de cet objet est initialisé et mis à jour juste avant le déclenchement des callbacks ngAfterViewInit() et/ou ngAfterViewChecked().
    • Si l’objet se trouve dans du contenu projeté: il est requêté avec @ContentChild() ou @ContentChildren(). Le contenu dynamique de cet objet est initialisé et mis à jour avant le déclenchement des callbacks ngAfterContentInit() et/ou ngAfterContentChecked().

A l’initialisation d’un composant, le DOM est mis à jour à 2 reprises: lors de la création du contenu statique de la vue et lors de la mise à jour du contenu dynamique. On peut résumer cette mise à jour, l’exécution des callbacks du cycle de vie et l’exécution des requêtes sur les vues dans le schéma suivant:

Légende du schéma
  1. A l’initialisation, le DOM est mis à jour avec le contenu statique de la vue.
  2. Les requêtes avec le paramètre { static: true } sur le contenu projeté (avec @ContentChild() ou @ContentChildren()) et sur la vue (avec @ViewChild() ou @ViewChildren()) sont exécutées sur le contenu statique de la vue.
  3. A l’exécution de la callback ngOnInit(), les requêtes sur le contenu statique ont été exécutées.
  4. Les requêtes avec le paramètre { static: false } sur le contenu projeté (avec @ContentChild() ou @ContentChildren()) sont exécutées. Le lien avec le composant parent n’apparaît pas sur ce schéma toutefois à ce state, le contenu dynamique du DOM du composant parent a été mise à jour.
  5. A chaque détection de changements, le contenu dynamique de la vue est mis à jour.
  6. Les requêtes avec le paramètre { static: false } sur la vue (avec @ViewChild() ou @ViewChildren()) sont exécutées sur le contenu dynamique de la vue.
  7. A l’exécution de la callback ngAfterViewInit(), les requêtes sur le contenu dynamique ont été exécutées.

Un objet peut être requêté dans le contenu statique ou dynamique de la vue suivant la valeur de l’option static:

  • { static: false }: valeur par défaut, elle permet de requêter le contenu statique et dynamique d’une vue. Le résultat de cette requête est disponible dans la propriété au déclenchement des callbacks:
    • ngAfterContentInit() et/ou ngAfterContentChecked() pour du contenu projeté.
    • ngAfterViewInit() et/ou ngAfterViewChecked() pour une requête directement sur la vue.
  • { static: true }: permet de requêter seulement le contenu statique d’une vue. Le résultat de cette requête est disponible dans la propriété au déclenchement des callbacks ngOnInit() et/ou ngDoCheck().

    Par exemple, pour requêter un élément HTML p nommé 'content' dans le contenu statique de la vue:

    @ViewChild('content', { static: true }) contentRef: ElementRef<HTMLParagraphElement>;
    

QueryList

Si on utilise @ViewChildren() ou @ContentChildren() pour requêter une liste d’objets, on peut utiliser la liste QueryList pour stocker la liste des objets.

Par exemple, pour requêter des directives de type TypedDirective directement sur la vue d’un composant:

@ViewChildren(TypedDirective) requestedDirectives: QueryList<TypedDirective>;

Injection de dépendances

Angular permet d’effectuer de l’injection des dépendances d’un composant à son instanciation. Suivant la façon dont les dépendances sont déclarées, le framework d’injection de dépendances pourra instancier un nouvel objet ou utiliser une instance existante sous forme de singleton. Les objets injectés dans un composant peuvent être des classes ou des services.

Par exemple, si on considère un service à injecter dans un composant. Ce composant est déclaré dans un module:

@NgModule({  
  declaration: [ ExampleComponent ],  
  ...  
})  

Le service est déclaré avec le décorateur @Injectable() pour indiquer qu’il s’agit d’une classe injectable:

import { Injectable } from '@angular/core';  

@Injectable({  
  providedIn: 'root'  
})  
export class InjectedService {  
}  

Un singleton de ce service peut être injecté dans le composant ExampleComponent simplement en ajoutant la paramètre du service en tant qu’argument du constructeur:

import { Component } from '@angular/core';  
import { InjectedService } from '../InjectedService';  

@Component({  
  ...  
})  
export class ExampleComponent {  
  constructor(private InjectedService: InjectedService) {}  
}  

Avec cette implémentation, le service est un singleton dont l’instance est accessible dans toute l’application à cause de la déclaration:

@Injectable({  
  providedIn: 'root'  
})  

Il existe d’autres configurations possible pour déclarer un objet à injecter. il possible de configurer l’injection pour qu’une instance soit créée au chargement d’un module ou qu’une nouvelle instance soit créée à chaque injection. Pour voir plus en détails tous ces éléments de configuration voir l’article Injection de dépendances dans une application Angular.

Méthodes pour afficher un composant

La vue d’un composant peut être affichée suivant différentes méthodes:

  • En utilisant le paramètre selector comme on a pu le voir plus haut.
  • Avec le module de routing.
  • Par programmation.

Utiliser un module de routing

Un module de routing permet de configurer l’affichage d’un composant en fonction d’indication dans l’URL. Avec un module de routing, il suffit de configurer le composant devant être affiché en fonction d’un chemin indiqué dans l’URL.

En créant une application Angular avec l’option --routing:

ng new <nom application> —-routing  

Avec cette configuration, le module de routing est créé dans le fichier src/app/app-routing.module.ts. Ce module est importé dans le Root Module dans src/app/app.module.ts:

@NgModule({  
  declarations: [ ... ],  
  imports: [  
    AppRoutingModule,  
    ...  
  ]  
})  
export class AppModule { }  

Le module de routing (dans notre exemple ce module se trouve dans src/app/app-routing.module.ts) contient des routes indiquant le chemin de l’URL et le composant à afficher suivant ce chemin, par exemple:

import { NgModule } from '@angular/core';  
import { Routes, RouterModule } from '@angular/router';  
import { AppComponent } from '../app.component';  
import { ExampleComponent } from './example/example.component';  

const routes: Routes = {  
  { path: 'example', component: ExampleComponent }  
  { path: '', component: AppComponent }  
}  

@NgModule({  
  imports: [RouterModule.forRoot(routes)],  
  exports: [RouterModule]  
})  

Ainsi cette configuration permet d’afficher:

  • Le composant ExampleComponent si l’URL est http://local host:4200/#example.
  • Le composant AppComponent si l’URL est http://local host:4200/#.

Ces composants seront affichés au niveau du composant principal (i.e. src/app/app.component.html) car le template du fichier principal app.component.html contient:

<router-outlet></router-outlet>  

Plus concrètement, à l’exécution, <router-outlet></router-outlet> sera remplacé par la vue du composant à afficher.

Afficher un composant par programmation

On peut afficher des composants par programmation en utilisant une factory pour instancier un composant à afficher.

Par exemple si on considère 2 composants Parent et Child. On souhaite afficher Child par programmation dans la vue de Parent.

  1. Pour créer les 2 composants, on exécute les instructions:
    ~% ng g c Parent  
    ~% ng g c Child  
    

    Ces instructions vont créer les composants et les rajouter au module principal dans src/app/app.module.ts.

  2. On indique l’emplacement dans la vue du composant Parent dans lequel on placera la vue du composant Child. Cet emplacement est de type <ng-template> qui est un élément Angular dans lequel on peut placer une vue. On modifie le template du comparent Parent (dans src/app/parent/parent.component.html) de cette façon:
    <p>Composant Parent</p>  
    <ng-template #childComponentPlace></ng-template>  
    

    #childComponentPlace correspond à une variable référence qui permet de nommer l’élément <ng-template>.

  3. Dans la classe du composant Parent (dans src/app/parent/parent.component.ts), on effectue une requête dans la vue de ce composant pour récupérer l’emplacement dans lequel on va placer la vue du composant Child. On effectue cette requête en utilisant @ViewChild:
    import { Component, ViewContainerRef, ViewChild } from '@angular/core';  
    
    @Component({  
      selector: 'app-parent',  
      templateUrl: './parent.component.html'  
    })  
    export class ParentComponent {  
      @ViewChild('childComponentPlace', { read: ViewContainerRef }) childComponentRef: ViewContainerRef;  
    }
    
  4. On modifie le template du composant principal (dans src/app/app.component.html) pour afficher le composant Parent (on peut supprimer tout ce qui se trouve dans ce fichier):
    <app-parent></app-parent>
    
  5. On injecte l’objet ComponentFactoryResolver dans le composant Parent par injection de dépendances et on implémente la callback ngAfterViewInit() pour instancier le composant Child et placer la vue de ce composant dans l’emplacement childComponentPlace:
    import { Component, ViewContainerRef, ViewChild, AfterViewInit, ComponentFactoryResolver } 
      from '@angular/core';  
    import { ChildComponent } from '../child/child.component';  
    
    @Component({  
      selector: 'app-parent',  
      templateUrl: './parent.component.html'  
    })  
    export class ParentComponent implements AfterViewInit {  
      @ViewChild('childComponentPlace', { read: ViewContainerRef }) childComponentRef: ViewContainerRef;  
    
      constructor(private componentFactoryResolver: ComponentFactoryResolver) {}  
    
      ngAfterViewInit() {  
        const childComponentFactory = this.componentFactoryResolver.resolveComponentFactory(ChildComponent);  
        const containerRef = this.childComponentRef;  
        containerRef.clear();  
        containerRef.createComponent(childComponentFactory);  
      }  
    }  
    

    On place le code qui permet l’instanciation du composant Child en utilisant la callback ngAfterViewInit() de façon à ce que le membre childComponentRef soit instancié. En effet si on considère le cycle de vie d’un composant, les requêtes sur la vue avec @ViewChild() sont exécutées juste avant que la callback ngAfterViewInit() soit appelée. Si on place le code avant dans le cycle de vie du composant, childComponentRef sera undefined.

En exécutant ce code on obtient:

Composant Parent
child work!

On peut voir que le composant Child est présent dans la vue du composant Parent.

Pour aller plus loin…

Les fonctionnalités indiquées dans cet article sont détaillées dans d’autres articles:

Share on RedditTweet about this on TwitterShare on LinkedInEmail this to someonePrint this page

Angular from scratch

@john_artifexfilms

Angular est un framework permettant d’implémenter des applications Javascript front-end exécutées coté client sous forme de Single Page Application (i.e. application web monopage). Le langage utilisé pour implémenter des applications Angular est Typescript.

Le but de cet article est d’indiquer les caractéristiques et les éléments de syntaxe principaux d’Angular en partant de zéro c’est-à-dire sans avoir de connaissances préalables sur le framework, sur Javascript ou sur d’autres technologie web. Le but n’est pas de paraphraser la documentation angular.io mais de donner un point de départ pour comprendre les éléments les plus importants du framework.

Avant de rentrer dans les détails de la syntaxe, on va indiquer quelques éléments de contexte pour mieux comprendre Angular.

Quelques généralités

Angular vs. AngularJS

Initialement, la première version d’Angular était implémentée en Javascript. Après cette première version, l’équipe d’Angular a réécrit tout le framework en Typescript. Cette nouvelle version en Typescript correspondait à Angular 2. Les versions suivantes d’Angular sont toutes en Typescript toutefois la première version est encore maintenue car c’est la seule qui soit en Javascript. Pour éviter la confusion la version 1 d’Angular en Javascript est appelée AngularJS; toutes les versions suivantes sont appelées Angular.

La maintenance des 2 frameworks est distincte, toutefois l’arrêt de la maintenance d’AngularJS est programmmé pour le 31 décembre 2021(*).

Typescript vs. Javascript

Malgré ce titre, ces 2 langages ne sont pas opposés car le code Typescript est transpilé en Javascript pour être exécuté. Fonctionnellement, le code Typescript apporte des améliorations de syntaxe par rapport au Javascript dans le but de l’enrichir et de le sécuriser du point de vue du typage et de la vérification syntaxique avant l’exécution. Ces points sont, toutefois, de moins en moins vrais avec l’avènement d’EcmaScript 6 (i.e. EcmaScript 2015).

Le code Typescript n’est utilisé que pour la programmation, il est transpilé en Javascript pour être exécutable sur les browsers. Le transpilage est effectué par un compilateur.

Pour davantage de détails sur la syntaxe Typescript, voir L’essentiel de la syntaxe Typescript en 10 min.

Single Page Application (i.e. SPA)

Angular permet de faciliter l’implémentation d’application s’exécutant en mode Single Page Application. Les applications SPA permettent d’améliorer l’expérience utilisateur lorsqu’elles sont exécutées sur un browser puisqu’elles évitent de devoir recharger toute la page entre différentes actions. Toutefois elles sont plus complexes à implémenter puisqu’elles nécessitent d’être exécutées principalement sur le browser d’où l’avènement de frameworks aidant à leur implémentation comme React, Vue.js, Knockout.js ou Angular.

Dans le cas d’Angular, il faut garder en tête que le code est principalement exécuté coté client et non du coté du serveur comme, par exemple, pour ASP.NET MVC. Ainsi tout le code doit être chargé par le browser pour être exécuté. Ce chargement peut poser quelques problèmes:

  • d’abord en terme de temps de chargement puisque plus le code est volumineux et plus il prendra du temps pour être chargé par le browser. Il existe des optimisations comme la minification du code pour en réduire la taille, l’utilisation de cache avec les content delivery networks (i.e. CDN) ou le chargement de modules par lazy-loading.
  • Ensuite, sachant que le code se trouve du coté du browser, des problèmes de sécurité peuvent survenir puisque le code est lisible directement sur le browser. Il existe des méthodes pour sécuriser les traitements par exemple en permettant le chargement conditionnel de modules d’une application en fonction des droits de l’utilisateur identifié.

Le plus souvent, une application Angular est exécutée dans le browser en effectuant un rendu des pages en utilisant le DOM. Toutefois il est possible d’exécuter une application coté serveur en générant des pages statiques avec la fonctionnalité Angular Universal.

DOM

Une page HTML est composée d’éléments avec une certaine hiérarchie (comme html, body, div, span, p, a etc…). Ces éléments peuvent être représentés dans un graphe d’objets qui est appelé DOM (i.e. Document Object Model). Ce DOM est utilisé par les navigateurs pour déterminer les objets qui peuvent être rendus à l’écran. En s’interfaçant avec le DOM avec du code Javascript, il est possible de modifier le contenu, la structure et le style de la page.

Invervenir fréquement dans le DOM est couteux en performance. Angular utilise le DOM pour s’interfacer avec les objets de la page, toutefois il minimise autant que possible les parcours et les modifications du DOM pour améliorer les performances. Une partie importante du framework est de procéder par incrémentation en cherchant à détecter des changements lorsqu’un évènement survient sur la page. En cas de changement détecté, le framework intervient dans le DOM en minisant le parcours du graphe d’objets et le nombre de modifications. Cet aspect de détection de changements est transparent pour le développeur.

Attributs et propriétés

Les caractéristiques des éléments HTML peuvent être précisées ou modifiées avec des attributs, par exemple:

<button name="clickMeButton">Click me</button>

Dans cet exemple name est un attribut de l’élément HTML button.
Les attributs peuvent posséder une valeur sous forme de chaînes de caractères ou ils peuvent ne pas avoir de valeur. Dans l’exemple suivant, disabled est un attribut sans valeur:

<button name="clickMeButton" disabled>Click me</button>

Symétriquement aux attributs, les objets du DOM possèdent des propriétés. Par exemple, le code Javascript suivant permet de créer un élément dans le DOM et de paramétrer la propriété innerHtml:

var btn = document.createElement("BUTTON");
btn.innerHTML = "Click me";
document.body.appendChild(btn);

De la même façon, on peut affecter une valeur à la propriété disabled équivalente à l’attribut du même nom:

btn.disabled = true;

La plupart du temps, les attributs servent à initialiser les propriétés toutefois ils ne sont pas modifiés si la valeur des propriétés change. Il peut exister un mapping strict entre les attributs et les propriétés (comme par exemple id). Dans certains cas, il n’existe pas de propriété correspondant à un attribut (par exemple: l’attribut colspan de l’élément td). Enfin pour d’autres cas, une propriété peut ne pas avoir de correspondance en attribut (comme textContent).
Angular utilise différent type de bindings pour affecter des valeurs aux propriétés des objets. Ces objets sont des élément HTML, des composants ou des directives.

Webpack

Angular est un framework utilisant de nombreux projets comme par exemple: Typescript, Node.js, RxJS, jQuery, Karma, Jasmine, Protractor, Babel etc… Parmi ces projets, on peut trouver Webpack qui est une des dépendances les plus importantes.

La fonctionalité initiale de Webpack est de permettre de générer un bundle à partir du code source. Un bundle est un code Javascript optimisé pour permettre un téléchargement et une exécution rapide par le browser. Webpack est un apport important à Angular puisque outre la génération du bundle, il permet de nombreuses autres fonctionnalités:

  • Transpilation du code Typescript: le code Typescript, Javascript, CSS, SASS ou less est transpilé en Javascript.
  • Permettre l’utilisation des fichiers “source map” pour faciliter le débugage du code Typescript directement dans le browser.
  • La prise en compte des différents types de modules (module ES2015, CommonJS, AMD etc…).
  • Fonctionnalité HMR (i.e. Hot Module Replacement) pour mettre à jour un module Angular sans avoir à recompiler et à recharger tout le projet.
  • Permettre le chargement de module en mode lazy-loading.
  • Minifier le code pour minimiser la taille du code Javascript du bundle par exemple, en supprimant les caractères espace, supprimant les variables inutiles ou en diminuant la taille des noms de variables.
  • Permettre l’uglification pour rendre le code du bundle plus difficile à lire.

Implémenter une application Angular

La plupart des manipulations d’une application Angular peuvent se faire en utilisant le CLI Angular. Il est possible d’installer le CLI avec le gestionnaire de package NPM.

NPM

Angular nécessite l’installation de Node.js et l’utilisation du gestionnaire de package NPM.

Pour plus de détails sur:

Créer une application Angular “from scratch”

On peut rapidement créer une application Angular en utilisant le CLI Angular (i.e. Command Line Interface). Pour installer le CLI Angular avec NPM, il faut exécuter l’instruction suivante:

npm install @angular/cli --global 

Cette commande permet d’installer le CLI dans le répertoire global de NPM. Par défaut, le répertoire global se trouve dans:

  • Sur Linux: /usr/local/lib/node ou /usr/local/lib/node_modules/
  • Sur Windows: %USERPROFILE%\AppData\Roaming\npm\node_modules

Pour avoir ce chemin, il faut exécuter:

npm config get prefix 

Dans le répertoire node_modules, ng se trouve dans:

node_modules/@angular/cli/bin 

Après avoir installé le CLI Angular, on peut créer un squelette d’une application Angular en exécutant l’instruction:

ng new <nom application> --style css --routing 

Pour compiler l’application, il faut exécuter:

ng build 

Pour exécuter l’application et l’afficher dans un browser (par défaut l’application est accessible à l’adresse http://localhost:4200):

ng serve --watch

Pour avoir davantage de détails sur le CLI Angular et le détail des options, voir Angular CLI en 5 min.

Comment débugger une application Angular ?

En exécutant l’application avec ng serve, il est possible de débugger en pas à pas avec le browser en affichant les outils de développement:

  1. Pour afficher les outils de développement dans un browser:
    • Sous Firefox: on peut utiliser la raccourci [Maj] + [F7] (sous MacOS: [⌥] + [⌘] + [Z], sous Linux: [Ctrl] + [Maj] + [Z]) ou en allant dans le menu “Outils” ⇒ “Développement web” ⇒ “Débogueur”.
    • Sous Chrome: utiliser le raccourci [F12] (sous MacOS: [⌥] + [⌘] + [I], sous Linux: [Ctrl] + [Maj] + [I]) puis cliquer sur l’onglet “Sources”. A partir du menu, il faut aller dans “Afficher” ⇒ “Options pour les développeurs” ⇒ “Outils de développement”.
  2. Dans l’onglet “Debugger” dans Firefox ou “Sources” dans Chrome, il faut déplier le nœud
    webpacksrcapp
    ou
    webpack://.srcapp
  3. Il est possible de placer des points d’arrêt en cliquant à coté de la ligne:
  4. On peut débugguer si on recharge la page avec [F5]:

    Ensuite, on peut taper:

    • [F8] pour relancer l’exécution jusqu’au prochain point d’arrêt,
    • [F10] pour exécuter la ligne de code sans entrer dans le corps des fonctions exécutées
    • [F11] pour exécuter la ligne de code en rentrant dans le corps des fonctions exécutées.

    Dans le débugger, on peut accéder à d’autres outils pour vérifier le contenu d’une variable, afficher la pile d’appels ou placer des points d’arrêts lorsque des évènements surviennent:

Bacs à sable pour tester du code

Il est possible d’exécuter du code directement en ligne pour tester une exécution sans avoir à effectuer toute une installation, par exemple:

  • Pour tester une application Angular: stackblitz.com permet d’implémenter une application avec l’interface de Visual Studio Code.
  • Pour exécuter du code Typescript et/ou Javascript: jsbin.com permet d’éditer et d’exécuter en utilisant des bibliothèques web courantes.
  • Pour exécuter du code HTML, Typescript ou Javascript et voir le rendu: https://jsfiddle.net.

Les principaux objets Angular

Pour permettre d’implémenter une application, Angular met à disposition des objets qui n’apparaîtront pas dans leur forme originelle dans le code Javascript après transpilation. Ces objets proposent un cadre pour organiser l’implémentation du code Typescript, HTML ou CSS. Ces objets sont composées:

  • D’une classe Typescript,
  • D’un décorateur qui permet d’indiquer quelle est la fonction de l’objet dans l’application. Par exemple, pour définir un composant, on utilise le décorateur @Component():
    @Component()
    export class ExampleComponent {}
    
  • De metadonnées qui sont renseignées avec le décorateur et qui permettent d’affiner les caractéristiques d’un objet. Ces métadonnées sont indiquées dans le décorateur, par exemple:
    @Component({
      selector: 'app-example',
      templateUrl: './example.component.html'
    })
    export class ExampleComponent {}
    

Les principaux objets Angular sont:

  • Composant (décorateur @Component()): chaque composant comporte un classe, une vue et éventuellement un style CSS. Ils permettent d’implémenter une vue et d’interagir dynamiquement avec les éléments de cette vue.
  • Module (décorateur @NgModule()): regroupement logique d’objets de façon à structurer, à faciliter la réutilisation et à partager le code. Il existe différents types de modules:
    • Le Root Module: il en existe qu’un seul qui est obligatoirement chargé au démarrage d’application.
    • Feature Module: ces modules permettent de regrouper des objets Angular par fonctionnalité. Ils peuvent être spécialisés suivant leurs caractéristiques: Domain Feature Module (module regroupant une fonctionalité entière), Service Feature Module (module ne comportant que des services), Widget Feature Module (regroupant des objets graphiques), Routed Module (module chargé en mode lazy-loading) ou Routing Module (permattant la fonctionnalité de routing).
  • Service (décorateur @Service()): classe permettant d’effectuer un traitement technique ou de garder en mémoire des objets. L’intérêt des services est de pouvoir être facilement injecté sous la forme d’un singleton ou d’instances distinctes dans des composants ou des directives.
  • Directive (décorateur @Directive()): objet permettant d’enrichir des éléments HTML. La différence entre un composant et une directive est que la directive ne possède pas de vue. Elle modifie des éléments HTML par programmation. Il existe 2 types de directives:
    • Structural directive: elle modifie le rendu graphique en ajoutant, supprimant ou en remplaçant des éléments du DOM.
    • Attribute directive: elle altère l’apparence ou le composant d’un élément HTML.
  • Pipe (décorateur @Pipe()): cet objet permet de transformer les données avant de les afficher en utilisant une syntaxe du type {{ expression | filter }}.

Détails d’une application Angular

Après avoir créé une application en utilisant l’instruction du CLI ng new, on obtient un squelette contenant:

  • Des fichiers permettant d’afficher une vue:
    • src/index.html: premier fichier HTML permettant de tirer la code Javascript et d’exécuter l’application.
    • src/main.ts: fichier Typescript permettant d’exécuter l’application en indiquant le premier module qui sera chargé.
    • src/app: répertoire contenant le code de l’application et en particulier:
      • le module principal AppModule dans app.module.ts,
      • éventuellement le module de routing AppRoutingModule dans app-routing.module.ts,
      • le composant AppComponent dans les fichiers app.component.ts, app.component.html et app.component.css.
    • src/styles.css: ce fichier est vide toutefois il permet de définir des styles et classes CSS utilisables dans l’application.
  • Des répertoires comme:
    • dist: répertoire de sortie par défaut des résultats de compilation (ce répertoire peut être modifié dans tsconfig.json).
    • nodes_modules: ensemble des packages NPM des dépendances de l’application.
    • src/environnements: éléments de configuration pour les différentes configuration de compilation (développement, production, etc…). Ce répertoire peut être modifié dans angular.json.
    • e2e: répertoire contenant les tests de “bout en bout” (i.e. end-to-end tests).
  • Des fichiers de configuration comme:
    • angular.json: ce fichier permet d’apporter des éléments de configuration pour le CLI Angular comme par exemple: les différents mode de configuration (dévelopement, production etc…); les indications des répertoires contenant les sources, répertoire de sortie, fichier de style; les éléments de configuration pour les commandes du CLI.
    • tsconfig.json: ce sont les options de compilation du compilateur Typescript.
    • package.json: éléments de configuration de NPM c’est-à-dire les actions à exécuter au lancement de scripts avec NPM; les dépendances de package.
    • Karma.conf.js: fichier de configuration du runner de tests Karma.
    • tslint.json: fichier de configuration du linter tslint (outil permettant d’augmenter la qualité du code). Tslint est remplacé par ESLint à partir d’Angular 11.

Si on exécute l’instruction du CLI ng serve --watch, on obtient l’affichage de l’application dans le browser à l’adresse http://localhost:4200 par défaut. Pour comprendre cet affichage, on peut suivre l’ordre d’exécution des différents objets en partant des fichiers src/index.html et src/main.ts:

  1. Les fichiers src/index.html et src/main.ts font partie de l’amorce de l’application car ils sont indiqués dans le fichier de configuration angular.json au niveau de "projects""<nom du projet>""architect""build""options":
    "options": {
      "index": "src/index.html",
      "main": "src/main.ts"
    }
    
  2. src/index.html: ce fichier est le point de départ lorsque le browser charge les fichiers de l’application. Dans ce fichier se trouve plusieurs éléments importants:
    <!doctype html>
    <html lang="en">
    <head>
      <meta charset="utf-8">
      <title>Example</title>
      <base href="/">
      <meta name="viewport" content="width=device-width, initial-scale=1">
      <link rel="icon" type="image/x-icon" href="favicon.ico">
    </head>
    <body>
      <app-root></app-root>
    </body>
    </html>
    

    Dans ce code, on peut voir les lignes:

    • Base href:
      <base href="/">
      

      Cette ligne permet d’indiquer au browser l’adresse du base de l’application. Suivant la stratégie de routing utilisée (PathLocationStrategy ou HashLocationStrategy) si l’URL est modifiée:

      • après la partie href: il n’y aura pas d’appels au serveur web. Le traitement sera effectué par l’application Angular dans le browser.
      • avant la partie href: un appel est effectué au serveur web.
    • Appel au composant principal:
      <body>
        <app-root></app-root>
      </body>
      

      Cette indication permet d’instancier le composant dont le paramètre selector dans le décorateur @Component() est app-root. Ce composant est src/app/app.component.ts.

  3. src/main.ts: ce fichier permet de charger le Root Module grâce aux lignes:
    import { AppModule } from './app/app.module';
    platformBrowserDynamic.bootstrapModule(AppModule)
      .catch(err => console.error(err));
    
  4. src/app/app.module.ts: il s’agit du Root Module de l’application. Il permet de charger tous les objets Angular:
    @NgModule({
      declarations: [
        AppComponent
      ],
      imports: [
        BrowserModule,
        AppRoutingModule
      ],
      providers: [],
      bootstrap: [AppComponent]
    })
    export class AppModule { }
    

    Dans l’implémentation, le Root Module charge:

    • Le module BrowserModule,
    • Le module de routing AppRoutingModule dans le fichier src/app/app-routing.module.ts,
    • Le composant AppComponent dans src/app/app.component.ts.
  5. src/app/app.component.ts: il s’agit du composant principal, il est affiché car il est appelé par <app-root></app-root> dans src/index.html. La vue de ce composant (i.e. fichier template) est src/app/app.component.html. La ligne importante de la vue est:
    <router-outlet></router-outlet>
    

    Cette ligne permet d’appeler le module de routing dans src/app/app-routing.module.ts.

  6. src/app/app-routing.module.ts: ce fichier est le module de routing. Il permet de rediriger l’affichage vers la vue d’un composant en fonction de ce qui est indiqué dans l’URL après la partie href:
    const routes: Routes = [];
    
    @NgModule({
      imports: [RouterModule.forRoot(routes)],
      exports: [RouterModule]
    })
    export class AppRoutingModule { }
    

    Etant donné qu’aucune route n’est configurée, ce module ne dirigera l’affichage que vers le composant principal dans src/app/app.component.ts.

Pour aller plus loin…

Les fonctionnalités principales d’Angular sont détaillées dans d’autres articles:

  1. Les composants: un composant correspond à une unité d’implémentation permettant d’afficher une vue. Chaque composant est formé:
    • D’une vue appelée template: cette partie comporte du code HTML enrichi. Elle permet d’implémenter les objets à afficher.
    • D’une classe en Typescript permettant d’exécuter des traitements utilisables par la vue.

    Les articles détaillants les fonctionnalités des composants sont:

  2. Injection de dépendances: cet article détaille la fonctionnalité d’injection de dépendances d’Angular.
  3. Les modules (article à venir)
  4. Le routing (article à venir)
  5. La détection de changements: il s’agit d’une fonctionnalité importante qui permet de lier les templates aux classes en utilisant différent type de bindings.
  6. Les directives: ces objets permettent de modifier ou d’enrichir un élément du DOM en rajoutant ou en modifiant une propriété par programmation.
  7. Angular CLI: décrit les commandes les plus importantes du CLI Angular.

Détails des versions d’Angular

Pour terminer, le tableau suivant résume les fonctionnalités d’Angular suivant les versions:

Version Date Dépendances Fonctionnalités importantes
AngularJS Octobre 2010 Appelée AngularJS, c’est la première version d’Angular écrite Javascript. La dernière version majeure est la 1.8.
Angular 2 Septembre 2016
  • Typescript 2.0
  • RxJs: 5.0
  • TsLib: N/A
  • Node 5.4
  • Webpack: 1.12
Angular 2 correspond à la réécriture d’AngularJS en Typescript
Angular 4 Mars 2017
  • Typescript 2.1
  • RxJs: 5.5
  • TsLib: N/A
  • TSLint 3.15
  • Node: 6.9
  • Webpack: 1.7
Pas de release en version 3.

Cette version ne comporte pas de fonctionnalités majeures par rapport à la version 2:

  • Compilation plus rapide
  • Les animations ne sont plus dans @angular/core
  • La directive *ngIf supporte else.
  • Les animations ne sont plus dans @angular/core mais dans @angular/platform-browser/animations.
  • Renderer2 remplace Renderer dans @angular/core.
  • Intégration de Angular Universal qui permet d’exécuter un pré-rendu (pre-rendering ahead of time) d’une application Angular coté serveur de façon à améliorer, le cas échéant, les performances, permettre de répondre aux problématiques SEO (Search Engine Optimization) et des aperçus effectués par les médias sociaux.
Angular 5 Novembre 2017
  • Typescript 2.4
  • RxJs: 5.5
  • TsLib 1.7
  • TSLint 5.7
  • Node: 6.9
  • Amélioration de la compilation
  • Activation de la fonctionnalité Build Optimizer par défaut. Cette fonctionnalité permet de réduire la taille du bundle résultant de la compilation.
  • Support de l’API Transfer State dans Angular Universal pour éviter la double création des objets XMLHttpRequest (i.e. XHR) coté client et coté serveur lorsque la vue client est créée.
  • @angular/http est remplacé par @angular/common/http. HttpModule est remplacé par HttpClientModule dans @angular/common/http.
  • Pour les pipes Plural, Decimal, Percent et Currency, l’internationalisation est supportée avec l’introduction du paramètre locale permettant de prendre en compte les spécificités régionales.
  • Ajout d’une option updateOn dans les objets FormControls, FormGroups et FormArrays permettant de retarder la mise à jour des controls jusqu’à ce qu’un évènement blur, submit ou change soit lancé. Cette option peut être rajouté sur les objets ngForm ou ngModel avec le paramètre, respectivement, ngFormOptions ou ngModelOptions.
  • Ajout du package @angular/service-worker.
Angular 6 Mai 2018
  • Typescript 2.7
  • RxJs: 6.0
  • TsLib: 1.7
  • TSLint: 5.7
  • Node: 8.9
  • Les packages du framework sont synchronisés à la version 6.0.0.
  • Les commmandes ng update et ng add sont rajoutés au CLI Angular pour respectivement mettre à jour ou ajouter un package NPM.
  • Ajout de Angular Elements (@angular/elements) pour créer des éléments HTML personnalisés.
  • Ajout du Component Dev Kit (@angular/cdk) pour permettre le développement de composant et tirer partie des fonctionnalités de Angular Material (@angular/material).
  • Amélioration de Angular Material (@angular/material) avec de nouveaux schematics accessibles avec le CLI Angular pour générer des modèles pour faciliter l’implémentation de composants de Material.
  • Adaptation du CLI pour utiliser des workspaces pour avoir plusieurs projets ou bibliothèques dans un même workspace.
  • Fonctionnalité de tree-shaking pour les services: cette fonctionnalité est une optimisation pour éviter d’inclure dans la build finale, des services qui ne sont jamais utilisés.
Angular 7 Octobre 2018
  • Typescript: 3.1
  • RxJs: 6.3
  • TsLib: 1.7
  • TSLint: 5.7
  • Node: 10.9
  • Amélioration du CLI Angular avec les CLI Prompts pour poser des questions lors de l’exécution de commandes du CLI et éviter l’utilisation d’options.
  • Ajout des bundle budgets dans le CLI pour contrôler la taille des bundles.
  • Ajout du Virtual Scrolling et du Drap and Drop dans Angular Material (@angular/material) et CDK (@angular/cdk).
  • Support de la projection de contenu dans Angular Elements (@angular/elements).
  • Amélioration des performances.
Angular 8 Mai 2019
  • Typescript: 3.4
  • RxJs: 6.4
  • TsLib: 1.9
  • TSLint: 5.7
  • Node: 10.9
  • Activation du Differential Loading par défaut permettant au browser de choisir un bundle Javascript en fonction de ses caractéristiques.
  • Imports dynamiques (i.e. Lazy-loading) dans la configuration des routes.
  • Builder API dans le CLI pour personnaliser des commandes comme ng serve, ng build, ng test, ng lint ou ng e2e en permettant l’exécution de fonctions.
  • Support des web workers pour exécuter des threads en arrière-plan. Le CLI supporte la création de web workers avec ng g web-worker.
  • Ajout des méthodes AbstractControl.
  • markAllAsTouched() pour marquer les éléments d’un formulaire comme “touched” et FormArray.clear() pour vider tous les controls dans un objet FormArray.
Angular 9 Février 2020
  • Typescript: 3.7
  • RxJs: 6.5
  • TSLib: 1.10
  • TSLint: 5.7
  • Node: 10.9
  • Ivy est utilisé par défaut comme compilateur et moteur de rendu. Les fonctionnalités et les performances d’Ivy sont meilleures que le view engine utilisé dans les versions précédentes notamment avec un rendu plus rapide avec la compilation AOT, la fonctionnalité tree-shaking, une meilleure capacité de debug, l’exécution plus rapide des tests et un runtime qui permet de mettre à jour le DOM de façon plus performante.
  • Deux nouvelles options pour le paramètre providedIn du décorateur @Injectable() utilisé pour les services:
    • 'platform' pour indiquer qu’un service est disponible à partir d’un injecteur singleton de niveau “platform”. Cet injecteur est partagé par toutes les applications de la page.
    • any pour fournir une instance unique d’un service pour tous les modules.
  • Les composants Youtube et Google Maps ont été rajoutés à Angular Material.
Angular 10 Juin 2020
  • Typescript: 4.0
  • RxJs: 6.5
  • TSLib: 2.0
  • TSLint: 6.0
  • Node: 10.9
  • Nouveau composant Date Range Picker dans Angular Material.
  • Warnings à la compilation quand des packages utilisent des imports CommonJS.
  • Ajout de l’option --strict pour la création de nouveaux workspaces avec ng new.
  • Cette option active des paramètres permettant de signaler des bugs plus en amont et autorise le CLI à effectuer des optimisations avancées.
Angular 11 Novembre 2020
  • Typescript: 4.0
  • RxJs: 6.5
  • TsLib: 2.0
  • TSLint: 6.1
  • Node: 10.9
  • Inlining automatique des polices de caractères dans index.html. Les polices sont automatiquement téléchargées et configurées en inline dans index.html par le CLI à la compilation. La fonctionnalité est activée par défaut mais peut être désactivée avec les paramètres suivants dans angular.json:
    "configurations": { 
      "optimization": true
    }
    

    Et

    "configurations": {
      "optimization": {
        "fonts": false
      }
    }
    

    Ou

    "configurations": {
      "optimization": {
        "fonts": { 
          "inline": false
        }
      }
    }
  • Les logs et le report d’erreurs ont été améliorés dans les retours sur la console du CLI.
  • L’utilisation de la fonctionnalité Hot Module Replacement (HMR) est facilitée pour les développeurs. Cette fonctionnalité permet de remplacer des modules sans rafraîchir tout le browser. HMR existait dans les versions précédentes d’Angular mais l’ajout d’une option dans la commande ng serve du CLI permet de faciliter son utilisation en développement:
    ng server --configuration hmr
    
  • TSLint est remplacé par ESLint.
  • Dans la configuration des routes, il désormais est possible de configurer des router outlets nommés (Named outlets) pour un composant se trouvant dans un module chargé en mode lazy-loading.
Références
Share on RedditTweet about this on TwitterShare on LinkedInEmail this to someonePrint this page

Appliquer des styles CSS aux vues Angular

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

@_stfeyes

Angular permet d’appliquer un style CSS sur les éléments des vues des composants. Ce style CSS peut être défini de façon globale pour toute l’application, au niveau d’un composant ou plus spécifiquement à un élément.

Le but de cet article est d’expliciter différentes méthodes pour appliquer un style CSS aux éléments d’un composant. Il est possible d’appliquer un style en utilisant des fichiers Sass, less ou stylus avec les extensions respectivement .scss, .less ou .styl toutefois ces formats de feuille de style ne seront pas traités dans cet article.

Dans un premier temps, on va indiquer comment appliquer un style ou une classe CSS aux éléments d’une application Angular de façon globale, puis à l’échelle d’un composant.

Pour comprendre les différents exemples, on va créer une application Angular en utilisant le CLI Angular:

ng new css-test --skip-tests --no-routing --style css

Cette ligne permet de créer une application nommée css-test:

  • en évitant la génération des fichiers de tests (avec --skip-tests),
  • en évitant d’installer un module de routing (avec --no-routing) et
  • en précisant l’extension des fichiers de style CSS (avec --style css)

On supprime tout ce qui se trouve dans le template du composant principal (cf. csstest/src/app/app.component.html).

Où définir les styles CSS ?

Appliquer un style globalement

Pour définir et appliquer des styles globalement à l’échelle de l’application, il faut indiquer le fichier CSS contenant ces styles dans les paramètres du projet Angular dans angular.json au niveau du paramètre styles. Ce paramètre permet d’indiquer le chemin des fichiers de style utilisables dans l’application:

{
  "$schema": "./node_modules/@angular/cli/lib/config/schema.json",
  "version": 1,
  "newProjectRoot": "projects",
  "projects": {
    "csstest": {
      "projectType": "application",
      "schematics": {},
      "root": "",
      "sourceRoot": "src",
      "prefix": "app",
      "architect": {
        "build": {
          "builder": "@angular-devkit/build-angular:browser",
          "options": {
            "outputPath": "dist/csstest",
            "index": "src/index.html",
            "main": "src/main.ts",
            "polyfills": "src/polyfills.ts",
            "tsConfig": "tsconfig.app.json",
            "aot": true,
            "assets": [
              "src/favicon.ico",
              "src/assets"
            ],
            "styles": [
              "src/styles.css"
            ],
            "scripts": []
          }
        }
      }
    }
  }
}

Par défaut le fichier src/styles.css existe et permet de définir des styles, toutefois on peut rajouter d’autres fichiers avec le paramètre styles dans angular.json.

Pour illustrer dans l’application d’exemple, on rajoute un composant nommé Parent en exécutant:

ng g c Parent

Avec l’implémentation suivante:

<h1>Composant parent</h1> 
<p>Contenu du composant parent</p> 

On modifie le composant principal:

<h1>Composant principal</h1> 
<div style="display: block; border: 1px solid black; padding: 10px; background-color: white;"> 
  <app-parent></app-parent> 
</div> 

<app-parent></app-parent> correspond au paramètre selector du composant parent. Pour plus de précisions sur ce paramètre, voir l’article Les vues des composants Angular.

On modifie le fichier css/src/styles.css pour préciser un style pour l’élément <h1>:

h1 { 
  color: blue; 
  font-family: arial,sans-serif; 
} 

.componenttitle { 
  text-transform: uppercase; 
} 

A l’exécution, on peut voir que le style défini pour <h1> est appliqué à l’élément dans le composant principal et dans le composant Parent, les éléments <h1> sont en bleu:

On peut aussi appliquer une classe définie dans un fichier global au niveau d’un composant. Par exemple si on modifie le template du composant Parent pour appliquer la classe componenttitle:

<h1 class="componenttitle">Composant parent</h1> 

La classe est prise en compte, à l’exécution, l’élément <h1> du composant Parent est en majuscules:

Si on affiche les outils développeurs du browser dans l’onglet “Editeur de styles” sous Firefox ou “Eléments” et “Styles” dans Chrome, on peut voir que la feuille de style styles.css contient les styles qui ont été définis pour toute l’application:

Affichage des outils de développement dans un browser

Pour afficher l’éditeur de styles:

  • Sous Firefox: on peut utiliser la raccourci [Maj] + [F7] ou en allant dans le menu “Outils” ⇒ “Développement web” ⇒ “Editeur de style”.
  • Sous Chrome: utiliser le raccourci [F12] (sous MacOS: [⌥] + [⌘] + [C], sous Linux: [Ctrl] + [Maj] + [I]) puis cliquer sur l’onglet “Elements”. A partir du menu, il faut aller dans “Afficher” ⇒ “Options pour les développeurs” ⇒ “Examiner les éléments”.

Appliquer un style sur les éléments d’un composant

Au lieu de définir des styles pour toute l’application, on peut les définir à l’échelle d’un composant seulement pour les éléments se trouvant dans ce composant.

Plusieurs méthodes sont possibles pour définir des styles dans un composant. Cette partie va détailler ces différentes méthodes.

Encapsulation des styles d’un composant

Par défaut, les styles définis dans un composant n’affecte que le composant dans lequel ils sont définis. Les styles des composants sont ainsi isolés et n’affectent pas les autres.

Pour illustrer cette partie, on va créer un 2e composant nommé other que l’on va placer sous le composant Parent en exécutant:

ng g c other

Et on modifie le template du composant principale (cf. src/app/app.component.html):

<h1>Composant principal</h1> 
<div style="display: block; border: 1px solid black; padding: 10px; margin-bottom: 10px; background-color: white;"> 
  <app-parent></app-parent> 
</div> 
<div style="display: block; border: 1px solid black; padding: 10px; margin-bottom: 10px; background-color: white;"> 
  <app-other></app-other> 
</div> 

L’implémentation du composant other est similaire à celle du composant Parent:

<h1>Composant other</h1> 
<p>Contenu du composant other</p> 

Si on modifie le contenu du fichier de style du composant Parent (cf. src/app/parent/parent.component.css):

p { 
  background-color: yellow; 
} 

On peut voir que seul le composant Parent est affecté par cette modification. Le contenu du composant other reste inchangé:

Ce comportement s’explique par le fait que le style des composants est isolé. Si on affiche l’onglet “Elements” des outils développeurs, on peut voir un code similaire au code suivant:

<app-root _nghost-thd-c19 ng-version="11.2.4"> 
  <h1 _ngcontent-thd-c19>Composant principal</h1> 
  <div _ngcontent-thd-c19 style="display: block; border: 1px solid black; padding: 10px; margin-bottom: 10px; background-color: white;"> 
    <app-parent _ngcontent-thd-c19 _nghost-thd-c16> 
      <h1 _ngcontent-thd-c16 class="componenttitle">Composant parent</h1> 
      <p _ngcontent-thd-c16>Contenu du composant parent</p> 
    </app-parent> 
  </div> 
  <div _ngcontent-thd-c19 style="display: block; border: 1px solid black; padding: 10px; margin-bottom: 10px; background-color: white;"> 
    <app-other _ngcontent-thd-c19 _nghost-thd-c18> 
      <h1 _ngcontent-thd-c18 >Composant other</h1> 
      <p _ngcontent-thd-c18>Contenu du composant other</p> 
    </app-other> 
  </div> 
</app-root> 

Ainsi des identifiants ont été rajoutés (parties en gras). Si on affiche le style de l’élément <p> en jaune du composant Parent, on peut voir:

p[_ngcontent-thd-c16] { 
  background-color: yellow; 
} 

Le style n’est pas défini de façon globale mais il est limité aux éléments contenant un attribut _ngcontent-thd-c16. Le seul élément <p> avec un attribut _ngcontent-thd-c16 est celui du composant Parent et non celui du composant other (qui est _ngcontent-thd-c18).

Ainsi, c’est en utilisant des attributs _nghost et _ngcontent spécifiques à un composant et en limitant la définition des styles aux éléments ayant certains attributs qu’Angular isole les styles pour le composant dans lequel ils ont été définis.

Comportement avec les composants enfants

L’encapsulation du style des vues implique que, par défaut, le style est strictement limité à la vue d’un composant. Le style défini dans un composant n’aura pas de conséquence sur des composants enfant et sur du contenu projeté. Seuls les éléments se trouvant dans le composant où le style est défini seront impactés.

Par exemple, si on considère 2 composants Parent et Child tels que Child est un composant enfant de Parent:

  • Composant Child:
    Template
    (child.component.html)
    <h1>Composant Child</h1>
    <p>Contenu composant Child</p> 
    Classe
    (child.component.ts)
    @Component({
      selector: 'app-child', 
      template: './child.component.html'
    }) 
    export class ChildComponent { 
    } 
  • Composant Parent:
    Template
    (parent.component.html)
    <h1>Composant Parent</h1>
    <p>Contenu composant Parent</p> 
    <app-child></app-child>
    Feuille CSS
    (parent.component.css)
    p {
      background-color: cadetblue; 
    } 
    Classe
    (parent.component.ts)
    @Component({
      selector: 'app-parent', 
      template: './parent.component.html', 
      styleUrls: [ './parent.component.css' ] 
    }) 
    export class ParentComponent { 
    } 

On peut voir que seul l’élément se trouvant dans le composant Parent est impacté par le style défini dans la feuille de style parent.component.css:

Si on modifie le code des composants de cette façon:

  • Composant Child:
    Template
    (child.component.html)
    <h1>Composant Child</h1>
    <p>Contenu composant Child</p> 
    Classe
    (child.component.ts)
    @Component({
      selector: '[app-child]',
      template: './child.component.html'
    }) 
    export class ChildComponent { 
    } 
  • Composant Parent:
    Template
    (parent.component.html)
    <h1>Composant Parent</h1>
    <p>Contenu composant Parent</p> 
    <p app-child></p> 
    Feuille CSS
    (parent.component.css)
    p {
      background-color: cadetblue; 
    } 
    Classe
    (parent.component.ts)
    @Component({
      selector: 'app-parent', 
      template: './parent.component.html', 
      styleUrls: [ './parent.component.css' ] 
    }) 
    export class ParentComponent { 
    } 

Le paramètre selector du composant Child est [app-child], ainsi pour afficher le composant Child dans la vue du composant Parent, il faut indiquer le selector sous forme d’attribut:

<p app-child></p> 

On peut voir que le style s’applique aussi à l’élément <p app-child></p>. On pourrait penser que le style défini dans le composant Parent s’applique au composant imbriqué Child toutefois ce n’est pas tout à fait le cas. En réalité, le style s’applique à l’élément hôte <p> du composant enfant qui se trouve dans le composant Parent:

Pour appliquer un style à des composants enfant, il faut utiliser le sélecteur :host ::ng-deep.

Définir les styles CSS

Paramètre styleUrls de @Component()

Par défaut, les styles CSS d’un composant sont définis dans un fichier séparé du template et de la classe du composant. Le chemin de ce fichier est indiqué en utilisant le paramètre styleUrls dans le décorateur @Component() du composant:

@Component({  
  selector: 'app-example',  
  templateUrl: './example.component.html',  
  styleUrls: ['./example.component.css']
})  
export class ExampleComponent {  
  constructor() { }  
} 

styleUrls est un tableau et permet d’indiquer plusieurs fichiers:

@Component({  
  selector: 'app-example',  
  templateUrl: './example.component.html',  
  styleUrls: ['./file1.css', './file2.css', './file3.css' ]
})  
export class ExampleComponent {  
  constructor() { }  
} 

Paramètre styles de @Component()

Utiliser un fichier séparé pour définir les styles CSS n’est pas obligatoire, on peut définir des styles directement dans la classe du composant en utilisant le paramètre styles du décorateur @Component(), par exemple:

@Component({  
  selector: 'app-example',  
  templateUrl: './example.component.html',  
  styles: [ 
    'div { display: block; border: 1px solid black; padding: 10px; margin-bottom: 10px; background-color: cadetblue; color: white }', 
    'h1 { text-transform: uppercase; color: blue }' 
  ]
})  
export class ExampleComponent {  
  constructor() { }  
} 

Le paramètre styles permet d’indiquer un tableau de façon à pouvoir définir plusieurs styles.

Avec le CLI Angular, on peut créer un composant directement avec le paramètre styles plutôt que le paramètre par défaut styleUrls en uilisant l’option --inline-style:

ng g c <nom du composant> --inline-style

Avec cette option, un fichier séparé contenant les styles ne sera pas créé.

Importer des fichiers de style

Comme pour les fichiers CSS classiques, il est possible d’importer des fichiers CSS dans les fichiers de styles des composants. L’intérêt est de profiter de l’encapsulation des vues de composant sans dupliquer le code des feuilles de style.

Pour importer un fichier CSS, il suffit d’indiquer le chemin relatif du fichier à importer avec la syntaxe suivante:

@import '<chemin relatif du fichier CSS à importer>'; 

Ou

@import url('<chemin relatif du fichier CSS à importer>'); 

Par exemple, pour importer un fichier CSS dans src/app/commonStyle.css dans le composant src/app/parent/parent.component.ts, il faut indiquer dans le fichier src/app/parent/parent.component.css:

@import url('../commonStyle.css'); 

L’intérêt est de pouvoir partager ce fichier pour plusieurs composants sans modifier le paramètre ViewEncapsulation et sans devoir dupliquer les styles.

Si le fichier à importer se trouve dans le même répertoire, il faut faire précéder le nom du fichier par './', par exemple:

@import './commonStyle.css'; 

Supprimer l’isolation des styles aux composants

On peut paramétrer un comportement différent concernant l’isolation des éléments de style définis dans un composant. Ce paramétrage se fait au niveau du paramètre encapsulation dans le décorateur @Component():

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

@Component({ 
  ... 
  encapsulation: ViewEncapsulation.Emulated 
}) 
export class CustomComponent { 
  ... 
} 

Les valeurs possibles de ViewEncapsulation sont:

  • ViewEncapsulation.Emulated (valeur par défaut): cette valeur isole les styles dans les composants par émulation. Angular effectue cette émulation en ajoutant des attributs _nghost et _ngcontent sur les éléments et des sélecteurs d’attributs sur les styles définis dans les composants. C’est le comportement décrit plus haut.
  • ViewEncapsulation.ShadowDom: avec ce paramètre les styles définis dans les composants sont aussi isolés dans le composant (comme pour ViewEncapsulation.Emulated). La différence est que ce n’est pas Angular qui va émuler ce comportement en rajoutant des attributs. Il va se servir de la fonctionnalité Shadow DOM du browser pour effectuer cette isolation.

    Ainsi pour utiliser la fonctionnalité Shadow DOM, Angular crée un Shadowing tree pour chaque composant de façon à séparer la structure et le style de ces composants du DOM.

    Si on reprend l’exemple précédent en affectant le paramètre ViewEncapsulation.ShadowDom au composant Parent, on peut voir qu’il n’y a plus de sélecteur de style pour l’élément <p> toutefois le style est toujours limité au composant Parent:

    p { 
      background-color: yellow; 
    } 
    
  • ViewEncapsulation.None: ce paramétrage consiste à ne pas isoler les styles au composant dans lesquels ils sont définis. En affectant ce paramètre sur un composant, le style sera défini de façon globale à toute l’application.

    Par exemple, si on considère 2 composants First et Second que l’on place directement dans le composant principal. Si on applique le paramètre ViewEncapsulation.None pour le composant First:

    • Template du composant principal (cf. src/app/app.component.html):
      <h1>Composant principal</h1> 
      <div style="display: block; border: 1px solid black; padding: 10px; margin-bottom: 10px; background-color: white;"> 
        <app-first></app-first> 
        <app-second></app-second> 
      </div>  
      
    • Composant First:
      Template
      (first.component.html)
      <h1>Composant First</h1>
      <p>Contenu composant Child</p> 
      Feuille CSS
      (first.component.css)
      p {
        background-color: yellow; 
      } 
      Classe
      (first.component.ts)
      @Component({
        selector: 'app-first', 
        template: './first.component.html', 
        styleUrls: [ './first.component.css' ], 
        encapsulation: ViewEncapsulation.None
      }) 
      export class FirstComponent { 
      } 
    • Composant Second:
      Template
      (second.component.html)
      <h1>Composant Second</h1>
      <p>Contenu composant Second</p>
      Classe
      (second.component.ts)
      @Component({
        selector: 'app-second', 
        template: './second.component.html'
      }) 
      export class SecondComponent { 
      } 

    On peut voir que les éléments <p> des composant First et Second sont affectés par le style (la propriété background-color des éléments <p> est yellow):

Si la vue d’un composant avec le paramètre encapsulation: ViewEncapsulation.None n’est pas affichée, le style défini dans ce composant ne sera pas appliqué de façon globale.

Créer directement des composants en précisant le paramètre encapsulation

Avec le CLI Angular, on peut créer des composants directement en précisant la valeur du paramètre encapsulation par exemple en exécutant la commande:

ng g c <nom du composant> --view-encapsulation None

Les valeurs possibles sont Emulated, None ou ShadowDom.

Comment appliquer des styles sur les éléments d’un composant ?

Plusieurs méthodes sont possibles pour définir et appliquer un style ou une classe CSS à un élément d’un composant:

  • Statiquement en définissant les styles ou en appliquant les classes CSS de façon inline directement dans l’élément en utilisant les attributs style et class.
  • Par attribute binding avec un attribut de type [attr.style] pour définir un style ou [attr.class] pour appliquer une classe CSS.
  • Par property binding avec un attribut de type [style] pour définir un style ou [class] pour appliquer une classe CSS.
  • En utilisant les objets ngStyle ou ngClass.

Appliquer un style ou une classe statiquement

Appliquer un style ou une classe sur un élément revient à utiliser les attributs style ou class directement comme pour une page HMTL classique, par exemple:

<div style="background-color: yellow"> 
  ... 
</div> 

Ou

<div class="cssClassName"> 
  ... 
</div> 

Si on prend l’exemple d’un composant dont l’implémentation est:

Template
(example.component.html)
<h1>Composant Example</h1>
<p>Contenu du composant example</p> 
Feuille CSS
(example.component.css)
.bluebackground {
  background-color: cadetblue; 
} 
 
.whitetext { 
  color: white; 
} 
Classe
(example.component.ts)
import { Component } from '@angular/core';

@Component({ 
  selector: 'app-example',  
  templateUrl: './example.component.html', 
  styleUrls: [ './example.component.css' ] 
}) 
export class ExampleComponent {} 

Appliquer un style est immédiat:

<h1>Composant Example</h1> 
<p style="background-color: cadetblue; color: white">Contenu du composant example</p> 

De même pour appliquer des classes:

<h1>Composant Example</h1> 
<p class="bluebackground whitetext">Contenu du composant example</p> 

Attribute binding

Pour appliquer des styles sur un élément par attribute binding, on peut utiliser directement l’attribut [attr.style] avec une chaîne de caractères contenant les styles:

<div [attr.style]="definedStyles"> 
  ... 
</div> 

definedStyles contient les styles sous forme de chaîne de caractères, par exemple:

background-color: cadetblue; color: white; 

De même pour appliquer des classes CSS, on peut utiliser l’attribut [attr.class] avec une chaîne de caractères contenant les classes à appliquer:

<div [attr.class]="definedClasses"> 
  ... 
</div> 

definedClasses contient les classes sous forme de chaîne de caractères, par exemple:

bluebackground whitetext 

Par exemple, une implémentation complête dans un composant pour appliquer un style par attribute binding serait du type:

Template
(example.component.html)
<h1>Composant Example</h1>
<div [attr.style]="definedStyles"> 
  Contenu du composant example 
</div> 

<button (click)="changeStyle()">Change style</button> 
Classe
(example.component.ts)
import { Component } from '@angular/core';

@Component({ 
  selector: 'app-example',  
  templateUrl: './example.component.html'
}) 
export class ExampleComponent { 
  definedStyles = ''; 

  changeStyle(): void { 
    if (this.definedStyles === '') { 
      this.definedStyles = 'background-color: cadetblue; color: white'; 
    } 
    else { 
      this.definedStyles = ''; 
    } 
  } 
} 

En cliquant sur le bouton, on peut voir que le style est ajouté dynamiquement sur l’élément <div>.

De même pour une classe CSS, on peut appliquer une classe par attribute binding en utilisant attr.class:

Template
(example.component.html)
<h1>Composant Example</h1>
<div [attr.class]="definedCssClasses"> 
  Contenu du composant example 
</div> 
 
<button (click)="changeCssClasses()">Change CSS classes</button> 
Feuille CSS
(example.component.css)
.bluebackground {
  background-color: cadetblue; 
} 

.whitetext { 
  color: white; 
}
Classe
(example.component.ts)
import { Component } from '@angular/core';

@Component({ 
  selector: 'app-example',  
  templateUrl: './example.component.html', 
  styleUrls: [ './example.component.css' ] 
}) 
export class ExampleComponent { 
  definedCssClasses = ''; 

  changeCssClasses(): void { 
    if (this.definedCssClasses === '') { 
      this.definedCssClasses = 'bluebackground whitetext'; 
    } 
    else { 
      this.definedCssClasses = ''; 
    } 
  } 
} 

Property binding

On peut appliquer des styles ou des classes CSS sur des éléments du template en utilisant des property bindings. A la différence de l’attribute binding, le propery binding passe par l’intermédiaire d’une propriété Angular pour effectuer les changements dans le DOM (l’attribute binding intervient plus directement sur l’attribut d’un élément HTML).

Le property binding permet plus de flexibilité en permettant d’affecter des styles ou des classes CSS sous forme d’une chaîne de caractères, d’un tableau de chaînes de caractères, d’une expression ou d’object literal.

Utiliser des chaines de caractères

Pour affecter des styles sous forme de chaînes de caractères, la syntaxe est du type:

<div [style]="definedStyles"> 
  ... 
</div> 

definedStyles contient les styles sous forme de chaîne de caractères, par exemple:

background-color: cadetblue; color: white; 

De même pour appliquer des classes CSS, on peut utiliser l’attribut [class] avec une chaîne de caractères contenant les classes à appliquer:

<div [class]="definedClasses"> 
  ... 
</div> 

definedClasses contient les classes sous forme de chaîne de caractères, par exemple:

bluebackground whitetext 

Utiliser une expression

On peut utiliser directement une expression pour la valeur des attributs [style] et [class] pour affecter respectivement des styles ou une classe CSS:

<div [style]="<expression>"> 
  ... 
</div> 

Par exemple:

Template
(example.component.html)
<h1>Composant Example</h1>
<div [style]="backgroundColorStyle + ';' + whiteTextStyle"> 
  Contenu du composant example 
</div> 
Classe
(example.component.ts)
import { Component } from '@angular/core';

@Component({ 
  selector: 'app-example',  
  templateUrl: './example.component.html' 
}) 
export class ExampleComponent { 
  backgroundColorStyle = 'background-color: cadetblue'; 
  whiteTextStyle = 'color: white'; 
} 

A l’exécution, on peut voir que le style défini sous forme d’une expression "backgroundColorStyle + ';' + whiteTextStyle" est appliqué sur l’élément <div>.

Utiliser un object literal

On peut affecter les styles ou les classes CSS en utilisant des object literals. L’intérêt de ce type d’objet est de permettre de les paramétrer plus facilement dans la classe du composant.

L’object literal doit comporter:

  • des propriétés dont les noms correspondent aux propriétés du style et
  • des valeurs correspondant aux valeurs des styles à appliquer.
{ 
  <propriété de style 1>: '<valeur 1>', 
  <propriété de style 2>: '<valeur 2>', 
  ... 
  <propriété de style N>: '<valeur N>' 
} 

De même pour les classes CSS, l’object literal doit comporter:

  • des propriétés dont le nom correspond au nom de la classe CSS et
  • des valeurs sont forme de booléen. La valeur True indiquant que la classe doit s’appliquer.
{ 
  <nom de la classe CSS 1>: '<true ou false>', 
  <nom de la classe CSS 2>: '<true ou false>', 
  ... 
  <nom de la classe CSS N>: '<true ou false>' 
} 

Exemple pour affecter un style
Par exemple, le composant suivant permet d’affecter un style sous la forme d’un object literal à un élément <div>:

Template
(example.component.html)
<h1>Composant Example</h1>
<div [style]="styleAsObjectLiteral"> 
  Contenu du composant example 
</div> 
 
<button (click)="changeStyle()">Change style</button> 
Classe
(example.component.ts)
import { Component } from '@angular/core';

@Component({ 
  selector: 'app-example',  
  templateUrl: './example.component.html' 
}) 
export class ExampleComponent { 
  styleAsObjectLiteral = ExampleComponent.getDivStyle('', ''); 

  static getDivStyle(backgroundColorValue: string, textColorValue: string): { 
    backgroundColor: string, 
    color: string 
  } { 
    return { 
      backgroundColor: backgroundColorValue, 
      color: textColorValue 
    }; 
  } 

  changeStyle(): void { 
    if (this.styleAsObjectLiteral.backgroundColor === '') { 
      this.styleAsObjectLiteral = ExampleComponent.getDivStyle('cadetblue', 'white'); 
    } 
    else { 
      this.styleAsObjectLiteral = ExampleComponent.getDivStyle('', ''); 
    } 
  } 
} 

Pour que la détection de changement fonctionne et que le changement de valeur de l’object literal soit répercuté dans la vue gràce au property binding, il faut affecter l’object literal entièrement et non modifier seulement la valeur des propriétés.

Le changement de style ne sera pas répercuté dans la vue si on implémente la méthode changeStyle() de cette façon:

changeStyle(): void { 
    if (this.styleAsObjectLiteral.backgroundColor === '') {
      this.styleAsObjectLiteral.backgroundColor = 'cadetblue'; 
      this.styleAsObjectLiteral.color = 'white'; 
    } 
    else { 
      this.styleAsObjectLiteral.backgroundColor = ''; 
      this.styleAsObjectLiteral.color = ''; 
    }
  } 

Il faut utiliser le Camel case pour définir les propriétés de l’object literal

Dans cet exemple, l’object literal permet de définir la propriété background-color et color de l’élément <div>:

{ 
  backgroundColor: 'cadetblue', 
  color: 'white' 
} 

Cet exemple utilise le Camel case pour nommer les propriétés de l’object literal de façon à paramétrer les propriétés de l’élément HTML qui sont en Kebab case:

  • backgroundColor en Camel case permet de paramétrer le style background-color en Kebab case,
  • color permet de paramétrer le style color.

Exemple pour affecter une classe CSS
L’exemple affecte une classe CSS à un élément <div> en utilisant un object literal:

Template
(example.component.html)
<h1>Composant Example</h1>
<div [class]="cssClassesAsObjectLiteral"> 
  Contenu du composant example 
</div> 
 
<button (click)="changeCssClass()">Change class CSS</button> 
Feuille CSS
(example.component.css)
.bluebackground {
  background-color: cadetblue; 
} 

.whitetext { 
  color: white;  
} 
Classe
(example.component.ts)
import { Component } from '@angular/core';

@Component({ 
  selector: 'app-example',  
  templateUrl: './example.component.html',
  styleUrls: [ './example.component.css' ] 
}) 
export class ExampleComponent { 
  applyBlueBackgroundClass = false; 
  applyWhiteText = false; 
  cssClassesAsObjectLiteral = { 
  bluebackground: this.applyBlueBackgroundClass; 
  whitetext: this.applyWhiteText; 

  changeCssClass(): void { 
    this.applyBlueBackgroundClass = !this.applyBlueBackgroundClass; 
    this.applyWhiteText = !this.applyWhiteText; 

    this.cssClassesAsObjectLiteral = { 
      bluebackground: this.applyBlueBackgroundClass; 
      whitetext: this.applyWhiteText; 
    }; 
  } 
} 

Property binding sur une seule propriété du style

Au lieu d’effecter le style complètement, on peut modifier une propriété par property binding. Par exemple, pour appliquer des property bindings seulement sur les propriétés background-color et color d’un élément <div>:

<div [style.background-color]="<membre de type string dans la classe du composant>" 
     [style.color]="<membre de type string dans la classe du composant>"> 
  ... 
</div>  
Camel case vs Kebab case

Les propriétés de style peuvent être nommées de 2 façons en Camel case et en Kebab case:

  • En Kebab case: pour désigner la propriété de style background-color, on peut effectuer le property binding avec la propriété du même nom de l’objet style, par exemple
    <div [style.background-color]="<membre de type string dans la classe>"> 
      ... 
    </div> 
    
  • Camel case: pour désigner la propriété de style background-color, on peut effectuer le property binding avec la propriété backgroundColor de l’objet style, par exemple:
    <div [style.backgroundColor]="<membre de type string dans la classe>"> 
      ... 
    </div> 
    

L’exemple complet est:

Template
(example.component.html)
<div [style.background-color]="backgroundColorStyle" [style.color]="textStyle" >
  Contenu composant Example 
</div> 

<button (click)="changeStyle()">Change style</button> 
Classe
(example.component.ts)
import { Component } from '@angular/core';

@Component({ 
  selector: 'app-example',  
  templateUrl: './example.component.html' 
}) 
export class ExampleComponent { 
  backgroundColorStyle = ''; 
  textStyle = ''; 

  changeStyle(): void { 
    if (this.backgroundColorStyle === '') { 
      this.backgroundColorStyle = 'cadetblue'; 
    } 
    else { 
      this.backgroundColorStyle = ''; 
    } 

    if (this.textStyle === '') { 
      this.textStyle = 'white'; 
    } 
    else { 
      this.textStyle = ''; 
    } 
  } 
} 

Property binding sur une seule classe CSS

Au lieu d’affecter toutes les classes CSS en utilisant un object literal, on peut effectuer un property binding pour une seule classe CSS. Par exemple, pour indiquer avec un booléen si on veut appliquer les classes CSS bluebackground et whitetext sur un élément <div>, on peut effectuer des property bindings séparément:

<div [class.bluebackground]="<membre de type booléen dans la classe du composant>" 
     [class.whitetext]="<membre de type booléen dans la classe du composant>"> 
  ... 
</div>  

L’exemple complet est:

Template
(example.component.html)
<div [class.bluebackground]="applyBlueBackgroundClass" [class.whitetext]="applyWhiteTextClass" >
  Contenu composant Example 
</div> 

<button (click)="changeCssClasses()">Change CSS classes</button> 
Feuille CSS
(example.component.css)
.bluebackground {
  background-color: cadetblue; 
} 

.whitetext { 
  color: white;  
} 
Classe
(example.component.ts)
import { Component } from '@angular/core';

@Component({ 
  selector: 'app-example',  
  templateUrl: './example.component.html',
  styleUrls: [ './example.component.css' ]  
}) 

export class ExampleComponent { 
  applyBlueBackgroundClass = true; 
  applyWhiteTextClass = true; 
 
  changeCssClasses(): void { 
    this.applyBlueBackgroundClass = !this.applyBlueBackgroundClass; 
    this.applyWhiteTextClass =!this.applyWhiteTextClass;  
  } 
} 
[ngStyle] et [ngClass]

Quand on applique des property bindings avec des tableaux ou des object literals, on peut utiliser [ngStyle] et [ngClass] à place de, respectivement, [style] et [class] pour appliquer des styles ou une classe.

Sélecteurs Angular

Il existe des sélecteurs de style Angular qui permettent d’affiner les conditions d’application des styles et des classes. Après compilation, ces styles n’apparaissent plus dans le code de l’application.

Sélecteur :host

Le sélecteur :host peut être utilisé dans la déclaration des styles pour désigner l’élément hôte de la vue d’un composant.

Par exemple si on considère 2 composants appelés Parent et Child tels que Child est un composant enfant de Parent:

L’implémentation est:

  • Composant Child:
    Template
    (child.component.html)
    <h1>Composant Child</h1>
    <p>Contenu composant Child</p> 
    Classe
    (child.component.ts)
    @Component({
      selector: 'app-child', 
      template: './child.component.html', 
      styleUrls: [ './child.component.css' ] 
    }) 
    export class ChildComponent { 
    } 
  • Composant Parent:
    Template
    (parent.component.html)
    <h1>Composant Parent</h1>
    <p>Contenu composant Parent</p> 
    <app-child></app-child>
    Classe
    (parent.component.ts)
    @Component({
      selector: 'app-parent', 
      template: './parent.component.html'
    }) 
    export class ParentComponent { 
    } 

Si on souhaite appliquer un style seulement au composant Child, on pourrait être tenté de définir le style CSS suivant dans le fichier child.component.css:

/* Ce code ne fonctionne pas */  
app-child { 
  display: block; 
  background-color: cadetblue; 
  border: 1px black solid; 
  font-family: Arial, sans-serif; 
} 

Cette définition ne fonctionne pas, pour appliquer un style sur le composant, il faut utiliser le sélecteur :host à la place de app-child:

:host { 
  display: block; 
  background-color: cadetblue; 
  border: 1px black solid; 
  font-family: Arial, sans-serif; 
  padding: 10px;
} 

Le résultat est:

Dans l’exemple précédent, le composant Child était directement l’élément affiché dans le composant Parent, le code HTML était du type:

<app-parent> 
  <h1>Composant Parent</h1> 
  <p>Contenu composant Parent</p> 
  <app-child> 
    <h1>Composant Child</h1> 
    <p>Contenu composant Child</p> 
  </app-child>
</app-parent> 

Au lieu d’être directement l’élément affiché, le composant enfant peut se trouver dans un élément hôte, par exemple si on définit le composant Child de cette façon:

Template
(child.component.html)
<h1>Composant Child</h1>
<p>Contenu composant Child</p> 
Classe
(child.component.ts)
@Component({
  selector: '[app-child]',
  template: './child.component.html', 
  styleUrls: [ './child.component.css' ] 
}) 
export class ChildComponent { 
} 

Pour l’afficher dans le composant Parent, on modifie le template de cette façon:

Composant Parent:

<h1>Composant Parent</h1> 
<p>Contenu composant Parent</p> 
<p app-child></p>

L’élément hôte de la vue du composant Child est désormais un élément <p>:

<app-parent> 
  <h1>Composant Parent</h1> 
  <p>Contenu composant Parent</p> 
  <p app-child> 
    <h1>Composant Child</h1> 
    <p>Contenu composant Child</p> 
  </p>
</app-parent> 

Le sélecteur :host s’applique à l’hôte du composant quel qu’il soit. Ainsi même si l’élément hôte a changé, le style est toujours appliqué. Le résultat est le même que précédemment.

Appliquer un style en appliquant une condition sur l’hôte

On peut restreindre l’application du style en appliquant une condition sur l’hôte du composant.

Par exemple si on veut appliquer le style si l’hôte est un élément <p>:

:host(p) { 
  ... 
} 

Si on veut appliquer le style si la classe redtext est appliquée sur l’hôte:

.host(.redtext) { 
  ... 
} 

On peut restreindre davantage les conditions en rajoutant d’autres conditions après :host(). Par exemple, si on veut appliquer un style:

  • Si l’hôte du composant est un élément <p>
  • Seulement sur les éléments <h1> se trouvant dans le composant

On définit le style de cette façon:

:host(p) h1 { 
  ... 
} 

Si considère les composants Parent et Child tels que Child est un composant enfant de Parent:

  • Composant Child:
    Template
    (child.component.html)
    <h1>Composant Child</h1>
    <p>Contenu composant Child</p> 
    Classe
    (child.component.ts)
    @Component({
      selector: '[app-child]', 
      template: './child.component.html', 
      styleUrls: [ './child.component.css' ] 
    }) 
    export class ChildComponent { 
    } 
  • Composant Parent:
    Template
    (parent.component.html)
    <h1>Composant Parent</h1>
    <p>Contenu composant Parent</p> 
    <p app-child></p> 
    <span app-child></span> 
    Classe
    (parent.component.ts)
    @Component({
      selector: 'app-parent', 
      template: './parent.component.html'
    }) 
    export class ParentComponent { 
    } 

Pour appliquer un style sur l’hôte du composant Child seulement si l’hôte est un élément <p>, on ajoute dans le fichier child.component.css:

:host(p) { 
  display: block; 
  background-color: cadetblue; 
  border: 1px black solid; 
  font-family: Arial, sans-serif; 
  padding: 10px;
} 

Dans cet exemple, on voit que le style n’est pas appliqué pour la ligne <span app-child></span>:

Sélecteur :host-context

Le sélecteur :host-context permet de désigner un antécédent du composant courant. On peut ainsi appliquer un style si un antécédent respecte une condition particulière.

Par exemple si on veut appliquer un style si un antécédent du composant est un élément <p>, on peut définir le style de cette façon:

:host-context(p) { 
  ... 
} 

Si on veut appliquer un style si la classe redtext est appliquée sur un antécédent du composant, on peut définir le style de cette façon:

:host-context(.redtext) { 
  ... 
} 

On peut restreindre l’application du style en rajoutant d’autres conditions après :host-context(). Par exemple, si on veut appliquer un style:

  • Si un antécédent du composant est un élément <p>
  • Seulement sur les éléments <h1> du composant.

On peut définir le style de cette façon:

:host-context(p) h1 { 
  ... 
} 

Si on considère 3 composants Parent, Middle et Child tels que:

  • Middle est un composant enfant de Parent et
  • Child est un composant enfant de Middle.

L’implémentation est:

  • Composant Child:
    Template
    (child.component.html)
    <h1>Composant Child</h1>
    <p>Contenu composant Child</p> 
    Classe
    (child.component.ts)
    @Component({
      selector: '[app-child]', 
      template: './child.component.html', 
      styleUrls: [ './child.component.css' ] 
    })
    export class ChildComponent { 
    } 
  • Composant Middle:
    Template
    (middle.component.html)
    <h1>Composant Middle</h1>
    <p>Contenu composant Middle</p> 
    <span app-child></span>
    Classe
    (middle.component.ts)
    @Component({
      selector: '[app-middle]', 
      template: './middle.component.html', 
      styleUrls: [ './middle.component.css' ] 
    }) 
    export class MiddleComponent { 
    } 
  • Composant Parent:
    Template
    (parent.component.html)
    <h1>Composant Parent</h1>
    <p>Contenu composant Parent</p> 
    <p app-middle></p>
    Classe
    (parent.component.ts)
    @Component({
      selector: 'app-parent', 
      template: './parent.component.html', 
      styleUrls: [ './parent.component.css' ] 
    })
    export class ParentComponent { 
    } 

Pour appliquer un style sur l’hôte du composant Child seulement si un antécédent de Child est un élément <p>, on ajoute dans le fichier child.component.css:

:host-context(p) { 
  display: block; 
  background-color: cadetblue; 
  border: 1px black solid; 
  font-family: Arial, sans-serif; 
  padding: 10px;
} 

Le résultat est:

Dans cet exemple, même si l’hôte du composant Child est un élément span, le style est appliqué car l’hôte du composant Middle dans lequel se trouve Child est un élément <p>.

Pour appliquer un style seulement sur les éléments <h1> du composant Child si l’hôte d’un antécédent de ce composant est un élément <p> (dans child.component.css):

:host-context(p) h1 { 
  color: greenyellow; 
} 

Le résultat est:

Sélecteur ::ng-deep

La documentation indique que ce sélecteur doit être décomissioné dans les futures versions d’Angular toutefois dans la version 11, il est toujours supporté.

Il sert à appliquer un style ou une classe globalement et en particulier à des composants enfant. L’intérêt de ce sélecteur est d’appliquer un style sur des composants enfant pour lesquels on ne peut pas modifier le code.

Concernant la syntaxe, il faut placer ::ng-deep devant les autres sélecteurs de style nécessitant une application globale:

::ng-deep <autres sélecteurs de style> 

Par exemple:

  • ::ng-deep p {} permet d’appliquer un style sur tous les éléments <p> de l’application.
  • ::ng-deep .bluebackground {} permet d’appliquer un style à tous les éléments pour lesquels la classe CSS bluebackground est utilisée.
  • ::ng-deep p.bluebackground {} applique un style sur tous les éléments <p> utilisant la classe CSS bluebackground.
  • span ::ng-deep p {} applique le même style sur les éléments <span> du composant et sur tous les éléments <p> de l’application. Il faut placer ::ng-deep devant les sélecteurs pour lesquels on veut une application globale.

Les sélecteurs /deep/ et >>> ont les mêmes fonctions que ::ng-deep toutefois il est déconseillé de les utiliser. La syntaxe est la même:

/deep/ <autres sélecteurs de style> 

Ou

>>> <autres sélecteurs de style> 
::ng-deep doit être utilisé avec ViewEncapsulation.Emulated

Il faut utiliser le sélecteur ::ng-deep avec le paramètre ViewEncapsulation.Emulated. Cette valeur étant la valeur par défaut, il n’est pas obligatoire de la préciser.

Appliquer un style globalement

Si on utilise ::ng-deep seul, la définition d’un style ou d’une classe devient globale.

Par exemple, si considère les 3 composants First, Second et Parent tels que First et Second sont des composants enfant de Parent:

  • Composant First:
    Template
    (first.component.html)
    <h1>Composant First</h1>
    <p>Contenu composant First</p> 
    Classe
    (first.component.ts)
    @Component({
      selector: 'app-first', 
      template: './first.component.html', 
      styleUrls: [ './first.component.css' ] 
    }) 
    export class FirstComponent { 
    } 
  • Composant Second:
    Template
    (second.component.html)
    <h1>Composant Second</h1>
    <p>Contenu composant Second</p> 
    Classe
    (second.component.ts)
    @Component({
      selector: 'app-second', 
      template: './second.component.html', 
      styleUrls: [ './second.component.css' ] 
    }) 
    export class SecondComponent { 
    } 
  • Composant Parent:
    Template
    (parent.component.html)
    <h1>Composant Parent</h1>
    <p>Contenu composant Parent</p> 
    <app-first></app-first> 
    <app-second></app-second> 
    Classe
    (parent.component.ts)
    @Component({
      selector: 'app-parent', 
      template: './parent.component.html', 
      styleUrls: [ './parent.component.css' ] 
    }) 
    export class ParentComponent { 
    } 

Si on définit le style suivant dans le fichier CSS du composant First (first.composant.css):

::ng-deep p { 
  background-color: cadetblue; 
} 

On peut voir que le style sera appliqué à tous les éléments <p>:

Appliquer un style à des composants enfant

Pour appliquer un style à des composants sans l’appliquer globalement, il faut que ::ng-deep soit précédé de :host:

:host ::ng-deep <autres sélecteurs de style> 

Par exemple, si on reprend l’exemple des composants Child, Middle et Parent tels que:

  • Child est un composant enfant de Middle et
  • Middle est un composant enfant de Parent.

L’implémentation est:

  • Composant Child:
    Template
    (child.component.html)
    <h1>Composant Child</h1>
    <p>Contenu composant Child</p> 
    Classe
    (child.component.ys)
    @Component({
      selector: 'app-child', 
      template: './child.component.html', 
      styleUrls: [ './child.component.css' ] 
    }) 
    export class ChildComponent { 
    } 
  • Composant Middle:
    Template
    (middle.component.html)
    <h1>Composant Middle</h1>
    <p>Contenu composant Middle</p> 
    <app-child></app-child> 
    Classe
    (middle.component.ts)
    @Component({
      selector: 'app-middle', 
      template: './middle.component.html', 
      styleUrls: [ './middle.component.css' ] 
    }) 
    export class MiddleComponent { 
    } 
  • Composant Parent:
    Template
    (parent.component.html)
    <h1>Composant Parent</h1>
    <p>Contenu composant Parent</p> 
    <app-middle></app-middle> 
    Classe
    (parent.component.ts)
    @Component({
      selector: 'app-parent', 
      template: './parent.component.html', 
      styleUrls: [ './parent.component.css' ] 
    }) 
    export class ParentComponent { 
    } 

Si on définit le style suivant dans le fichier CSS du composant Middle (cf. Middle.composant.css):

:host ::ng-deep p { 
  background-color: cadetblue; 
} 

On peut voir que le style sera appliqué aux élément <p> de Middle et Child et non celui de Parent:

:host ::ng-deep est particulièrement utile si on veut personnaliser des éléments situés sur des composants pour lesquels on ne maitrise pas l’implémentation, par exemple des composants se trouvant dans @angular/material.

Appliquer un style dans la classe du composant

Jusqu’içi, on a indiqué des méthodes pour appliquer des styles à partir du template ou d’une feuille de style d’un composant. Il existe d’autres méthodes permettant d’appliquer un style par programmation dans le classe du composant en utilisant, par exemple, les décorateurs @HostBinding() ou @ViewChild().

Avec @HostBinding()

Le décorateur @HostBinding() permet d’effectuer un binding entre un attribut dans la classe d’un composant et une propriété de l’objet du DOM qui est l’hôte du composant. Par exemple, utiliser ce décorateur permet d’accéder directement à des propriétés de style ou de classe.

Par exemple, si on considère les composants Child et Parent tels Child est un composant enfant de Parent. L’implémentation est:

  • Composant Child:
    Template
    (child.component.html)
    <h1>Composant Child</h1>
    <p>Contenu composant Child</p> 
    Classe
    (child.component.ts)
    import { Component, HostBinding } from '@angular/ core';
    
    @Component({  
      selector: '[app-child]', 
      template: './child.component.html'
    })  
    export class ChildComponent {  
      @HostBinding('style.background-color') backgroundColor: string = 'cadetblue';
    }  
  • Composant Parent:
    Template
    (parent.component.html)
    <h1>Composant Parent</h1>
    <p>Contenu composant Parent</p> 
    <p app-child></p> 
    Classe
    (parent.component.ts)
    @Component({
      selector: 'app-parent', 
      template: './parent.component.html'
    }) 
    export class ParentComponent { 
    } 

En utilisant @HostBinding() dans le composant Child, on peut modifier la propriété style.background-color pour appliquer à l’élément hôte du composant la couleur d’arrière plan. La vue du composant Child est affichée dans le composant Parent grâce à <p app-child></p>. Ainsi l’élément hôte de Child est <p>, par suite @HostBinding() s’applique à cet élément <p>:

L’implémentation est similaire pour appliquer une classe CSS. Il faut au préalable définir la classe dans la feuille CSS du composant Parent car l’encapsulation des styles limite la portée de la classe à ce composant seulement:

  • Composant Child:
    Template
    (child.component.html)
    <h1>Composant Child</h1>
    <p>Contenu composant Child</p> 
    Classe
    (child.component.ts)
    import { Component, HostBinding } from '@angular/ core';
     
    @Component({  
      selector: '[app-child]', 
      template: './child.component.html'
    })  
    export class ChildComponent {  
      @HostBinding('class.bluebackground') applyBlueBackground: boolean = true; 
    }  
  • Composant Parent:
    Template
    (parent.component.html)
    <h1>Composant Parent</h1>
    <p>Contenu composant Parent</p> 
    <p app-child></p> 
    Feuille CSS
    (parent.component.css)
    .bluebackground {
      background-color: cadetblue; 
    } 
    Classe
    (parent.component.ts)
    @Component({
      selector: 'app-parent', 
      template: './parent.component.html', 
      styleUrls: [ './parent.component.css' ] 
    }) 
    export class ParentComponent { 
    } 

Le résultat est similaire à l’exemple précédent.

Avec ElementRef.nativeElement

On peut appliquer des styles ou des classes CSS en utilisant des objets de type ElementRef et Renderer2. Ces objets sont:

  • ElementRef: objet Angular permettant d’accéder à l’objet du DOM correspondant à un élément HTML grâce à la propriété ElementRef.nativeElement.
  • Renderer2: il s’agit d’un objet Angular permettant d’effectuer des modifications dans les objets du DOM.

La combinaison de ces 2 objets permet d’appliquer des éléments de style ou une classe CSS.

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

Template
(example.component.html)
<h1>Composant Example</h1>
<p #content>Contenu composant Example</p> 
Feuille CSS
(example.component.css)
.redtext {
  color: red; 
} 
Classe
(example.component.ts)
import { Component, OnInit, ViewChild, Renderer2 } from '@angular/core';

@Component({ 
  selector: 'app-example', 
  template: './example.component.html', 
  styleUrls: [ './example.component.css' ] 
}) 
export class ExampleComponent implements OnInit { 
  @ViewChild('content', { static: true }) contentElementRef !: ElementRef; 

  constructor(private renderer: Renderer2) {} 

  ngOnInit(): void { 
    this.renderer.setStyle(this.contentElementRef.nativeElement, 
      'background-color', 'cadetblue'); 

    this.renderer.setAttribute(this.contentElementRef.nativeElement, 
      'class', 'redtext'); 
  } 

}

Dans cet exemple, on utilise les éléments d’implémentation suivant:

  • On définit un élément <p> dans le template du composant et on l’identifie avec la variable référence #content. Cette variable référence va permettre d’atteindre cet élément dans la classe du composant.
  • Dans la classe du composant, on utilise le décorateur @ViewChild() pour requêter la vue et accéder à un élément qui y est définit. On identifie l’élément en utlisant la variable référence #content. L’option { static: true } permet d’indiquer qu’il faut effectuer la requête sur le contenu statique de la vue.

    @ViewChild() permet d’instancier un objet de type ElementRef correspondant à un objet wrappant l’objet du DOM correspondant à l’élément HTML <p> se trouvant dans la vue.

    Pour plus de détails sur @ViewChild() voir l’article Requêter les éléments d’une vue d’un composant Angular.

  • L’objet Renderer2 peut être initialisé pour le composant par injection de dépendances.

Grâce aux objets Renderer2 et ElementRef, on va:

  • Appliquer la couleur cadetblue à l’arrière plan de l’élément <p>:
    this.renderer.setStyle(this.contentElementRef.nativeElement, 'background-color', 'cadetblue');
    
  • Appliquer la classe CSS redtext au contenu de l’élément <p> (cette classe est définie dans la feuille de style du composant):
    this.renderer.setAttribute(this.contentElementRef.nativeElement, 'class', 'redtext');
    

Priorités d’application des styles et classes CSS

L’application des styles et des classes CSS se fait suivant un ordre particulier. Cet ordre s’applique de plusieurs façons:

  • Pour un élément à l’intérieur d’un composant: si plusieurs styles sont appliqués, la priorité s’exerce en considérant d’abord une définition spécifique:
    1. Les property bindings s’appliquant sur une propriété spécifique:
      [style.background-color]='backgroundColor'
      

      Et:

      [class.bluebackground]='canApplyBlueBackground'
      
    2. Les property bindings s’appliquant avec [style] ou [class]:
      [style]='appliedStyles'
      

      Et:

      [class]='appliedCssClasses'
      
    3. Les styles et classes appliqués statiquement:
      style='background-color: cadetblue'
      

      Et:

      class='redtext' 
      
  • Si des styles ou classes sont appliqués sur l’hôte d’un composant, la priorité s’exerce à partant du template du composant vers ce qui est définit à l’extérieur du composant:
    1. Bindings effectués sur l’hôte du composant
    2. Les host bindings effectués par une directive
    3. Les host bindings effectués par le composant

Priorités d’application des styles sur un élément

Si on applique des styles sur un élément de plusieurs façons, la priorité d’application des styles s’effectuera en priorité sur les styles définis par:

  1. Property binding sur une propriété spécifique,
  2. Property binding en utilisant [style],
  3. Les styles appliqués statiquement.

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

Template
(example.component.html)
<h1>Composant Example</h1>
<p [style.background-color]='greenBackgroundColor' 
   [style]='appliedStyles' 
   style='background-color: cadetblue'>
        1. Property binding sur une propriété spécifique
</p> 
<p [style]='appliedStyles' 
   style='background-color: cadetblue'>
        2. Property binding sur [style]
</p> 
<p style='background-color: cadetblue'>
        3. Style statique
</p>
Classe
(example.component.ts)
@Component({
  selector: 'app-example', 
  template: './example.component.html' 
}) 
export class ExampleComponent implements OnInit { 

  appliedStyles = 'background-color: yellow'; 
  greenBackgroundColor = 'lime'; 
} 

On peut voir la priorité d’application des styles:

Priorités d’application des styles sur l’hôte d’un composant

Si on applique des styles sur l’hôte d’un composant, la priorité d’application des styles s’effectuera en priorité sur les styles définis par:

  1. Bindings effectués sur l’hôte du composant
  2. Les host bindings effectués par une directive
  3. Les host bindings effectués par le composant

Par exemple, si on considère 2 composants Child et Parent tels que Child est un composant enfant de Parent ainsi que la directive applyGreenBackground. L’implémentation de ces éléments est:

  • La directive applyGreenBackground:
    import { Directive, HostBinding } from '@angular/core'; 
    
    @Directive({ 
      selector: '[applyGreenBackground]' 
    }) 
    export class ApplyGreenBackgroundDirective { 
      @HostBinding('style.background-color') backgroundColor: string = 'lime'; 
    } 
    
  • Composant Child:
    Template
    (child.component.html)
    <h1>Composant Child</h1>
    <ng-content></ng-content> 
    Classe
    (child.component.ts)
    import { Component, HostBinding } from '@angular/core';
    
    @Component({  
      selector: '[app-child]', 
      template: './child.component.html'  
    })  
    export class ChildComponent {  
      @HostBinding('style.background-color') backgroundColor: string = 'cadetblue'; 
    }  
  • Composant Parent:
    Template
    (parent.component.html)
    <h1>Composant Parent</h1>
    <p app-child applyGreenBackground [style]='appliedStyles'>
      1. Binding effectué dans le template
    </p> 
    <p app-child applyGreenBackground>
      2. Host binding effectué par la directive
    </p> 
    <p app-child>
      3. Host binding effectué par le composant
    </p> 
    Classe
    (parent.component.ts)
    @Component({
      selector: 'app-parent', 
      template: './parent.component.html'
    }) 
    export class ParentComponent { 
      appliedStyles = 'background-color: yellow'; 
    } 

Cet exemple montre 3 applications de styles par bindings sur le composant Child. Le template du composant Child contient <ng-content></ng-content> pour effectuer une projection de contenu (voir content projection pour plus de détails).

Les 3 exemples d’applications de styles sont:

  • Un binding effectué dans le template du composant Parent:
    <p app-child applyGreenBackground [style]='appliedStyles'>1. Binding effectué dans le template</p>
    

    Cet exemple montre que ce binding est le plus prioritaire.

  • Un host binding effectué par une directive:
    <p app-child applyGreenBackground>2. Host binding effectué par la directive</p> 
    

    La directive ApplyGreenBackgroundDirective modifie l’arrière-plan de son élément hôte avec le décorateur @HostBinding().

  • Un host binding efectué par le composant Child:
    <p app-child>3. Host binding effectué par le composant</p> 
    

    Le composant Child modifie l’arrière-plan de son élément hôte avec le décorateur @HostBinding().

On peut voir la priorité d’application des styles:

Pour résumer…

Dans une application Angular, on peut définir des styles ou des classes CSS de plusieurs façons:

  • Globalement pour toute l’application, au niveau d’un ou plusieurs fichiers indiqué dans angular.json au niveau du paramètre styles:
    
    {
      "projects": {
        "<nom du projet>": {
          "architect": {
            "build": {
              "options": {
                "styles": [
                  "src/styles.css"
                ]
              }
            }
          }
        }
      }
    }
    
  • Plus spécifiquement aux éléments affichés dans la vue d’un composant en définissant les styles ou les classes:
    • Dans des fichiers de style .css référencés au niveau du paramètre styleUrls du décorateur @Component() du composant:
      @Component({
          styleUrls: [ './<nom du composant>.component.css' ] 
      })
      
    • Directement dans la classe du composant en utilisant le paramètre style du décorateur @Component():
      @Component({
        styles: [ 
          '<styles CSS>' 
        ]
      })
      

Dans un fichier .css, il est possibe d’importer un autre fichier CSS en indiquant directement son chemin relatif:

@import url('<chemin relatif du fichier CSS à importer>'); 

Par défaut, les styles définis dans un composant sont limités aux éléments de la vue de ce composant. On peut supprimer cette isolation en utilisant le paramètre encapsulation du décorateur @Component():

@Component({ 
  ... 
  encapsulation: ViewEncapsulation.Emulated 
}) 

Les différentes valeurs possibles de ce paramètre sont:

  • ViewEncapsulation.Emulated (valeur par défaut): le style est isolé au composant. Angular effectue cette isolation en rajoutant des attributs _nghost et _ngcontent aux éléments HTML au moment de la compilation.
  • ViewEncapsulation.ShadowDom: le style est isolé au composant en utilisant la fonctionnalité Shadow DOM du browser.
  • ViewEncapsulation.None: le style n’est pas isolé au composant. Il sera appliqué à tous les éléments affichés à condition que la vue du composant soit affichée.

Appliquer un style ou une classe sur un élément d’un composant

Plusieurs méthodes sont possibles:

  • Statiquement en utilisant les attributs style et class sur l’élément du template:
    <div style="background-color: yellow"> 
      ... 
    </div>
    

    Ou

    <div class="cssClassName"> 
      ... 
    </div> 
    
  • Par attribute binding:
    <div [attr.style]="definedStyles"> 
      ... 
    </div>
    

    definedStyles est un membre de la classe contenant la chaîne de caractères avec le style.
    Ou

    <div [attr.class]="definedClasses"> 
      ... 
    </div>
    

    definedClasses contient les classes sous forme de chaînes de caractères séparées par des espaces.

  • Par Property binding:
    <div [style]="definedStyles"> 
      ... 
    </div> 
    

    definedStyles peut être:

    • Une chaîne de caractères contenant les styles,
    • Un tableau de chaînes de caractères contenant les styles,
    • Une expression,
    • Un object literal dont le nom des propriétés correspond au nom du style à appliquer. Le nom de la propriété doit être en Camel case.

    Ou

    <div [class]="definedClasses"> 
      ... 
    </div>
    

    definedClasses peut être:

    • Une chaîne de caractères contenant les noms de classe séparés par un espace,
    • Un tableau de chaînes de caractères contenant les noms de classe,
    • Un object literal dont le nom des propriétés correspond au nom de la classe, la valeur étant un booléan (True indique que la classe doit être appliquée, False indique que la classe ne doit pas être appliquée).

    On peut appliquer une seule propriété de style en utilisant les syntaxes:

    • En Kebab case:
      <div [style.background-color]="<membre de type string dans la classe du composant>"> 
        ... 
      </div>
      
    • En Camel case:
      <div [style.backgroundColor]="<membre de type string dans la classe du composant>"> 
        ... 
      </div>
      

    De même on peut appliquer une seule classe avec la syntaxe:

    <div [class.bluebackground]="<membre de type booléen dans la classe du composant>" 
      ... 
    </div>  
    

Sélecteurs Angular

Angular permet d’utiliser les sélecteurs spécifiques pour affiner l’application des styles:

  • :host applique un style à l’élément hôte de la vue d’un composant:
    :host { 
      /* Styles CSS */
    } 
    

    Pour appliquer le style si le type de l’hôte est <p>:

    :host(p) { 
      /* Styles CSS */
    } 
    

    Pour appliquer le style si la classe redtest est appliquée à l’hôte:

    .host(.redtext) { 
      /* Styles CSS */
    } 
    

    Pour appliquer le style si l’hôte est <p> sur les éléments <h1> du composant:

    :host(p) h1 { 
      /* Styles CSS */
    }
    
  • :host-context permet d’appliquer un style suivant une condition sur un antécédent du composant:
    :host-context(p) { 
      /* Styles CSS */
    }
    

    Ce style est appliqué si un antécédent de la vue du composant est un élément <p>.

    Pour appliquer un style si la classe redtest est appliquée à un antécédent du composant:

    :host-context(.redtext) { 
      /* Styles CSS */
    } 
    

    Pour appliquer un style si un antécédent est un élément <p> en limitant aux éléments <h1> de la vue du composant:

    :host-context(p) h1 { 
      /* Styles CSS */
    } 
    
  • ::ng-deep permet d’appliquer un style ou une classe globalement. Le sélecteur ::ng-deep doit être utilisé avec le paramètre ViewEncapsulation.Emulated. Il faut placer ::ng-deep devant les sélecteurs pour lesquels on veut une application globale:
    ::ng-deep p {
      /* Styles CSS */
    }
    

    Cette définition applique le style sur tous les éléments <p>.

    Pour appliquer un style si la classe bluebackground est appliquée sur un élément:

    ::ng-deep .bluebackground {
      /* Styles CSS */
    }
    

    Pour appliquer un style sur les éléments <p> sur lesquels la classe bluebackground est appliquée:

    ::ng-deep p.bluebackground {
      /* Styles CSS */
    }
    

    Dans l’exemple suivant, le style est appliquée spécifiquement aux éléments <span> du composant et globalement à tous les éléments <p>:

    span ::ng-deep p {
      /* Styles CSS */
    }
    
  • :host ::ng-deep permet d’appliquer un style aux composants enfant:
    :host ::ng-deep p {
      /* Styles CSS */
    }
    

    Le style sera appliqué aux éléments <p> du composant et de ses enfants.

Appliquer un style dans la classe d’un composant

  • @HostBinding(): ce décorateur effectue un binding entre un membre de la classe et une propriété de l’objet du DOM qui est l’hôte du composant.
    export class ExampleComponent {  
      @HostBinding('style.background-color') backgroundColor: string = 'cadetblue';
    } 
    

    Cette implémentation permet d’appliquer la couleur cadetblue à la propriété background-color de l’élément hôte du composant Example.

    export class ExampleComponent {  
      @HostBinding('class.bluebackground') applyBlueBackground: boolean = true; 
    } 
    

    Cet exemple permet d’appliquer le classe bluebackground à l’élément hôte du composant Example.

  • ElementRef.nativeElement: permet de s’interfacer avec l’objet du DOM d’un élément affiché sur la vue d’un composant. On peut appliquer un style ou une classe en passant par cet objet, par exemple:
    export class ExampleComponent implements OnInit { 
      @ViewChild('content', { static: true }) contentElementRef !: ElementRef; 
    
      constructor(private renderer: Renderer2) {} 
    
      ngOnInit(): void { 
        this.renderer.setStyle(this.contentElementRef.nativeElement, 
          'background-color', 'cadetblue'); 
    
        this.renderer.setAttribute(this.contentElementRef.nativeElement, 
          'class', 'redtext'); 
      } 
    }
    

    Dans cet exemple:

    • On effectue une requête sur la vue du composant pour récupérer un objet identifié par une variable référence #content.
    • On affecte la couleur cadetblue à la propriété de style background-color de l’élément requêté.
    • On applique la classe redtext à cet élément requêté.

Priorités d’application des styles sur un élément

Si des styles sont appliqués de façon concurrente sur un élément, la priorité d’application de ces styles se fait dans cet ordre:

  1. Property binding sur une propriété spécifique,
  2. Property binding en utilisant [style],
  3. Les styles appliqués statiquement.

Priorités d’application des styles sur l’hôte d’un composant

Si des styles sont appliqués de façon concurrente sur l’hôte d’un composant, la prorité d’application de ces styles se fait dans cet ordre:

  1. Bindings effectués sur l’hôte du composant
  2. Les host bindings effectués par une directive
  3. Les host bindings effectués par le composant
Share on RedditTweet about this on TwitterShare on LinkedInEmail this to someonePrint this page

Les vues des composants Angular

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

@amyb99

Les composants Angular sont des objets permettant d’afficher une vue. Il s’agit d’une unité d’implémentation correspondant à une partie de l’affichage d’une application Angular. Cette unité comporte de nombreux éléments permettant de faciliter l’implémentation de façon à, dans un 1er temps, initialiser les objets statiques de la vue et dans un 2e temps, à interagir avec le DOM pour modifier dynamiquement des éléments d’affichage.

Pour ordonner cette unité d’implémentation, un composant est formé de différents éléments:

  • La vue du composant appelée template: cette partie comporte du code HTML correspondant aux objets statiques. Ce squelette statique est enrichi par du code interprété par Angular pour permettre des interactions dynamiques entre les objets statiques et du code métier se trouvant dans le reste du composant.
    Cet article détaille quelques éléments de cette partie du composant.
  • La classe du composant: c’est une classe Typescript dans laquelle on peut définir:
    • des membres contenant les données affichées par la vue,
    • des méthodes et fonctions pour exécuter des traitements déclenchés par des actions sur la vue ou par des évènements divers.
  • Des métadonnées: il s’agit d’informations supplémentaires d’implémentation qui permettront à Angular d’interfacer le template avec la classe du composant.

Ce schéma provenant de la documentation Angular permet de résumer les différents éléments du composant:

Source: angular.io/guide/architecture-components

Le but de cet article est de passer en revue les éléments les plus importants pour implémenter un template:

  • Indiquer comment on peut implémenter le template d’un composant.
  • Détailler l’utilité du paramètre selector qui permettra de placer la vue dans le reste de l’application Angular.
  • Présenter les différents types de bindings pour permettre les interactions entre le template et la classe du composant.
  • Indiquer comment on peut nommer des éléments sur le template avec une variable référence.
  • Détailler quelques directives usuelles facilitant l’implémentation du template.

Implémenter le template

Pour créer un composant Angular, il faut exécuter la commande suivante en utilisant le CLI Angular:

ng g c <nom composant>

Par exemple, pour créer le composant Example:

ng g c example

Cette instruction va créer 4 fichiers:

  • src/app/example/example.component.ts: il s’agit de la classe du composant. C’est le seul fichier obligatoire.
  • src/app/example/example.component.html: ce fichier est le template du composant.
  • src/app/example/example.component.css: ce fichier permet d’implémenter, le cas échéant, les styles ou classes CSS relatifs à la vue (le détail de ce fichier ne sera pas traité dans cet article et fera l’objet d’un article ultérieur).
  • src/app/example/example.component.spec.ts: ce fichier permet d’implémenter des tests liés à la classe du composant (le détail de ce fichier ne sera pas traité dans cet article).

Le contenu du template et de la classe du composant est:

Template
(example.component.html)
<p>example works!</p> 
Classe du composant
(example.component.ts)
import { Component, OnInit } from '@angular/core'; 

@Component({ 
  selector: 'app-example', 
  templateUrl: './example.component.html', 
  styleUrls: ['./example.component.css'] 
}) 
export class ExampleComponent implements OnInit { 

  constructor() { } 

  ngOnInit(): void { 
  } 
} 

Dans la classe du composant dans le décorateur @Component(), quelques paramètres permettent d’enrichir les métadonnées du composant:

  • selector: ce paramètre permet d’indiquer où la vue correspondant au composant sera affichée dans l’application Angular. Ce paramètre sera détaillé dans Paramètre selector.
  • templateUrl: ce paramètre indique le chemin du fichier template.
  • styleUrls: ce paramètre indique les chemins des fichiers CSS contenant les styles utilisés par la vue du composant.

Il est possible de ne pas utiliser un fichier séparé pour l’implémentation du template. Avec le paramètre template dans le décorateur @Component(), on peut implémenter le template directement dans la classe du composant, par exemple:

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

@Component({ 
  selector: 'app-example', 
  template: `<p>example works!</p>`,
  styleUrls: ['./example.component.css'] 
}) 
export class ExampleComponent implements OnInit { 
  
  constructor() { } 

  ngOnInit(): void { 
  } 
} 

La valeur du paramètre template doit être indiquée entre les caractères `...` (accessible avec les touches [AltGr] + [7]) et non de simples quotes '...'.

Paramètre selector

Le paramètre selector peut être utilisé au niveau du décorateur @Component() d’un composant et plus généralement d’une directive. Il permet d’indiquer où le rendu de la vue du composant sera effectué. Ce paramètre n’est pas obligatoire, si on utilise un router Angular il peut être omis.

Pour comprendre l’intérêt de ce paramètre, on prend l’exemple du composant Example créé précédemment:

Template
<p>example works!</p>
Classe du composant
import { Component } from '@angular/core'; 

@Component({ 
  selector: 'app-example',
  templateUrl: './example.component.html' 
}) 
export class ExampleComponent  { 
} 

La valeur du paramètre selector de ce composant est:

app-example 

Cela signifie que le rendu sera effectué sur la vue d’un composant si le template de ce composant contient:

<app-example></app-example> 

Ainsi si:

  1. On supprime tout le contenu de la vue du composant principal dans le fichier src/app/app.component.html.
  2. On importe le composant ExampleComponent dans la classe du composant principal (dans src/app/app.component.ts) en ajoutant:
    import { ExampleComponent } from './example/example.component';
    
  3. Dans le template du composant principal (dans src/app/app.component.html), on indique l’appel au selector:
    <app-example></app-example>
    

L’implémentation du composant principal devient:

Template
<app-example></app-example> 
Classe du composant
import { Component } from '@angular/core'; 
import { ExampleComponent } from './example/example.component'; 

@Component({ 
  selector: 'app-root', 
  templateUrl: './app.component.html', 
  styleUrls: ['./app.component.css'] 
}) 

export class AppComponent { 
} 

Après avoir exécuté les commandes suivantes:

npm install 
ng serve 

L’affichage est:

Ainsi le rendu de la vue du composant ExampleComponent (dans src/app/example/example.component.html) a été effectué dans la vue du composant principal AppComponent (dans src/app/app.component.html).

Le paramètre selector peut servir dans le cadre de composant enfant c’est-à-dire si on imbrique un composant dans un autre (pour plus de détails voir les composants enfants).

Enfin le paramètre selector peut être utilisé sous des formes différentes de celle présentée dans cet exemple:

  • Attribut d’un élément HTML:
    Si le paramètre selector est défini de cette façon dans le composant:

    selector: '[custom-directive]' 
    

    La vue du composant sera affichée si le selector est utilisé sous forme d’attribut d’un élément HTML, par exemple:

    <span custom-directive></span> 
    

    L’attribut peut comporter une valeur:

    <span custom-directive="attribute value"></span> 
    

    La directive est reconnue aussi dans le cas d’un property binding (cette notion est définie plus bas):

    <span [custom-directive]="bindedProperty"></span> 
    

    Toutefois dans ce dernier cas, la directive devra comporter un paramètre d’entrée avec le même nom ou le même alias que le paramètre selector.

  • Classe CSS:
    Si le paramètre selector est défini de cette façon dans le composant:

    selector: '.custom-directive'  
    

    Dans ce cas, la directive doit être utilisée dans une classe CSS d’un élément HTML, par exemple:

    <span class="custom-directive"></span>
    

D’autres types de conditions sont possibles pour afficher le composant (voir les directives pour plus détails sur ces conditions).

Binding

Les interactions entre la vue et la classe du composant sont possibles avec différents types de bindings. L’implémentation de ces bindings permet d’enrichir la vue avec des données ou de déclencher l’exécution de code dans la classe du composant.

Le binding permet l’interfaçage entre le template et la classe du composant en:

  • Faisant passer des données de la classe du composant vers le template.
  • Déclanchant l’exécution de méthodes dans la classe du composant à la suite du déclenchement d’évènements dans le template.

Le binding permet de faciliter l’implémentation de tous ces mécanismes en rendant, par exemple, automatique:

  • La mise à jour d’un élément graphique quand la valeur d’un membre a changé dans la classe du composant,
  • Déclenchant l’exécution d’une méthode quand un évènement est survenu dans un élément HTML de la vue.

Ces mécanismes sont possibles grâce à la détection de changements effectuée par Angular (pour plus de détails, voir le Fonctionnement de la détection de changements).

Il existe plusieurs méthodes pour effectuer le binding:

  • L’interpolation permettant d’exécuter dans le template une expression Javascript pouvant contenir des attributs provenant de la classe du composant.
  • Le property binding qui permet d’effectuer un binding d’un membre de la classe du composant vers le template.
  • L’event binding permettant de déclencher un évènement dans la classe du composant à partir d’un évènement déclenché dans un objet du DOM.
  • L’attribute binding permettant d’effectuer un binding entre l’attribut d’un élément HTML et un attribut de la classe du composant.
  • Le two-way binding permettant à la fois l’échange de données entre la classe du composant et le template (1er sens) et le déclenchement d’évènements dans la classe du composant (2e sens).

On va détailler chacune de ces méthodes.

Interpolation

C’est le binding le plus simple qui permet l’échange de données dans un sens: de la classe du composant vers le template. Il permet d’exécuter une expression et d’utiliser directement des membres ou des fonctions publiques de la classe du composant dans le template avec la syntaxe {{ ... }}.

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

Template
<h1>{{title}}</h1>
Classe du composant
@Component({ 
  selector: 'app-example', 
  templateUrl: './example.component.html' 
}) 
export class ExampleComponent { 
     title = 'Page title';
} 

L’instruction {{title}} permet d’utiliser la valeur de le membre title de la classe dans le template.

Il est possible d’exécuter une fonction et d’utiliser sa valeur de retour, par exemple:

Template
<h1>{{getPageTitle()}}</h1> 
Classe du composant
@Component({ 
  selector: 'app-example', 
  templateUrl: './example.component.html' 
}) 
export class ExampleComponent { 
     private title = 'Page title'; 

     getPageTitle: string { 
          return this.title; 
     } 
} 

On exécute la fonction getPageTitle() pour afficher sa valeur de retour.

Dans la partie entre les crochets, on peut exécuter une expression, par exemple:

Template
{{getPageTitle().toUpperCase()}} 
Classe du composant
@Component({ 
  selector: 'app-example', 
  templateUrl: './example.component.html' 
}) 
export class ExampleComponent { 
  private title = 'Page title'; 

  getPageTitle: string { 
    return this.title; 
  } 
} 

getPageTittle().toUpperCase() permet de transformer les lettres du titre en majuscules.

Pour que le code de l’expression soit valide, il ne doit pas provoquer la création d’un nouvel objet ou le changement de valeur d’un objet existant. L’instanciation d’un objet ou les affectations ne sont, par exemple, pas possibles.

Property binding

Le property binding permet d’échanger des données de la classe du composant vers le template. Il permet de renseigner une propriété d’un objet dans le DOM avec une valeur provenant de le membre de la classe du composant. Indirectement la propriété dans le DOM se reflète sur son équivalent au niveau de la vue.

Par exemple, pour renseigner la propriété alt d’un élément HTML img avec un membre imgAltText du composant, le code est:

Template
<p>Example Componant</p> 
<img [alt]='imgAltText' /> 
Classe du composant
@Component({ 
  selector: 'app-example', 
  templateUrl: './example.component.html' 
}) 
export class ExampleComponent { 
  imgAltText = 'image to display';
} 

Le code [alt]='imgAltText' permet de renseigner la propriété alt de l’élément HTML avec la valeur du membre imgAltText de la classe du composant.

L’affichage est:

Ainsi:

  • [ ] est le propriété de l’élément cible du DOM
  • ' ' est la source du binding dans la classe du composant.

Avec l’interpolation, on pourrait avoir un résultat similaire en utilisant l’implémentation suivante dans le template:

<img alt='{{imgAltText}}' /> 

Si dans cet exemple on omet les crochets [ ] autour de la propriété de l’élément HTML il n’y a pas de binding et le texte 'imgAltText' est affecté statiquement à l’attribut alt de img, par exemple:

Si la propriété de l’élément HTML n’existe pas, une erreur se produit, par exemple:

<img [unknown]='imgAltText' /> 

L’erreur est du type:

Can't bind to 'unknown' since it isn't a known property of 'img'.

Le property binding permet d’effectuer un binding avec une propriété d’un élément HTML mais il permet aussi d’effectuer un binding dans des cas plus complexes comme:

  • Effectuer une binding avec un paramètre d’entrée d’un composant enfant: voir les composants enfant pour plus de détails.
  • Plus généralement effectuer un binding avec le paramètre d’une directive: voir les directives pour plus de détails.
Ne pas confondre propriété d’un objet du DOM et attribut d’un élément HTML

Le property binding permet d’interagir avec une propriété d’un objet du DOM et non directement avec un attribut d’un élément HTML. Une propriété du DOM et un attribut HTML ne correspondent pas à la même chose même s’ils possèdent le même nom, par exemple:

  • Des attributs HTML peuvent ne pas avoir de propriétés correspondantes comme colspan.
  • Des propriétés du DOM peuvent ne pas avoir d’équivalent au niveau des attributs HTML comme textContent.
  • Des attributs HTML et des propriétés DOM n’ont pas une correspondance directe comme value. Dans le cas du DOM c’est la valeur actuelle alors que dans le cas de l’attribut c’est la valeur initiale.

Si on prend l’exemple de l’attribut colspan de l’élément HTML td, il faut utiliser la propriété DOM colSpan. Ainsi, pour effectuer un property binding entre la propriété colSpan de l’objet du DOM et un attribut nommé spanValue dans la classe du composant, on écrira:

<td [colSpan]='spanValue'></div>

Le code suivant produit une erreur car la propriété DOM colspan n’existe pas:

<td [colspan]='spanValue'></div>

Il n’est pas évident de connaître les équivalents entre attribut HTML et propriété du DOM, on peut s’aider de ce tableau dans le code source d’Angular: dom_element_schema_registry.ts.

Attribute binding

L’attribute binding est similaire au property binding sauf qu’on utilise la syntaxe attr.<nom de l'attribut> pour désigner l’attribut de l’élément HTML.

Ainsi pour effectuer un binding entre le membre imgAltText de la classe du composant et l’attribut alt d’un élément img, la syntaxe est:

Template
<p>Example Componant</p> 
<img [attr.alt]='imgAltText' />
Classe du composant
@Component({ 
  selector: 'app-example', 
  templateUrl: './example.component.html' 
}) 
export class ExampleComponent { 
  imgAltText = 'image to display'; 
} 
Différence entre l’attribute binding et le property binding

La différence entre l’attribute binding et le property binding est que l’attribute binding intervient sur l’attribut de l’élément HTML pour affecter dynamiquement une valeur. Le property binding passe par l’intermédiaire d’une propriété dans l’objet dans le DOM correspondant à l’élément HTML.
L’attribute binding fait toujours référence à un attribut existant alors que le property binding peut faire référence à une propriété Angular qui n’a pas forcément d’équivalent dans l’objet dans le DOM.

Par exemple, si on considère l’exemple suivant:

<div [ngStyle]="{'color': yellow}"></div>

Ce code permet d’affecter un style à l’élément HTML div en passant par la directive Angular ngStyle. ngStyle ne correspond pas à un attribut HTML. On ne peut pas écrire:

<div ngStyle="{'color': yellow}"></div>

De même, l’affectation de la propriété colspan de l’élément HTML td peut prêter à confusion:

  • colspan fait référence à l’attribut de l’élément HTML td.
  • colSpan fait référence à la propriété de l’objet dans le DOM qu’Angular pourra modifier.

Ainsi, pour effectuer un attribute binding entre l’attribut colspan de l’élément HTML td et un attribut nommé spanValue dans la classe du composant, on écrira:

<td [attr.colspan]='spanValue'></div>

Pour effectuer un property binding entre la propriété colSpan de l’objet du DOM et un attribut nommé spanValue dans la classe du composant, on écrira:

<td [colSpan]='spanValue'></div>

Event binding

L’event binding permet d’exécuter du code dans la classe du composant à partir d’évènements déclenchés sur un élément du DOM.

Par exemple, pour exécuter la méthode displayText() dans la classe du composant à partir d’un clique sur un bouton, l’implémentation est:

Template
<p>{{textToDisplay}}</p> 
<p><button (click)='displayText()'>Click Me</button></p> 
Classe du composant
@Component({ 
  selector: 'app-example', 
  templateUrl: './example.component.html' 
}) 
export class ExampleComponent { 
    TextToDisplay = ''; 

    displayText(): void { 
        this.textToDisplay = 'My Text'; 
    } 
} 

Ainsi l’évènementclick dans le code HTML déclenche l’exécution de la méthode displayText(). Par binding, la valeur de la propriété textToDisplay est mise à jour directement dans le DOM.

Cet exemple permet de montrer l’exemple d’event binding entre un élément HTML et une méthode dans la classe du composant toutefois il existe des cas d’utilisations plus complexes dans le cadre:

Two-way binding

Le two-way binding permet d’effectuer un binding dans plusieurs directions:

  • Un property binding pour injecter la valeur d’un membre d’une classe de composant dans une propriété d’un composant.
  • Un event binding permettant d’exécuter une méthode après le déclenchement d’un évènement.

On indiquera 2 cas d’application d’un two-way binding:

  • Dans le cas d’un composant enfant et
  • Dans le cas d’un formulaire avec l’objet Angular ngModel.

Composant enfant

L’implémentation du two-way binding avec un composant enfant est possible en implémentant:

  • un paramètre d’entrée avec le décorateur @Input().
  • Un évènement auquel on peut s’abonner à l’extérieur du composant avec le décorateur @Output(). Pour que le two-way binding fonctionne, l’évènement doit s’appeler <nom du paramètre d'entrée>Change.

Par exemple, on crée le composant counter en exécutant:

ng g c counter

On modifie l’implémentation pour que ce composant contienne un compteur qui sera incrémenté en cliquant sur un bouton. La valeur initiale de ce compteur sera injectée avec un paramètre d’entrée. Chaque évènement de click sur le bouton déclenchera un évènement à l’extérieur du composant. L’implémentation est:

Template
<p>Counter: {{count}}</p> 
<button (click)='incrementValue()'>Increment</button> 
Classe du composant
import { Component, Input, Output, EventEmitter } from '@angular/core'; 

@Component({ 
  selector: 'counter', 
  templateUrl: './counter.component.html' 
}) 
export class CounterComponent { 

  @Input() count: number; 
  @Output() countChange: EventEmitter<number>= new EventEmitter<number>();

  incrementValue(): void { 
    this.count++;
    this.countChange.emit(this.count); 
  } 
} 

On peut remarquer que le paramètre d’entrée s’appelle count. Pour permettre le two-way binding l’évènement doit s’appeler countChange.

On utilise ce composant à l’intérieur d’un autre composant en tant que composant enfant.

On crée un composant parent qui contiendra le composant counter en exécutant:

ng g c parent

L’implémentation du composant parent est:

Template
<p>Parent Componant</p> 
<counter [(count)]="bindedCount"></counter> 
<p>Two-way binded counter: {{bindedCount}}</p> 
Classe du composant
import { Component} from '@angular/core'; 

@Component({ 
  selector: 'app-parent', 
  templateUrl: './parent.component.html' 
}) 
export class ParentComponent { 
  bindedCount = 10;
} 

De point de vue de l’implémentation:

  • Le template du composant parent contient le code pour afficher le composant enfant counter.
  • L’attribut [(count)]='bindedCount' permet d’implémenter un two-way binding avec la notation [(...)]:
    • Un property binding permet d’injecter dans la propriété count du composant counter la valeur du membre bindedCount provenant de la classe du composant parent (1er sens du binding).
    • Un event binding permet de mettre la valeur bindedCount dans la classe du composant parent à chaque fois que la propriété count est mise à jour dans le composant counter (2e sens du binding).

Ainsi, à l’exécution, on peut voir que:

  • La paramètre d’entrée count est bien paramétrer avec la valeur d’initialisation 10 provenant du membre bindedCount du composant parent.
  • A chaque click sur le bouton, le membre count dans le composant counter est incrémenté et le membre bindedCount dans le composant parent est aussi mis à jour.

L’interface se présente de cette façon:

ngModel

Une autre implémentation permet d’effectuer un two-way binding dans le cas de formulaire en utilisant ngModel.

Par exemple, si on utilise l’élément HTML input:

Template
<p><input [(ngModel)]='textToDisplay' /></p> 
<p><button (click)='eraseInputValue()'>Erase</button></p> 
<p>{{textToDisplay}}</p> 
Classe du composant
@Component({ 
  selector: 'app-example', 
  templateUrl: './example.component.html'  
}) 
export class ExampleComponent { 
    TextToDisplay = ''; 

    eraseInputValue(): void { 
        this.textToDisplay = 'My Text'; 
    } 
} 

Si on exécute ce code directement, un erreur se produira:

Can't bind to 'ngModel' since it isn't a known property of 'input'.

Pour corriger le problème, il faut importer le module FormsModule dans le module du composant. Dans notre exemple, le module du composant est le module root. Ainsi il faut rajouter dans le fichier src/app/app.module.ts, l’import de FormsModule:

import { FormsModule } from '@angular/forms'; 

@NgModule({ 
  imports: [ CommonModule, FormsModule ] 
}) 
export class AppModule {} 

Dans cet exemple, si on ecrit dans la partie input, la propriété textToDisplay sera directement mise à jour et l’interpolation {{textToDisplay}} affichera directement la nouvelle valeur. Ainsi l’échange se fait du template vers le membre de la classe du composant.

Si on clique sur le bouton, la valeur de la propriété textToDisplay est effacée par la méthode eraseInputValue() et la nouvelle valeur est directement répercutée dans la vue. L’échange se fait dans l’autre sens c’est-à-dire du membre de la classe du composant vers la vue.

Si on n’utilisait pas ngModel, il aurait fallu écrire le code coté template de cette façon:

<input [value]='textToDisplay' (input)='textToDisplay = $event.target.value' /> 

Cette implémentation associe plusieurs comportements:

  • Un property binding entre la propriété value de l’élément HTML input et le membre textToDisplay de la classe du composant. Ce binding permet l’échange de valeur du composant vers la vue.
  • Un event binding sur l’évènement input qui permet d’exécuter le code: textToDisplay = $event.target.value permettant d’affecter une nouvelle valeur au membre textToDisplay du composant à partir du contenu de l’élément input.

Utiliser ngModel simplifie la syntaxe en permettant d’effectuer un binding entre les membres de la classe du composant avec des éléments HTML de type input, select ou textarea. Par exemple cette directive s’utilise de cette façon pour un élément de type input:

<input [ngModel]='textToDisplay' (ngModelChange)='textToDisplay = $event' /> 

Avec cette syntaxe, le property binding et l’event binding apparaissent plus explicitement. L’event binding (ngModelChange) notifie quand un changement est détecté dans la vue, il extrait la cible de l’évènement déclenché à partir de event au lieu d’utliser event.target.value comme dans la syntaxe précédente.

Variable référence dans le template (#variable)

On peut définir des variables dans le template pour référencer un élément du DOM. La portée de cette variable sera le template c’est-à-dire qu’elle sera accessible et qu’elle doit être unique dans le cadre du template.

Par défaut, la variable contient une référence vers un élément du DOM et non un objet Angular. Toutefois il est possible de modifier le comportement pour que la variable contienne un objet Angular utilisé dans le template comme ngModel ou ngForm.

Pour déclarer une variable référence, il faut utiliser la syntaxe #<nom de la variable> ou ref-<nom de la variable> (cette dernière syntaxe n’est pas courante).

Par exemple:

<p> 
  <input #inputElement type='text' value='initialValue' /> 
  <button #clickButton>Click Me</button> 
</p> 

#inputElement et #clickButton permettent d’accéder à l’objet dans le DOM de, respectivement, l’élément input et button dans le reste du code du template. Ces variables ne sont toutefois pas directement accessible dans la classe du composant.

On peut, ainsi, utiliser les variables référence pour accéder aux objets en utilisant le nom de la variable sans le #:

<p>Input element value: {{inputElement.value}}</p> 
<p>Button textContent: {{clickButton.textContent}}</p> 

Le résultat est:

Il est important de comprendre que ces variables font référence aux objets du DOM, c’est la raison pour laquelle elles ne sont pas directement accessibles dans la classe du composant. Elles donnent la possibilité d’accéder aux 3 types d’objets dans le template:

  • Les éléments HTML: si on exécute le code suivant, on peut accéder à l’attribut value de l’élément HTML input. La valeur de cet attribut ne changera pas si on change la valeur de l’objet DOM correspondant.
  • Les objets du DOM et
  • Certains objets Angular comme ngModel ou ngForm.

Variable référence contenant une propriété du DOM

Pour comprendre les différences, on peut considérer l’exemple suivant;

<p><input #inputElement type='text' value='initialValue' /></p> 
<p>Input element: {{inputElement.getAttribute('value')}}</p> 

On peut accéder à l’attribut value de l’élément HTML input. La valeur de cet attribut ne changera pas même si on change la valeur de l’objet DOM correspondant. Le résultat de inputElement.getAttribute('value') est 'initial value'.

Si on modifie le code en effectuant un binding dans les 2 directions (i.e. two-way binding) avec ngModel, on peut modifier le contenu de value de l’élément input sans modifier la valeur de l’attribut HTML value:

<p><input #inputElement type='text' value='initialValue' [(ngModel)]='inputValue' /></p> 
<p>Input element: {{inputElement.getAttribute('value')}}</p> 
<p>Input element: {{inputElement.value}}</p> 

Il faut rajouter la propriété inputValue dans la classe du composant:

@Component({ ... }) 
export class ExampleComponent { 
  inputValue: string; 
} 

Le résultat est:

Dans cet exemple, l’attribut HTML initial n’est pas modifié toutefois la propriété de l’objet DOM contenant la valeur est modifié.

On peut voir le détail de l’objet dans le DOM en rajoutant la méthode suivante dans la classe du composant:

log(elementToLog) { 
  console.log(elementToLog); 
} 

Et en modifiant le template:

<p><input #inputElement type='text' value='initialValue' [(ngModel)]='inputValue' /></p> 
<p>Input element: {{inputElement.getAttribute('value')}}</p> 
<p>Input element: {{inputElement.value}}</p> 
<p>{{log(inputElement)}}</p>

Dans la console, on peut voir la structure de l’objet dans le DOM:

Variable référence contenant ngModel

On peut modifier le comportement pour que la variable référence inputElement contienne un autre objet que l’objet du DOM. Par exemple, en utilisant l’exemple précédent on peut affecter l’objet Angular ngModel dans la variable référence inputElement:

<p><input #inputElement='ngModel' type='text' [(ngModel)]='inputValue' /></p> 
<p>Input element: {{inputElement.value}}</p> 

En ajoutant la ligne:

{{log(inputElement)}} 

On peut voir qu’à la différence de l’exemple précédent, la variable référence inputElement ne contient plus l’objet du DOM mais l’objet Angular de type ngModel:

Accéder à une variable référence dans la classe du composant

Il est possible d’accéder dans la classe du composant à un objet identifié avec une variable référence dans le template. L’accès à cet objet n’est pas direct et doit se faire en effectuant une requête sur les éléments de la vue. Le décorateur @ViewChild() permet d’effectuer cette requête en utilisant le nom de la variable référence.

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

Template
<div #divElement>Texte à afficher</div> 
Classe du composant
import { Component, OnInit, ViewChild, ElementRef } from '@angular/core';

@Component({ ... }) 
export class ExampleComponent implements OnInit { 
    @ViewChild('divElement', {static: true}) htmlDivElement: ElementRef;

    ngOnInit(): void { 
        console.Log(this.htmlDivElement.nativeElement.innerText);
    } 
} 

Dans cet exemple, le template contient un élément HTML div identifié avec une variable référence divElement. Dans la classe du composant, on peut requêter la vue en utilisant le décorateur @ViewChild() et obtenir un objet permettant d’accéder à l’élément div du template.
Les paramètres du décorateur @ViewChild() sont:

  • 'divElement': ce paramètre correspond à la variable référence utilisée dans le template.
  • { static: true }: ce paramètre est optionnel et permet d’indiquer que l’élément à requêter fait partie du contenu statique de la vue en opposition au contenu dynamique. L’intérêt de ce paramètre est qu’il autorise à effectuer la requête au début du cycle de vie du composant de façon à ce que l’objet requêté soit accessible lors de l’exécution de ngOnInit().

Si on ne précise pas { static: true }, la valeur par défaut est { static: false }. Cela signifie que la requête sera exécutée plus tard lors du cycle de vie du composant. L’objet ne sera pas disponible à l’exécution de ngOnInit(), à ce moment la variable htmlDivElement est indéfinie (i.e. undefined). La variable htmlDivElement sera renseignée à l’exécution de ngAfterViewInit():

import { Component, OnInit, AfterViewInit, ViewChild, ElementRef } from '@angular/core';

@Component({ ... }) 
export class ExampleComponent implements OnInit, AfterViewInit { 
    @ViewChild('divElement') htmlDivElement: ElementRef;

    ngOnInit(): void { 
        // ERREUR undefined
        // console.Log(this.htmlDivElement.nativeElement.innerText);
    } 

    ngAfterViewInit(): void { 
        // OK
        console.Log(this.htmlDivElement.nativeElement.innerText);
    } 
} 

Pour plus de détails sur le paramètre static, sur le cycle de vie d’un composant et sur le requêtage d’une vue, voir Requêter les éléments d’une vue d’un composant Angular.

Directives structurelles usuelles

Les directives sont des objets Angular permettant de modifier ou d’enrichir un élément du DOM en rajoutant ou en modifiant une propriété par programmation. L’intérêt des directives est de pouvoir les utiliser dans un composant en les implémentant dans le template.

Fonctionnellement les directives peuvent paraître semblables aux composants enfant toutefois la grande différence entre les directives et les composants est que la directive n’a pas de vue. Dans la documentation Angular, les directives sont découpées en 3 catégories:

  • Les composants: ce sont des directives avec une vue implémentée dans un fichier template.
  • Les autres types de directives ne disposent pas de vue mais elles permettent de modifier le DOM en ajoutant ou en supprimant des éléments du DOM:
    • Les directives attribut (i.e. attribute directives): ces directives peuvent modifier l’apparence et le comportement des éléments, composants et d’autres directives.
    • Les directives structurelles (i.e. structural directives): elles se distinguent des directives attribut car elles utilisent un modèle (i.e. template) pour modifier le DOM. Il ne faut pas confondre ce modèle avec le template d’un composant.

Il est possible d’implémenter complêtement des directives toutefois Angular propose des directives usuelles qu’on peut directement utiliser dans le fichier template. Le but de cette partie est d’expliciter certaines directives les plus usuelles comme:

  • ngIf pour implémenter une instruction conditionnelle if...then...else.
  • ngFor pour implémenter l’équivalent d’une boucle for.
  • ngSwitch pour implémenter un switch...case.

L’article sur les directives permet d’indiquer davantage de détails pour les implémenter: cdiese.fr/angular-directives.

ngIf

ngIf permet d’afficher des éléments après avoir évalué si une condition est vraie. Cette directive transpose l’instruction conditionnelle if...then...else pour qu’elle soit utilisable dans le template d’un composant.

La syntaxe sous sa forme la plus simple si on l’utilise dans un élément HTML div, est:

<div *ngIf="<condition>">Contenu affiché si la condition est vraie</div> 

Ainsi si <condition> est égale à true, l’élément HTML sera affiché de cette façon:

<div>Contenu affiché si la condition est vraie</div> 

Si <condition> == false, rien ne sera affiché.

La syntaxe découle directement du fonctionnement des directives:

  • La directive est implémentée sous la forme d’un attribut d’un élément HTML car le paramètre selector de la directive est indiqué sous la forme selector: '[ngIf]' (voir paramètre selector pour plus de détails).
  • La caractère * devant ngIf indique qu’il s’agit d’une directive structurelle.

La syntaxe indiquée plus haut est une forme compacte dont l’équivalent dans sa forme plus étendue est:

<ng-template [ngIf]="<condition>"> 
  <div>Contenu affiché si <condition> == true</div> 
</ng-template> 

<ng-template> est un objet complexe permettant d’implémenter un modèle utilisé pour créer des vues intégrées (i.e. embedded view). Ces vues seront directement ajoutées au DOM suivant l’implémentation de modèle.

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

Template
<div *ngIf="condition">condition==true</div> 
<div *ngIf="!condition">condition==false</div> 

<p>Value of 'condition': {{condition}}</p> 
Classe du composant
@Component({ ... }) 
export class NgifExampleComponent { 
    condition = true; 
} 

Dans le fichier .css du composant, on ajoute le style:

div {  
    border: 1px solid black;   
    display: block;  
    background-color: lightgreen;  
} 

La résultat sera du type:

Ainsi comme condition == true alors seul le 1er élément div est affiché.

La forme étendue de la syntaxe indiquée plus haut est:

<ng-template [ngIf]="condition"> 
    <div>condition=true</div> 
</ng-template> 
<ng-template [ngIf]="!condition"> 
    <div>condition=false</div> 
</ng-template> 

Syntaxe avec else

On peut utiliser une syntaxe correspondant à une clause else, toutefois il faut passer par une variable référence. Cette variable référence contiendra la vue intégrée à afficher si la condition est fausse.

Avec else, la syntaxe équivalent à l’exemple précédent est:

<div *ngIf="condition else whenFalse"> 
    condition=true 
</div> 
<ng-template #whenFalse><div>condition=false</div></ng-template> 

Dans cet exemple:

  • "condition else whenFalse" est la configuration de la directive ngIf sous la forme d’une microsyntaxe (voir Configurer une directive structurelle par microsyntaxe pour plus de détails).
  • #whenFalse est la variable référence utilisée pour indiquer la vue intégrée à afficher quand la condition est fausse.

La forme étendue de la syntaxe avec else est:

<ng-template [ngIf]="condition" [ngIfElse]="whenFalse"> 
    <div>condition=true</div> 
</ng-template> 
<ng-template #whenFalse><div>condition=false</div></ng-template> 

Syntaxe avec then et else

On peut utiliser une syntaxe indiquant explicitement une clause then:

<div *ngIf="condition then whenTrue else whenFalse"></div> 
<ng-template #whenTrue><div>condition=true</div></ng-template> 
<ng-template #whenFalse><div>condition=false</div></ng-template> 

Cette syntaxe utilise 2 variables références whenTrue et whenFalse correspondant aux vues intégrées à afficher suivant la valeur de la condition.

La forme étendue équivalente de la syntaxe est:

<ng-template [ngIf]="condition" [ngIfThen]="whenTrue" [ngIfElse]="whenFalse"> 
</ng-template> 
<ng-template #whenTrue><div>condition=true</div></ng-template> 
<ng-template #whenFalse><div>condition=false</div></ng-template> 

ngFor

ngFor permet de parcourir une liste d’objets. Cette directive transpose la boucle for pour qu’elle soit utilisable dans le template d’un composant.

La syntaxe sous sa forme la plus simple si on l’utilise dans un élément HTML div, est:

<div *ngFor="let <nom variable locale d'un élément> of <liste d'éléments>"> 
  {{<nom variable locale d'un élément>}} 
</div> 

L’intérêt est de pouvoir répéter l’affichage d’un élément HTML. Par exemple si on considère l’exemple suivant:

<div *ngFor="let item of items">{{item}}</div> 

Si la liste d’éléments items contient les entiers 0, 1, 2, 3, 4 le résultat de l’exécution sera:

<div>0</div> 
<div>1</div> 
<div>2</div> 
<div>3</div> 
<div>4</div> 

Le détail de la syntaxe utilisée est:

  • ngFor correspond à une directive implémentée sous la forme d’un attribut d’un élément HTML car le paramètre selector de la directive est indiqué sous la forme selector: '[ngFor]' (voir paramètre selector pour plus de détails).
  • La caractère * devant ngFor indique qu’il s’agit d’une directive structurelle.
  • Le code "let item of items" correspond à la configuration de la directive en utilisant une microsyntaxe (voir Configurer une directive structurelle par microsyntaxe pour plus de détails). Cette configuration comporte 2 expressions:
    • let item permettant de définir une variable locale nommée item. Cette variable est affectée de façon implicite (cf. Propriété implicit) par la directive lors de l’exécution de la boucle.
    • of items: cette expression permet d’affecter le contenu de la variable items au paramètre d’entrée (cf. Paramètre inputs) nommé ngForOf de la directive. items contient la liste des éléments qui seront répétés par la boucle.
Les caractères ';', ':' et le retour à la ligne sont facultatifs dans les expressions par microsyntaxe

Dans la suite d’expressions par microsyntaxe utilisées pour configurer la directive, les caractères ';', ':' et le retour à la ligne n’ont pas d’incidence. Si on considère l’exemple:

<div *ngFor="let item of items">{{item}}</div> 

Les expressions suivantes sont équivalentes malgré la présence des caractères ';', ':' et du retour à la ligne:

<div *ngFor="let item; of items">{{item}}</div> 
<div *ngFor="let item; of: items">{{item}}</div> 

Ou

<div *ngFor="let item;  
of items;">{{item}}</div> 

La syntaxe indiquée plus haut est une forme compacte dont l’équivalent dans sa forme plus étendue est:

<ng-template [ngFor] let-item="$implicit" [ngForOf]="items"> 
  <div>{{item}}</div> 
</ng-template> 

Voir <ng-template> pour plus de détails.

Par exemple si on considère le composant suivant:

Template
<p>Car types are:</p> 
<table> 
    <tr> 
        <th>Id</th> 
        <th>Name</th> 
    </tr> 
    <tr *ngFor="let carType of carTypes">
        <td>{{carType.id}}</td> 
        <td>{{carType.name}}</td> 
    </tr> 
</table> 
Classe du composant
@Component({ ... }) 
export class NgForOfExampleComponent { 
  carTypes = [ 
      { id: "A", name: "Pick-Up"}, 
      { id: "B", name: "Van"}, 
      { id: "C", name: "Truck"}, 
      { id: "D", name: "Sedan"}, 
      { id: "E", name: "Cabriolet"}, 
    ]; 
} 

Dans le fichier .css du composant, on ajoute le style:

table, td, th {
    border: 1px solid black;   
} 

La résultat sera du type:

Dans cet exemple, le tableau carTypes est parcouru de façon à répéter l’affichage d’une ligne pour chaque élément du tableau.

La forme étendue de la syntaxe indiquée plus haut est:

<ng-template [ngFor] let-carType="$implicit"[ngForOf]="carTypes"> 
<tr> 
<td>{{carType.id}}</td> 
<td>{{carType.name}}</td> 
</tr> 
</ng-template> 

index, count, first, last, odd et even

D’autres propriétés dans ngFor peuvent être utilisées pour indiquer des informations supplémentaires:

  • index indique l’index de l’élément courant dans la liste.
  • count contient le nombre d’éléments de la liste.
  • first est un booléen contenant true si l’élément courant est le premier élément de la liste.
  • last est un booléen contenant true si l’élément courant est le dernier élément de la liste.
  • odd contient true si l’index de l’élément courant est impair.
  • even contient true si l’index de l’élément courant est pair.

Si on modifie l’exemple précédent en utilisant ces propriétés dans le template:

<p>Car types are:</p> 
<table> 
    <tr> 
        <th>Index</th> 
        <th>Id</th> 
        <th>Car type</th> 
        <th>Is first type?</th> 
        <th>Is last type?</th> 
        <th>Is odd index?</th> 
        <th>Is even index?</th> 
        <th>Type count</th> 
    </tr> 
    <tr *ngFor="let carType of carTypes  
            index as typeIndex  
            first as isFirstType  
            last as isLastType 
            odd as isOddIndex 
            even as isEvenIndex 
            count as typeCount"> 
        <td>{{typeIndex}}</td> 
        <td>{{carType.id}}</td> 
        <td>{{carType.name}}</td> 
        <td>{{isFirstType}}</td> 
        <td>{{isLastType}}</td> 
        <td>{{isOddIndex}}</td> 
        <td>{{isEvenIndex}}</td> 
        <td>{{typeCount}}</td> 
    </tr> 
</table> 

Dans les différentes expressions en microsyntaxe, la forme index as typeIndex permet d’affecter la valeur de la propriété index à une variable locale nommée typeIndex.

Le résultat de cette implémentation est:

ngSwitch

ngSwitch est une directive permettant d’afficher un élément HTML si une expression est vraie. Cette directive transpose switch...case pour qu’il soit utilisable dans le template d’un composant.

La syntaxe sous sa forme la plus simple si on l’utilise dans un élément HTML div, est:

<div [ngSwitch]="<expression utilisée pour la comparaison>"> 
<div *ngSwitchCase="<expression comparée 1>">...</div> 
<div *ngSwitchCase="<expression comparée 2>">...</div> 
... 
<div *ngSwitchCase="<expression comparée N>">...</div> 
<div *ngSwitchDefault>...</div> 
</div> 

Ainsi:

  • L’attribut ngSwitch permet d’indiquer l’expression qui sera utilisée pour effectuer la comparaison.
  • ngSwitchCase va permettre d’indiquer l’expression pour laquelle l’égalité sera vérifiée.
  • ngSwitchDefault correspond au cas par défaut si l’égalité n’a été trouvée pour aucune expression.

Par exemple si on considère le composant suivant:

Template
<p>Vehicle size is:</p> 
<div [ngSwitch]="vehicleSize"> 
    <div *ngSwitchCase="'big'"><b>Big</b></div> 
    <div *ngSwitchCase="'medium'">Medium</div> 
    <div *ngSwitchCase="'little'"><i>Little</i></div> 
    <div *ngSwitchDefault>(unknown)</div> 
</div> 
Classe du composant
@Component({ ... }) 
export class NgSwitchExampleComponent { 
    vehicleSize='big' 
} 

La résultat sera du type:

Dans cet exemple, le membre vehicleSize dans la classe du composant contient la valeur 'big'. Ainsi seul l’élément HTML <div><b>Big</b></div> sera affiché.

Il est possible d’utiliser une expression pour l’attribut ngSwitch mais aussi pour l’attribut ngSwitchCase. Par exemple si on considère le composant suivant:

Template
<table> 
        <tr> 
            <th>Car type</th> 
            <th>Size</th> 
        </tr> 
        <tr *ngFor="let carType of carTypes" [ngSwitch]="getVehicleSizeRange(carType.size)"> 
            <td>{{carType.name}}</td> 
            <td *ngSwitchCase="bigSize" class='big'>Big</td> 
            <td *ngSwitchCase="mediumSize" class='medium'>Medium</td> 
            <td *ngSwitchCase="littleSize" class='little'>Little</td> 
            <td *ngSwitchDefault>(unknown)</td> 
        </tr> 
</table> 
Classe du composant
@Component({ ... }) 
export class NgSwitchExampleComponent { 
    vehicleSize='big'; 
    carTypes = [ 
      { id: 'A', name: 'Pick-Up', size:4}, 
      { id: 'B', name: 'Van', size:6}, 
      { id: 'C', name: 'Truck', size:7}, 
      { id: 'D', name: 'Sedan', size:4}, 
      { id: 'E', name: 'Cabriolet', size:3}, 
    ]; 
  
    bigSize='big'; 
    mediumSize='medium'; 
    littleSize='little'; 

  getVehicleSizeRange(size: number): string { 
    if (size < 4) return this.littleSize; 
    else if (size >= 4 && size < 6) return this.mediumSize; 
    else return this.bigSize; 
  } 
} 

On ajoute les styles suivants dans le fichier .css:

table, td, th {
    border: 1px solid black;   
  }

.big { 
    background-color: red;   
} 

.medium { 
    background-color: orange;   
} 

.little { 
    background-color: green;   
} 

Le résultat est du type:

Cet exemple est plus élaboré et permet de montrer qu’une expression est utilisée à la fois pour ngSwitch et les différentes clauses ngSwitchCase.

ngPlural

ngPlural permet d’apporter une solution pour la gestion des pluriels. Suivant le nombre d’éléments d’une liste, il va permettre d’afficher une forme au singulier ou au pluriel, par exemple:

  • "Un élément"
  • "Des éléments"
  • "Aucun élément"
  • Etc..

ngPlural donne la possibilité d’indiquer toutes les formes possibles en utilisant l’attribut ngPluralCase:

<p [ngPlural]="<nom d'éléments>"> 
  <ng-template ngPluralCase="0">Aucun élément</ng-template> 
  <ng-template ngPluralCase="1">Un élément</ng-template> 
  <ng-template ngPluralCase="other">Des éléments</ng-template> 
</p> 

Ainsi:

  • ngPlural est utilisé pour indiquer le nombre d’éléments.
  • ngPluralCase permet d’indiquer les différents cas de figures.

On ne peut pas utiliser n’importe quelle valeur avec ngPluralCase. La liste des valeurs acceptées est:

  • Directement des valeurs numériques: "0", "1", "2" et "other" pour indiquer le cas par défaut.
  • Des expressions comportant une valeur précédée de =. "other" peut être utilisé pour indiquer le cas par défaut.

    Par exemple:

    <p [ngPlural]="itemCount"> 
      <ng-template ngPluralCase="=0">Aucun élément</ng-template> 
      <ng-template ngPluralCase="=1">Un élément</ng-template> 
      <ng-template ngPluralCase="other">Des éléments</ng-template> 
    </p> 
    
  • Des valeurs correspondants aux CDLR (i.e. Common Locale Data Repository): ces valeurs sont:
    • "zero" pour indiquer aucun élément.
    • "one" pour indiquer exactement un élément.
    • "two" pour indiquer exactement 2 éléments.
    • "few" pour indiquer entre 3 et 10 éléments.
    • "many" pour indiquer entre 11 et 99 éléments.
    • "other" pour les autres cas de figure.

    Les valeurs prisent en compte dépendent du paramètre de langue. Par exemple pour le français ou l’anglais, il n’existe qu’une forme singulière ou une forme plurielle. Donc seules les valeurs "one" ou "other" sont utilisées. Les valeurs "zero", "two", "few" et "many" ne sont pas prises en comptes.

Dans le cas où aucune valeur ne peut satisfaire le nombre d’élément évalué, une erreur du type suivant peut se produire:

Error: No plural message found for value "..."

Par exemple, si on considère l’exemple du composant suivant:

Template
La liste contient <span [ngPlural]="items.length">  
   <ng-template ngPluralCase="=0">aucun élément</ng-template>  
   <ng-template ngPluralCase="=1">un élément</ng-template>  
   <ng-template ngPluralCase="=2">deux éléments</ng-template>  
   <ng-template ngPluralCase="other">des éléments</ng-template>
</span>. 
<p><button (click)="addItem()">Add item</button></p> 
<p>{{ items.length }}</p> 
Classe du composant
import { Component, OnInit, Inject } from '@angular/core'; 

@Component({ 
  ... 
}) 
export class NgPluralExampleComponent implements OnInit { 
  items: number[]; 
  
  ngOnInit(): void { 
    this.items = []; 
  } 

  addItem(): void { 
    this.items.push(Math.random()); 
  } 
} 

Ce composant permet de montrer le comportement si on augmente le nombre d’éléments dans la liste en cliquant sur Add item.

Dans le cas d’une utilisation explicite des valeurs "=0", "=1", "=2" et "other", les 4 formes seront affichées successivement:

  • 0: La liste contient aucun élément.
  • 1: La liste contient un élément.
  • 2: La liste contient deux éléments.
  • Au delà: La liste contient des éléments.

Le comportement est le même si on indique les valeurs "0", "1", "2" et "other":

<ng-template ngPluralCase="0">aucun élément</ng-template>  
<ng-template ngPluralCase="1">un élément</ng-template>  
<ng-template ngPluralCase="2">deux éléments</ng-template>  
<ng-template ngPluralCase="other">des éléments</ng-template>   

En revanche si on utilise les valeurs "zero", "one", "two", "few", "many" et "other", seules les valeurs "one" et "other" sont prises en compte:

<ng-template ngPluralCase="zero">aucun élément</ng-template>  
<ng-template ngPluralCase="one">un élément</ng-template>  
<ng-template ngPluralCase="two">deux éléments</ng-template>  
<ng-template ngPluralCase="few">un peu d'éléments</ng-template>  
<ng-template ngPluralCase="many">quelques éléments</ng-template>  
<ng-template ngPluralCase="other">des éléments</ng-template>  

L’affichage est:

  • 0: La liste contient des éléments.
  • 1: La liste contient un élément.
  • Au delà: La liste contient des éléments.

En anglais ou en français, seules les formes singulières et plurielles sont prises en compte. Ce n’est pas le cas de toutes les langues. Dans le cas de l’arabe les autres formes peuvent être prises en compte.

Par exemple si on change les paramètres locaux de langues en effectuant les étapes suivantes:

  1. Dans le module du composant (par exemple app.module.ts), si on modifie la langue pour choisir "ar-AE" correspondant à l’arabe des Emirats Arabes Unis:
    import { BrowserModule } from '@angular/platform-browser'; 
    import { NgModule } from '@angular/core'; 
    ... 
    import { LOCALE_ID } from '@angular/core';
    import { registerLocaleData } from '@angular/common'; 
    import localeAr from '@angular/common/locales/ar-AE';
    
    registerLocaleData(localeAr);
    
    @NgModule({ 
      declarations: [ 
        AppComponent, 
      ], 
      imports: [ 
        BrowserModule, 
        AppRoutingModule 
      ], 
      providers: [ 
        { provide: LOCALE_ID, useValue: 'ar-AE'}
      ], 
      bootstrap: [AppComponent] 
    }) 
    
    export class AppModule { } 
    
  2. On modifie le composant pour afficher le paramètre de langue:
    import { Component, OnInit, Inject } from '@angular/core'; 
    import { LOCALE_ID } from '@angular/core';
    
    @Component({ 
        ... 
    }) 
    export class NgPluralExampleComponent implements OnInit { 
      items: number[]; 
    
      constructor(@Inject(LOCALE_ID) localeId) {
        console.log(localeId); 
      } 
    
      ngOnInit(): void { 
        this.items = []; 
      } 
    
      addItem(): void { 
        this.items.push(Math.random()); 
      } 
    } 
    

A l’exécution, on peut voir que l’affichage se fait de cette façon:

  • 0: La liste contient aucun élément.
  • 1: La liste contient un élément.
  • 2: La liste contient deux éléments.
  • Si n % 100 = 3..10: La liste contient un peu d'éléments.
  • Si n % 100 = 11..99: La liste contient quelques éléments.

Pour résumer…

Architecture d’un composant

Un composant est composé de:

  • Une vue appelée template dans laquelle on implémente les éléments visibles.
  • Une classe du composant implémentée un Typescript.
  • Des métadonnées permettant d’apporter à Angular des informations supplémentaires concernant le composant.

La vue d’un composant correspond au template. Ce template peut être implémenté en utilisant du code HTML enrichi de façon à rendre la vue dynamique. L’implémentation de cet aspect dynamique de la vue est possible grâce à différents mécanismes spécifiques à Angular:

  • Le binding qui permet la mise à jour de données et l’exécution de fonctions à partir du déclenchement d’évènements dans la vue.
  • Des directives qui facilitent l’implémentation de comportements dans la vue.

A la création d’un composant:

  • La classe du composant se trouve dans un fichier .ts: cette classe doit comporter le décorateur @Component():
    import { Component } from '@angular/core'; 
    
    @Component({ 
      
    })
    export class ExampleComponent { 
    }
    
  • Le template se trouve généralement dans un fichier séparé .html: le paramètre templateUrl dans le décorateur @Component() indique le chemin de ce fichier:
    @Component({ 
      templateUrl: './example.component.html'
    })
    export class ExampleComponent { 
    }
    

    Le template peut aussi être implémenté directement dans le fichier de la classe du composant en utilisant le paramètre template:

    @Component({ 
      template: `<p>Contenu du template</p>`
    })
    export class ExampleComponent { 
    }
    
  • Les métadonnées sont renseignées dans des paramètres dans le décorateur @Component(). Les paramètres les plus courants sont:
    • selector indiquant où la vue sera affiché dans l’application
    • styleUrls qui est un tableau indiquant les chemins des fichiers CSS permettant de définir des styles ou des classes CSS utilisés dans la vue du composant.

    Ces paramètres sont facultatifs:

    @Component({ 
      selector: 'app-example', 
      templateUrl: './example.component.html', 
      styleUrls: ['./example.component.css'] 
    })
    export class ExampleComponent { 
    }
    
Paramètre selector

Le paramètre selector peut être utilisé si l’affichage du composant n’est pas géré par un router Angular. La valeur de ce paramètre peut être utilisée pour indiquer où la vue du composant sera rendue dans l’application.
Par exemple, si la valeur du paramètre selector est 'app-example' alors la vue du composant sera rendue si un autre composant contient dans son template:

<app-example></app-example>

D’autres conditions sont possibles pour afficher le composant:

  • Si le paramètre selector est selector: '[app-example]' alors le composant sera affiché dans l’élément HTML contenant l’attribut app-example, par exemple:
    <span app-example></span> 
    
  • Si le paramètre selector est selector: '.app-example' alors le composant sera affiché dans l’élément HTML dont la classe CSS est app-example, par exemple:
    <span class="app-example"></span>
    
Binding

Il existe différent type de bindings correspondant à des interactions différentes entre le template et la classe du composant:
Interpolation
Exécution d’une expression Typescript contenant des membres ou des fonctions publics dans la classe du composant:

Template
<div>{{textToDisplay}}</div>
Classe du composant
@Component({ ... }) 
export class ExampleComponent { 
     textToDisplay = 'Texte à afficher';
}

Property binding
Permet de mettre à jour le contenu d’une propriété DOM d’un élément affiché avec la valeur d’un membre dans la classe du composant.

Template
<div [innerText]="textToDisplay"></div>
Classe du composant
@Component({ ... }) 
export class ExampleComponent { 
     textToDisplay = 'Texte à afficher';
}

Attribute binding
Permet de mettre à jour le contenu d’un attribut d’un élément HTML avec la valeur d’un membre dans la classe du composant:

Template
<input type="text" [attr.value]="textToDisplay" />

ou

<input type="text" attr.value="{{textToDisplay}}" />
Classe du composant
@Component({ ... }) 
export class ExampleComponent { 
     textToDisplay = 'Texte à afficher';
}

Event binding
Déclenche l’exécution d’une fonction dans la classe du composant à partir du déclenchement d’un évènement dans un objet du DOM:

Template
<p>{{valueToDisplay}}</p>
<button (click)="incrementValue()">Increment</button>
Classe du composant
@Component({ ... }) 
export class ExampleComponent { 
    valueToDisplay = 0;

    incrementValue(): void {
      this.valueToDisplay++;
   }
}

Two-way binding
Permet de renseigner la valeur d’une propriété d’un composant enfant et d’être notifié des changements de valeur de cette propriété de façon à mettre à jour le membre d’un composant parent:

  • Composant enfant:
    Template
    <button (click)='incrementValue()'>Increment</button> 
    Classe du composant
    @Component({ 
      selector: 'counter', 
      templateUrl: './counter.component.html' 
    }) 
    export class CounterComponent { 
    
      @Input() count: number; 
      @Output() countChange: EventEmitter<number>= 
          new EventEmitter<number>();
    
      incrementValue(): void { 
        this.count++;
        this.countChange.emit(this.count); 
      } 
    } 
    
  • Composant parent:
    Template
    <counter [(count)]="bindedCount"></counter> 
    <p>Count: {{bindedCount}}</p> 
    Classe du composant
    @Component({ 
      selector: 'app-parent', 
      templateUrl: './parent.component.html' 
    }) 
    export class ParentComponent { 
      bindedCount = 0;
    } 
    
Variable référence

Une variable référence permet de nommer un élément HTML dans le template de façon à utiliser des propriétés de cet élément dans d’autres parties du template:

  • Référencer un élément HTML (contenu statique):
    Template
    <p><input #inputElement type='text' value="Valeur initial"/></p>
    <p>Contenu de input: {{inputElement.value}}</p> 
    Classe du composant
    @Component({ ... }) 
    export class ExampleComponent { 
    }
    
  • Accéder à une variable référence à partir de la classe du composant:
    Template
    <div #divElement>Texte à afficher</div>
    Classe du composant
    @Component({ ... }) 
    export class ExampleComponent implements OnInit { 
        @ViewChild('divElement', {static: true}) htmlDivElement: ElementRef;
    
        ngOnInit(): void { 
            console.Log(this.htmlDivElement.nativeElement.innerText);
        } 
    }
    

    Ou

    @Component({ ... }) 
    export class ExampleComponent implements AfterViewInit { 
        @ViewChild('divElement', {static: false}) htmlDivElement: ElementRef;
    
        ngAfterViewInit(): void { 
            console.Log(this.htmlDivElement.nativeElement.innerText);
        } 
    }
    
Directives usuelles

Ces directives permettent d’implémenter des comportements dans le template:

ngIf
Permet d’implémenter l’équivalent d’un if...then...else:

  • Equivalent de if...then:
    Template
    <div *ngIf="condition">Afficher si vrai</div> 
    Classe du composant
    @Component({ ... }) 
    export class ExampleComponent { 
        condition = true; 
    }
    
  • Equivalent de if...then...else:
    Template
    <div *ngIf="condition else whenFalse">Afficher si vrai</div> 
    <ng-template #whenFalse><div>Afficher si faux</div></ng-template> 
    Classe du composant
    @Component({ ... }) 
    export class ExampleComponent { 
        condition = true; 
    }
    

ngFor
Permet d’implémenter l’équivalent d’une boucle for:

  • Template
    <div *ngFor="let item of items">{{item}}</div> 
    Classe du composant
    @Component({ ... }) 
    export class ExampleComponent { 
      items = [ 0, 1, 2, 3, 4 ]; 
    }
    
  • Avec une propriété index contenant l’index de l’élément courant:
    Template
    <div *ngFor="let item of items index as itemIndex">
        Index {{itemIndex}}: {{item}}
    </div>
    Classe du composant
    @Component({ ... }) 
    export class ExampleComponent { 
      items = [ 0, 1, 2, 3, 4 ]; 
    }
    

Les autres propriétés disponibles sont:

  • count contient le nombre d’éléments de la liste.
  • first est un booléen contenant true si l’élément courant est le premier élément de la liste.
  • last est un booléen contenant true si l’élément courant est le dernier élément de la liste.
  • odd contient true si l’index de l’élément courant est impair.
  • even contient true si l’index de l’élément courant est pair.

ngSwitch
Permet d’implémenter l’équivalent de switch...case:

Template
<p>La taille est:</p> 
<div [ngSwitch]="size"> 
    <div *ngSwitchCase="'little'">Petite</div> 
    <div *ngSwitchCase="'medium'">Moyenne</div> 
    <div *ngSwitchCase="'great'">Grande</div> 
    <div *ngSwitchDefault>(inconnue)</div> 
</div>
Classe du composant
@Component({ ... }) 
export class ExampleComponent { 
    size = 'big' 
} 
Références
Share on RedditTweet about this on TwitterShare on LinkedInEmail this to someonePrint this page

Fonctionnalités C# 8.0


Le but de cet article est de résumer et d’expliquer les fonctionnalités de C# 8.0. Dans un premier temps, on explicitera le contexte de C# 8 par rapport aux différents frameworks qui permettent de l’utiliser. Ensuite, on rentrera dans le détail des fonctionnalités.
Les fonctionnalités les plus rapides à expliquer se trouvent dans cet article. Les autres fonctionnalités nécessitant davantage d’explications se trouvent dans des articles séparés.

Précisions sur les versions de C#

Depuis C# 7, l’environnement .NET s’est étauffé avec .NET Core. Du code C# peut, ainsi, être compilé à partir de plusieurs frameworks. A partir de C# 8.0, l’environnement historique du framework .NET commence à être remplacé par .NET Core. Ainsi, certaines fonctionnalités de C# 8.0 ne sont pas disponibles dans le framework .NET mais seulement dans .NET Core. Le but de cette partie est d’expliciter les versions des composants .NET en relation avec C# 8.0.

Chronologie des releases

Ce tableau permet de résumer les dates de sorties de C# 8.0, de Visual Studio, du compilateur Roslyn, des versions du framework .NET et de .NET Core.

Date Version C# Version Visual Studio Compilateur Version Framework .NET Version .NET Core
Mai 2018 C# 7.3 VS 2017 (15.7) Roslyn 2.7/2.8 .NET 4.7.2
(NET Standard 1.0⇒2.0)
.NET Core 2.1
(NET Standard 1.0⇒2.0)
Aout 2018 VS 2017 (15.8) Roslyn 2.9
Novembre 2018 VS 2017 (15.9) Roslyn 2.10 .NET Core 2.2
(NET Standard 1.0⇒2.0)
Avril 2019 VS 2019 (16.0) Roslyn 3.0 .NET 4.8
(NET Standard 1.0⇒2.0)
Mai 2019 VS 2019 (16.1) Roslyn 3.1
Aout 2019 VS 2019 (16.2) Roslyn 3.2
Septembre 2019 C# 8.0 VS2019 (16.3) .NET Core 3.0
(NET Standard 1.0⇒2.1)
Novembre 2019 VS2019 (16.4)
Décembre 2019 .NET Core 3.1
(NET Standard 1.0⇒2.1)
Mars 2020 VS2019 (16.5)
Mai 2020 VS2019 (16.6) Roslyn 3.7
Juillet 2020 VS2019 (16.7)
Novembre 2020 C# 9.0 VS2019 (16.8) Roslyn 3.8 .NET 5.0
(NET Standard 1.0⇒2.1)

Lien entre la version C# et le compilateur

Le tableau précédent permet d’indiquer la version de C# dans le contexte des frameworks de façon à avoir une idée des sorties des autres éléments de l’environnement .NET. Toutefois, la version de C# est liée à la version du compilateur C#. Le compilateur est ensuite livré avec Visual Studio (depuis Visual Studio 2017 15.3) ou avec le SDK .NET Core.

Le chemin du compilateur est lié au composant avec lequel il est livré:

  • Avec Visual Studio: par exemple pour Visual Studio 2019 Professional: C:\Program Files (x86)\Microsoft Visual Studio\2019\Professional\MSBuild\Current\Bin\Roslyn\csc.exe
  • Avec les Build tools: par exemple pour les Build Tools for Visual Studio 2019: C:\Program Files (x86)\Microsoft Visual Studio\2019\BuildTools\MSBuild\Current\Bin\Roslyn\csc.exe
  • Avec le SDK .NET Core:
    • Sur Linux: /usr/share/dotnet/sdk/<version>/Roslyn/bincore/csc.dll
    • Sur Windows: C:\Program Files\dotnet\sdk\<version>\Roslyn\bincore\csc.dll

On peut connaître la version du compilateur en tapant:

csc.exe -help

On peut savoir quelles sont les versions de C# que le compilateur peut gérer en exécutant:

csc -langversion:? 

Limiter la version C# à compiler

Par défaut, les versions C# traitées par le compilateur sont:

  • Framework .NET: C# 7.3
  • .NET Core 3.x: C# 8.0
  • .NET Core 2.x: C# 7.3
  • .NET Standard 2.1: C# 8.0
  • .NET Standard 2.0: C# 7.3
  • .NET Standard 1.x: C# 7.3

On peut volontairement limiter la versions C# que le compilateur va traiter.

  • Dans Visual Studio:
    dans les propriétés du projet ⇒ Onglet Build ⇒ Advanced ⇒ Paramètre Language version.
  • En éditant directement le fichier csproj du projet et en indiquant la version avec le paramètre LangVersion:
    <Project Sdk="Microsoft.NET.Sdk"> 
        <PropertyGroup> 
            <OutputType>Exe</OutputType> 
            <TargetFramework>netcoreapp2.0</TargetFramework> 
            <LangVersion>8.0</LangVersion> 
        </PropertyGroup> 
    </Project> 
    

Fonctionnalités C# 8.0

Les fonctionnalités les plus basiques de C# 8.0 sont présentées dans cet article. Les autres fonctionnalités nécessitant davantage d’explications sont présentées dans d’autres articles:

C# 8.0 n’est pas supporté par le framework .NET

Officiellement C# 8.0 est supporté par les frameworks satisfaisant .NET Standard 2.1 c’est-à-dire .NET Core 3.0 et .NET Core 3.1. Ainsi comme le framework .NET satisfait au maximum avec .NET Standard 2.0, il ne permet pas officiellement de compiler du code C# 8.0.

Microsoft ne fait plus évoluer les fonctionnalités du CLR du framework .NET ce qui exclut les fonctionnalités nécessitant une modification du CLR. Pour les autres fonctionnalités qui ne concernent que des éléments de syntaxe, il est possible de les utiliser parfois avec quelques aménagements.

Les fonctionnalités directement compatibles avec .NET Standard 2.0 sont:

Ces fonctionnalités sont directement utilisables à condition de compiler du code C# 8.0. Par exemple, si on cible le .NET Standard 2.0 avec le SDK .NET Core en indiquant netstandard20 dans la cible du fichier .csproj:

<Project Sdk="Microsoft.NET.Sdk"> 
    <PropertyGroup> 
        <OutputType>Exe</OutputType> 
        <TargetFramework>netstandard20</TargetFramework>
    </PropertyGroup> 
</Project> 

On obtiendra une erreur de compilation indiquant que la fonctionnalité n’est pas disponible en C# 7.3 (car, par défaut, pour générer une assembly .NET Standard 2.0 le compilateur compile du code C# 7.3):

error CS8370: Feature 'unmanaged constructed types' is not available in C# 7.3.
  Please use language version 8.0 or greater.

Si on précise explicitement la version C#, l’erreur n’est plus générée à la compilation:

<Project Sdk="Microsoft.NET.Sdk"> 
    <PropertyGroup> 
        <OutputType>Exe</OutputType> 
        <TargetFramework>netstandard20</TargetFramework>
        <LangVersion>8.0</LangVersion>
    </PropertyGroup> 
</Project> 

D’autres fonctionnalités ne sont pas supportées toutefois il est possible de les utiliser en implémentant les types manquants:

La fonctionnalité “Méthode d’interface par défaut” (i.e. default interface members) n’est pas compatible car elle nécessite une modification du CLR.

Fonction locale statique

C# 7.0 a permis de déclarer une fonction à l’intérieur d’une autre fonction. Cette fonction locale permet d’accéder aux variables et arguments de la fonction parente:

IEnumerable<int> GetPositiveNumber(IEnumerable<int> numbers, bool strictComparison)
{
  return numbers.Where(n => isPositive(n));

  bool isPositive(int number)
  {
    return strictComparison ? number > 0 : number >= 0;
  }
}

A partir de C# 8.0, la fonction locale peut être statique pour ne pas avoir accès au contexte de la fonction parente:

IEnumerable<int> GetPositiveNumber(IEnumerable<int> numbers, bool strictComparison)
{
  return numbers.Where(n => isPositive(n, strictComparison));

  static bool isPositive(int number, bool isStrict)
  {
    return isStrict ? number > 0 ; number >= 0;
  }
}

Utilisation de using sans bloc de code

Avant C# 8.0, using devait obligatoirement être suivi d’un bloc de code:

using (<objet satisfaisant IDisposable>) 
{ 
  // Bloc de code
  // ... 
} 

C# 8.0 permet d’utiliser using sans bloc de code. La portée de l’objet concerné par using correspond au bloc de code dans lequel se trouve using. La méthode Dispose() sera exécutée à la sortie de ce bloc de code.

Par exemple, dans le cas d’une méthode:

public void UseDisposableObject() 
{ 
  using var disposableObject = new DisposableObject(); 

  // Utilisation de disposableObject 
  // ... 

  // disposableObject.Dispose() est exécuté juste avant la sortie de la méthode 
} 

Dans le cas d’un bloc de code explicite:

public void UseDisposableObject() 
{ 
  {
    using var disposableObject = new DisposableObject(); 
    // Utilisation de disposableObject 
    // ...

    // disposableObject.Dispose() est exécuté juste avant la sortie du bloc 
  } 

  // A ce niveau disposableObject est hors de portée 
}

Méthode d’interface par défaut

Il est désormais possible, à partir de C# 8.0, de fournir une implémentation par défaut d’une méthode au niveau d’une interface, par exemple:

public interface IQuadrangle
{
  int Length { get; }

  int Width { get; }

  int GetArea()
  {
    return this.Length * this.Width;
  }
}

L’implémentation de la méthode GetArea() se trouve directement au niveau de l’interface IQuadrangle. Dans cette méthode, il est possible d’accéder à des propriétés déclarées dans l’interface comme Length et Width.

Pour rendre la lecture plus facile, on peut utiliser la version réduite de la syntaxe d’une méthode (disponible à partir de C# 6.0):

public interface IQuadrangle
{
  // ...
  int GetArea() => this.Length * this.Width;
}

Les règles liées à l’implémentation d’une méthode dans une interface sont différentes de celles appliquées dans le cas d’un héritage: la méthode n’est accessible que pour les variables dont le type est celui de l’interface. Cela signifie que:

  • La méthode n’est pas accessible si une variable est d’un type différent de l’interface.
  • Il n’y a pas de règles liées à l’héritage en utilisant new ou override.

Par exemple, si on considère la classe suivante satisfaisant IQuadrangle:

public class Rectangle : IQuadrangle
{
  public Rectangle(int length, int width)
  {
    this.Length = length;
    this.Width = width;
  }

  public int Length { get; }
  public int Width { get; }
}

Accessible avec le type de l’interface

On peut utiliser la méthode si la variable est du type de l’interface:

IQuadrangle rect = new Rectangle(2, 3);
int area = rect.GetArea(); // OK

Par contre si on considère le type de la classe, la méthode n’est pas accessible:

Rectangle rect = new Rectangle(2, 3);
int area = rect.GetArea(); // ERREUR: 'Rectangle' does not contain a definition for 'GetArea'.

Modifier l’implémentation dans la classe

On peut réimplémenter la méthode dans la classe. L’implémentation dans la classe sera utilisée en priorité:

public class Rectangle : IQuadrangle
{
  // ...

  public int GetArea()
  {
    Console.WriteLine("From Rectangle");
    return this.Length * this.Width;
  }
}

Quelque soit le type de la variable, l’implémentation utilisée sera celle de la classe:

IQuadrangle quad = new Rectangle(2, 3);
int area = quad.GetArea(); // From Rectangle

Rectangle rect = new Rectangle(2, 3);
int area = rect.GetArea(); // From Rectangle

Accéder à la méthode dans la classe

Accéder à la méthode dans la classe n’est pas direct car la méthode n’est accessible que si on considère le type de l’interface, par exemple:

public class Rectangle : IQuadrangle
{
  // ...

  public int AddToRectangleArea(int otherArea)
  {
    return otherArea + GetArea(); // ERREUR: GetArea() n'est pas accessible dans la classe si elle n'est pas réimplémentée dans la classe
  }
}

Pour accéder à la méthode, il faut considérer l’interface:

public class Rectangle : IQuadrangle
{
  // ...

  public int AddToRectangleArea(int otherArea)
  {
    return otherArea + ((IQuadrangle)this).GetArea(); // OK
  }
}

Implémenter une méthode statique dans l’interface

Les méthodes statiques sont supportées par cette fonctionnalité:

public interface IQuadrangle
{
  int Length { get; }

  int Width { get; }

  static int GetArea(IQuadrangle quadrangle)
  {
    return quadrangle.Length * quadrangle.Width;
  }
}

La méthode étant statique, ne permet pas d’accéder aux propriétés instanciées de l’interface.

Les mêmes règles s’appliquent quant à l’accès de la méthode statique à l’extérieur ou dans une classe satisfaisant l’interface: la méthode n’est accessible que si on considère le type de l’interface. Cependant comme la méthode est statique, l’accès à la méthode se faire en considérant le type de l’interface:

  • A l’extérieur:
    Rectangle rect = new Rectangle(2, 3);
    int area = IQuadrangle.GetArea(rect);
    
  • A l’intérieur de la classe:
    public class Rectangle : IQuadrangle
    {
      // ...
    
      public int GetRectangleArea()
      {
        Console.WriteLine("From Rectangle");
        return IQuadrangle.GetArea(this);
      }
    }
    

Index et plage d’une liste

A partir de C# 8.0, 2 nouveaux types sont supportés par les structures de données de type liste comme System.Array ou les listes génériques:

Le support de ces types par les listes permet de gérer davantage de cas de figure.

System.Index

Cette structure permet de stocker l’index d’une liste à partir du début ou de la fin de la liste en commençant par 0, par exemple:

  • Index à partir du début d’une structure:
    Index index = new Index(2);
    // ou
    Index index = new Index(2, false);
    
  • Index à partir de la fin d’une structure:
    Index index = new Index(2, true); // 2e valeur en partant de la fin
    Index index = new Index(0, true); // ERREUR: le premier index en partant de la fin est 1
    

    Une autre notation possible:

    Index index = ^1; // Dernière valeur de la liste
    Index index = ^2; // 2e valeur en partant de la fin
    Index index = ^0; // ERREUR
    

L’index s’utilise avec une liste:

var array = new int[]{ 0, 1, 2, 3, 4, 5 };
var index = ^1;
int value = array[index];
// ou plus directement
value = array[^1];

System.Range

Cette nouvelle structure est une plage d’index pouvant être utilisée avec une liste. Cette plage comprend un index de début et un index de fin. Utilisée avec une liste, la plage permet d’obtenir une autre liste dont les valeurs correspondent à la plage d’index.

L’index de fin de la plage est exclusif

L’index de fin est exclusif, cela signifie qu’il ne fait pas partie de la plage d’index.
Si on considère une liste d’entiers et la plage d’index suivantes:

var values = new char[] { 'A', 'B', 'C', 'D', 'E', 'F' };
Range range = new Range(0, 3); // Plage de la 1ère à la 3e valeur

values[range] contient les valeurs 'A', 'B' et 'C'. 'D' ne fait pas partie des valeurs de la plage car la plage est définie avec:

  • 0 en tant qu’index de début et
  • 3 en tant qu’index exclusif de fin.

Plusieurs syntaxes sont possibles pour instancier un objet Range:

  • En utilisant la syntaxe courte des index:
    Range range = new Range(2, ^1); // Plage de la 3e à l'avant dernière valeur 
    range = new Range(2, ^0); // Plage de la 3e à la dernière valeur 
    
  • Avec un index ou plusieurs objets de type Index:
    Index startIndex = new Index(0);
    Index endIndex = new Index(2);
    Range range = new Range(0, endIndex);
    range = new Range(startIndex, 2);
    range = new Range(startIndex, endIndex);
    
  • Les plages peuvent utilisées une syntaxe courte:
    Range range = 0..2;
    range = 0..endIndex;
    range= startIndex..endIndex;
    range = 2..^0; // Plage de la 3e à la dernière valeur
    range = 2..^1; // Plage de la 3e à l'avant dernière valeur
    

Les objets Range s’utilise directement avec les listes:

var values = new char[] { 'A', 'B', 'C', 'D', 'E', 'F' };
Range range = 2..^0;
char[] subSet = values[range];

subSet contient les valeurs de values de la 3e à la dernière valeur: 'C', 'D', 'E' et 'F'.

Autre exemple:

range = 2..^1;
subSet = values[range];

subSet contient les valeurs de values de la 3e à l’avant dernière valeur: 'C', 'D' et 'E'.

Une exception System.ArgumentOutOfRangeException est lancée si la plage est en dehors de valeurs disponibles dans la liste:

Range range = 2..7;
char[] subSet = values[range]; // ERREUR: la liste values contient 6 valeurs, le 7e index n'existe pas. 

Si la liste contient les index de la plage:

Range range = 2..6;
char[] subSet = values[range]; // OK

subSet contient les valeurs du 2e index au 5e index (l’index de fin est exclusif): 'C', 'D', 'E' et 'F'.

Si la plage ne permet pas de renvoyer des valeurs alors une exception est lancée:

Range range = 7..^1; // Plage de la 8e à l'avant dernière valeur
char[] subSet = values[range]; // ERREUR: values ne contient que 6 valeurs, l'index 7 n'existe pas.

Amélioration des chaines de caractères textuelles interpolées

Cette fonctionnalité permet de déclarer des chaînes de caractères textuelles interpolées avec $@"..." et @$"...". Avant C# 8.0, seule la syntaxe $@"..." était possible.

Ainsi:

int fileCount = 2;
string interpolatedString = $@"C:\MyRepo contient {fileCount} fichiers.";

est équivalent à:

string interpolatedString = @$"C:\MyRepo contient {fileCount} fichiers.";

Pour rappel, une chaîne de caractères textuelle interpolée correspond à 2 fonctionnalités:

  • Une chaîne de caractères textuelle (i.e. verbatim string literal): déclarée en la préfixant avec @"...". Ce type de chaîne permet d’indiquer un contenu dans lequel il n’est pas nécessaire d’échapper certains caractères spéciaux comme \ (i.e. antislash), retour chariot \r (i.e. carriage return) ou saut de ligne \n (i.e. line feed). Ces caractères sont interprétés directement, par exemple:
    • Avec le caractère \: pour déclarer une chaîne de caractères contenant C:\CustomFolder\InnerFolder\, on peut utiliser la syntaxe "C:\\CustomFolder\\InnerFolder\\" ou @"C:\CustomFolder\InnerFolder\".
    • Avec les caractères \r (i.e. carriage return) et \n (i.e. line feed): pour déclarer une chaîne contenant:
      Retour
      à
      la
      ligne
      

      On peut utiliser la syntaxe: Retour\r\nà\r\nla\r\nligne ou plus directement avec une chaîne textuelle:

      @"Retour
      à
      la
      ligne"
      

    Avec une chaîne de caractères textuelles, le caractère " peut être échappé avec "" (dans le cas d’une chaîne normale, il faut utiliser \").

  • Une chaîne de caractères interpolée: permet de déclarer une chaîne en évaluant une expression entre les caractères {...} par exemple $"La date du jour est: {DateTime.Now}".

    Cette syntaxe permet d’autres raccourcis comme:

    1. Permettre d’aligner des chaînes en indiquant un nombre minimum de caractères avec la syntaxe {<expression>,<nombre de caractères>}:
      • Si le nombre de caractères d’alignement > 0 ⇒ des espaces sont rajoutés à gauche, par exemple:
        int result = 2;
        Console.WriteLine($"Le résultat est: '{result,5}'.");
        

        L’affichage est:

        Le résultat est: '    2'.
        
      • Si le nombre de caractères d’alignement < 0 ⇒ des espaces sont rajoutés à droite, par exemple:
        int result = 2;
        Console.WriteLine($"Le résultat est: '{result,-5}'.");
        

        L’affichage est:

        Le résultat est: '2    '.
        
    2. Formatter une chaîne en utilisant la syntaxe {<expression>:<formattage de la chaîne>}, par exemple:
      DateTime now = DateTime.Now;
      string syntax1 = $"La date du jour est: {now.ToString("dd/MM/yyyy")}.";
      string syntax2 = $"La date du jour est: {now:dd/MM/yyyy}.";
      

      Le contenu de syntax1 et syntax2 est le même:

      La date du jour est: 05/02/2021.
      

      Une liste exhautive des possibilités de formattage d’une chaîne se trouve sur: docs.microsoft.com/fr-fr/dotnet/standard/base-types/composite-formatting.

    Pour échapper les caractères { et } dans une chaîne interpolée, il faut utiliser {{ et }}.

Autres fonctionnalités

Les autres fonctionnalités sont traitées dans d’autres articles:

Références
Share on RedditTweet about this on TwitterShare on LinkedInEmail this to someonePrint this page