Cours Angular · Module 4: Routing

Le routage avancé - les rapports du sentier

18 min de lecture
Dans cette leçon6

Ton sentier est tracé. Les routes sont inscrites, les paramètres se lisent sur le parchemin, des gardes veillent aux portes et le dojo a désormais ses salles dans ses salles. Et pourtant le voyage reste inconfortable - de plusieurs façons à la fois.

Tu cliques sur "Armurerie" dans la navigation et, pendant une demi-seconde, il ne se passe rien. L'écran ne bouge pas, parce que le navigateur télécharge le paquet de code de l'armurerie et que le resolver attend ses données. L'utilisateur ignore si son clic est seulement arrivé, alors il clique une seconde fois. À cela s'ajoutent douze onglets ouverts qui portent tous exactement le même nom, puisque le titre de la page a été fixé une fois pour toutes dans le fichier index.html. Et pour finir, le conseil de guerre : le panneau de discussion doit tenir sur le côté et ne pas s'éteindre quand la vue principale voyage du dojo à l'armurerie et retour.

À l'Académie, aucune marche ne se fait en silence. Quand la colonne s'ébranle, le poste de sortie frappe le tambour ; quand elle arrive, la porte de destination répond d'un coup. Les éclaireurs partent avant le gros de la troupe pour que les vivres attendent déjà à l'étape. Au-dessus de chaque porte pend un cartouche portant le nom du lieu, et à côté de la porte principale s'ouvre parfois une poterne par laquelle on entre au conseil sans quitter la cour. Dans cette leçon, tu vas ajouter à ton sentier exactement ces quatre choses.

Les tambours des postes - les événements du routeur

Commençons par le silence pendant la transition. Le service Router, qui te servait à donner l'ordre de marche dans la leçon sur les paramètres de route, possède aussi un versant passif : le champ events. C'est un flux - le même genre de flux que celui rencontré à la lecture des paramètres - donc tu t'y abonnes avec la méthode subscribe(), et Angular appelle ta fonction à chaque nouveau rapport. La différence, c'est que cette fois tu ne reçois pas une carte de paramètres, mais un objet décrivant une étape du voyage, et chaque étape possède sa propre classe.

Deux classes suffisent pour construire une barre de chargement. NavigationStart, Angular l'émet à l'instant où la navigation commence - juste après le clic sur un lien ou après l'appel à navigate(). NavigationEnd arrive à la fin, quand la route a été trouvée, que les gardes ont laissé passer, que les données sont chargées et que le composant a rejoint son outlet. Retiens ces noms à la lettre près : dans le paquet @angular/router, rien ne s'appelle RouteStart ni NavigationBegin. Je l'ai vérifié au compilateur - importer un nom pareil se solde par une erreur d'export introuvable et l'application ne se construit même pas.

Pour reconnaître l'objet qui vient d'arriver, nous utiliserons l'opérateur instanceof. C'est un opérateur ordinaire de JavaScript : il demande si l'objet placé à sa gauche est un exemplaire de la classe placée à sa droite, et il répond vrai ou faux. Je pose l'abonnement dans le constructeur plutôt que dans ngOnInit, parce que la toute première navigation démarre aussitôt après le lancement de l'application et que je veux entendre son tambour elle aussi.

1import { Component, inject } from '@angular/core';
2import { Router, NavigationStart, NavigationEnd } from '@angular/router';
3
4@Component({
5  selector: 'app-root',
6  standalone: true,
7  template: '<p>Academie</p>'
8})
9export class AppComponent {
10  private router = inject(Router);
11  isLoading = false;
12
13  constructor() {
14    this.router.events.subscribe(event => {
15      if (event instanceof NavigationStart) {
16        this.isLoading = true;
17      }
18      if (event instanceof NavigationEnd) {
19        this.isLoading = false;
20      }
21    });
22  }
23}

