Comment créer une application Angular avec le rendu côté serveur ?

Gestion du rendu côté serveur : Une nouveauté Angular imposant un défi

Angular est un framework utilisant le langage de programmation TypeScript. La version 5 a été mise en ligne en novembre 2017, avec de nouvelles fonctionnalités et corrections de bugs. Cette dernière version est accompagnée de l’outil en ligne de commande Angular CLI, mais aussi de nouveautés  attendues et réellement plébiscitées par la communauté, comme la gestion du rendu côté serveur.

Un des problèmes récurrents que l’on peut rencontrer lorsque l’on met en place un site web avec une librairie moderne (Angular, React, Vue.js, etc.) est que, de manière générale, les robots ne pourront pas lire votre site. Par conséquent, si vous souhaitez partager votre article sur Facebook, ou simplement être bien référencé sur les moteurs de recherche, la partie risque d’être compliquée (bien que dans le cas de Google, il semblerait que leurs robots soient capable de lire des pages générées par Javascript depuis récemment).

La raison est simple, la plupart des robots ne savent pas à quel moment votre page sera entièrement générée par Javascript. Par conséquent, ils ne seront en mesure de lire qu’une infime partie de votre HTML, à savoir, bien souvent, un petit ‘Loading…’ pas très explicite !

Comment peut-on alors créer une application Angular avec le rendu côté serveur ?

Angular CLI

Pour pallier à ce problème, Angular CLI (l’outil qui aide à la génération d’une application Angular) peut venir à la rescousse. Également, avec la version 5 d’Angular, il est désormais possible de gérer le transfert d’état serveur vers le client. Par exemple, une application qui fait une requête à une API Rest ne fera pas la même requête deux fois (une fois côté serveur, une autre fois côté client) mais transférera le résultat côté serveur au client. Nous allons étudier ces deux cas successivement afin de pouvoir bien démarrer une application avec le rendu côté serveur.

Comment initier le projet Angular?

Démarrons en installant l’outil Angular CLI :
npm i -g @angular/cli

On initialise ensuite notre projet :
ng new ssr-app style=scss # Parce que c'est mieux avec le SCSS

On va dans le répertoire de l’application générée et on lance le serveur de développement :

cd ssr-app
ng serve

Allez sur http://localhost:4200/ et enjoy ! Maintenant, nous devons configurer la gestion du rendu côté serveur.

Comment configurer le rendu côté serveur?

Commençons par installer le package platform-server : npm i -S @angular/platform-server

Dans le fichier .angular-cli.json à la racine du projet, il faut ajouter dans la partie « apps », la configuration suivante :

{
   "name": "universal",
   "platform": "server",
   "root": "src",
   "outDir": "dist-server",
   "main": "main.server.ts",
   "tsconfig": "tsconfig.server.json",
   "environmentSource": "environments/environment.ts",
   "environments": {
      "dev": "environments/environment.ts",
      "prod": "environments/environment.prod.ts"
   }
}

Dans notre dossier src/app, on a désormais besoin d’un fichier app.server.module.ts à côté de notre fichier app.module.ts. Ce sera notre point d’entrée pour notre application côté serveur. Son contenu :

import {NgModule} from '@angular/core';
import {ServerModule} from '@angular/platform-server';

import {AppModule} from './app.module';
import {AppComponent} from './app.component';

@NgModule({
   imports: [
      AppModule,
      ServerModule
   ],
   bootstrap: [AppComponent]
})
export class AppServerModule {
}

Le fichier hérite simplement des modules du fichier app.module.ts et ajoute le module ServerModule.

Quant au fichier main.server.js renseigné dans le .angular-cli.json, nous devons le créer à côté du fichier main.ts dans le répertoire src/ avec le contenu suivant :

import {enableProdMode} from '@angular/core';
export {AppServerModule} from './app/app.server.module';

enableProdMode();

Nous devons également créer un fichier tsconfig.app.server.json pour la compilation de TypeScript à côté du tsconfig.app.json dans le même répertoire :

