Angular server-side rendering dans Node avec le moteur Express Universal

Publié le

Le projet Universal a commencé il y a longtemps, alors qu’Angular était encore en beta. Après plus d’un an et demi d’attente, il est enfin possible d’activer le pré-rendu serveur dans une vraie application Angular !

Note : le CLI 1.6 fait désormais l’essentiel du travail pour vous, cet article est donc obsolète.

Mais il ne s’agit pas simplement d’une configuration à activer pour l’instant, je vais donc vous expliquer toutes les étapes. Merci à Philippe Martin et son article Creating an Angular Universal app with the Angular CLI, reproduit dans le wiki officiel du CLI. J’ai démarré de là and beaucoup de points y sont déjà expliqués, mais certains points manquent pour appliquer ça à une vraie app, alors c’est parti !

Universal bundle

D’abord, vous aurez besoin d’Angular ≥ 4.3. Si vous n’avez pas encore fait la mise à jour, faites-le maintenant, c’est vraiment simple.

Vous aurez aussi besoin d’Angular CLI ≥ 1.3.0-rc.3 et du module platform-server :

npm install @angular/cli@1.3.0-rc.3 @angular/platform-server

Attention : une mise à jour directe du CLI engendre souvent des problèmes. Si des erreurs surviennent lors du build, supprimez le répertoire node_modules et le package-lock.json, et relancez npm install.

Les étapes qui suivent sont juste du copier/coller, le CLI pourrait les automatiser prochainement.

Premièrement, vous devez ajouter une nouvelle configuration dans votre .angular-cli.json, pour générer le bundle pour le serveur, en complément de celui pour le navigateur :

{
"apps": [
{
/* Keep your actual config here */
},
/* Add this second config */
{
/* Give it a name for easier use */
"name": "universal",
/* Tell the CLI to package for the server */
"platform": "server",
"root": "src",
/* Change the output directory to not mix with browser files, and add it to your .gitignore */
"outDir": "dist-server",
/* Server will use a different entry point */
"main": "main-server.ts",
/* Server will use a different TypeScript config */
"tsconfig": "tsconfig.server.json",
"environmentSource": "environments/environment.ts",
"environments": {
"dev": "environments/environment.ts",
"prod": "environments/environment.prod.ts"
}
/* Delete polyfills, the server does not need them */
/* Other properties like assets, styles, scripts… can be removed,
* they are already generated for the browser, so build will be faster */
}
]
}

Il vous faut ensuite un AppServerModule spécifique, à côté du AppModule normal, qui va surcharger toutes les APIs pour les adapter à un usage côté serveur :

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 {}

Ensuite un nouveau point d’entrée main-server.ts, à côté du main.ts classique :

import { enableProdMode } from '@angular/core';
export { AppServerModule } from './app/app-server.module';
enableProdMode();
view raw main-server.ts hosted with ❤ by GitHub

Et un nouveau tsconfig.server.json, à côté du tsconfig.app.json normal :

{
"extends": "./tsconfig.app.json",
"compilerOptions": {
"outDir": "../out-tsc/server",
/* For now, Node only understand CommonJS modules, so you can't stick to es2015 here */
"module": "commonjs"
},
"exclude": [
"test.ts",
"**/*.spec.ts"
],
/* Additional informations to bootstrap Angular */
"angularCompilerOptions": {
"entryModule": "app/app-server.module#AppServerModule"
}
}

Enfin, un léger changement dans votre AppModule classique :

import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { AppComponent } from './app.component';
@NgModule({
declarations: [
AppComponent
],
imports: [
/* Id just needs to be unique, put what you want */
BrowserModule.withServerTransition({ appId: 'universal' })
],
bootstrap: [AppComponent]
})
export class AppModule {}
view raw app.module.ts hosted with ❤ by GitHub