Le champ isLoading colle désormais toujours à l'état du voyage : vrai pendant, faux après. Lis l'intérieur de l'abonnement ligne par ligne, car c'est exactement l'ordre dans lequel ces lignes se suivent dans le fichier : d'abord la ligne qui ouvre l'abonnement avec la fonction recevant l'événement, puis la condition qui allume la barre, puis celle qui l'éteint, et enfin l'accolade qui referme l'appel entier. Remarque ce que ce changement n'a pas touché - la configuration des routes est identique, les composants de destination ignorent tout de la barre, les gardes travaillent comme avant. L'abonnement se contente d'écouter les rapports et ne bloque rien : si tu le supprimais, la navigation se déroulerait exactement pareil, simplement sans informer l'utilisateur.

Entre le tambour et la réponse de la porte, Angular émet encore quelques rapports, et il vaut la peine de savoir qu'ils sont là. J'ai lancé le routeur et relevé le flux : après NavigationStart vient RoutesRecognized (le routeur sait déjà quelle route correspond), puis GuardsCheckStart et GuardsCheckEnd - ce sont tes gardes de la leçon sur les guards, et entre les deux se glissent encore les rapports d'activation des composants - ensuite ResolveStart et ResolveEnd, c'est-à-dire le travail du resolver de la leçon précédente, et tout à la fin seulement NavigationEnd. Le cycle complet d'une route, que tu as découvert par morceaux tout au long de ce module, passe par ce seul champ.

Le drapeau logique ne suffit pas - l'utilisateur doit voir quelque chose. Dans le template du composant racine, j'affiche la barre sous condition, avec la syntaxe @if de la leçon sur les composants, et je laisse en dessous l'outlet principal exactement là où il se trouvait.

1@if (isLoading) {
2  <div class="loader">Messager en route...</div>
3}
4
5<router-outlet></router-outlet>

Désormais, chaque passage d'une route à l'autre allume le message et l'éteint dès que le composant de destination est à l'écran. L'endroit du rendu, lui, n'a pas bougé : <router-outlet> reste la même porte où apparaît le composant de la route active, et la barre n'est qu'un échafaudage posé à côté. Le champ isLoading est un champ de classe ordinaire et Angular rafraîchira la vue tout seul après sa modification - dans le module des signaux de fumée, tu le remplaceras par un signal et tu verras pourquoi c'est une meilleure idée.

Il y a un piège dans ce code, et mieux vaut y tomber maintenant qu'en production. Je l'ai vérifié sur un routeur en marche : quand un garde renvoie l'utilisateur du seuil, Angular n'émet pas NavigationEnd. À sa place arrive NavigationCancel, et l'adresse dans la barre reste la même. Ta condition ne s'exécutera jamais, isLoading reste à vrai et la barre de chargement pend à l'écran jusqu'à la fin de la session. NavigationError, le rapport d'une erreur survenue en chemin, se termine de la même manière. Éteins donc la barre sur les trois rapports à la fois.

1// le meme constructeur, deux noms ajoutes a l'import depuis @angular/router
2this.router.events.subscribe(event => {
3  if (event instanceof NavigationStart) {
4    this.isLoading = true;
5  }
6  if (event instanceof NavigationEnd
7    || event instanceof NavigationCancel
8    || event instanceof NavigationError) {
9    this.isLoading = false;
10  }
11});

Maintenant, chaque voyage se termine par l'extinction de la barre, qu'il ait atteint son but, qu'un garde l'ait fait rebrousser chemin ou qu'il ait trébuché sur une erreur. La condition qui allume la barre n'a pas bougé d'un caractère - l'événement de départ reste unique et s'appelle toujours NavigationStart. Je te recommande cette version comme choix par défaut : la variante avec le seul NavigationEnd a meilleure allure dans une leçon, mais dans une vraie application avec des gardes aux portes elle fige la barre dès le premier refus.

Les éclaireurs devant la colonne - le préchargement des routes

