Makina Blog

Le blog Makina-corpus

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

Image
Capture d'une partie de carte montrant un réseau de voies sur un fond de carte sombre. Au centre, une popup affiche les information de l'un des tronçons du réseau.
28/02/2024

Géné­rer un fichier PMTiles avec Tippe­ca­noe

Exemple de géné­ra­tion et d’af­fi­chage d’un jeu de tuiles vecto­rielles en PMTiles à partir de données publiques.

Voir l'article
Image
Read The Docs
01/02/2024

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.

Voir l'article
Image
Widget
04/04/2023

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.

Voir l'article

Inscription à la newsletter

Nous vous avons convaincus