Les secrets du Router d'Angular

Featured image

Le routing est un élément inévitable dans une Application Web. Nous allons donc nous concentrer sur cette partie du framework Angular.

Si vous êtes uniquement intéressé pas un point précis du routing, n’hésitez pas à aller voir la table des matières.

C’est quoi le routing ?

Le système de routing est la gestion de l’affichage via l’URL dans le navigateur. Le fait d’avoir l’url /home affichera des informations différentes de l’url /contact par exemple.

Pour que cela fonctionne, il faut que ça soit le JavaScript qui ait la main sur l’url à la place du navigateur. Cela est possible de plusieurs façons:

Les bonnes pratiques pour la création d’urls sont les mêmes que pour la méthode GET des API REST.

Aperçu du module

Le module à utiliser est le RouterModule qu’il faudra importer depuis le package @angular/router.

Ce module contient deux fonctions statiques dont vous commencez probablement à avoir l’habitude:

L’utilisation d’aucune de ces fonctions permet d’utiliser le système de route (comme la directive routerLink), dans un module séparé, sans ajouter de configuration supplémentaire.

// AppRoutingModule
RouterModule.forRoot(routes)
// FeatureRoutingModule
RouterModule.forChild(routes)
// SharedModule
RouterModule

Fonctionnement des routes

Concentrons-nous sur les éléments les plus utilisés d’une route (l’interface complète peut être retrouvée ici):

interface Route {
  path?: string
  pathMatch?: string
  component?: Type<any>
  redirectTo?: string
  canActivate?: any[]
  canDeactivate?: any[]
  canLoad?: any[]
  data?: Data
  children?: Routes
  loadChildren?: LoadChildren
  ...
}

Utilisation simple

Voici une utilisation très simple:

const routes: Routes = [
    { path: '', redirectTo: '/home', pathMatch: 'full' },
    { path: 'home', component: HomeComponent },
    { path: 'contact', component: ContactComponent },
    { path: '**', redirectTo: '/home' }
];

Vous pouvez remarquer deux paths étranges qui sont, en réalité, les seuls obligatoires pour le bon fonctionnement de notre routing:

L’option choisie dans cet exemple est de les rediriger vers la page “home” à l’aide de l’attribut redirectTo. L’attribut pathMatch mis à la valeur 'full' permet de déterminer que ce cas ne doit être exécuté que si le path est complètement vide (soit “/” ou “”), mais absolument rien d’autre.

Concentrons-nous maintenant sur les paths connus (“home” et “contact”). Vous pouvez voir que nous spécifions le composant à afficher dans ces deux cas, mais où vont-ils s’afficher ? C’est là que le composant <router-outlet></router-outlet> entre en jeux. Ce composant vous permet de choisir où le composant géré par le router s’affichera dans votre application.

Utilisation de router-outlet imbriqués

Prenons par exemple le fait que nous voulons le même header et le même footer sur toutes les pages de notre application sauf sur la page de connexion. Un premier réflex pourrait être d’ajouter ces composants dans l’AppComponent au même niveau que notre router-outlet.

app.component.html :

<app-header></app-header>
<router-outlet></router-outlet>
<app-footer></app-footer>

Mais comment peut-on masquer ces éléments dans le cas où nous nous trouvons sur la page de connexion ? Non nous n’utiliserons pas de *ngIf pour arriver à nos fins. Heureusement Angular nous permet de faire cela avec plusieurs router-outlet imbriqués.

Créons alors un nouveau composant “container” qui s’occupera d’ajouter le header ainsi que le footer seulement dans certains cas et nettoyons notre AppComponent.

app.component.html :

<router-outlet></router-outlet>

container.component.html :

<app-header></app-header>
<router-outlet></router-outlet>
<app-footer></app-footer>

Regardons maintenant comment arriver à remplir plusieurs router-outlet. Cela est possible grâce aux attributs children et loadChildren (lazy-loading).