Le deuxième inconfort, c'est l'attente du paquet de code. Dans le module sur les clans, tu as mis en place le chargement paresseux : une route avec loadComponent ou loadChildren n'entre pas dans le premier paquet de l'application, elle arrive séparément, quand l'utilisateur s'y rend vraiment. Le démarrage y gagne en vitesse, mais le coût ne disparaît pas - il se déplace sur le premier clic, et sur une connexion faible cela fait quelques secondes passées à contempler la barre que tu viens de construire.

On peut avoir le beurre et l'argent du beurre : que les paquets arrivent en arrière-plan, une fois l'application debout et pendant qu'il ne se passe rien. C'est le rôle d'une stratégie de préchargement. Angular en fournit deux toutes faites : NoPreloading, c'est-à-dire ne rien télécharger d'avance - et c'est le comportement par défaut - ainsi que PreloadAllModules, c'est-à-dire télécharger en arrière-plan toutes les routes paresseuses. La stratégie se donne au routeur au moment de l'enregistrement, avec la fonction withPreloading(). C'est l'une des fonctionnalités du routeur, ces réglages supplémentaires que provideRouter() accepte comme arguments suivants, après le tableau de routes. Lis cet appel comme une seule phrase : le nom withPreloading, la parenthèse ouvrante, le nom de la stratégie au milieu, la parenthèse fermante.

1// app.config.ts
2import { ApplicationConfig } from '@angular/core';
3import { provideRouter, withPreloading, PreloadAllModules } from '@angular/router';
4import { routes } from './app.routes';
5
6export const appConfig: ApplicationConfig = {
7  providers: [
8    provideRouter(routes, withPreloading(PreloadAllModules))
9  ]
10};

Remarque que withPreloading() reçoit le nom de la classe tout seul, sans new et sans parenthèses - Angular fabriquera lui-même l'exemplaire de la stratégie. Et maintenant le plus important, c'est-à-dire ce qui n'a pas changé ici : le tableau de routes est intact, les entrées avec loadComponent restent paresseuses, et le premier paquet de l'application n'a pas grossi d'un octet. Seul le moment du téléchargement a changé.

Je l'ai vérifié sur un routeur en marche avec une route paresseuse. Avec la stratégie par défaut, le code de cette route n'a jamais été téléchargé tant que je n'y suis pas entré. Après activation de PreloadAllModules, ce même code s'est téléchargé tout seul juste après le premier NavigationEnd, alors que personne n'allait sur cette route - et la visite ultérieure ne l'a pas téléchargé une seconde fois, puisqu'il attendait déjà en mémoire. Tout le bénéfice vient de là : une transition qui durait le temps d'un téléchargement devient instantanée.

Quand s'en abstenir ? Quand l'application possède un lourd panneau d'administration qu'un utilisateur ordinaire n'ouvrira jamais : PreloadAllModules le fera descendre chez tout le monde et gaspillera de la bande passante. Pour une application de la taille de notre Académie, je la recommande sans hésiter, car elle coûte un seul appel ; sur des projets plus vastes, on écrit sa propre stratégie fondée sur l'interface PreloadingStrategy et on ne précharge que certaines routes choisies.

La poterne à côté de la porte - les outlets nommés

Le troisième inconfort, c'est le conseil de guerre. Tu veux qu'à côté de la vue principale siège un panneau latéral avec sa propre route, que le changement de vue principale ne l'éteigne pas et qu'un rafraîchissement de page ne le ferme pas. Cela signifie que l'état du panneau doit être inscrit dans l'adresse, tout comme celui de la vue principale.

Jusqu'ici, chacun de tes templates n'avait qu'un seul <router-outlet>, alors que personne n'a jamais dit qu'il devait être unique. Cet outlet par défaut porte un nom - primary - seulement tu ne l'écris jamais. À côté de lui, tu as le droit d'en poser un second avec l'attribut name, et d'ajouter dans l'entrée de route un champ outlet portant le même nom. La route dit alors : rends-moi dans cette poterne, pas dans la porte principale. Commençons par les routes, car c'est là que la décision se prend.