Pourquoi cette modification ? Car maintenant que le contenu va être pré-généré par le serveur, le navigateur n’a plus besoin de créer l’intégralité de l’application par lui-même : le HTML et le CSS sont déjà là, il n’aura plus qu’à appliquer les bindings et autres fonctionnalités d’Angular pour rendre la page dynamique.

Vous pouvez maintenant mettre à jour votre script npm dans le package.json :

{
"scripts": {
/* Build the browser bundle and the server bundle
* Deactivating output hashing option for the server bundle is important, so its name stays the same */
"build": "ng build –prod && ng build –prod –app universal –output-hashing=none",
}
}
view raw package.json5 hosted with ❤ by GitHub

Et :

npm build

Server-side rendering dans Node

A l’exception de l’option output-hashing, les étapes de build sont déjà expliquées dans le wiki. Mais les explications sur le serveur sont assez légères.

Contraitement au wiki, nous n’utiliserons pas platform-server directement, mais le moteur Universal officiel pour Express :

npm install express @nguniversal/express-engine

Vous pouvez ensuite créer votre fichier server.js :

'use strict';
/* Server specific version of Zone.js */
require('zone.js/dist/zone-node');
const express = require('express');
const ngUniversal = require('@nguniversal/express-engine');
/* The server bundle is loaded here, it's why you don't want a changing hash in it */
const appServer = require('./dist-server/main.bundle');
/* Server-side rendering */
function angularRouter(req, res) {
/* Server-side rendering */
res.render('index', { req, res });
}
const app = express();
/* Root route before static files, or it will serve a static index.html, without pre-rendering */
app.get('/', angularRouter);
/* Serve the static files generated by the CLI (index.html, CSS? JS, assets…) */
app.use(express.static(`${__dirname}/dist`));
/* Configure Angular Express engine */
app.engine('html', ngUniversal.ngExpressEngine({
bootstrap: appServer.AppServerModuleNgFactory
}));
app.set('view engine', 'html');
app.set('views', 'dist');
/* Direct all routes to index.html, where Angular will take care of routing */
app.get('*', angularRouter);
app.listen(3000, () => {
console.log(`Listening on http://localhost:3000`);
});
view raw server.js hosted with ❤ by GitHub

Et lancer :

node server.js

Ta da ! Pour être sûr que le serveur fait son boulot, désactivez JavaScript dans votre navigation, afin de vérifier que quelque chose est bien pré-généré.

Mais il est probable que cela ne fonctionne pas tout à fait. Des points importants doivent être gérés dans une vraie application.

Piège n°1 : être compatible Universal

Pourquoi le projet de pré-rendu serveur s’appelle-t-il Angular ? Car le code Angular peut être utilisé dans n’importe quel contexte, pas seulement dans un navigateur. Votre code JavaScript/TypeScript peut ainsi fonctionner également côté serveur.

Pour cela, vous devez respecter de façon très stricte le principe de rester « platform agnostic ». Cela signifie de ne jamais accéder directement à des APIs spécifiques au navigateur. Cela inclut :

  • window, document… et tous les autres objets du navigateur, ainsi que leur méthodes (comme setTimeout)
  • toutes les APIs du DOM
  • toutes les autres APIs spécifiques au navigateur, comme localStorage, IndexedDB

Les deux premiers ne sont pas un problème : il vous suffit d’utiliser le templating Angular (ou l’API de rendu d’Angular pour des besoins avancés). Mais comment faire si vous avez vraiment besoin de localStorage ou d’une autre API spécifique au navigateur ?

Solution minimale : un try/catch pour rendre l’erreur silencieuse quand le code sera utilisé en dehors du navigateur. Une meilleure solution : Angular vous indique quel est le contexte actuel :

import { Injectable, Inject, PLATFORM_ID } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
@Injectable()
export class LocalStorage {
constructor(@Inject(PLATFORM_ID) protected platformId: Object) {}
setItem(key: string, value: any) {
if (isPlatformBrowser(this.platformId)) {
localStorage.setItem(key, JSON.stringify(value));
}
}
}