const routes: Routes = [
    { path: '', component: ContainerComponent, children: [
        { path: 'home', component: HomeComponent },
        { path: 'contact', component: ContactComponent },
    ] },
    { path: 'signin', component: SigninComponent }, 
    { path: '**', redirectTo: '/home' }
];

Avec cette configuration nous pourrons retrouver dans les router-outlet

Paramètres

Nous avons, jusqu’à présent, vu des routes statiques, mais comment peut-on rendre une partie de la route dynamique ?

Deux solutions sont possibles:

Cela respecte encore une fois la norme REST.

Path param

Les path params se définissent à l’aide du préfix : suivi de son nom.

// Paramètre unique
{ path: 'users/:id', component: UserListComponent }
// Plusieurs paramètres
{ path: 'users/:category/:id', component: UserListComponent }

Ce paramètre est alors récupérable via la route active.

// Synchrone
this.id = this.route.snapshot.params['id'];
// Asynchrone
this.route.params.subscribe(params => this.id = params['id']);

Query param

Dans ce cas, il n’y a rien à spécifier dans la configuration des routes. Nous n’avons cas essayer de récupérer le paramètre optionnel.

// Synchrone
this.referrer = this.route.snapshot.queryParams['referrer'];
// Asynchrone
this.route.queryParams.subscribe(params => this.referrer = params['referrer']);

Le lazy-loading

Le lazy-loading permet de scinder votre “dist” en plusieurs fichiers qui ne seront chargés que si, et quand, une utilisation est nécessaire.

Si vous faite une recherche à ce propos sur google, vous tomberez probablement sur un syntaxe de ce type: loadChildren: './admin/admin.module#AdminModule'. Il s’agit d’une syntaxe maintenant dépréciée qui a été créée par Angular afin d’être décortiquée et d’utiliser les informations avec SystemJS.

À partir de la version 8 d’Angular, nous allons éviter SystemJS et utiliser la puissance du mécanisme d’import de l’ES6.

Cependant le principe est le même dans les deux cas. Il s’agit de définir:

La nouvelle syntaxe complète est donc la suivante: loadChildren: import('./admin/admin.module').then(m => m.AdminModule). Attention que vous ne pouvez pas modifier cette syntaxe (en ajoutant par exemple plus de code dans le then), car le build AOT ne la comprendra pas. Il est donc impératif de la respecter.

const routes: Routes = [
    ...
    { path: 'admin', loadChildren: import('./admin/admin.module').then(m => m.AdminModule) },
    ...
];

Données statiques

Si vous avez besoin de déterminer des données comme le titre dans la page selon l’url, il n’est pas conseillé de décortiquer l’url afin de comprendre le contexte. Pour pouvoir faire cela, nous pourrions directement associer le titre à une route.

Cela est possible à l’aide de l’attribut data des routes.

const routes: Routes = [
    { path: '', component: ContainerComponent, children: [
        { path: 'home', component: HomeComponent, data: { myTitle: 'accueil' } },
        { path: 'contact', component: ContactComponent, data: { myTitle: 'contact' } },
    ] },
    ...
];

La donnée est récupérable à l’aide de l’ActivatedRoute.

constructor(private route: ActivatedRoute, private title: Title) {}

ngOnInit() {
    this.title.setTitle(this.route.snapshot.data.myTitle);
}

Il est possible naviguer aussi bien depuis l’HTML que le TypeScript. Découvrons comment.

HTML

Pour naviguer, nous allons utiliser la directive routerLink.

Cette directive devrait être uniquement utilisée sur des balises de lien (a), mais elle peut en réalité se mettre sur n’importe quelle balise HTML. Cependant, le fait de le mettre en attribut d’un a génère également un href afin de pouvoir naviguer même si le JavaScript est désactivé côté client et que vous utilisez Universal.

<a [routerLink]="['/admin','users']">Go to user list</a>