1// app.routes.ts
2import { Routes } from '@angular/router';
3import { DojoComponent } from './dojo/dojo.component';
4import { ChatComponent } from './chat/chat.component';
5
6export const routes: Routes = [
7  { path: 'dojo', component: DojoComponent },
8  { path: 'chat', component: ChatComponent, outlet: 'sidebar' }
9];

L'entrée avec outlet: 'sidebar' n'est l'enfant d'aucune route - c'est un sentier à part, qui court parallèlement au principal. Elle ne change rien à la correspondance de l'adresse /dojo : quand tu y entres, le routeur choisit toujours DojoComponent et le place dans la porte principale, tandis que la poterne reste vide tant que personne ne l'ouvre. Passons au template. L'outlet principal reste inchangé, j'en ajoute un second à côté et je lui donne exactement le nom que j'ai inscrit dans la route.

1<main>
2  <router-outlet></router-outlet>
3</main>
4
5<aside>
6  <router-outlet name="sidebar"></router-outlet>
7</aside>

Deux balises, une seule différence : l'attribut name avec sa valeur entre guillemets. Celle qui n'a pas d'attribut est primary, celle qui en a un dessert les routes marquées sidebar. L'ordre d'écriture est toujours le même et vaut la peine d'être gravé : l'ouverture de la balise, le nom de l'attribut avec le signe égal, la valeur entre guillemets, et à la fin la partie fermante. La balise de base suit exactement le même ordre, l'attribut en moins : le chevron ouvrant, le nom router-outlet, le chevron fermant, puis la balise fermante d'un seul tenant. J'ai vérifié ce qui arrive quand cette dernière manque, car ce n'est pas évident : Angular ne signale aucune erreur, il considère simplement tout le reste du template comme le contenu de l'outlet et insère le composant de la route après lui, si bien que les éléments s'affichent à l'écran dans l'ordre inverse. L'écriture abrégée avec la barre oblique fonctionne elle aussi, mais la paire complète de balises est l'écriture de base en Angular et c'est celle que je garde.

Reste le lien qui ouvre cette poterne. Au lieu d'un chemin ordinaire, tu passes à routerLink un objet avec un champ outlets, et dedans une carte : le nom de la poterne et le tableau des segments qui doivent y apparaître. N'oublie pas que la directive RouterLink doit figurer dans le tableau imports du composant, exactement comme pour les liens ordinaires.

1<a [routerLink]="[{ outlets: { sidebar: ['chat'] } }]">Convoquer le conseil</a>

J'ai vérifié à quoi ressemble l'adresse après ce clic, car la syntaxe n'a rien d'évident : depuis /dojo, tu obtiens /dojo(sidebar:chat). La poterne nommée atterrit entre parenthèses après le chemin principal, ce qui lui permet de survivre à un rafraîchissement de page et d'être envoyée à quelqu'un par lien. Le plus important est ce qui ne se voit pas : la porte principale n'a même pas frémi - DojoComponent était à l'écran, il y reste, seul le contenu du panneau s'est ajouté. Pour refermer la poterne, tu navigues vers elle avec la valeur null à la place du tableau, soit { outlets: { sidebar: null } }, et l'adresse revient au simple /dojo.

Le cartouche au-dessus de la porte - le titre de la page

Le quatrième inconfort est le plus simple à réparer. Le titre de la page - celui que l'on voit sur l'onglet du navigateur, dans l'historique et dans un lecteur d'écran - est, dans un projet ordinaire, fixé une fois dans le fichier index.html et ne change plus jamais. Le routeur peut s'en occuper à ta place : une entrée de route accepte un champ title, et Angular inscrit cette valeur dans le titre du document une fois la navigation terminée.

Trois autres noms viennent à l'esprit à cet endroit, et tous les trois sont faux. pageTitle est une habitude venue d'autres bibliothèques, inconnue d'Angular. head fait penser à la section du document HTML, mais une entrée de route n'a jamais entendu parler d'une chose pareille. name sonne le plus naturel des trois et c'est précisément pour cela qu'il est dangereux, car la route ne possède aucun champ de ce nom. J'ai compilé chacune de ces trois tentatives et chacune se termine par le même message de TypeScript : cette propriété n'existe pas dans le type Route. Le bon champ est unique et il s'appelle title.

