Mise à jour

Le service d’appel vers une API a été changé pour l’utilisation d’une API sur un poste local.

Avant de commencer ce tutoriel, retrouvez toutes les informations pour exécuter l’api sur le dépôt GitHub de la série d’articles : https://github.com/ineat/micro-frontend-series/tree/main/swapi

Présentation

Cet article est le deuxième d’une série consacrée aux micro-frontends, le premier était dédié à VueJS : création d’une application micro-frontend avec VueJS.

Les applications micro-frontend ont le vent en poupe ces derniers temps car ils permettent à des équipes de travailler sur les différentes parties d’une application sans se soucier du travail des autres ni des technologies employées. Elles sont le pendant front des micro-services côté back.

Nous verrons donc ici comment créer un micro-frontend avec le framework Angular, de la mise en place de l’environnement de développement jusqu’à son intégration dans une page HTML.

Le code source présenté est disponible sur le repository Github d’Ineat.

Installation du CLI

Le tooling de développement d’Angular est l’un des plus aboutis des différents frameworks / librairies front-end. Le CLI est particulièrement bien fait et facile d’utilisation pour les différentes étapes du développement.

Pour pouvoir l’utiliser, il suffit de l’installer via la commande suivante :

npm install -g @angular/cli

Il est également possible d’utiliser yarn à la place de npm.

Création du projet

La création du projet est relativement facile avec le CLI.

ng new sw-planet-component

Voici les paramètres à utiliser pour ce projet :

  • Do you want to enforce stricter type checking and stricter bundle budgets in the workspace? : No
  • Would you like to add Angular routing : No
  • Which stylesheet format would you like to use? : SCSS

Une fois la structure du projet créée, accédez au dossier sw-planet-component et exécutez la commande suivante pour lancer le serveur de développement via le CLI :

npm run start

Création du service

Pour que notre application micro-frontend soit autonome et puisse communiquer avec une API, nous allons créer un service en utilisant le CLI.

ng generate service services/api

Cette action crée un fichier api.services.ts dans le dossier src/app/services.

import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Planet } from '../interfaces/planets.interface';
 
@Injectable({
  providedIn: 'root'
})
export class ApiService {
 
  constructor(private httpClient: HttpClient) { }
 
  getPlanets(page: String) {
    return this.httpClient.get<Planet[]>(`http://localhost:3010/planet?_page=${page}&_limit=10`, {observe: 'response'});
  }
 
  searchPlanets(search: String, page: number) {
    return this.httpClient.get<Planet[]>(`http://localhost:3010/planet?q=${search}&_page=${page}&_limit=10`, {observe: 'response'});
  }
}

Ce service va permettre de communiquer avec l’api Star Wars mockée en local et va nous renvoyer la liste des planètes de la célèbre série de films. Ce service contient deux méthodes, la première nous donne accès à la liste des planètes et la seconde nous permet d’effectuer une recherche dans la liste des planètes.

Angular utilise le langage TypeScript et par conséquent, la plupart des variables doivent être typées. Même si nous n’utilisons pas le mode strict, nous allons créer un fichier d’interface pour typer certains éléments de notre code.

ng g interface interfaces/planets --type=interface

Une fois le fichier créé, nous allons le modifier avec le code suivant :

// src/interfaces/planets.interface.ts
export interface Planet {
  id?: String;
  climate: String;
  surface_water: String;
  name: String;
  diameter: String;
  rotation_period: String;
  terrain: String;
  gravity: String;
  orbital_period: String;
  population: String;
}

De plus, pour que le service fonctionne correctement, il faut ajouter le module HttpClientModule d’Angular au niveau du fichier app.module.ts.

// app.module.ts
...
import { HttpClientModule } from '@angular/common/http';
...
@NgModule({
    ...
    imports: [
        BrowserModule,
        HttpClientModule
    ],
    ...
})
...

On relance la commande npm run start, il ne doit plus y avoir d’erreur au niveau de la compilation.

Créer les composants

Comme pour l’application micro-frontend réalisée avec VueJS, notre application Angular pourrait ne comporter qu’un seul composant. Mais là encore ce n’est pas la philosophie du framework.
Notre application va donc contenir un composant dit container et 3 composants de présentation : 

  • Une recherche
  • La liste des planètes
  • Une pagination

Le composant de recherche

De la même manière que pour la création du service, nous allons créer les composants en utilisant le CLI.

ng generate component components/search

Une fois le composant créé, nous allons modifier les 3 fichiers qui le composent; à savoir le template HTML, le fichier TypeScript et le fichier de style SCSS.

<!-- search.component.html -->
<div class="sw-search">
  <input type="text" [(ngModel)]="searchValue" />
  <button class="btn-clear" *ngIf="searchValue !== ''" (click)="handleClear()">
    clear
  </button>
  <button (click)="handleSearch()">
    Search
  </button>
