Angular Routing: Optimiser le chargement des pages

angular-roputing-optimize-speed-application

Connais-tu les techniques pour optimiser le chargement des pages de ton application Angular? Elles permettent d’accélérer le chargement de ton application et offrent ainsi une meilleure expérience à l’utilisateur. Dans cet article, je te montre mes astuces de pro pour améliorer le chargement des pages en utilisant le routeur Angular.

Tu as certainement déjà remarqué que ton application a tendance à « flasher » lors du chargement des pages non? Cela arrive quand le rendu d’un composent se fait avant qu’il ait récupéré les données nécessaires à son fonctionnement. Rien de bien grave mais cela ralentit drastiquement l’utilisation d’une app et offre une expérience utilisateur pas très agréable par manque de fluidité.

En effet, il est courant d’utiliser le life hook Angular « OnInit » pour lancer le chargement des données mais cela à un désavantage!

Le component est interprété et le DOM s’implémente à ce moment. Or les données ne sont pas forcément déjà disponibles, ce qui cause cet effet de « clignotement » pas très agréable pour l’utilisateur.

L’idée est donc de corriger ce problème en proposant une solution qui va effectuer le chargement des données avant le rendu du component. Cela permettra d’instancier ce dernier avec les données déjà disponibles et donc, plus d’effet de clignotement. Voici comment mettre en place cette solution.

Optimiser la navigation entre les pages avec les Resolver

Les Resolvers Angular permettent de charger des données avant le rendu des components. Nous allons donc utiliser cette fonctionnalité du routing pour demander un chargement des données avant le rendu et ainsi, lors du chargement dans le DOM, le component ne clignotera plus car les données seront directement disponibles. Comment cela est possible??

Les Resolvers…

Les Resolvers bloquent le chargement du component tant que la fonction n’est pas terminée. Tu connaissais déjà les Guards pour protégé tes routes?? Et bien les Resolvers servent à effectuer un chargement de données avant le rendu du component. Super-Pratique! Je te montre comment implémenter cela.

Creation d’un Resolver Angular

En premier on va créer un service qui va utiliser un autre service qui s’occupe du chargement des données. Dans l’exemple suivant, le resolver utilise le service « ApiService » pour rechercher le produit correspondant a l’ID qui se trouve dans l’URL de navigation avant de retourner les données trouvées.

// product.resolver.ts
@Injectable()
export class ProductResolverService implements Resolve<IProduct> {

  constructor(private readonly _api: ApiService) {}
  
  async resolve(route: ActivatedRouteSnapshot) {
    const {id = null} = route.params;
    const item = await this._api.loadItemById(id);
    return item
  }
}

L’utilisation du resolver se fait au niveau du routing ce qui évite de répéter le code sur plusieurs components. Ici, on utilise le resolver sur la route enfant de la page ProductsPage pour charger dans le component les données dont il a besoin pour fonctionner.

// products.module.ts
@NgModule({
  imports: [RouterModule.forChild([
    {
      path: '',
      children: [
        {
          path: '',
          component: ProductsPageComponent
        },
        {
          path: ':id',
          component: ProductPageComponent,
          resolve: {
            item: ProductResolverService
          }
        },     
      ]
    }
  ])],
  exports: [RouterModule]
})
export class ProductsRoutingModule { }

On peut alors récupérer les données directement dans le component comme ceci:

// product-page.component.ts
@Component({
  selector: 'product-page',
  templateUrl: './product-page.component.html',
  styleUrls: ['./product-page.component.scss']
})
export class ProductPageComponent implements OnInit {

  item: IItem;

  constructor(private readonly _route: ActivatedRoute) {}

  async ngOnInit(): Promise<void> {
    // extract data from resolver as promise
    const {item = null} = await this._route.data.pipe(first()).toPromise();
    this.item = item;
  }
}

Avec cette technique, les données sont directement disponibles et l’affichage du component s’effectue sans aucun clignotement ou problème d’affichage car les données sont déjà disponibles au moment d’instancier le component.

Cela permet aussi de réduire le code car le chargement des données se fait de manière centralisée au niveau du routing et plus dans chaque component.

Utile aussi de savoir que l’on peut très facilement récupérer les données d’un component parent depuis un enfant si ces données sont renseignées au niveau du resolver. Voici un exemple:

// products.module.ts
@NgModule({
  imports: [RouterModule.forChild([
    {
      path: '',
      children: [
        {
          path: '',
          component: ProductsPageComponent,
          resolve: {
            user: UserResolverService // resolver parent
          },
        },
        {
          path: ':id',
          component: ProductPageComponent,
          resolve: {
            item: ProductResolverService // resolver child
          }
        },     
      ]
    }
  ])],
  exports: [RouterModule]
})
export class ProductsRoutingModule { }
// product-page.component.ts
@Component({
  selector: 'product-page',
  templateUrl: './product-page.component.html',
  styleUrls: ['./product-page.component.scss']
})
export class ProductPageComponent implements OnInit {

