Makina Blog
Comment mettre en place Angular Universal
Toutes les étapes détaillées et expliquées. Les pièges à éviter.
Cet article concerne Angular 4, nous avons écrit un article plus complet et adapté à Angular 6.
Ceci fait suite à l'article SEO : indexer une application Javascript où nous exposons les enjeux du rendu serveur de vos applications Angular grâce à Universal. On va s'attarder ici sur les aspects pratiques de l'utilisation d'Universal.
Configuration de l'application JS
Côté client
Si vous avez construit votre projet Angular avec Angular CLI (ce que nous recommandons), vous aurez besoin d'outillage supplémentaire:
npm install ts-loader webpack webpack-node-externals --save-dev
Au niveau du module, vous allez simplement changer la façon d'importer BrowserModule :
@NgModule({ imports: [ BrowserModule.withServerTransition({ appId: 'super-app-universal' }),
On s'assure ainsi que l'application, dans le navigateur, pourra s'initialiser à partir d'un rendu serveur.
Et vous allez créer un nouveau module spécifique pour le mode serveur. Appelons-le src/app.server.ts :
import { ServerModule } from '@angular/platform-server'; import { NgModule } from '@angular/core'; import { AppModule } from './app.module'; import { AppComponent } from './app.component'; @NgModule({ declarations: [], imports: [ ServerModule, AppModule, ], providers: [], bootstrap: [AppComponent] }) export class AppServerModule { }
On importe le module serveur et le module applicatif pour qu'ils partagent le même appId.
Voilà pour ce qui concerne l'application elle-même.
Attention : si vous nommez ce fichier src/app.server.module.ts (comme c'est souvent recommandé dans les articles de blog sur le sujet), pensez à bien préciser le paramètre --module quand vous générez de nouveaux composants avec ng, car sinon ng va trouver plusieurs *.module.ts dans ./src et ne saura pas lequel mettre à jour (l'erreur qu'il renvoie n'est pas très explicite: "SilentError: Multiple module files found").
Côté serveur
Côté serveur, il vous faudra un script TypeScript qui sera compilé en un Javascript lancé par NodeJS, src/server.ts
// src/server.ts import 'reflect-metadata'; import 'zone.js/dist/zone-node'; import { platformServer, renderModuleFactory } from '@angular/platform-server'; import { enableProdMode } from '@angular/core'; import { AppServerModuleNgFactory } from '../dist/ngfactory/src/app/app.server.ngfactory'; import * as express from 'express'; import { readFileSync } from 'fs'; import { join } from 'path'; const PORT = process.env.PORT || 4000; const IP = process.env.IP || '0.0.0.0'; const viewDir = __dirname; enableProdMode(); const app = express(); let template = readFileSync(join(__dirname, '..', 'dist', 'index.html')).toString(); app.engine('html', (_, options, callback) => { const opts = { document: template, url: options.req.url }; renderModuleFactory(AppServerModuleNgFactory, opts) .then(html => callback(null, html)); }); app.set('view engine', 'html'); app.set('views', viewDir); app.get('*.*', express.static(join(__dirname, '..', 'dist'))); app.get('*', (req, res) => { res.render('index', { req }); }); app.listen(PORT, () => { console.log(`listening on http://${IP}:${PORT}!`); });
Au niveau de la compilation, on doit s'assurer qu'on génère bien les ngfactory dans notre build car Node en aura besoin, pour cela, donc tsconfig.json, on ajoute :
"angularCompilerOptions": { "genDir": "./dist/ngfactory", "skipMetadataEmit": true }
et dans src/tsconfig.app.json :
"extends": "../tsconfig.json", "exclude": [ "test.ts", "server.ts", "**/*.spec.ts" ]
Et il vous faut une petite configuration Webpack pour faire le bundling pour Node, webpack.config.js :
// webpack.config.js const path = require('path'); const webpack = require('webpack'); const nodeExternals = require('webpack-node-externals'); module.exports = { entry: { server: './src/server.ts' }, resolve: { extensions: ['.ts', '.js'] }, target: 'node', plugins: [ new webpack.NormalModuleReplacementPlugin(/\.\.\/environments\/environment/, '../environments/environment.prod') ], externals: [nodeExternals({ whitelist: [ /^angular2-schema-form/, /^angular-traversal/, /^ng2-auto-complete/, /^ng2-bootstrap/, /^@plone\/restapi-angular/ ] })], node: { __dirname: false, __filename: false }, output: { path: path.join(__dirname, 'dist'), filename: '[name].js' }, module: { rules: [ { test: /\.ts$/, loader: 'ts-loader' } ] } }
La partie importante ici est ce qu'on met dans les nodeExternals. Cela permet d'exclure du bundling Node les dépendances du projet. En effet, la plupart des packages Angular sont publiés au format ES6, mais Node utilise CommonJS, et donc si on les inclut, on se retrouve avec des erreurs de syntaxes (typiquement: Unexpected token export ou Unexpected token import).
N'oubliez pas non plus la ligne commençant par webpack.NormalModuleReplacementPlugin qui remplace environment par environment.prod. En effet, on n'est pas ici dans le cadre de angular-cli (qui normalement fait ça lui même)
Et pour finir, on ajoute 2 scripts NPM dans votre package.json :
"start": "node dist/server.js", "build": "ng build --prod && ngc && webpack",
Comme on peut le voir, on fait le build Angular CLI normal avec ng, mais aussi la compilation ngc (c'est pour avoir les ngfactory dont on a parlé plus haut).
Pour démarrer votre app sous Universal, il suffit de lancer :
npm start
Pour le côté client, dans une configuration simple il ne sera pas nécessaire de faire des modifications à votre module. Néanmoins, nous vous conseillons d'avoir un module spécifique client, que vous appellerez par exemple AppBrowserModule dans app.browser.module.ts, qui importera le module AppModule comme le fait AppServerModule. Il vous faudra mettre à jour le main.ts pour remplacer les références à AppModule, qui devient en quelque sorte un module abstrait, par AppBrowserModule. Dans AppBrowserModule, vous importerez les modules qui ne devront pas être importés côté serveur.
Un petit point sur les versions
À l'heure actuelle, vous aurez besoin de :
- Angular >= 4.1.2
- Webpack >= 2.2 et < 3.x
- TypeScript >= 2.3.2, <= 2.3.4
- uglify-js < 3.0
Configuration du serveur (Nginx)
Si votre projet de Single Page Application est déjà en production ou en qualification, vous devriez avoir un serveur qui la sert de manière statique. Il vous faut maintenant servir, pour les robots, la version générée dynamiquement.
Vous aurez besoin d'un upstream et d'une location supplémentaire permettant de passer les requêtes au serveur node…
upstream universalnode { server localhost:4000; } location @prerender { proxy_pass http://universalnode }
…et de configurer le fait d'utiliser cette location si vous détectez que la requête est un robot. Ci-dessous, nous donnons la valeur 1 à la variable prerender si l'on détecte que l'on a affaire à un robot suivant le user agent ou la string (attention à tenir cette configuration à jour !)
map $http_user_agent $prerender_via_agent { default 0; "~(?i)googlebot|yahoo|bingbot|baiduspider|yandex|yeti|yodaobot|gigabot|ia_archiver|facebookexternalhit|twitterbot|developers\.google\.com" 1; } map $args $prerender_via_uri { default 0; "~(_escaped_fragment_|prerender)=1" 1; } map "$prerender_via_agent_prerender_via_uri" "$prerender" { default 0; "~1" 1; }
et nous mettons à jour la location des ressources statiques pour préférer la location @prerender dans ce cas (notez l'usage a minima du if)
error_page 430 = @prerender; location / { if ( $prerender = 1 ) { return 430; } try_files $uri$args $uri$args/ /index.html; // }
Il est enfin conseillé de mettre en cache les pages prérendues (par exemple avec un proxy_cache nginx).
Processus de développement
Si vous prévoyez de le mettre en place, il vaut mieux le faire dès le début du projet, et s'assurer régulièrement de son fonctionnement..
Les pièges à éviter
Universal n'est pas capable d'utiliser les objets globaux existants au runtime dans votre navigateur comme document, window, localStorage, etc.
De la même façon, le DOM doit être uniquement manipulé par Angular, et pas de façon externe (jQuery, vanilla javascript…), ce qui force à respecter les bonnes pratiques de développement Angular. Attention donc si votre équipe n'est pas un peu expérimentée sur la solution.
Si votre application se sert de window ou d'autres objets du navigateur (ou, plus probablement, si elle appelle des librairies qui s'en servent), il suffit de tester au préalable si on est dans un contexte serveur ou navigateur. Angular fournit les méthodes isPlatformBrowser et isPlatformServer pour cela :
import { Inject, PLATFORM_ID } from '@angular/core'; import { isPlatformBrowser } from '@angular/common'; class MyService { constructor(@Inject(PLATFORM_ID) private platformId: Object) {} store(key: string, value: string) { if(isPlatformBrowser(this.platformId)) { localStorage.setItem(key, value); } } }
Et ainsi le code est compatible avec Angular Universal.
Si vous utilisez les animations, vous devrez faire attention à les activer côté client seulement. Pour cela, le module d'animations BrowserAnimationsModule sera importé dans le module AppBrowserModule seulement, tandis que dans le module AppServerModule, vous importerez le module NoopAnimationsModule qui mock le module d'animations.
// app.server.js @NgModule({ imports: [ ServerModule, AppModule, NoopAnimationsModule ], bootstrap: [AppComponent] }) export class AppServerModule { }
Votre application fait certainement des requêtes HTTP. Cela est bien géré par universal : il attend que toutes les souscriptions à des requêtes HTTP soient exécutées avant de renvoyer la réponse au client. La seule modification que vous aurez à apporter côté serveur est que les urls de vos requêtes HTTP doivent être absolues.
Notez également qu'il vous faudra réenvisager côté serveur toutes les routines qui font des opérations régulières ou temporisées, avec des opérateurs de type repeatWhen, delay, etc. Elles peuvent ralentir voire bloquer le renvoi de la réponse !
Le serveur ne gèrera pas les cookies. La bonne nouvelle c'est que vous n'en aurez pas expressément besoin. En effet, si vous utilisez Angular pour le SEO vous ne souhaitez pas de toute façon faire le rendu des pages connectées. Si vous utilisez Universal pour tous les utilisateurs, la connection se fera lors du rendu côté client. Pensez juste à activer les services utilisant les cookies, par exemple la stratégie XSRFStrategy, côté client seulement (donc dans AppBrowserModule).
Le server side rendering n'est pas compatible avec le lazy loading des modules. Si vous avez absolument besoin du lazy loading côté front, pour une grosse application notamment, vous pouvez toujours créer deux modules différents pour le même périmètre, l'un lazy loaded pour le front et l'autre eagerly loaded pour le back. Nous pourrons vous présenter cela plus en détail dans un article ultérieur.
Optimisations pour le SEO et les réseaux sociaux
Vous ne vous êtes peut-être pas encore posé la question de gérer le titre ou les méta de la balise HEAD jusqu'ici, mais cela devient sûrement plus utile maintenant qu'on fait du rendu côté serveur (à destination des moteurs de recherche et des réseaux sociaux). cela est très simple à faire, Angular fournit des services pour cela et ils sont parfaitement opérants sous Universal :
import { Meta, Title } from '@angular/platform-browser'; ... constructor( private meta: Meta, private title: Title, ) {} ngOnInit() { this.title.setTitle('Ma page'); this.meta.updateTag({ name: 'description', content: '...et ma description' }); }
Ici on n'a mis que la description, mais le service meta nous permettrait de la même manière de renseigner les attributs de la Twitter Card, les attribut OpenGraph, etc.
Vous serez probablement aussi bien inspirés de mettre à disposition un robots.txt et un sitemap.xml.gz à la racine de votre site.
Le robots.txt peut-être mis directement dans src/, il suffit de le mentionner dans les assets du .angular-cli.json pour qu'il se retrouve dans le build.
Vous pouvez faire la même chose pour le sitemap.xml(.gz), sauf que souvent son contenu est dynamique, donc c'est plus embêtant. Si vous avez la chance d'utiliser un CMS en backend (comme Plone dans notre cas), vous avez un endpoint qui vous renvoie ce fichier, il suffit alors de mettre cette URL dans le robots.txt.
Codes d'erreur HTTP
A priori, vous n'enverrez que des statuts HTTP 200 depuis votre serveur node universal. En effet, votre code front-end est initialement conçu, en cas d'absence de contenu, ou en cas d'erreur, pour afficher simplement un contenu spécifique. Si vous avez besoin de répondre des 404, 410, 500, etc. aux requêtes provenant des bots, vous pouvez injecter votre objet Response express dans votre code angular, avec l'attribut extraProviders, de cette façon (on injecte aussi l'objet request, ici) :
// remplacez dans le fichier server.ts: app.engine('html', (_, options, callback) => { const opts: PlatformOptions = { document: template, url: options.req.url, extraProviders: [ <ValueProvider>{ provide: 'REQUEST', useValue: options.req }, <ValueProvider>{ provide: 'RESPONSE', useValue: options.req.res, }] }; renderModuleFactory(AppServerModuleNgFactory, opts) .then(html => { return callback(null, html) }); });
Dans le composant qui affiche la page 404, vous pourrez faire ceci :
constructor(@Inject(PLATFORM_ID) platformId: Object, @Inject('RESPONSE') response: Object) {}
ngOnInit() { if (!isPlatformBrowser(platformId)) { this.response.statusCode = 404 } }
À vous maintenant
L'avez-vous mis en place ? Avez-vous des retours d'expérience à partager ? Rencontrez-vous des problèmes ? Contactez-nous si vous avez besoin d'aide ou validez son bon fonctionnement en nous demandant un audit technique de référencement !
Actualités en lien
Générer un fichier PMTiles avec Tippecanoe
Exemple de génération et d’affichage d’un jeu de tuiles vectorielles en PMTiles à partir de données publiques.
Publier une documentation VitePress sur Read The Docs
À l'origine, le site de documentation Read The Docs n'acceptait que les documentations Sphinx ou MKDocs. Depuis peu, le site laisse les mains libres pour builder sa documentation avec l'outil de son choix. Voici un exemple avec VitePress.
Créer une application en tant que composant web avec Stencil
Mise en place dans le cadre de Geotrek, cette solution permet de se passer d'une iFrame pour afficher une application dans n'importe quel site.