</div>
// search.component.scss
.sw-search {
  display: flex;
  flex-direction: row;
  width: 100%;
 
  button {
    min-width: 6rem;
    margin-left: 1rem;
    border: 1px solid rgba(darkred, 0.7);
    background: darkred;
    color: #fff;
 
    &.btn-clear {
      border: 1px solid rgba(grey, 0.7);
      background: lightgrey;
      color: grey;
    }
  }
 
  input {
    width: 100%;
    height: 35px;
    padding-left: 1rem;
    border: 1px solid #eee;
    background: #fafafa;
    font-size: 1.125rem;
  }
}
// search.component.ts
import { Component, EventEmitter, Output } from '@angular/core';
 
@Component({
  selector: 'app-search',
  templateUrl: './search.component.html',
  styleUrls: ['./search.component.scss']
})
export class SearchComponent {
  @Output() search = new EventEmitter<string>()
  searchValue: string = '';
 
  handleSearch() {
    this.search.emit(this.searchValue)
  }
 
  handleClear() {
    this.searchValue = ''
    this.search.emit(this.searchValue)
  }
}

Nous n’allons pas ici détailler la structure Angular du composant mais il contient une variable searchValue liée à l’input du template via le ngModel afin de récupérer la valeur entrée par l’utilisateur.
Il contient également deux méthodes :

  • La première qui va émettre un évènement via Output contenant la valeur du champ de recherche, qui sera utilisée dans le composant conteneur pour effectuer une recherche, grâce à un appel API.
  • La seconde qui va permettre de vider le champ de recherche et de remettre à jour la liste des planètes via la même Output.

Pour que ce composant fonctionne correctement, il faut importer le module de gestion des formulaires d’Angular afin de reconnaitre la propriété ngModel. Pour cela, nous allons donc déclarer le module FormsModule au niveau du fichier app.module.ts.

// app.module.ts
...
import { FormsModule } from '@angular/forms';
...
@NgModule({
    ...
    imports: [
        BrowserModule,
        HttpClientModule,
        FormsModule
    ],
    ...
})
...

Le composant affichant la liste des planètes

On génère le composant avec le CLI.

ng generate component components/planetTile

Puis nous allons modifier le template, le style et le fichier TypeScript.

<!-- planet-tile.component.html -->
<li>
  <span>{{ planet.name }}</span>
  <a class="btn btn-primary">Voir</a>
</li>
// planet-tile.component.scss
li {
  list-style: none;
  display: flex;
  flex-direction: row;
  align-items: center;
  justify-content: space-between;
  padding: 1rem 2rem;
  border-bottom: 1px solid #efefef;
 
  span {
    font-size: 1.2rem;
    font-weight: 500;
  }
 
  .btn {
    background: firebrick;
    color: white;
    padding: 0.25rem 0.75rem;
    border-radius: 0.5rem;
    border: 1px solid firebrick
  }
}
// planet-tile.component.ts
import { Component, Input, ChangeDetectionStrategy } from '@angular/core';
import { Planet } from '../../interfaces/planets.interface';