1// app.routes.ts, imports comme ci-dessus
2export const routes: Routes = [
3  { path: 'dojo', component: DojoComponent, title: 'Dojo - Academie des Samourais' },
4  { path: 'armory', component: ArmoryComponent, title: 'Armurerie - Academie des Samourais' }
5];

Après une entrée sur /dojo, l'onglet du navigateur s'appelle maintenant "Dojo - Academie des Samourais" - je l'ai lancé et le titre du document se substitue effectivement à chaque transition. Ce qui ne change pas au passage : la vue elle-même. Le champ title ne rend rien, n'ajoute aucun en-tête au template, il ne touche que la barre de l'onglet. Le titre affiché sur la page, tu dois toujours l'écrire toi-même.

Un texte statique suffit pour les écrans qui n'existent qu'en un seul exemplaire. C'est plus délicat pour le profil d'un guerrier : sous la route samurai/:id se cachent autant de pages que tu as de samouraïs, et le titre "Profil du samouraï" n'en distingue aucune. C'est pourquoi title accepte non seulement un texte, mais aussi une fonction. Angular l'appellera à chaque navigation et lui passera l'instantané de la route - ce même objet dont tu lisais paramMap dans la leçon sur les paramètres. Le texte renvoyé par la fonction deviendra le titre.

1{
2  path: 'samurai/:id',
3  component: SamuraiDetailComponent,
4  title: (route) => 'Samourai #' + (route.paramMap.get('id') ?? '')
5}

Entrer sur /samurai/7 donne le titre "Samourai #7" - je l'ai lancé aussi, et c'est bien ce qui se produit. L'opérateur ?? t'est familier depuis la lecture des paramètres : il protège du cas où le paramètre manquerait et où get() aurait renvoyé une valeur vide. Ce qui n'a pas changé, en revanche, c'est la façon de lire le paramètre - la même paramMap, la même méthode get(), simplement appelée dans la configuration de la route au lieu du composant. Tu peux remplacer cette concaténation au signe plus par un gabarit de texte entre apostrophes inversées, le résultat sera identique ; sur un titre aussi court, je te recommande le plus, car il n'oblige pas le lecteur à guetter des accolades.

L'ordre de marche avec une note en marge

Reste à refermer une chose que tu viens d'apercevoir du côté de l'adresse. Puisque l'adresse sait porter l'état d'une poterne latérale, elle portera à plus forte raison une broutille du genre "ouvre le profil directement sur l'onglet des compétences". De telles notes sont des paramètres de requête et, lors d'une navigation depuis le code, tu les indiques par le champ queryParams dans le second argument de la méthode navigate(). Le premier argument ne change pas : le tableau de segments, où le numéro du guerrier suit le nom de la route.

1import { Component, inject } from '@angular/core';
2import { Router } from '@angular/router';
3
4@Component({
5  selector: 'app-samurai-list',
6  standalone: true,
7  template: '<button (click)="goToSamurai(5)">Voir le guerrier</button>'
8})
9export class SamuraiListComponent {
10  private router = inject(Router);
11
12  goToSamurai(id: number): void {
13    this.router.navigate(['/samurai', id], {
14      queryParams: { tab: 'skills' }
15    });
16  }
17}

