Routage
Chargement différé, guards, resolvers, routes fonctionnelles.
Vérifié en septembre 2026 · Angular 22.1 · environ 18 min
Le routeur fait correspondre une URL à un arbre de composants. À chaque navigation, il lit l'adresse et cherche les routes qui correspondent — redirections et canMatch compris, en téléchargeant les branches loadChildren —, exécute les guards puis les resolvers, charge le composant différé, puis l'affiche. Tout se déclare dans un tableau de routes, et les décisions qu'on y attache sont de simples fonctions, qui obtiennent leurs dépendances par inject(). Le cours suit à peu près ces étapes, avec les routes de ce site comme exemple réel. Les composants et input() sont supposés connus (cours Bases), de même que resource (cours Signals).
Déclarer les routes
provideRouter(routes, ...) installe le routeur parmi les fournisseurs de l'application ; les fonctions with… qui suivent activent chacune une option. Une route associe un path, relatif et sans barre oblique initiale, à ce qu'il faut afficher. RouterOutlet marque l'emplacement du composant actif, RouterLink remplace href pour naviguer sans recharger le document, et RouterLinkActive pose une classe sur le lien de la page courante.
import { ApplicationConfig, Component } from '@angular/core';
import { RouterLink, RouterLinkActive, RouterOutlet, Routes, provideRouter } from '@angular/router';
@Component({ selector: 'app-accueil', template: '<h1>Accueil</h1>' })
export class Accueil {}
@Component({ selector: 'app-liste-produits', template: '<h1>Produits</h1>' })
export class ListeProduits {}
@Component({ selector: 'app-page-introuvable', template: '<h1>Page introuvable</h1>' })
export class PageIntrouvable {}
export const routes: Routes = [
// Un chemin ne commence jamais par une barre oblique. title devient le
// titre du document une fois la navigation terminée.
{ path: '', component: Accueil, title: 'Accueil' },
{ path: 'produits', component: ListeProduits, title: 'Produits' },
// Le routeur retient la première route qui correspond, dans l'ordre du
// tableau : '**' accepte toute URL, il vient donc en dernier.
{ path: '**', component: PageIntrouvable, title: 'Page introuvable' },
];
export const appConfig: ApplicationConfig = {
providers: [provideRouter(routes)],
};
@Component({
selector: 'app-root',
imports: [RouterOutlet, RouterLink, RouterLinkActive],
template: `
<nav>
<!-- '/' est le début de toutes les URL : sans exact, ce lien serait
actif sur chaque page. -->
<a routerLink="/" routerLinkActive="actif" [routerLinkActiveOptions]="{ exact: true }"
>Accueil</a
>
<a routerLink="/produits" routerLinkActive="actif">Produits</a>
</nav>
<!-- Le composant de la route active est inséré juste après cette balise. -->
<router-outlet />
`,
})
export class App {} L'ordre du tableau compte : le routeur retient la première route qui correspond, il ne cherche pas la meilleure. Un '**' placé en tête capte toutes les URL, /produits comprise, et rien ne le signale. title fixe le titre du document à la fin de la navigation : sur /produits, document.title vaut Produits. Le composant routé n'est pas placé dans <router-outlet> mais juste après, en frère, ce qui compte pour le CSS. routerLinkActive compare par préfixe : sans exact: true, le lien vers / reste actif sur toutes les pages.
Les routes de ce site ne sont pas écrites à la main : elles sont dérivées du catalogue des cours, chacune chargée à la demande et porteuse de l'identité de son cours dans data.
// web/src/app/app.routes.ts de ce site (commentaire d'origine abrégé)
import { Routes } from '@angular/router';
import { TOPICS } from './catalogue/catalogue';
// Une route par cours rédigé, dérivée du catalogue : ajouter un cours au
// catalogue suffit à le rendre joignable.
const coursRoutes: Routes = TOPICS.flatMap((topic) =>
topic.cours
.filter((cours) => cours.charger !== undefined)
.map((cours) => ({
path: `${topic.slug}/${cours.slug}`,
title: `${cours.titre} — ${topic.nom}`,
data: { topic: topic.slug, cours: cours.slug },
loadComponent: cours.charger!,
})),
);
export const routes: Routes = [
{
path: '',
title: 'Cours',
loadComponent: () => import('./catalogue/catalogue-page').then((m) => m.CataloguePage),
},
...coursRoutes,
{ path: '**', redirectTo: '' },
];Chargement différé : loadComponent et loadChildren
Un import() dynamique est un point de coupure pour le bundler : ce qu'il importe part dans un fichier à part, que le navigateur ne télécharge qu'au besoin. loadComponent diffère un composant, loadChildren une branche entière de routes. Le routeur appelle la fonction à la première navigation qui en a besoin, puis garde le résultat : revenir sur la route ne la rappelle pas.
// ---- app.routes.ts ----
import { Routes } from '@angular/router';
export const routes: Routes = [
{
path: '',
// Un import() dynamique : le bundler en fait un fichier à part, téléchargé
// à la première navigation vers la route, puis gardé en mémoire.
loadComponent: () => import('./accueil/accueil').then((m) => m.Accueil),
},
{
path: 'compte',
// compte.ts se termine par « export default class Compte » : le then
// devient inutile.
loadComponent: () => import('./compte/compte'),
},
{
path: 'admin',
// Toute une branche : ses routes et tout ce qu'elles importent.
loadChildren: () => import('./admin/admin.routes').then((m) => m.ROUTES_ADMIN),
},
];
// ---- admin/admin.routes.ts ----
// TableauDeBord et Utilisateurs sont des composants ordinaires ; Statistiques
// est un service @Injectable() sans providedIn.
import { Routes } from '@angular/router';
import { Statistiques } from './statistiques';
import { TableauDeBord } from './tableau-de-bord';
import { Utilisateurs } from './utilisateurs';
export const ROUTES_ADMIN: Routes = [
{
path: '',
// Un injecteur propre à la branche : Statistiques n'existe que sous
// /admin, et son code voyage dans le même fichier différé.
providers: [Statistiques],
children: [
{ path: '', component: TableauDeBord },
{ path: 'utilisateurs', component: Utilisateurs },
],
},
];ng build range alors accueil, compte et admin-routes parmi les Lazy chunk files, hors du bundle initial. Les providers d'une route forment un injecteur réservé à sa branche. La coupure ne tient que si rien dans le bundle initial n'importe le fichier différé de façon statique, et l'erreur typique consiste à ranger le guard d'une page à côté de son composant.
// ---- compte/compte.ts ----
import { Component, signal } from '@angular/core';
import { CanDeactivateFn } from '@angular/router';
@Component({ selector: 'app-compte', template: '<h1>Mon compte</h1>' })
export default class Compte {
readonly modifie = signal(false);
}
// Rangé à côté du composant qu'il protège...
export const quitterCompteGuard: CanDeactivateFn<Compte> = (compte) =>
!compte.modifie() || confirm('Quitter sans enregistrer ?');
// ---- app.routes.ts ----
import { Routes } from '@angular/router';
// ...et importé ici pour être déclaré sur la route. Ce fichier fait partie du
// bundle initial : cet import y fait entrer compte.ts tout entier.
import { quitterCompteGuard } from './compte/compte';
export const routes: Routes = [
{
path: 'compte',
loadComponent: () => import('./compte/compte'),
canDeactivate: [quitterCompteGuard],
},
]; Le build réussit sans le moindre avertissement. Le fichier différé compte existe toujours, mais il ne contient plus qu'une réexportation du composant, désormais présent dans le bundle initial. Le guard va dans son propre fichier, et s'il a besoin du type du composant, import type ne laisse aucune trace dans le JavaScript produit.
// ---- compte/compte.ts : le composant seul ----
import { Component, signal } from '@angular/core';
@Component({ selector: 'app-compte', template: '<h1>Mon compte</h1>' })
export default class Compte {
readonly modifie = signal(false);
}
// ---- compte/compte.guard.ts ----
import { CanDeactivateFn } from '@angular/router';
// import type est effacé à la compilation : aucun lien ne subsiste entre les
// fichiers JavaScript produits.
import type Compte from './compte';
export const quitterCompteGuard: CanDeactivateFn<Compte> = (compte) =>
!compte.modifie() || confirm('Quitter sans enregistrer ?');
// ---- app.routes.ts ----
import { Routes } from '@angular/router';
import { quitterCompteGuard } from './compte/compte.guard';
export const routes: Routes = [
{
path: 'compte',
loadComponent: () => import('./compte/compte'),
canDeactivate: [quitterCompteGuard],
},
];Paramètres et withComponentInputBinding
Un segment :id capte une valeur de l'URL. ActivatedRoute l'expose de deux façons : paramMap, un observable qui émet à chaque changement, et snapshot, la valeur du moment. Le piège tient à la réutilisation : entre deux URL qui ne diffèrent que par leurs paramètres, le routeur garde l'instance du composant et ne met à jour que les paramètres. Un instantané lu à la construction ne voit que la première valeur.
import { Component, inject } from '@angular/core';
import { ActivatedRoute, RouterLink } from '@angular/router';
// Route : { path: 'produits/:id', component: FicheProduit }
@Component({
selector: 'app-fiche-produit',
imports: [RouterLink],
template: `
<h1>Produit {{ id }}</h1>
<a [routerLink]="['/produits', id + 1]">Produit suivant</a>
`,
})
export class FicheProduit {
// Lu une fois, à la construction. De /produits/1 à /produits/2, seule la
// valeur du paramètre change : le routeur garde la même instance, et
// l'URL dit 2 quand l'écran dit toujours 1.
protected readonly id = Number(inject(ActivatedRoute).snapshot.paramMap.get('id'));
} Après le clic, l'URL vaut /produits/2, l'instance est la même, et l'écran affiche toujours « Produit 1 ». withComponentInputBinding() règle le problème à la racine : à chaque navigation, le routeur écrit les paramètres de chemin, ceux de la query string, data et les résultats des resolvers dans les entrées de même nom du composant routé. Quand une clé existe dans plusieurs sources, la query string cède au chemin, le chemin à data, et data aux resolvers. L'entrée est un signal, et tout ce qui s'en déduit suit.
import { ApplicationConfig, Component, input, numberAttribute } from '@angular/core';
import { RouterLink, Routes, provideRouter, withComponentInputBinding } from '@angular/router';
@Component({
selector: 'app-fiche-produit',
imports: [RouterLink],
template: `
<h1>Produit {{ id() }}</h1>
@if (onglet() === 'avis') {
<p>Avis des clients</p>
}
<a [routerLink]="['/produits', id() + 1]" [queryParams]="{ onglet: 'avis' }"
>Produit suivant, ses avis</a
>
`,
})
export class FicheProduit {
// :id de l'URL, réécrit à chaque navigation, y compris quand l'instance
// est réutilisée. Il arrive en chaîne : numberAttribute le convertit.
readonly id = input.required({ transform: numberAttribute });
// ?onglet=avis. Absent de l'URL, il vaut undefined.
readonly onglet = input<string>();
}
export const routes: Routes = [{ path: 'produits/:id', component: FicheProduit }];
export const appConfig: ApplicationConfig = {
// Sans withComponentInputBinding, les entrées ne reçoivent rien de la route.
providers: [provideRouter(routes, withComponentInputBinding())],
}; Un paramètre arrive toujours en chaîne : numberAttribute le convertit, et abc donne NaN. Une clé absente de l'URL remet l'entrée à undefined — le comportement par défaut, 'alwaysUndefined', évite de garder une valeur périmée. Sans la fonctionnalité, input.required lève NG0950 au premier rendu. Et la liaison ne vise que le composant routé, jamais ses enfants. C'est pourquoi le composant qui met en page les cours de ce site, placé dans le template de chaque cours, relit data par ActivatedRoute et toSignal au lieu de déclarer des entrées.
Guards fonctionnels
Un guard est une fonction que le routeur appelle pour décider d'une navigation. Elle rend true, false, une UrlTree ou une RedirectCommand, directement, dans une promesse ou dans un observable, et s'exécute dans un contexte d'injection. Trois types couvrent l'essentiel. CanActivateFn décide d'entrer. CanDeactivateFn<T> décide de sortir et reçoit l'instance du composant quitté. CanMatchFn décide si la route participe même à la correspondance : refusée, elle est ignorée et le routeur essaie la suivante.
import { Injectable, inject, signal } from '@angular/core';
import { CanActivateFn, CanDeactivateFn, CanMatchFn, Router, Routes } from '@angular/router';
type Role = 'client' | 'admin';
@Injectable({ providedIn: 'root' })
export class Session {
readonly utilisateur = signal<{ readonly nom: string; readonly role: Role } | null>(null);
}
// Un guard est une fonction. Le routeur l'appelle dans un contexte
// d'injection : inject() y fonctionne comme dans un constructeur.
export const connecteGuard: CanActivateFn = (_route, etat) => {
if (inject(Session).utilisateur() !== null) {
return true;
}
// Rediriger, c'est rendre une UrlTree : la navigation en cours est
// remplacée par une autre, sans appel à navigate().
return inject(Router).createUrlTree(['/connexion'], { queryParams: { retour: etat.url } });
};
// Une fabrique : le paramètre remplace ce qu'une classe aurait lu dans data.
export const aLeRole =
(role: Role): CanMatchFn =>
() =>
inject(Session).utilisateur()?.role === role;
// Le guard reçoit l'instance du composant qu'on quitte.
export interface AvecBrouillon {
aUnBrouillon(): boolean;
}
export const brouillonGuard: CanDeactivateFn<AvecBrouillon> = (composant) =>
!composant.aUnBrouillon() || confirm('Quitter sans enregistrer le brouillon ?');
// Chaque fichier importé ci-dessous exporte par défaut un composant (ou,
// pour admin.routes, un tableau de routes).
export const routes: Routes = [
{ path: 'connexion', loadComponent: () => import('./connexion') },
{
path: 'compte',
loadComponent: () => import('./compte'),
canActivate: [connecteGuard],
canDeactivate: [brouillonGuard],
},
// Refusée par canMatch, la route est ignorée comme si elle n'existait pas,
// et le routeur essaie la suivante.
{ path: 'admin', loadChildren: () => import('./admin.routes'), canMatch: [aLeRole('admin')] },
{ path: '**', loadComponent: () => import('./introuvable') },
]; Pour rediriger, un guard rend une UrlTree : la navigation vers /compte est annulée avec le code Redirect et remplacée par celle vers /connexion?retour=%2Fcompte, et qui attendait la navigation vers /compte reçoit true. Rendre false après avoir appelé navigate() mène au même écran par deux navigations concurrentes : la première est annulée comme remplacée, et qui attendait sa promesse reçoit false. Le typage de canDeactivate est lâche, la route acceptant un CanDeactivateFn<any> : un composant sans la méthode attendue compile, puis lève composant.aUnBrouillon is not a function au moment de le quitter.
La différence entre canActivate et canMatch pèse surtout sur une branche différée, parce que les guards d'activation s'exécutent après la reconnaissance des routes.
import { inject } from '@angular/core';
import { CanActivateFn, Routes } from '@angular/router';
import { Session } from './guards';
const estAdmin: CanActivateFn = () => inject(Session).utilisateur()?.role === 'admin';
export const routes: Routes = [
// canActivate s'exécute après la reconnaissance des routes, et la
// reconnaissance d'une branche différée passe par son chargement : pour un
// client, le fichier de l'administration est téléchargé, puis refusé.
{ path: 'admin', loadChildren: () => import('./admin.routes'), canActivate: [estAdmin] },
{ path: '**', loadComponent: () => import('./introuvable') },
]; Pour un client, le fichier de l'administration est téléchargé, le guard le refuse ensuite, et l'URL reste celle d'avant : le code d'une page interdite a été livré à qui ne devait pas la voir. canMatch ne fait pas pour autant un contrôle d'accès — tout code livré au navigateur peut être lu et modifié, et c'est le serveur qui protège les données —, mais il évite de livrer ce qui ne servira pas. canLoad, qui servait à cela, est déprécié en sa faveur.
import { inject } from '@angular/core';
import { CanMatchFn, Routes } from '@angular/router';
import { Session } from './guards';
const estAdmin: CanMatchFn = () => inject(Session).utilisateur()?.role === 'admin';
export const routes: Routes = [
// canMatch s'exécute avant : refusée, la route n'est pas retenue, rien
// n'est téléchargé, et /admin tombe sur la page introuvable.
{ path: 'admin', loadChildren: () => import('./admin.routes'), canMatch: [estAdmin] },
{ path: '**', loadComponent: () => import('./introuvable') },
]; Restent les guards de classe. Les interfaces CanActivate, CanMatch, CanDeactivate et Resolve ne sont pas dépréciées ; ce qui l'est, c'est de placer une classe, un InjectionToken ou une chaîne dans le tableau d'une route, ce que les types d'Angular 22 nomment DeprecatedGuard. La raison tient à ce qu'apporte une fonction : pas de classe injectable à déclarer pour une ligne de logique, un paramètre passé par une fabrique plutôt que lu dans data, deux conditions composées par une simple fonction. Une classe existante se migre sans être réécrite.
import { Injectable, inject } from '@angular/core';
import { CanActivate, Router, Routes, UrlTree, mapToCanActivate } from '@angular/router';
import { Session } from './guards';
// L'ancienne forme : une classe injectable, que le routeur retrouve par son jeton.
@Injectable({ providedIn: 'root' })
export class ConnecteGuard implements CanActivate {
private readonly session = inject(Session);
private readonly router = inject(Router);
canActivate(): boolean | UrlTree {
return this.session.utilisateur() !== null || this.router.parseUrl('/connexion');
}
}
// Déprécié : la classe elle-même dans le tableau. Cela fonctionne encore.
export const avant: Routes = [
{ path: 'compte', loadComponent: () => import('./compte'), canActivate: [ConnecteGuard] },
];
// Migration sans réécrire la classe : une fonction qui la délègue.
export const apres: Routes = [
{
path: 'compte',
loadComponent: () => import('./compte'),
canActivate: [() => inject(ConnecteGuard).canActivate()],
},
];
// Le même emballage, fourni par le routeur.
export const apresBis: Routes = [
{
path: 'compte',
loadComponent: () => import('./compte'),
canActivate: mapToCanActivate([ConnecteGuard]),
},
];Resolvers fonctionnels et leurs limites
Un resolver est une fonction ResolveFn<T> qui charge une donnée avant l'activation de la route. Son résultat rejoint data sous la clé choisie dans resolve et, avec withComponentInputBinding, l'entrée du même nom. Il peut rendre une RedirectCommand quand la donnée n'existe pas. Les exemples s'appuient sur ce service :
import { Injectable } from '@angular/core';
export interface Produit {
readonly id: number;
readonly nom: string;
}
@Injectable({ providedIn: 'root' })
export class Catalogue {
private readonly produits: readonly Produit[] = [
{ id: 1, nom: 'Clavier' },
{ id: 2, nom: 'Souris' },
];
// Un appel réseau, simulé : la réponse arrive 300 ms plus tard.
async trouver(id: number): Promise<Produit | undefined> {
await new Promise((fin) => setTimeout(fin, 300));
return this.produits.find((produit) => produit.id === id);
}
} Le piège propre aux resolvers asynchrones est le contexte d'injection, qui ne survit pas à un await.
import { inject } from '@angular/core';
import { RedirectCommand, ResolveFn, Router } from '@angular/router';
import { Catalogue, Produit } from './catalogue';
export const produitResolver: ResolveFn<Produit> = async (route) => {
const produit = await inject(Catalogue).trouver(Number(route.paramMap.get('id')));
// Après await, le contexte d'injection n'existe plus. Un produit connu ne
// passe pas ici ; un produit inconnu appelle inject(), qui lève NG0203 :
// au lieu de la redirection prévue, la navigation échoue.
return produit ?? new RedirectCommand(inject(Router).parseUrl('/introuvable'));
}; Le produit 1 s'affiche, et le défaut échappe à tout test qui n'essaie que des produits existants. Pour le produit 99, la navigation échoue sur NG0203, The `Router` token injection failed, et l'URL reste l'ancienne. Tout s'injecte en tête de fonction.
import { ApplicationConfig, Component, inject, input } from '@angular/core';
import {
RedirectCommand,
ResolveFn,
Router,
Routes,
provideRouter,
withComponentInputBinding,
} from '@angular/router';
import { Catalogue, Produit } from './catalogue';
export const produitResolver: ResolveFn<Produit> = async (route) => {
// Tout ce qui s'injecte s'injecte avant le premier await.
const catalogue = inject(Catalogue);
const router = inject(Router);
const produit = await catalogue.trouver(Number(route.paramMap.get('id')));
// Introuvable : une redirection plutôt qu'une page vide.
return produit ?? new RedirectCommand(router.parseUrl('/introuvable'));
};
@Component({ selector: 'app-fiche-produit', template: '<h1>{{ produit().nom }}</h1>' })
export class FicheProduit {
// La clé de resolve devient le nom de l'entrée (withComponentInputBinding).
// Le composant ne voit jamais d'état de chargement : il n'existe qu'une
// fois la donnée arrivée.
readonly produit = input.required<Produit>();
}
@Component({ selector: 'app-introuvable', template: '<h1>Produit introuvable</h1>' })
export class Introuvable {}
export const routes: Routes = [
{ path: 'produits/:id', component: FicheProduit, resolve: { produit: produitResolver } },
{ path: 'introuvable', component: Introuvable },
];
export const appConfig: ApplicationConfig = {
// Sans withComponentInputBinding, l'entrée produit ne reçoit rien.
providers: [provideRouter(routes, withComponentInputBinding())],
};La limite de fond est ailleurs : un resolver bloque la navigation. Tant qu'il attend, l'URL et l'écran restent ceux de la page précédente, et rien ne montre que le clic a été pris en compte, sauf ce que l'application affiche elle-même.
import { Component, computed, inject } from '@angular/core';
import { Router, RouterOutlet } from '@angular/router';
@Component({
selector: 'app-root',
imports: [RouterOutlet],
template: `
@if (navigationEnCours()) {
<p role="status">Chargement…</p>
}
<router-outlet />
`,
})
export class App {
private readonly router = inject(Router);
// currentNavigation est un signal (Angular 20.2) : la navigation en cours,
// ou null. Pendant qu'un resolver attend, c'est le seul signe visible.
protected readonly navigationEnCours = computed(() => this.router.currentNavigation() !== null);
} Deux autres limites en découlent. Les resolvers d'une route parente et de ses enfants s'exécutent l'un après l'autre, et leurs durées s'additionnent. Et un resolver ne se relance, par défaut, que si les paramètres de chemin changent : passer de ?page=1 à ?page=2 le laisse muet, sauf à déclarer runGuardsAndResolvers: 'paramsOrQueryParamsChange' sur la route. Quand l'attente pèse, le chargement passe dans le composant : la navigation se termine aussitôt, et le composant affiche son propre état.
import { Component, inject, input, numberAttribute, resource } from '@angular/core';
import { Catalogue } from './catalogue';
// Route : { path: 'produits/:id', component: FicheProduit }, sans resolve.
@Component({
selector: 'app-fiche-produit',
template: `
@if (produit.isLoading()) {
<p>Chargement…</p>
} @else if (produit.error()) {
<p>Le catalogue ne répond pas.</p>
} @else if (produit.value(); as trouve) {
<h1>{{ trouve.nom }}</h1>
} @else {
<h1>Produit introuvable</h1>
}
`,
})
export class FicheProduit {
readonly id = input.required({ transform: numberAttribute });
private readonly catalogue = inject(Catalogue);
// La navigation se termine tout de suite ; le chargement suit id().
protected readonly produit = resource({
params: () => this.id(),
loader: ({ params }) => this.catalogue.trouver(params),
});
} La documentation d'angular.dev décrit une troisième voie, des resources déclarées sur la route, chargées en parallèle et éventuellement non bloquantes. Dans @angular/router 22.1.7, withRouterResources et nonBlocking ne sont exportés que sous un nom privé préfixé de ɵ, marqués expérimentaux, et le type Route n'a pas de propriété resources : dans la version de ce site, ce n'est pas une API à employer. Le CHANGELOG de la 22.2 annonce que les resources du routeur passent dans l'API publique.
Redirections
redirectTo remplace l'URL sans rien afficher. Une chaîne qui commence par / est absolue, les autres sont relatives à la route, et :ref y recopie un paramètre. Le piège classique est la redirection de l'URL vide : pathMatch vaut 'prefix' par défaut, et la chaîne vide est le préfixe de toutes les URL. En développement, le routeur refuse cette configuration dès sa création, avec NG04014 : please provide 'pathMatch'. La route de repli de ce site, { path: '**', redirectTo: '' }, n'a pas ce problème, puisque c'est sa cible qui est vide.
redirectTo accepte aussi une fonction, exécutée dans un contexte d'injection.
import { Component, Injectable, inject, signal } from '@angular/core';
import { Router, Routes } from '@angular/router';
@Injectable({ providedIn: 'root' })
export class Session {
readonly role = signal<'client' | 'admin' | null>(null);
}
@Component({ selector: 'app-page', template: '<h1>Page</h1>' })
export class Page {}
export const routes: Routes = [
// 'full' : la route ne correspond qu'à l'URL vide. Sans pathMatch, '' serait
// le préfixe de toutes les URL ; le mode développement refuse cette
// configuration dès la création du routeur (NG04014).
{ path: '', pathMatch: 'full', redirectTo: 'produits' },
// Une décision. La fonction reçoit ce que le routeur sait déjà de la route
// (params, queryParams, fragment, data…) et s'exécute dans un contexte
// d'injection. Elle rend une chaîne ou une UrlTree, directement ou de façon
// asynchrone (promesse, observable).
{
path: 'mon-espace',
pathMatch: 'full',
redirectTo: () => {
const role = inject(Session).role();
if (role === null) {
return inject(Router).createUrlTree(['/connexion'], {
queryParams: { retour: '/mon-espace' },
});
}
return role === 'admin' ? '/admin' : '/compte';
},
},
{ path: 'produits', component: Page },
{ path: 'connexion', component: Page },
{ path: 'compte', component: Page },
{ path: 'admin', component: Page },
]; La fonction reçoit un PartialMatchRouteSnapshot : les paramètres, la query string, le fragment, data, mais rien de ce que la correspondance n'a pas encore atteint, comme les résultats des resolvers. Elle rend une chaîne ou une UrlTree, directement ou de façon asynchrone. Elle sert aussi là où la chaîne est trop pauvre, car une redirection écrite en chaîne ne recopie que ce qu'elle nomme.
import { Component } from '@angular/core';
import { Routes } from '@angular/router';
@Component({ selector: 'app-fiche-produit', template: '<h1>Fiche</h1>' })
export class FicheProduit {}
export const routes: Routes = [
// L'ancienne adresse d'une fiche, encore présente dans des favoris et des
// courriels. :ref est recopié ; le reste ne l'est pas :
// /fiche/42?onglet=avis#photos devient /produits/42.
{ path: 'fiche/:ref', redirectTo: '/produits/:ref' },
{ path: 'produits/:ref', component: FicheProduit },
]; Les favoris qui pointaient vers l'onglet des avis retombent sur la fiche, sans erreur. Nommer un paramètre, ?onglet=:onglet, recopie celui-là et perd les autres. La fonction reconstruit l'adresse entière.
import { Component, inject } from '@angular/core';
import { Router, Routes } from '@angular/router';
@Component({ selector: 'app-fiche-produit', template: '<h1>Fiche</h1>' })
export class FicheProduit {}
export const routes: Routes = [
{
path: 'fiche/:ref',
// La fonction reconstruit l'URL entière : /fiche/42?onglet=avis#photos
// devient /produits/42?onglet=avis#photos.
redirectTo: ({ params, queryParams, fragment }) =>
inject(Router).createUrlTree(['/produits', params['ref']], {
queryParams,
fragment: fragment ?? undefined,
}),
},
{ path: 'produits/:ref', component: FicheProduit },
]; Dans un guard ou un resolver, RedirectCommand joue le même rôle, et son second argument, par exemple { replaceUrl: true }, décide de la façon dont l'historique enregistre la redirection.
Défilement et ancres
Une application monopage ne recharge pas le document, si bien que le navigateur ne gère plus le défilement de façon fiable. Sans configuration, le routeur ne s'en charge pas non plus : la page suivante s'ouvre à la hauteur de la précédente, et un fragment #guards dans l'URL n'amène nulle part. withInMemoryScrolling rétablit les deux comportements. anchorScrolling: 'enabled' fait défiler vers l'élément dont l'id vaut le fragment. scrollPositionRestoration: 'enabled' restaure la position enregistrée lors d'un retour arrière et remonte en haut sinon ; 'top' remonte toujours. Les deux options valent 'disabled' par défaut, et ce site les active.
// web/src/app/app.config.ts de ce site (commentaire d'origine reformulé)
import {
ApplicationConfig,
inject,
provideAppInitializer,
provideZonelessChangeDetection,
} from '@angular/core';
import { ViewportScroller } from '@angular/common';
import { provideRouter, withComponentInputBinding, withInMemoryScrolling } from '@angular/router';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [
provideZonelessChangeDetection(),
provideRouter(
routes,
withComponentInputBinding(),
withInMemoryScrolling({ anchorScrolling: 'enabled', scrollPositionRestoration: 'enabled' }),
),
// ViewportScroller défile vers des coordonnées calculées, ce qui ignore
// le scroll-margin-top du CSS : un décalage de 24 px le remplace pour
// tout défilement vers une ancre.
provideAppInitializer(() => inject(ViewportScroller).setOffset([0, 24])),
],
}; Le défilement a lieu après la fin de la navigation, au premier de setTimeout et requestAnimationFrame : un élément rendu plus tard, après un chargement de données, n'existe pas encore, et rien ne se passe. ViewportScroller défile par window.scrollTo vers des coordonnées qu'il calcule, d'où le décalage que ce site lui fournit. Reste le lien lui-même, où href ne connaît pas la route courante.
import { Component } from '@angular/core';
@Component({
selector: 'app-sommaire',
template: `
<!-- index.html déclare <base href="/"> : le navigateur résout #guards
contre cette base, pas contre la page. Sur /angular/routing, le lien
vise /#guards, une autre adresse : l'application entière se recharge
sur l'accueil. -->
<a href="#guards">Guards</a>
`,
})
export class Sommaire {} Le navigateur résout toute URL relative contre <base href="/">, pas contre la page : href="#guards" vise la racine du site, une autre adresse que la page affichée, et le clic recharge l'application. RouterLink, avec [] pour la route courante et fragment, construit l'adresse complète et confie le clic au routeur. C'est la forme qu'emploie le sommaire de cette page.
import { Component } from '@angular/core';
import { RouterLink } from '@angular/router';
@Component({
selector: 'app-sommaire',
imports: [RouterLink],
template: `
<!-- [] désigne la route courante : le lien porte
href="/angular/routing#guards", le clic passe par le routeur, et
anchorScrolling fait défiler jusqu'à l'élément d'id guards. -->
<a [routerLink]="[]" fragment="guards">Guards</a>
`,
})
export class Sommaire {}