{
   "extends": "./tsconfig.app.json",
   "compilerOptions": {
      "outDir": "../out-tsc/server",
      "module": "commonjs"
   },
   "exclude": [
      "test.ts",
      "**/*.spec.ts"
   ],
   "angularCompilerOptions": {
      "entryModule": "app/app.server.module#AppServerModule"
   }
}

Dans le répertoire src/app, il nous faut désormais modifier le fichier app.module.ts en ajoutant une méthode à notre module BrowserModule :

import {BrowserModule} from '@angular/platform-browser';
import {NgModule} from '@angular/core';
import {AppComponent} from './app.component';

@NgModule({
   declarations: [
      AppComponent
   ],
   imports: [
      BrowserModule.withServerTransition({ appId: 'universal' })
   ],
   providers: [],
   bootstrap: [AppComponent]
})
export class AppModule {
}

On peut désormais builder notre application côté serveur/côté client. Pour cela, rajoutons une ligne dans les scripts de notre package.json :

"scripts": {
   ...
   "universal": "ng build --prod && ng build --prod --app universal --output-hashing=none",
   ...
}

A quoi sert cette commande ? Tout simplement à générer d’une part, une build côté client et, d’une autre part, une build côté serveur (avec la référence à notre app configurée dans le fichier .angular-cli.json). Pour le côté serveur on enlève la création d’un hash sur le fichier généré main.bundle.js pour pouvoir y faire référence plus tard.

Si on lance la commande npm run universal, on obtient bien nos deux builds dans deux fichiers respectifs dist/ et dist-server/.

Désormais, on souhaite pouvoir lancer notre serveur. Pour l’instant, le package Universal d’Angular ne supporte que Node.js et ASP.NET. Nous allons écrire notre serveur en Node.js.

Il faut tout d’abord installer le package @nguniversal/express-engine qui permettra de bootstraper notre serveur Node.js avec le framework Express. Il suffit donc de lancer la commande npm i -S @nguniversal/express-engine.

On va ensuite écrire un fichier server.js à la racine de notre projet avec ce contenu :

require('zone.js/dist/zone-node');

const express = require('express');
const ngUniversal = require('@nguniversal/express-engine');

/**
* On charge le seul fichier de notre build côté serveur
*/
const bootstrap = require('./dist-server/main.bundle');

/**
* On crée motre instance Express
*/
const app = express();

app.get('/', (req, res) => res.render('index', {req, res}));

/**
* Obtient les fichiers statics provenant de la build dist/
*/
app.use(express.static(`${__dirname}/dist`));

/**
* Configuration de Express Engine
*/
app.engine('html', ngUniversal.ngExpressEngine({
   bootstrap: bootstrap.AppServerModuleNgFactory
}));
app.set('view engine', 'html');
app.set('views', 'dist');

/**
* Toutes les URLs renvoient à l'index.html du répertoire dist/
* Angular se charge se rediriger vers les bonnes routes
*/
app.get('*', (req, res) => res.render('index', {req, res}));

/**
* On lance notre serveur
*/
app.listen(3000, () => console.log(`Listening on http://localhost:3000`));

Champagne ! Nous avons enfin configuré notre application Angular ainsi que notre serveur Node.js.

Lancez le serveur en tapant ‘node server.js’ et accédez à l’URL http://localhost:3000. Votre page est générée côté serveur au lancement, puis prise en charge par la build côté client une fois chargée. Si vous désactivez le Javascript de votre navigateur, miracle ! Votre page est tout de même générée grâce à notre configuration.

Comment transférer l’état de l’application ?

Bien que notre application fonctionne parfaitement avec cette configuration, celle-ci n’est pas complètement optimale.

Si votre application nécessite des requêtes via une API Rest par exemple, celles-ci seront effectuées deux fois au lieu d’une. En effet, si le serveur est sollicité pour effectuer une requête, le navigateur ne le sait pas et effectuera cette même requête une seconde fois lorsque le côté serveur prendra le relai.

Pour l’exemple, nous allons commencer par ajouter le HttpClientModule dans notre app.module.ts et notre app.server.module.ts (l’idéal serait d’avoir un fichier de modules en commun, mais je vous laisse la liberté de le faire !) :

import {HttpClientModule} from '@angular/common/http';