@Component({
  selector: 'app-planet-tile',
  templateUrl: './planet-tile.component.html',
  styleUrls: ['./planet-tile.component.scss'],
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class PlanetTileComponent {
  @Input() planet: Planet;
}

Ce composant sert à afficher le nom de chacune des planètes et un bouton pour éventuellement accéder à la page de la planète. Il ne contient qu’une props (Input en Angular) correspondant à l’objet JSON de la planète.

Afin d’utiliser au mieux le langage TypeScript, nous avons typé la props en entrée de ce composant.

Le composant de pagination

Cette fois encore, nous allons générer le composant via le CLI.

ng generate component components/pagination

puis modifier les 3 fichiers :

<!-- pagination.component.html -->
<div class="pagination-wrapper">
  <ul>
    <li [ngClass]="{'current': item.current}" *ngFor="let item of pages" (click)="handleClick(item.id)">
      <span>{{ item.text }}</span>
    </li>
  </ul>
</div>
// pagination.component.scss
.pagination-wrapper {
  display: flex;
  flex-direction: row-reverse;
  padding: 1rem 0;
 
  ul {
    display:flex;
    margin: 0;
    padding: 0;
    flex-direction: row;
    align-items: center;
 
    li {
      list-style: none;
      padding: 0.25rem .5rem;
      margin: 0 0.125rem;
      border: 1px solid #fff;
      cursor: pointer;
 
      &:hover {
        color: #fff;
        background: rgba($color: #FF2200, $alpha: 0.5);
        border: 1px solid rgba($color: #FF2200, $alpha: 0.5);
      }
 
      &.current {
        border: 1px solid darkred;
        cursor: default;
        color: #fff;
        background: darkred;
      }
    }
  }
}
// pagination.component.ts
import { Component, OnInit, Input, Output, EventEmitter, OnChanges, SimpleChanges } from '@angular/core';
 
@Component({
  selector: 'app-pagination',
  templateUrl: './pagination.component.html',
  styleUrls: ['./pagination.component.scss']
})
export class PaginationComponent implements OnInit, OnChanges {
  @Input() pageNumber: number
  @Input() currentPage: string
  @Output() pageChange = new EventEmitter<string>()
  pages = []
 
  constructor() { }
 
  ngOnInit(): void {
    this.createPagination()
  }
 
  ngOnChanges(changes: SimpleChanges) {
    this.createPagination()
  }
 
  createPagination() {
    this.pages = [];
    for(let i = 1; i <= this.pageNumber; i++) {
      this.pages = [...this.pages, {id: i, text: i, current: i === parseInt(this.currentPage) }]
    }
  }
 
  handleClick(pageIndex) {
    this.pageChange.emit(pageIndex)
  }
 
}

Ce composant contient plusieurs éléments :

  • Deux props, l’une pour définir la page courante current qui est propre au composant de la pagination et la seconde pageNumber qui vient du conteneur pour définir le nombre total de pages que contient la pagination.
  • Deux méthodes, la première createPagination pour créer la pagination à partir du nombre total de page et la seconde handleClick qui va émettre un évènement vers le conteneur pour savoir sur quel élément de la pagination l’utilisateur a cliqué.

Le composant conteneur

Pour ne pas changer, nous allons utiliser le CLI pour générer le composant conteneur et modifier les fichiers.

ng generate component core/planets
<!-- planets.component.html -->
<h2>{{title}}</h2>
<div class="sw-bloc">
  <app-search (search)="onSearch($event)"></app-search>
  <div class="sw-list">
    <ul class="list">
      <app-planet-tile *ngFor="let item of planets" [planet]="item"></app-planet-tile>
    </ul>
  </div>
  <app-pagination [pageNumber]="pageNumber" [currentPage]="currentPage" (pageChange)="onPageChange($event)"></app-pagination>
</div>
// planets.component.scss
.sw-bloc {
  padding: 0rem 1rem;
}
 
.sw-list {
  flex: 1;
 
  ul {
    margin: 0;
    padding: 0;
  }
}
// planets.component.ts
import { Component, Input, OnInit } from '@angular/core'
import { ApiService } from 'src/app/services/api.service'

import { take } from 'rxjs/operators'

@Component({
  selector: 'app-planets',
  templateUrl: './planets.component.html',
  styleUrls: ['./planets.component.scss']
})
export class PlanetsComponent implements OnInit {
  @Input() title: string = 'Liste des planètes'
  planets: any[]
  pageNumber: number
  isSearch: boolean
  search: string
  currentPage: string
  resultPerPage: number = 10

  constructor(private apiService: ApiService) { }

  ngOnInit(): void {
    this.isSearch = false
    this.search = ''
    this.currentPage = '1'
    this.fetchPlanets()
  }

  fetchPlanets(page = '1') {
    this.apiService.getPlanets(page)
      .pipe(
        take(1)
      )
      .subscribe(data => {
        this.planets = data.results;
        this.pageNumber = data.total_pages;
      })
  }

  onSearch(event: string) {
    if(event !== '') {
      this.isSearch = true
      this.search = event
      this.apiService.searchPlanets(this.search, parseInt(this.currentPage)).subscribe(response => {
        this.planets = response.body
        this.pageNumber = Math.round(parseInt(response.headers.get('X-Total-Count')) / this.resultPerPage)
      })
    } else {
      this.isSearch = false
      this.search = ''
      this.fetchPlanets()
    }
  }

  onPageChange(event: string) {
    this.currentPage = event
    if(!this.isSearch) {
      this.fetchPlanets(event)
    } else {
      this.onSearch(this.search)
    }
  }

}

Le conteneur qui sera aussi le corps de notre application micro-frontend va contenir plusieurs éléments:

  • Un Input pour définir le titre affiché dans l’application (par défaut “Liste des planètes”).
  • 4 props internes au composant, avec la définition par défaut de la page courante, du nombre de pages, de la liste des planètes et de l’état de recherche.
  • 3 méthodes, une première fetchPlanets qui va faire un appel API pour aller chercher la liste des planètes, la seconde onPageChange qui va gérer le changement de page lors d’un clic sur la pagination et la troisième onSearch qui va gérer la recherche.
  • La méthode ngOnInit ( cf. cycle de vie) va appeler la méthode fetchPlanets pour charger la liste des planètes

Nous allons maintenant déclarer notre composant dans la page par défaut de l’application Angular. Pour cela on remplace une partie du contenu du fichier app.component.html :

<-- app.component.html -->
...
    <!-- Resources -->
    <h2>SW Planet</h2>
 
    <app-planets></app-planets>
...

Générer le WebComponent

Avant de compiler notre application, nous allons vérifier que celle-ci fonctionne. Dans votre navigateur vous devez obtenir ceci :

Application micro-frontend avec Angular

L’application fonctionne correctement. Nous allons maintenant packager cette application en WebComponent.

Contrairement à VueJS, le CLI d’Angular ne nous permet pas directement d’exporter notre composant en WebComponent. Il faut faire quelques manipulations pour générer notre WebComponent.

Nous allons dans un premier temps ajouter la librairie Elements (https://angular.io/api/elements) pour pouvoir transformer notre composant en Angular Elements.

ng add @angular/elements

Puis, nous allons modifier le fichier app.module.ts afin de déclarer notre composant comme un “Elements”.

// app.module.ts
import { BrowserModule } from '@angular/platform-browser';
import { Injector, NgModule } from '@angular/core';
import { HttpClientModule } from '@angular/common/http';
import { FormsModule } from '@angular/forms';
import { createCustomElement } from '@angular/elements';
 
import { AppComponent } from './app.component';
import { PlanetsComponent } from './core/planets/characters.component';
import { PlanetTileComponent } from './components/planet-tile/planet-tile.component';
import { SearchComponent } from './components/search/search.component';
import { PaginationComponent } from './components/pagination/pagination.component';
 
@NgModule({
  declarations: [
    AppComponent,
    PlanetsComponent,
    PlanetTileComponent,
    SearchComponent,
    PaginationComponent
  ],
  imports: [
    BrowserModule,
    HttpClientModule,
    FormsModule
  ],
  providers: [],
  bootstrap: [AppComponent, PlanetsComponent]
})
export class AppModule {
  constructor(private injector: Injector) {
    const el = createCustomElement(PlanetsComponent, {injector});
    customElements.define('sw-planets', el);
  }
 
  ngDoBootstrap() {}
}

Nous utilisons la méthode createCustomElement de la librairie @angular/elements pour dire à Angular que ce composant va être exporté en customElement autrement dit en WebComponent. On définira également le tag HTML de notre custom élément, ici sw-planets.

Pour packager notre composant, il ne nous reste plus qu’à exécuter la commande suivante dans le terminal :

ng build --prod --output-hashing none
Compilation du composant Angular en WebComponent

Utiliser le composant micro-frontend

Le WebComponent est maintenant packagé en version minifiée “Ready for Production”. Il nous reste maintenant à l’utiliser dans une page HTML classique.

Dans votre projet, créez un dossier sample. Dans ce dossier, créez un fichier index.html et copiez les 3 fichiers du WebComponent (runtime.js, polyfill.js, main.js) présents dans le dossier dist.

<!-- sample/index.html -->
<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>SW Planets Web Component</title>
    <base href="/">
  </head>
  <body>
    <sw-planets></sw-planets>
 
    <script type="text/javascript" src="runtime.js"></script>
    <script type="text/javascript" src="polyfills.js"></script>
    <script type="text/javascript" src="main.js"></script>
  </body>
</html>

Dans ce fichier on appelle les scripts de notre WebComponent juste avant la fermeture de la balise </body> et on inclut notre WebComponent dans le corps de notre page HTML à la ligne 11.
Contrairement à VueJS, nous n’avons pas besoin de faire appel a un runtime tierce. Celui d’Angular est inclus dans les 3 fichiers du WebComponent (le fichier runtime.js).

Pour voir le résultat, il suffit d’exposer avec un serveur HTTP le dossier sample. (ex: HTTP-Server => https://github.com/http-party/http-server#readme)

Performance

Notre application micro-frontend fonctionne correctement. Nous pouvons l’intégrer dans une page HTML ou dans n’importe quel framework frontend.

Côté performance, notre application est relativement simple et comporte peu de code. Malgré cela, notre WebComponent pèse au total 255kB ce qui est assez conséquent. Mais contrairement a VueJS, il inclut le runtime d’Angular.

Analyse du poids de notre application micro-frontend

Une fois minifié et en utilisant la compression GZip, notre WebComponent fait une bonne cure d’amaigrissement et ne pèse plus que 77.8kB.

Conclusion

La réalisation d’applications micro-frontend avec Angular est relativement facile. Le CLI permet de mettre en place un environnement de développement efficace et avec juste quelques manipulations supplémentaires de créer un WebComponent.

Avec un WebComponent buildé et compressé pesant moins de 78kB, Angular permet de créer une application micro-frontend intégrable dans n’importe quelle page Web sans trop l’alourdir ou plomber les performances.

Dans le prochain article, nous allons passer au crible une autre librairie phare du développement front-end… React.

Liste des articles liés :