Server Side rendering avec Angular universal 8

14/11/19 dannyEnglish Version

Qu’allons nous faire ?

Nous allons appliquer le Server Side Rendering dans notre Application Web.
Nous utiliserons pour cela le framework javascript Angular version 8.2.14

Il s'agit de l'étape 5 de notre guide Angular qui nous permettra d'obtenir une Application Web de type PWA.
Le projet Angular de base que nous utiliserons dispose déjà des caractéristiques suivantes

  • Généré avec Angular CLI
  • Le Routing
  • Le Lazy Loading
  • Le framework CSS Bootstrap

Tous les sources utilisés sont indiqués en fin de tutoriel.

L' application est à l'adresse suivante 


Avant de commencer

Pour être visité par un grand nombre d'utilisateurs, un site web se doit de remplir deux conditions essentielles.

  • S'afficher le plus rapidement possible.
  • Etre bien référencé par les moteurs de recherche.

La technique qui permet de le faire porte un nom.

  • Le Rendu Côté Serveur ou Server Side Rendering en anglais.


Nous allons appliquer cette technique dans un projet Angular.
Pour cela nous emploierons la technologie préconisée par les équipes de Google 

  • Angular Universal.

Cette technologie permettra d'améliorer le référencement naturel ou SEO (Search Engine Optimization) de notre site.


Théorie

Les pages web générées avec des framework javascript utilise javascript.
Les moteurs de recherche ont à l'heure actuelle du mal à interpréter le javascript.

Nous allons vérifier cette notion de façon pratique.

Nous allons éxécuter notre application avec le script correspondant.

# Exécution de l'application
npm run start

# Affichage du site dans le navigateur
http://localhost:4200/

Nous allons vérifier le code source produit dans la page correspondante.
En utilsant le navigateur Chrome il faut taper Ctrl + U pour voir le code html.

On remarque que le code "home works!" qui s'affiche dans le navigateur n'apparait pas dans le code.

<!doctype html>
<html lang="en">

<head>
    <meta charset="utf-8">
    <title>AngularStarter</title>
    <base href="/">

    <meta name="viewport" content="width=device-width, initial-scale=1">
    <link rel="icon" type="image/x-icon" href="favicon.ico">
</head>

<body>
    <app-root></app-root>
    <script src="runtime.js" type="module"></script>
    <script src="polyfills.js" type="module"></script>
    <script src="styles.js" type="module"></script>
    <script src="scripts.js" defer></script>
    <script src="vendor.js" type="module"></script>
    <script src="main.js" type="module"></script>
</body>

</html>

En cliquant sur main.js nous ouvrons ce fichier qui contient  le texte "home works!"

Exécutons une compilation avec npm run build.
Le répertoire dist/angular-starter contient les fichiers main-es5.js et main-es2015.js.
Ces fichiers permettent de générer le fichier main.js.
Ouvrons ces fichier avec notre éditeur VS code , puis effectuons une recherche (Ctrl + F) du texte "home works!".

Ce fichier est un fichier javascript, il sera donc mal interprété par les moteurs de recherche.

Nous verrons plus loin dans ce tutoriel qu'une fois le SSR appliqué le code apparait directement dans le code HTML et sera ainsi bien interprété par les moteurs de recherche.


Installation

L'outil que nous utiliserons pour appliquer le SSR à notre projet est

  • Angular universal version 8.1.1

La dernière version de cet outil est disponible ci-dessous


Angular Universal permet de générer des pages statiques via un processus appelé Server side rendering (SSR).

La procédure à suivre est détaillée sur le site officiel d'Angular.
https://angular.io/guide/universal

Nous allons utiliser une simple commande CLI

# Installation
ng add @nguniversal/express-engine --clientProject angular-starter

Angular universal

Pour rappel angular CLI utilise via la directive ng add le principe des schematics pour modifier notre code et l'adapter à la nouvelle fonctionnalité (ici le ssr).

De nombreuses opérations ont été effectuées automatiquement sur notre projet.

Si nous avions dû réaliser cette opération manuellement voici les différentes étapes que nous aurions dû suivre.

  • Installation des nouvelles dépendances nécessaires
  • Modification du fichier main.ts
  • Modification du fichier app.module.ts
  • Modification du fichier angular.json
  • Création du fichier src/app/app.server.module.ts
  • Création du fichier src/main.server.ts
  • Création du fichier server.ts
  • Création du fichier tsconfig.server.json
  • Création du fichier webpack.server.config.js
  • Modification du fichier angular.json
  • Modification du fichier package.json

Installation des dépendances.