Nous pouvons utiliser un string ou un tableau de string dans le routerLink (contrairement en TypeScript), cela revient au même résultat, mais je préconise le fait de toujours utiliser la même syntaxe et c’est le tableau qui se trouve être le plus utile. En effet cela permet de rendre une partie de l’url dynamique comme un paramètre.

<a [routerLink]="['/admin','users', id]">Go to user</a>

Paramètres

Les paramètres du routerLink se font via des inputs (attributs) de cette directive.

Nous pouvons ajouter des éléments dans l’url:

<a [routerLink]="['/admin','users', id]" [queryParams]="{param1: 'test1', param2: 'test2'}" fragment="email" >
    Go to user email
</a>

Ou gérer notre stratégie de “préservation de données” en cas de changement d’url

<a [routerLink]="['/admin','users', otherId]"  preserveQueryParams preserveFragment >
    Go to other user
</a>

TypeScript

L’API TypeScript pour la navigation (Router) permet de naviguer dans notre application de deux façons (via deux fonctions):

Pour avoir accès à cette API, il vous suffit d’injecter Router.

constructor(private router: Router) {}

Commençons par reproduire un exemple complet du routerLink avec la fonction navigate.

this.router.navigate(['/admin','users', this.id], {
    queryParams: {param1: 'test1', param2: 'test2'},
    queryParamsHandling: 'merge'
    preserveFragment: true
});

Vous remarquerez que l’utilisation est sensiblement la même.

Jetons alors un coup d’oeil sur les paramètres additionnels que le navigate nous fournis:

Il n’y a pas vraiment besoin de plus d’explication pour les deux premières options, mais la dernière demande peut-être un petit exemple:

Nous sommes sur un utilisateur précis /admin/users/1 et nous voulons faire un bouton transversal qui permet de remonter le “fil d’ariane” (autrement dit, nous voulons naviguer vers /admin/users sans connaitre cette url).

Nous allons pouvoir récupérer le parent de l’url active et demander une navigation relative à cette route.

constructor(private route: ActivatedRoute, private router: Router) {}
...
// Navigation vers le parent
this.router.navigate(['.'], { relativeTo: this.route.parent });
// Navigation vers un autre enfant
this.router.navigate([otherId], { relativeTo: this.route.parent });

Si vous connaissez déjà l’url de destination (exemple: /admin/users/1?param1=test1), utilisez la fonction navigateByUrl.

this.router.navigateByUrl(`/admin/users/1?param1=test1`, { preserveFragment: true });

Les options sont identiques à celles de la fonction navigate à l’exception prêt que les options modifiants l’url fournie en premier paramètre (comme queryParams, fragment ou relativeTo) ne fonctionneront pas.

Configuration

Globale

La configuration globale se fait au niveau de l’utilisation de la fonction statique forRoot du RouterModule.

Comme pour les routes, concentrons-nous sur les options les plus utilisés (l’interface est disponible ici):

interface ExtraOptions {
  useHash?: boolean
  preloadingStrategy?: PreloadAllModules | NoPreloading
  onSameUrlNavigation?: 'reload' | 'ignore'
  scrollPositionRestoration?: 'disabled' | 'enabled' | 'top'
  anchorScrolling?: 'disabled' | 'enabled'
  ...
}

Comme expliqué précédemment, il est possible de changer la stratégie de routing de l’API HTML5 en ancres, cela est possible via l’option useHash.

Lazy-load preloading

L’option preloadingStrategy permet de changer la stratégie de “préloading” pour les modules qui sont en lazy-loading. Le fait de changer cette option en PreloadAllModules ( NoPreloading est utilisé par défaut ) chargera tous les modules une fois que l’application sera est fonctionnelle.

Rafraichissement

Par défaut, si la route (pas spécialement l’url) source est identique à celle destination, Angular n’effectue aucun rafraichissement. Cela semble logique, mais peut ne pas être le comportement voulu surtout pour des routes avec paramètre.

Par défaut nous serons obligés d’écouter nous-mêmes les navigations:

this.router.events.pipe(filter(e => e instanceof NavigationEnd).subscribe((e) => { ... });

Pour éviter de devoir faire cela, nous pouvons appliquer l’option: onSameUrlNavigation: 'reload'.

Scroll

Si vos pages dépassent la hauteur de l’écran, vous aurez remarqué un comportement indésirable des SPA: le scroll reste le même d’une page à l’autre.

En effet le comportement d’un site normal voudrait que nous scrollions jusqu’au dessus à chaque changement de page. De plus, l’ancre n’a aucune influence sur le comportement du scroll tant que vous ne rafraichissez pas la page.

Vous avez de la chance, deux options sont maintenant disponibles pour éviter ces problèmes et leur valeur par défaut va bientôt changer.

Déployement sous dossier

Si votre url de base de votre site ressemble à http://mondomaine.com/blog, vous utilisez un sous-dossier (ici “blog”).

Angular arrive à s’y retrouver grâce à la balise HTML base. Ça valeur par défaut est <base href="/"> ce qui définit qu’il n’y pas de sous dossier.

Vous pourriez directement changer cette valeur dans votre fichier index.hml, je préconise d’utiliser une configuration dynamique au moment du build dans votre package.json.

ng build --base-href=/blog

Sécurité

Vous l’avez peut-être remarqué, nous n’avons pas traité tous paramètres de route précédemment cité dans l’article. La raison et que ces trois paramètres restants méritent de créer une nouvelle section, car ils touchent la sécurité de votre application.

Le fait de supprimer le lien vers certaines routes n’est pas suffisant, car les utilisateurs pourraient se partager un lien ou sauver celui-ci en favoris. Il faut donc également bloquer l’accès à ces urls, et c’est là que les derniers paramètres interviennent.

Chacun de ces paramètres attend un tableau de ce qu’on appelle des guards.

Le rôle d’un gard est de déterminer si, pour lui, l’utilisateur a le droit d’accéder à la ressource demandée.

Un guard est un injectable qui implémente une interface (une interface par type de responsabilité).

// canActivate
@Injectable(providedIn: 'root')
class CanActivateGuard implements CanActivate {
    canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean>|boolean {
        return true;
    }
}
// canDeactivate
@Injectable(providedIn: 'root')
class CanDeactivateGuard implements CanDeactivate {
    canDeactivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean>|boolean {
        return true;
    }
}
// canLoad
@Injectable(providedIn: 'root')
class CanLoadGuard implements CanLoad {
    canLoad(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean>|boolean {
        return true;
    }
}

Il est possible de retourner un boolean ou un Observable de boolean. Cela dépend si vous savez calculer directement la valeur ou si elle nécessite un appel asynchrone.

Il ne nous reste plus qu’à les utiliser dans nos routes.

{
    path: 'users/:id',
    component: UserListComponent,
    canActivate: [CanActivateGuard],
    canDeactivate: [CanDeactivateGuard]
},
{
    path: 'admin',
    loadChildren: import('./admin/admin.module').then(m => m.AdminModule),
    canLoad: [CanLoadGuard]
},

MERCI

d'avoir pris le temps de lire cet article


Table des matières

  1. C’est quoi le routing ?
  2. Aperçu du module
  3. Fonctionnement des routes
    1. Utilisation simple
    2. Utilisation de router-outlet imbriqués
    3. Paramètres
      1. Path param
      2. Query param
    4. Le lazy-loading
    5. Données statiques
  4. Navigation
    1. HTML
      1. Paramètres
    2. TypeScript
      1. Navigate
      2. NavigateByUrl
  5. Configuration
    1. Globale
      1. Lazy-load preloading
      2. Rafraichissement
      3. Scroll
    2. Déployement sous dossier
  6. Sécurité