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 courantecurrent
qui est propre au composant de la pagination et la secondepageNumber
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 secondehandleClick
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 secondeonPageChange
qui va gérer le changement de page lors d’un clic sur la pagination et la troisièmeonSearch
qui va gérer la recherche. - La méthode
ngOnInit
( cf. cycle de vie) va appeler la méthodefetchPlanets
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 :
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
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.
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 :