Piège n°2 : les requêtes Http

Vous allez découvrir que tout le contenu provenant de requêtes Http n’est pas pré-généré : c’est parce qu’Universal a besoin d’URLs absolues.

Comme votre serveur de développement et celui de production n’ont pas la même URL, c’est contraignant de gérer cela manuellement.

Ma solution pour automatiser cela : utiliser la nouvelle fonctionnalité d’intercepteur d’Angular 4.3, combinée avec le moteur Express.

L’intercepteur attrape toutes les requêtes quand l’application est en contexte serveur, pour y ajouter une URL absolute. Il ressemble à cela (vous pouvez juste copier/coller) :

import { Injectable, Inject, Optional } from '@angular/core';
import { HttpInterceptor, HttpHandler, HttpRequest } from '@angular/common/http';
@Injectable()
export class UniversalInterceptor implements HttpInterceptor {
constructor(@Optional() @Inject('serverUrl') protected serverUrl: string) {}
intercept(req: HttpRequest<any>, next: HttpHandler) {
const serverReq = !this.serverUrl ? req : req.clone({
url: `${this.serverUrl}${req.url}`
});
return next.handle(serverReq);
}
}

Fournissez-le ensuite ainsi dans votre AppServerModule :

import { NgModule } from '@angular/core';
import { ServerModule } from '@angular/platform-server';
import { HTTP_INTERCEPTORS } from '@angular/common/http';
import { AppModule } from './app.module';
import { AppComponent } from './app.component';
import { UniversalInterceptor } from './universal.interceptor';
@NgModule({
imports: [
AppModule,
ServerModule
],
providers: [{
provide: HTTP_INTERCEPTORS,
useClass: UniversalInterceptor,
/* Multi is important or you will delete all the other interceptors */
multi: true
}],
bootstrap: [AppComponent]
})
export class AppServerModule {}

Vous pouvez maintenant utiliser le moteur Express pour passer l’URL absolue à Angular, en mettant à jour votre server.js :

function angularRouter(req, res) {
res.render('index', {
req,
res,
providers: [{
provide: 'serverUrl',
useValue: `${req.protocol}://${req.get('host')}`
}]
});
}
view raw server.js hosted with ❤ by GitHub

Note : vous devez utiliser la nouvelle API HttpClient d’Angular 4.3. Cela ne fonctionnera pas si vous faites vos requêtes avec la précédente API Http.

J’ai ouvert un ticket pour proposer une PR incluant cela directement dans le moteur d’Express, sentez-vous libres de me soutenir.

Piège n°3 : librairies tierces et betas

Dernier problème : tous les modules que vous utilisez dans votre application doivent être compatibles Universal et éviter le premier piège.

Tous les modules Angular4+ stables sont compatibles Universal. Mais méfiez-vous des betas :

  • la dernière beta de Material est compatible Universal (mais attention à importer hammerjs dans main.ts et non ailleurs) ;
  • la beta 8 de FlexLayout n’est pas compatible Universal ; la prochaine beta 9 devrait l’être (PR ici).

Pour les librairies tierces, vous devrez tester par vous-même. La plupart causeront un problème pour l’instant (« Unexpected token import ») à cause d’un problème de format (ticket en cours de résolution).

Conclusion

J’espère vous avoir aidé/e. Comme Angular CLI 1.3 et le moteur Express sont encore en betas, je mettrai à jour cet article au besoin.

Devenez un Pro!

Cet article vous a aidé ? Et vous voulez soutenir mes contributions open source (avec notamment un outil utilisé par 600 000 développeurs) ? Faites-vous une faveur à vous-même : Schematics Pro est un outil d’automatisation de code pour Angular, React, Vue et tous les autres frameworks JavaScript, qui vous fait gagner en productivité et vous aide à vous assurer du suivi des bonnes pratiques.

En savoir plus sur Schematics Pro

Découvrez notre formation Angular, animée par l’auteur de cet article !