# Installer les nouvelles dépendances dans package.json
npm install --save @angular/platform-server 
npm install --save @nguniversal/express-engine
npm install --save @nguniversal/module-map-ngfactory-loader 
npm install --save express
npm install --save ts-loader 
npm install --save webpack-cli
src/main.ts
import { enableProdMode } from '@angular/core';
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';

import { AppModule } from './app/app.module';
import { environment } from './environments/environment';

if (environment.production) {
  enableProdMode();
}

document.addEventListener('DOMContentLoaded', () => {
  platformBrowserDynamic().bootstrapModule(AppModule)
  .catch(err => console.error(err));
});

Modification du fichier app.module.ts
Nous allons rajouter la valeur appId pour identifier l'application.

src/app/app.module.ts
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';

import { AppComponent } from './app.component';
import { HomeComponent } from './modules/general/home/home.component';
import { NotFoundComponent } from './modules/general/not-found/not-found.component';
import { AppRoutingModule } from './app-routing.module';

@NgModule({
  declarations: [
    AppComponent,
    HomeComponent,
    NotFoundComponent
  ],
  imports: [
    BrowserModule.withServerTransition({ appId: 'angular-starter' }),
    AppRoutingModule,
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

Modification du fichier angular.json.
Modification de outputPath

  • dist/browser à la place de dist/angular-starter.
angular.json
"builder": "@angular-devkit/build-angular:browser",
"options": {
  "outputPath": "dist/browser",
  "index": "src/index.html",
  "main": "src/main.ts",
  "polyfills": "src/polyfills.ts",
  "tsConfig": "tsconfig.app.json",
  "aot": false,
  "assets": [
    "src/favicon.ico",
    "src/assets"
  ],
  "styles": [
    "src/styles.css",
    "node_modules/@fortawesome/fontawesome-free/css/all.min.css",
    "node_modules/bootstrap/dist/css/bootstrap.min.css"
  ],
  "scripts": [
    "node_modules/jquery/dist/jquery.min.js",
    "node_modules/bootstrap/dist/js/bootstrap.min.js",
    "src/assets/params/js/index.js"
  ]
},

Création du fichier app.server.module.ts
Remarque
Avec certaine versions 8 d'Angular le fichier généré peut être incomplet, nous le modifierons plus loin dans ce tutoriel.


La version 8.2.14 fonctionne parfaitement.

src/app/app.server.module.ts
import { NgModule } from '@angular/core';
import { ServerModule } from '@angular/platform-server';

import { AppModule } from './app.module';
import { AppComponent } from './app.component';
import { ModuleMapLoaderModule } from '@nguniversal/module-map-ngfactory-loader';

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

Création du fichier main.server.ts
Nous modifierons le code généré automatiquement pour que le test lint fonctionne correctement.

src/main.server.ts
import { enableProdMode } from '@angular/core';

import { environment } from './environments/environment';

if (environment.production) {
  enableProdMode();
}

export { AppServerModule } from './app/app.server.module';
export { ngExpressEngine } from '@nguniversal/express-engine';
export { provideModuleMap } from '@nguniversal/module-map-ngfactory-loader';

Création du fichier server.ts

Le port utilisé par défaut est 4000 nous pouvons le changer si nécessaire dans ce fichier.

server.ts
/**
 * *** NOTE ON IMPORTING FROM ANGULAR AND NGUNIVERSAL IN THIS FILE ***
 *
 * If your application uses third-party dependencies, you'll need to
 * either use Webpack or the Angular CLI's `bundleDependencies` feature
 * in order to adequately package them for use on the server without a
 * node_modules directory.
 *
 * However, due to the nature of the CLI's `bundleDependencies`, importing
 * Angular in this file will create a different instance of Angular than
 * the version in the compiled application code. This leads to unavoidable
 * conflicts. Therefore, please do not explicitly import from @angular or
 * @nguniversal in this file. You can export any needed resources
 * from your application's main.server.ts file, as seen below with the
 * import for `ngExpressEngine`.
 */

import 'zone.js/dist/zone-node';

import * as express from 'express';
import {join} from 'path';

// Express server
const app = express();

const PORT = process.env.PORT || 4000;
const DIST_FOLDER = join(process.cwd(), 'dist/browser');

// * NOTE :: leave this as require() since this file is built Dynamically from webpack
const {AppServerModuleNgFactory, LAZY_MODULE_MAP, ngExpressEngine, provideModuleMap} = require('./dist/server/main');

// Our Universal express-engine (found @ https://github.com/angular/universal/tree/master/modules/express-engine)
app.engine('html', ngExpressEngine({
  bootstrap: AppServerModuleNgFactory,
  providers: [
    provideModuleMap(LAZY_MODULE_MAP)
  ]
}));

app.set('view engine', 'html');
app.set('views', DIST_FOLDER);

// Example Express Rest API endpoints
// app.get('/api/**', (req, res) => { });
// Serve static files from /browser
app.get('*.*', express.static(DIST_FOLDER, {
  maxAge: '1y'
}));

// All regular routes use the Universal engine
app.get('*', (req, res) => {
  res.render('index', { req });
});

// Start up the Node server
app.listen(PORT, () => {
  console.log(`Node Express server listening on http://localhost:${PORT}`);
});

Création des fichiers

  • tsconfig.server.json
  • webpack.server.config.js
tsconfig.server.json
{
  "extends": "./tsconfig.app.json",
  "compilerOptions": {
    "outDir": "./out-tsc/app-server",
    "module": "commonjs"
  },
  "files": [
    "src/main.server.ts"
  ],
  "angularCompilerOptions": {
    "entryModule": "./src/app/app.server.module#AppServerModule"
  }
}
webpack.server.config.js
// Work around for https://github.com/angular/angular-cli/issues/7200

const path = require('path');
const webpack = require('webpack');

module.exports = {
  mode: 'none',
  entry: {
    // This is our Express server for Dynamic universal
    server: './server.ts'
  },
  externals: {
    './dist/server/main': 'require("./server/main")'
  },
  target: 'node',
  resolve: { extensions: ['.ts', '.js'] },
  optimization: {
    minimize: false
  },
  output: {
    // Puts the output at the root of the dist folder
    path: path.join(__dirname, 'dist'),
    filename: '[name].js'
  },
  module: {
    noParse: /polyfills-.*\.js/,
    rules: [
      { test: /\.ts$/, loader: 'ts-loader' },
      {
        // Mark files inside `@angular/core` as using SystemJS style dynamic imports.
        // Removing this will cause deprecation warnings to appear.
        test: /(\\|\/)@angular(\\|\/)core(\\|\/).+\.js$/,
        parser: { system: true },
      },
    ]
  },
  plugins: [
    new webpack.ContextReplacementPlugin(
      // fixes WARNING Critical dependency: the request of a dependency is an expression
      /(.+)?angular(\\|\/)core(.+)?/,
      path.join(__dirname, 'src'), // location of your src
      {} // a map of your routes
    ),
    new webpack.ContextReplacementPlugin(
      // fixes WARNING Critical dependency: the request of a dependency is an expression
      /(.+)?express(\\|\/)(.+)?/,
      path.join(__dirname, 'src'),
      {}
    )
  ]
};

Modifications du fichier angular.json

La propriété "server" est rajoutée après la propriété e2e.

angular.json
        "e2e": {
          "builder": "@angular-devkit/build-angular:protractor",
          "options": {
            "protractorConfig": "e2e/protractor.conf.js",
            "devServerTarget": "angular-starter:serve"
          },
          "configurations": {
            "production": {
              "devServerTarget": "angular-starter:serve:production"
            }
          }
        },
        "server": {
          "builder": "@angular-devkit/build-angular:server",
          "options": {
            "outputPath": "dist/server",
            "main": "src/main.server.ts",
            "tsConfig": "tsconfig.server.json"
          },
          "configurations": {
            "production": {
              "fileReplacements": [
                {
                  "replace": "src/environments/environment.ts",
                  "with": "src/environments/environment.prod.ts"
                }
              ],
              "sourceMap": false,
              "optimization": {
                "scripts": false,
                "styles": true
              }
            }
          }
        }

Modification du fichier package.json

package.json
"scripts": {
    ...
    "compile:server": "webpack --config webpack.server.config.js --progress --colors",
    "serve:ssr": "node dist/server",
    "build:ssr": "npm run build:client-and-server-bundles && npm run compile:server",
    "build:client-and-server-bundles": "ng build --prod && ng run angular-starter:server:production --bundleDependencies all"
    ...
}

Erreurs

Si vous utilisez la version 8.2.14 cette erreur n'apparait pas.

La commande que nous avons utilisée pour mettre à jour ce projet était la commande 

  • ng add

Il s'agit d'une commande fournie par Angular cli, elle repose sur l'utilisation des Schematics.

Elle permet de modifier le code suivant des règles précises.
Dans certaines version 8  d'angular cli un élément n'est pas généré automatiquement.
L'utilisation de ModuleMapLoaderModule qui permet de traiter le lazy loading des modules.

Sans ce module des erreurs apparaissent lors de l'éxécution.
Pour vérifier ces erreurs.

  • lancer une compilation (npm run build:ssr)
  • lancer une éxécution (npm run server:ssr)
  • lancer http://localhost:4000/contact ou http://localhost:4000/about
     

Vous verrez apparaitre une erreur
Error: Cannot find module './modules/general/contact/contact.module.ngfactory'

Il suffit de modifier le fichier app.server.module.ts pour résoudre le problème.

src/app/app.srver.module.ts
import { NgModule } from '@angular/core';
import { ServerModule } from '@angular/platform-server';

import { AppModule } from './app.module';
import { AppComponent } from './app.component';
import { ModuleMapLoaderModule } from '@nguniversal/module-map-ngfactory-loader';

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

Mise à jour

Nous pouvons en profiter pour mettre à jour les dépendances du fichier package.json et adapter les descripteurs de version.

Les dépendances suivantes

  • @nguniversal/express-engine
  • @nguniversal/module-map-ngfactory-loader

Peuvent être mises à jour avec les versions

  • 8.1.1 

Le fichier contiendra au final les dépendances suivantes.

  "dependencies": {
    "@angular/animations": "8.2.14",
    "@angular/common": "8.2.14",
    "@angular/compiler": "8.2.14",
    "@angular/core": "8.2.14",
    "@angular/forms": "8.2.14",
    "@angular/platform-browser": "8.2.14",
    "@angular/platform-browser-dynamic": "8.2.14",
    "@angular/platform-server": "8.2.14",
    "@angular/router": "8.2.14",
    "@fortawesome/fontawesome-free": "5.11.2",
    "@nguniversal/express-engine": "8.1.1",
    "@nguniversal/module-map-ngfactory-loader": "8.1.1",
    "bootstrap": "4.3.1",
    "express": "4.17.1",
    "jquery": "3.4.1",
    "rxjs": "6.5.3",
    "tslib": "1.10.0",
    "zone.js": "0.10.2"
  },
  "devDependencies": {
    "@angular-devkit/build-angular": "0.803.19",
    "@angular/cli": "8.3.19",
    "@angular/compiler-cli": "8.2.14",
    "@angular/language-service": "8.2.14",
    "@types/node": "12.12.7",
    "@types/jasmine": "3.4.6",
    "@types/jasminewd2": "2.0.8",
    "codelyzer": "5.2.0",
    "jasmine-core": "3.5.0",
    "jasmine-spec-reporter": "4.2.1",
    "karma": "4.4.1",
    "karma-chrome-launcher": "3.1.0",
    "karma-coverage-istanbul-reporter": "2.1.0",
    "karma-jasmine": "2.0.1",
    "karma-jasmine-html-reporter": "1.4.2",
    "protractor": "5.4.2",
    "ts-loader": "6.2.1",
    "ts-node": "8.5.2",
    "tslint": "5.20.1",
    "typescript": "3.5.3",
    "webpack-cli": "3.3.10"
  }

Conclusion

Il ne reste plus qu'à tester tous les scripts précédents et finaliser par le SSR.

# Développement
npm run start
http://localhost:4200/

# Tests
npm run lint
npm run test
npm run e2e

# AOT Compilation
npm run build

# SSR Compilation
npm run build:ssr
npm run serve:ssr
http://localhost:4000/

Enfin nous allons vérifier le code source produit dans la page correspondante à la compilation SSR..
En utilsant le navigateur Chrome il faut taper Ctrl + U pour voir le code html.

On remarque que le code "Home works!" s'affiche cette fois dans le navigateur.
La page sera dès lors bien interprétée par les moteurs de recherche.


Commentaires 2
User Image
danny
Bonjour,
Merci tout d'abord pour le compliment.

L'application Web développée avec Angular dans cet exemple repose sur le principe des applications
micro service. ( Voir le tutoriel - Créer une application Web complète avec Angular).
Les données proviennent d'une API externe, ici elle est développée arbitrairement avec Node.js.
( Voir le tutoriel - Api rest avec Nodejs, Express et Postgresql).
C'est ce qui fait le charme des micro services, elle pourrait l'être avec Java(spring par exemple) ou PHP (Laravel,Symfony) ou n'importe quel autre langage.
Il suffit que l'API respecte les règles définies dans le cahier des charges.
L'application Front end elle ne change pas.

De multiples exemples en PHP existent sur github, je vous renvoie vers ceux-ci.

Cordialement

User Image
Marx Brou
Salut Danny Ganatan,

Merci de votre initiative pour Application Web avec Angular CLI 8.
Et superbe tuto.
Est-ce possible d'utiliser ce procédé avec PHP au lieu de NodeJs. Si oui, comment dois-je m'y prendre.

merci d'avance!!!
Cordialement


Laissez un commentaire

Votre avis
Cette adresse ne sera pas publiée