  item: IItem;
  user: IUser;

  constructor(private readonly _route: ActivatedRoute) {}

  async ngOnInit(): Promise<void> {
    // extract data from resolver as promise
    const { item = null, user = null } = await this._route.data.pipe(first()).toPromise();
    this.item = item;
    this.user = user;
  }
}

Il faut aussi ajouter une option « paramsInheritanceStrategy » au router:

  imports: [RouterModule.forRoot(routes, {
    paramsInheritanceStrategy: 'always'
  })],

Optimiser les accès aux pages avec les Guards

Je pense que tu connais déjà les guards de Angular. Ils permettent de bloquer ou autoriser l’accès à une partie de l’application en fonction de conditions que l’on définira au sein d’une Class de type Guard.

Définie au niveau du routing cette fonction s’exécutera avant chaque tentative de chargement d’une route et ainsi, autorisera ou bloquera son accès à l’utilisateur.

Avec les Guards, plus besoin de faire les tests d’authentification dans tous les composants! Cela se fait directement au niveau du routing!

// products.module.ts
@NgModule({
  imports: [RouterModule.forChild([
    {
      path: '',
      children: [
        {
          path: '',
          component: ProductsPageComponent,
          resolve: {
            user: UserResolverService // resolver parent
          },
        },
        {
          path: ':id',
          component: ProductPageComponent,
          resolve: {
            item: ProductResolverService // resolver child
          },
          canActivate: [AuthGuardService] // guard route
        },     
      ]
    }
  ])],
  exports: [RouterModule]
})
export class ProductsRoutingModule { }

Et voici ce que pourrai contenir notre Guard:

// auth-guard.service.ts
@Injectable()
export class AuthGuardService implements CanActivate {

  constructor(private readonly _authService: MyAuthenticationService,
              private readonly _router: Router) {}

  canActivate() {
    if (this._authService.isAuthenticated.getValue()) {
      return true;
    } else {
      this._router.navigate(['/login']);
      return false;
    }
  }
}

Les Guard permettent de bien séparer les opérations. Tous comme les Resolvers, ils sont réutilisables et nous évitent de réécrire du code plusieurs fois dans divers components.

À savoir aussi, les Guard sont exécutés avant les Resolvers. Donc si la restriction d’accès à une route dépend du retour de la récupération d’une donnée, il est normal d’implémenter cette logique dans le resolver.

Il existe encore d’autres type de Guard Angular que je te laisserai checker sur leur docs:
https://angular.io/guide/router#milestone-5-route-guards

Optimiser le rendu de la landing page grâce à l’initializer

Celui-ci je pense pas que tu l’a déjà rencontré souvent… C’est une fonctionnalité avancée qui est utilisée pour charger des données avant le chargement d’un module spécifique. Ainsi, l’APP_INITIALIZER bloquera l’initialisation d’un module tant que les données ne sont pas récupéré et retournée sous forme de promesse. Super-Pratiques pour initialiser un module avec une config spécifique par exemple. Voici comment l’utiliser:

// app.module.ts
@NgModule({
  providers: [
    {
      provide: APP_INITIALIZER,
      useFactory: initConfig,
      deps: [Injector],
      multi: true
    },
  ...
})
// init-config.factory.ts
export async function initConfig(injector: Injector) {
  const config: ConfigService = injector.get(ConfigService);
  return await config.init().pipe(first()).toPromise();
});

Avec cette fonctionnalité, on est certains d’avoir chargé la configuration de l’application avant le chargement du module.

Conclusion

Avec ces techniques qui utilisent Resolvers, Guards et APP_INITALIZER on évite de charger les components de l’application avec des « états intermédiaire ». Ce qui améliore l’expérience utilisateur et rend plus fluide la navigation entre les différentes pages. Ainsi plus de page blanche avant implémentation, plus de tableau de données vides avec les *ngFor, plus besoin de tester avec les *ngIf

On réduit drastiquement le code et centralise la gestion des données laissant les components simplement binder les propriétés avec le template. Les services s’occupent des données et tout est bien organisé.

Je te montrerai dans les prochains articles quelques autres astuces que j’utilise pour optimiser mes application Angular.

En attendant, je vous laisse consulter les autres articles traitant du même sujet dans la section Angular.

Tu as besoin de support pour ton projet?
Je t’invite à te rendre sur le lien suivant pour définir ensemble une solution de support qui te convienne.

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *