Makina Blog
Routing Angular : optimisez le rendu au changement de page
Avec les Resolvers, les Guards et les Initializers, découvrez les bonnes pratiques pour éviter les états vides et autres inconvénients d'affichage lors de la navigation dans une application Angular.
Prérequis: vous devez connaître les bases sur l'utilisation des routes Angular, les Observables et les Composants. Tout le code d'exemple est du code Angular 5 et RxJS 5.5.
Il y a quelques semaines, je me décidais à aller confirmer un voyage que j'avais réservé plusieurs mois auparavant pour des vacances bien méritées. Je me connecte sur l'application web, je clique sur le lien "Mes réservations", et là, malheur ! Que vois-je écrit : "Vous n'avez réservé aucun voyage" ! Pendant quelques secondes, j'imagine tous les scénarios : ai-je bien cliqué sur le dernier bouton du parcours de réservation ? Ai-je omis un quelconque lien de confirmation ?… C'est le temps de ces questions qu'il aura fallu pour que le message soit avantageusement remplacé par la référence de ma réservation.
L'application est une SPA. Quand je clique sur "Mes réservations", le routeur déclenche l'affichage du composant "Liste de voyages". À l'initialisation de celui-ci se déclenche une requête de récupération de ladite liste. Le temps que la donnée soit récupérée, le composant fait le rendu d'un état vide, durant lequel s'affiche le message "Vous n'avez réservé aucun voyage". C'est plus tard que le message est remplacé par la "vraie" liste.
Optimiser la navigation avec les Resolver
Le réflexe que l'on peut avoir lorsque l'on développe une application
Angular, c'est de définir, dans la méthode ngOnInit
d'un composant, un subscribe à une requête de récupération de données, d'affecter cette donnée à un attribut du composant, et d'utiliser cet
attribut dans un template. Par exemple :
// src/app/app-routing.module.ts
{ path: 'travels', component: MyTravelsComponent },
// src/app/components/my-travels.component.ts
@Component({
selector: 'app-my-travels',
template: `<h1>My travels</h1>
<ul>
<li *ngFor="let travel of travels">
{{ travel.id }} - {{ travel.destination.name }} {{ travel.date | date:'dd/MM/YYYY' }}
</li>
</ul>
<p *ngIf="!travels?.length">Vous n'avez réservé aucun voyage.</p>
`})
class MyTravelsComponent implements ngOnInit {
travels: Travel[] = [];
constructor(private travels: TravelService) {}
ngOnInit() {
travels.getAuthenticatedUserTravels()
// méthode qui récupère via http les voyages de l'utilisateur connecté
.subscribe(travels => this.travels = travels);
}
}
L'inconvénient avec ce motif, c'est que le premier rendu du template va se faire, avec pour valeur de travels, une liste vide. Tel quel, cela va provoquer un désagréable effet de clignotement, voire, si la requête tarde à arriver, un instant de doute pour l'utilisateur.
Pour éviter ces désagréments visuels aux changements de page, nous avons deux options.
L'une est de positionner, à la place des blocs en attente de contenu, un placeholder et un marqueur de chargement. C'est en général l'option choisie pour les parties secondaires de l'écran. Nous présenterons nos techniques pour réaliser cela avec Angular dans un prochain billet.
L'autre option, que nous présentons ici, est de faire patienter l'utilisateur avant le changement de page et d'afficher directement le rendu complet, à l'image de l'expérience habituelle de la navigation web.
Les Resolver
d'Angular
permettent d'attendre le retour d'un
observable avant d'initialiser / mettre à jour un composant après une mise à jour de l'url. Le
Resolver
est une classe que l'on associe à la route du composant.
Leur utilisation classique est la récupération de données.
On écrirait plutôt :
// src/app/resolvers/travels.resolver.ts
@Injectable()
export class TravelsResolver implements Resolve<Travel[]> {
constructor(private travels: TravelService) {}
resolve(): Observable<Travel[]> {
return this.travels.getAuthenticatedUserTravels();
}
}
// src/app/app-routing.module.ts
{ path: 'travels',
component: MyTravelsComponent,
resolve: {
travels: TravelsResolver // on associe un resolver à la route
},
},
Le composant MyTravelsComponent
ressemblerait à ceci :
// src/app/components/my-travels.component.ts
@Component([...])
class MyTravelsComponent implements ngOnInit {
constructor(private route: ActivatedRoute) {}
ngOnInit() {
this.route.data.subscribe((data: { travels: Travels }) => this.travels = data.travels);
}
}
L'exploitation des paramètres de la route, s'il y en a, se fera dans le Resolver
.
Imaginons une route permettant d'afficher les voyages d'un utilisateur
suivant son userId
:
// src/app/app-routing.module.ts
{ path: 'admin/:userId/travels',
component: AdminTravelsComponent,
resolve: {
travel: AdminTravelsResolver
},
},
// src/app/resolvers/admin-travels.resolver.ts
@Injectable()
export class AdminTravelsResolver implements Resolve<Travel[]> {
constructor(private travels: TravelService) {}
resolve(): Observable<Travel[]> {
const userId = <string>route.paramMap.get('userId');
// méthode qui récupère via http les voyages de l'utilisateur indiqué
return this.travels.getUserTravels(userId);
}
}
Réutiliser les resolver des parent routes
Les Resolver
semblent verbeux. En réalité, sur des applications plus
complexes, ils vont permettre de réutiliser du code.
Par exemple, vous pouvez récupérer les données des routes parentes.
Imaginons la configuration suivante :
// src/app/app-routing.module.ts
{ path: 'admin/:userId',
component: AdminUserComponent,
resolve: {
user: AdminUserResolver
},
children: [
{
path: '/travels',
component: AdminTravelsComponent,
resolve: {
travels: AdminUserTravelsResolver
},
},
]
}
Supposons que nous avons besoin de la donnée user
(les informations du
profil de l'utilisateur) dans la childroute /travels
, on écrira
simplement ceci :
// src/app/components/admin-travels.component.ts
@Component([...])
class AdminTravelsComponent {
user: Observable<User>;
travels: Observable<Travel[]>;
constructor(route: ActivatedRoute) {
this.user = route.parent.data.map((data: { user: User } => data.user)
this.travels = route.data.map((data: { travels: Travel[] } => data.travels)
}
}
Notez néanmoins que vous couplez fortement vos composants quand vous faites cela.
Gérer les cas d'erreur dans les Resolver
Vous souhaitez gérer les erreurs de récupération de données de manière complète, en redirigeant votre utilisateur vers une page adéquate quand votre api renvoit une 404, 401, 403, 500…
Les Resolver
vous permettront de faire cela avant le rendu du
composant, évitant ainsi un effet de clignotement du à une redirection.
Surtout, ils simplifient votre code et vous évitent des bugs : on
oublie souvent de gérer les cas d'erreurs dans les composants !
Le premier Resolver
que vous écrirez ressemblera à ceci :
// src/app/resolvers/admin-travels.resolver.ts
@Injectable()
export class AdminTravelsResolver implements Resolve<Travel[]> {
constructor(private travels: TravelService) {}
resolve(route: ActivatedRouteSnapshot): Observable<Travel[]> {
const userId = <string>route.paramMap.get('userId');
return this.travels.getUserTravels(userId)
.catch(errorResponse => this.handleError(route, errorResponse);
}
handleError(route: ActivatedRouteSnapshot, errorResponse: HTTPErrorResponse) {
switch (errorResponse.status) {
case 404: {
this.router.navigate(['/not-found']);
return Observable.of(null);
}
case 401: {
const returnURL: string = '/' + route.url.map(segment => segment.path).join('/');
this.router.navigate(['/login'], { queryParams: { returnURL: returnURL }});
return Observable.of(null);
}
case 403: {
this.router.navigate(['/unauthorized']);
return Observable.of(null);
}
case default: {
console.error(error);
this.router.navigate(['/error']);
return Observable.of(null);
}
}
}
}
Le mieux sera de déléguer la méthode handleError à une super-classe ou
à un service, pour être ensuite réutilisée par tous les Resolver
.
L'important ici est de voir que le Resolver
est le meilleur moment
du cycle pour gérer ces cas d'utilisations.
Si vous implémentez le server side rendering, c'est aussi le bon endroit pour définir, côté serveur, le code d'erreur de la réponse du serveur node.
Optimiser les restrictions d'accès aux pages avec les Guard
Comme vous le voyez, nous cherchons, dans ce billet, à améliorer l'expérience utilisateur en vous prévenant d'afficher du contenu immédiatement obsolète.
Voici un autre scénario très classique :
- l'utilisateur se rend sur une page par un lien direct.
- à l'initialisation du composant, on se rend compte que l'utilisateur n'est pas connecté.
- en conséquence, on provoque une redirection vers une page de login.
Ici encore, vous êtes amenés à commencer le rendu d'un composant pour le détruire immédiatement.
Cela peut être évité grâce aux Guard, qui vous permettent d'empêcher l'initialisation d'un composant en fonction d'une contrainte.
Il s'agit d'une classe qui implémente CanActivate et que vous associez à votre route.
Par exemple voici un Guard qui empêche l'accès à un utilisateur non connecté et redirige vers une page de login dans ce cas :
// src/app/guards/is-authenticated-guard.ts
@Injectable()
export class IsAuthenticated implements CanActivate {
auth: any = {};
constructor(private authService: MyAuthenticationService,
private router: Router) {
}
canActivate() {
if (this.authService.isAuthenticated.getValue()) {
return true;
} else {
this.router.navigate(['/login']);
return false;
}
}
}
Et voici la configuration de la route qui applique cette règle à la page travels :
// src/app/app-routing.module.ts
{ path: 'travels',
component: MyTravelsComponent,
canActivate: [IsAuthenticated]
resolve: {
travel: TravelsResolver // on associe un resolver à la route
},
},
Les Guard
, comme les Resolver
, ont également pour vertu de
séparer les aspects, de permettre la réutilisation du code, et de faciliter les tests.
Notez que les Guard
(on peut les cumuler) d'une page sont vérifiés
avant le Resolver
. Si la restriction d'accès dépend du retour de la
récupération de la donnée, dans ce cas, il est normal, comme présenté dans le
paragraphe précédent, de l'implémenter dans le resolver (voir
https://github.com/angular/angular/issues/15026)
Pour aller plus loin: le Guard CanActivate est le plus classique, mais il y en a d'autres ( https://blog.thoughtram.io/angular/2016/07/18/guards-in-angular-2.html)
Optimiser le rendu de la landing page grâce à l'initializer
Finissons par un cas particulier, celui de l'affichage de la première page accédée (landing page).
C'est à l'initialisation de l'application que l'on doit récupérer, outre les données de la page, un certain nombre d'informations d'intérêt général, comme par exemple :
- les données de profil de l'utilisateur déjà connecté (nom, avatar, langue, …)
- les informations de configuration dynamique générale (image de fond configurable, …)
- des textes dynamiques (listes de choix, chaînes de traduction)
A priori, ces données récupérées progressivement depuis le back sont gérées en tant qu'observables, et leur affichage va se faire de manière asynchrone. Cela peut provoquer des rendus malheureux, comme des clignotements, de brusques décalages et des interpolations de chaînes. Cela complique aussi le code pour rien, car il faut que le développeur prévoit, un peu partout, le cas où certaines informations ne sont pas encore disponibles.
C'est une question de choix, mais le développeur pourra préférer faire patienter l'utilisateur quelques dixièmes de secondes de plus et éviter cette situation.
Les APP_INITIALIZER permettent de reporter l'initialisation d'un module Angular à la résolution d'une promesse. Nous pouvons nous en servir pour forcer Angular à attendre que certaines données soient récupérées avant de commencer le rendu.
APP_INITIALIZER
est une clé d'injection (injection token) du
coeur d'Angular. Vous pouvez déclarer pour cette clé d'injection un ou
plusieurs providers.
// src/app/app.module.ts
@NgModule({
providers: [
{
provide: APP_INITIALIZER,
useFactory: initializeConfiguration,
deps: [Injector],
multi: true
},
...
})
Le provider
sera une fonction qui renvoit une promesse qui se résout
quand les données ont été initialisées. Ici par exemple, nous récupérons
un jeu de configuration qui contient l'url d'une image de fond
configurable.
// src/app.app.initializers.ts
export function initializeConfiguration(injector: Injector) {
const configuration: ConfigurationService = injector.get(ConfigurationService);
return new Promise((resolve, reject) => {
configuration
.initialize()
.subscribe((initialized: boolean) =>
initialized === true ? resolve(true) : reject(), error => reject(error));
});
Pour l'opération en question, vous pourrez utiliser un AsyncSubject
(pour en savoir plus à ce sujet, voir notre article sur les bases de RxJs:
https://makina-corpus.com/blog/metier/2017/premiers-pas-avec-rxjs-dans-angular)
// src/services/configuration.service.ts
interface Configuration: { backgroundImage: string }
@Injectable()
class ConfigurationService(http: Http) {
public configuration: Configuration;
public initialize(): AsyncSubject<true | any> {
const initialized: AsyncSubject<boolean> = new AsyncSubject();
// récupération de la configuration dynamique par http
http.get('/configuration').subscribe((configuration: Configuration) => {
this.configuration = configuration;
initialized.next(true);
initialized.complete();
}, (error: any) => {
initialized.next(false);
initialized.complete();
}
}
return initialized;
}
Grâce à ce code, nous sommes certains que l'attribut configuration
du service Configuration
est renseigné dès l'initialisation de
l'application, ce qui simplifie le code qui l'utilise.
À noter : nous ne faisons dépendre ce provider que de Injector, et nous utilisons l'injecteur pour accéder aux services. Cela permet d'éviter des dépendances cycliques entre APP_INITIALIZER, Http et Router.
Conclusion
Les Resolver, Guard et App initalizer permettent d'éviter à vos composants de passer par des états intermédiaires rapidement obsolètes. Ce faisant, ils permettent d'améliorer la qualité du rendu. En les utilisant, vous améliorez la modularité et la testabilité de votre code.
Si vous souhaitez en apprendre davantage, vous pouvez participer à nos formations Angular (https://makina-corpus.com/formations/formation-angular). Mais si vous aimez Angular et souhaitez devenir expert, le mieux reste de rejoindre nos équipes !
Actualités en lien
Générer un fichier PMTiles avec Tippecanoe
Exemple de génération et d’affichage d’un jeu de tuiles vectorielles en PMTiles à partir de données publiques.
Publier une documentation VitePress sur Read The Docs
À l'origine, le site de documentation Read The Docs n'acceptait que les documentations Sphinx ou MKDocs. Depuis peu, le site laisse les mains libres pour builder sa documentation avec l'outil de son choix. Voici un exemple avec VitePress.
Créer une application en tant que composant web avec Stencil
Mise en place dans le cadre de Geotrek, cette solution permet de se passer d'une iFrame pour afficher une application dans n'importe quel site.