Le clic sur le bouton mène à l'adresse /samurai/5?tab=skills - le segment avec le numéro après la barre oblique, la note après le point d'interrogation. La route correspond exactement comme sans la note, car les paramètres de requête ne participent pas à la correspondance, et le composant de profil lira l'onglet quand il en aura besoin. Il y a encore un détail qu'il est bon de comprendre après la leçon sur les gardes : navigate() renvoie une promesse, et dedans une valeur logique. Vrai signifie que la navigation a abouti, faux qu'elle a été bloquée en chemin. J'ai vérifié les deux variantes et elles diffèrent plus qu'on ne le croirait. Un garde qui renvoie false te donne faux, l'adresse dans la barre reste l'ancienne et seul NavigationCancel atterrit dans le flux. Un garde qui renvoie un UrlTree, la variante recommandée dans la lecon sur les gardes, se comporte autrement : la promesse vaut vrai, car la redirection a reussi, l'adresse devient celle de la cible, et le flux montre d'abord NavigationCancel puis NavigationEnd pour la nouvelle route. Si tu fais quoi que ce soit après la navigation, par exemple vider un formulaire ou afficher un message, il vaut la peine de jeter un oeil à cette valeur avec await ou then().

Tout le sentier en quatre pas

Pour clore le module, remettons la route en une seule séquence, car tu l'as assemblée par morceaux au long de cinq leçons. Tout commence par le tableau Routes : c'est là que tu inscris les paires chemin et composant, et au passage les paramètres, les enfants, les gardes, les resolvers et les titres. Le tableau seul ne fait rien - ce n'est qu'une carte posée sur la table. Le deuxième pas consiste à enregistrer cette carte dans l'application par provideRouter(routes) dans le fichier de configuration app.config.ts - c'est seulement là que le routeur apprend que des sentiers existent, et c'est là aussi que tu ajoutes des fonctionnalités comme withPreloading(). Le troisième pas appartient à l'utilisateur : il clique sur un lien routerLink ou saisit une adresse dans la barre du navigateur. Ce même pas, tu peux l'accomplir à sa place depuis le code, en appelant sur le service injecté l'une des deux méthodes : navigate(), qui assemble la route à partir d'un tableau de segments, ou navigateByUrl(), si tu tiens l'adresse entière en un seul morceau de texte - la ligne complète est alors this, un point et le nom du service, un point et le nom de la méthode, et entre parenthèses l'adresse entre apostrophes, ce qui donne par exemple this.router.navigateByUrl('/samurai/123'). Le quatrième pas revient au routeur : il fait correspondre l'adresse à une entrée du tableau, la fait passer par les gardes et les resolvers, puis rend le composant dans <router-outlet>. Tout ce que tu ajouteras plus tard au routage, tu l'ajouteras à l'un de ces quatre pas.

Souviens-toi : le sentier n'a aucune raison de rester muet - il bat le tambour à chaque halte, envoie les éclaireurs chercher les vivres avant que la colonne ne s'ébranle et inscrit le nom du lieu sur le cartouche au-dessus de la porte ; ta tâche à toi est d'écouter ces rapports.

Tu as repéré une erreur dans cette leçon ?

Teste tes connaissances

Réponds aux questions de cette leçon. Choisis une réponse pour voir tout de suite si elle est correcte.

  1. 1. Quel événement du routeur signale le début de la navigation ?

  2. 2. Quelle propriété de route utilise-t-on pour définir le titre de la page (Title Strategy) dans Angular ?

Tâches pratiques dans le jeu

  • Clique dans l'ordre

    Clique dans l'ordre pour construire l'appel qui active le préchargement de tous les modules en lazy loading (transmis à provideRouter) :

  • Tri horizontal

    Ordonne sur une seule ligne un second router-outlet (nommé) portant le nom 'sidebar' :

  • Éditeur de code

    Complète la méthode de navigation programmatique avec un paramètre ID et des query params.

  • Tri vertical

    Ordonne les lignes d'un abonnement aux événements du routeur (de haut en bas, comme dans le composant) :

  • Tri vertical

    Ordonne les étapes du fonctionnement du routage - de la configuration jusqu'à l'affichage du composant :

  • Clique dans l'ordre

    Clique dans l'ordre pour construire l'appel de navigation par URL complète :

  • Clique dans l'ordre

    Clique dans l'ordre pour construire la balise router-outlet de base (le point d'affichage principal des routes) :

Articles utiles