@NgModule({
   ...
   imports: [
      ...
      HttpClientModule
   ],
   ...
})
export class AppModule {
}

Allons dans notre app.component.ts et supprimons le contenu que nous allons remplacer par :

import {Component, OnInit} from '@angular/core';
import {HttpClient} from '@angular/common/http';

@Component({
   selector: 'app-root',
   templateUrl: './app.component.html'
})
export class AppComponent implements OnInit {
   image: any;

   constructor(private http: HttpClient) {
}

ngOnInit() {
   this.http.get('http://www.splashbase.co/api/v1/images/random')
      .subscribe(data => this.image = data);
    }
}

Nous nous chargeons d’afficher une image random récupérée via une API Rest.

Pour le rendu, éditons notre fichier app.component.html que nous remplacerons par cette unique balise :

< img *ngIf="image" [src]="image.url" >

Cette image sera donc le résultat de notre requête à l’API.

Si nous démarrons une build avec npm run universal et que nous lançons notre serveur avec ‘node server.js’, nous avons bien notre page qui fonctionne correctement. Petit bémol : l’image apparaît quelques millisecondes et est remplacée par une autre image.

La raison est le même que précédemment : une requête est faite côté serveur, et une autre est faite côté client une fois la page chargée. Le requête côté client est donc inutile.

L’idée est donc de transférer l’état de notre requête vers le côté client afin de ne pas multiplier les requêtes inutiles.

Pour cela, nous allons importer le ServerTransferStateModule dans notre app.server.module.ts et le BrowserTransferStateModule dans notre app.module.ts :

/* app.server.module.ts */
import {ServerTransferStateModule} from '@angular/platform-server';

@NgModule({
   imports: [
      ServerTransferStateModule /* Ici ! */
   ],
   bootstrap: [AppComponent],
})
export class AppServerModule {}

/* app.module.ts */
import {BrowserTransferStateModule} from '@angular/platform-browser'

@NgModule({
imports: [
   ...
   BrowserTransferStateModule /* Ici ! */
   ],
   bootstrap: [AppComponent],
})
export class AppServerModule {}

Une fois la configuration effectuée, on peut se rendre dans notre fichier app.component.ts. On crée ensuite une clef avant la déclaration de notre Class et on ajoute TransferState dans notre constructeur :

import {makeStateKey, TransferState} from '@angular/platform-browser';

const IMAGE_KEY = makeStateKey('image');

@Component({
   selector: 'app-root',
   templateUrl: './app.component.html'
})
export class AppComponent implements OnInit {
   ...
   constructor(private http: HttpClient,
      private state: TransferState) {
   }
...
}

Ensuite on modifie notre méthode ngOnInit de cette manière :

ngOnInit() {
   const image: any = this.state.get(IMAGE_KEY, null);

   /**
   * Si l'image n'est pac contenue dans notre clef, on effectue la requête
   */
   if (!image) {
      this.http.get('http://www.splashbase.co/api/v1/images/random')
         .subscribe(data =&gt; {
            this.image = data;
            this.state.set(IMAGE_KEY, data as any);
          });
   }

   /**
   * Sinon, on récupère le contenu de notre clef
   */
   else {
      this.image = image;
   }
}

On lance ensuite une build de notre application ainsi que notre serveur :

npm run universal
node server.js

Désormais, le contenu de l’image est transféré du serveur au client et la requête n’est effectuée qu’une fois !

Conclusion

Vous avez maintenant toutes les cartes en mains pour démarrer une application Angular avec le rendu côté serveur configuré.

Il suffit maintenant de faire attention à ne pas appeler des objets disponibles uniquement sur le navigateur (comme window par exemple) sans condition.

Par exemple pour vérifier que l’on est côté client ou non :

import { PLATFORM_ID, Inject } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';

   constructor(
      @Inject(PLATFORM_ID) private platformId: Object,
      @Inject(APP_ID) private appId: string) {
      if (isPlatformBrowser(platformId)) {
         console.log('Je suis appelé côté client !')
      }
    }

Toutes ces astuces simplifieront vos développements Angular avec tous les avantages du rendu côté serveur !