Modules JavaScript natifs et isomorphisme avec import, export et require

Isomorphisme

Si vous êtes développeur web, vous devez savoir que pour que l'utilisateur final puisse afficher une page web sur son navigateur via le protocole HTTP, il faut deux choses : un code client, et un code serveur :

  • Dans son plus simple appareil, le code serveur est délivré par un serveur web comme Apache, nginx ou IIS à partir d'un fichier. Dans de nombreux cas, ce n'est pas à partir d'un fichier HTML, mais à partir du résultat créé en analysant du code serveur dans des fichiers PHP, .NET, Python, Ruby, etc. qu'est généré le rendu HTML.

  • Côté client, une fois la page reçue, le HTML sert de base au navigateur pour construire un DOM qui permettra d'afficher le site web. C'est alors le code JavaScript appelé par la page qui permettra de changer le DOM et donc, de faire des interactions à l'écran.

Le développeur à donc deux travaux, développer un code qui fonctionne côté serveur et développer un autre code qui fonctionne côté client (le serveur web étant là passerelle entre client / serveur), d'où la séparation connu des rôles de développeur front-end (partie cliente) et développeur back-end (partie serveur).

Imaginez que l'on puisse, à partir d'exactement le même code, produire du code côté serveur et côté client ! C'est ce que l'on appelle l'isomorphisme. Un code isomorphique est un code qui peut-être exécuté par le serveur et par le client.

Nous allons donc utiliser le sujet de l'isomorphisme comme fil conducteur dans cet article pour traiter :

  • de l'import / export de Modules ECMAScript en version 6,
  • du JavaScript côté serveur avec Node.js,
  • des équivalences ECMAScript version 5 pour le require / export,
  • de l'isomorphisme exploitable pour faire du web avec Vanilla JS et Node.js.

Exécution côté client avec les Modules ECMAScript

Pour commencer, utilisons un navigateur récent (Chrome, Firefox, Edge, Safari…) et faisons des choses très simples. En ECMAScript version 6 (« ES6 ») —qui succède la très populaire ECMAScript version 5 (« ES5 ») progressivement dans tous les navigateurs— il existe une manière de packager et servir du code JavaScript sous forme d'unités de code. Ces unités de code se suffisent à elles-mêmes et peuvent être réutilisées par d'autres unités de code. C'est ce qu'on appelle les Modules ECMAScript. Voici les étapes de mise en place :

  • je crée un module JavaScript grâce au nouveau mot clé réservé du langage export et
  • j'utilise un module JavaScript grâce au nouveau mot clé réservé du langage import.

Architecture

Testons donc cela dans un navigateur à travers l'architecture de fichier suivante :

isomorphism/
├─ javascripts/
│  ├─ operation.js
│  └─ isomorphic.js
└─ es6.htm

Nous allons donc remplir le fichier es6.htm avec le contenu suivant :

es6.htm (code source)

<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="utf-8" />
        <title>ES6 example</title>
    </head>
    <body>
        <section class="main-content">
            <h1>Instructions:</h1>
            <p>Open console with F12.</p>
        </section>
        <!-- Appel des différents fichiers
             à faire exécuter par le
             moteur JavaScript du navigateur. -->
        <script src="javascripts/operation.js"></script>
        <script src="javascripts/isomorphic.js"></script>
    </body>
</html>

Nous allons ensuite nous créer un module JavaScript dans le fichier operation.js :

javascripts/operation.js (code source)

/* Export direct. */
export default function (number) {
    return {
        round: Math.round(number),
        floor: Math.floor(number),
        ceil: Math.ceil(number)
    };
}

/* Export nommé `addition`. */
export function addition(number1, number2) {
    return number1 + number2;
}

/* Export nommé `substraction`. */
export function substraction(number1, number2) {
    return number1 - number2;
}

/* Export nommé `multiplication`. */
export function multiplication(number1, number2) {
    return number1 * number2;
}

/* Export nommé `division`. */
export function division(number1, number2) {
    return number1 / number2;
}

Et nous allons créer le cœur du programme dans un module isomorphic.js qui fera office de contrôleur :

javascripts/isomorphic.js (code source)

/* Récupération du module direct depuis `export default function ()` */
import tools from "./operation.js";

/* Récupération des exports nommés du module avec `export function <name>()` */
import { addition, substraction, multiplication, division } from "./operation.js";

/* Variables à tester. */
var number1 = 13,
    number2 = 7.7;

/* Utilisation des fonctions de nos modules. */
console.log('addition', addition(number1, number2));
console.log('substraction', substraction(number1, number2));
console.log('multiplication', multiplication(number1, number2));
console.log('division', division(number1, number2));
console.log('round', tools(number2).round);
console.log('floor', tools(number2).floor);
console.log('ceil', tools(number2).ceil);

Quelques erreurs

Nous allons donc ouvrir le fichier es6.htm dans le navigateur et ouvrir notre console avec F12.

Les erreurs suivantes sont affichées (dans Chrome) :

Access to Script at 'file:///<path/to/your/workspace>/isomorphism/javascripts/operation.js' from origin 'null' has been blocked by CORS policy: Invalid response. Origin 'null' is therefore not allowed access.
Access to Script at 'file:///<path/to/your/workspace>/isomorphism/javascripts/isomporphic.js' from origin 'null' has been blocked by CORS policy: Invalid response. Origin 'null' is therefore not allowed access.

Cela est dû au fait qu'il vous faut l'autorisation d'utiliser un module depuis un autre nom de domaine que le vôtre à cause du mécanisme de « Cross-origin resource sharing » des navigateurs. Vous allez me dire que vos fichiers .js sont pourtant sur le même serveur web que votre page .htm ? En fait, pour que ce soit le cas, il faudrait que votre page soit sur un serveur web ! Aussi dans l'URL de votre page dans le navigateur, il faudrait que file:///<path/to/your/workspace>/es6.htm soit remplacée, par exemple par http://<your-local-domain-name>/es6.htm. Lançons donc un serveur web.

De mon côté, je vais utiliser Node.js qui une fois installé me donne accès à la commande npm install -g node-atlas, ce qui me permet d'utiliser la commande node-atlas. Celle-ci lance un serveur web basique là où elle est lancée. Vous pouvez tout autant utiliser http-server ou votre propre serveur Apache, etc. pour tester ça.

Une fois le serveur web lancé, en vous rendant à http://<your-local-domain-name>/es6.htm, vous aurez cette fois l'erreur suivante :

Uncaught SyntaxError: Unexpected token export
Uncaught SyntaxError: Unexpected token import

Cela vient du fait que pour utiliser des modules JavaScript, il faut le préciser dans le type de la balise <script>. Notre code HTML précédent devient donc :

es6.htm (code source)

...
        <script src="javascripts/operation.js" type="module"></script>
        <script src="javascripts/isomorphic.js" type="module"></script>
...

Résultat

Cette fois la magie opère ! Vous constaterez dans votre console les sorties suivantes :

addition 20.7
substraction 5.3
multiplication 100.10000000000001
division 1.6883116883116882
round 8
floor 7
ceil 8

Vous pouvez aussi voir ce résultat en live en vous rendant à cette adresse qui se sert d'un serveur web GitHub Pages pour faire fonctionner l'exemple ES6.

Exécution côté serveur des Modules ECMAScript avec Node.js

L'idée ici va être de faire exécuter le fichier isomorphic.js du côté serveur. Il va faire appel à operation.js afin d'obtenir le même résultat que côté client dans votre console. Pour cela nous allons utiliser la commande :

> node ./javascripts/isomorphic.js

depuis le dossier où se situe actuellement es6.htm.

Mais faire cela nous renvoie l'erreur :

SyntaxError: Unexpected token import
...

Fonctionnalité expérimentale

Pour pouvoir exécuter notre fichier isomorphic.js côté serveur, il va falloir utiliser une fonctionnalité expérimentale de Node.js car, à l'heure actuelle, les Modules ECMAScript (« ESM ») ne sont pas supportés par Node.js en standard. En réalité, Node.js a déjà son propre système de chargement de module basé sur une spécification appelée CommonJS. Parce que Node.js a déjà son système d'import, appelé require, le meilleur moyen pour lui de savoir si un fichier doit être interprété en tant que Module ECMAScript ou en tant que module Node.js standard est de vérifier l'extension du fichier. C'est pourquoi un fichier JavaScript écrit sous forme de module ne doit plus avoir l'extension .js mais l'extension .mjs. Dans ce cas, Node.js sait que c'est un Module ECMAScript et utilise le système de chargement de module ESM et non CommonJS.

Nous allons donc dans un premier temps renommer nos fichiers operation.js et isomorphic.js en operation.mjs et isomorphic.mjs :

isomorphism/
├─ javascripts/
│  ├─ operation.mjs
│  └─ isomorphic.mjs
└─ es6.htm

Et puisque le nom a changé, notre fichier isomorphic.mjs va maintenant faire appel à operation.mjs.

javascripts/isomorphic.mjs (code source)

/* Récupération du module direct depuis `export default function ()` */
import tools from "./operation.mjs";

/* Récupération des exports nommés du module avec `export function <name>()` */
import { addition, substraction, multiplication, division } from "./operation.mjs";

...

Résultat

Il est à présent possible d'obtenir le même résultat que côté client avec la commande :

> node --experimental-modules ./javascripts/isomorphic.mjs

Vous obtiendrez alors la sortie :

addition 20.7
substraction 5.3
multiplication 100.10000000000001
division 1.6883116883116882
round 8
floor 7
ceil 8

Et le client ?

Pour finir, afin de toujours rendre opérationnel notre appel depuis http://<your-local-domain-name>/es6.htm, nous allons également changer les chemins vers les nouveaux fichiers operation.mjs et isomorphic.mjs :

...
        <script src="javascripts/operation.mjs" type="module"></script>
        <script src="javascripts/isomorphic.mjs" type="module"></script>
...

Nous avons ici un exemple de fichiers ES6 parfaitement isomorphiques !

Vous pouvez revoir ce résultat en live en vous rendant sur le serveur web GitHub Pages.

Méthodes ES5 pour l'import / export

Comme vous avez pu le constater, la fonctionnalité de Modules ECMAScript est expérimentale côté serveur et pas encore totalement supportée par tous les navigateurs côté client, car elle est introduite avec ES6. La question que l'on peut se poser est la suivante : est-il nécessaire d'utiliser une syntaxe ES6 et un Module ECMAScript pour faire de l'isomorphisme ? La réponse est non. Il est tout à fait possible d'arriver au même résultat en utilisant les modules CommonJS utilisés par Node.js et en mimant ce mécanisme côté client.

Nous allons ajouter les fichiers operation.js et isomorphic.js qui n'utilisent pas la syntaxe de module ES6, et créer un nouveau fichier es5.htm qui utilisera ces fichiers.

isomorphism/
├─ javascripts/
│  ├─ operation.js
│  ├─ operation.mjs
│  ├─ isomorphic.js
│  └─ isomorphic.mjs
├─ es5.htm
└─ es6.htm

Côté serveur

Nous allons par ailleurs dans chacun de ces fichiers utiliser l'export CommonJS de Node.js. Celui-ci fonctionne avec les propriétés module.exports et require.

Nous permettons donc l'export de nos fonctionnalités :

javascripts/operation.js (code source)

/* Export CommonJS de Node.js. */
module.exports = function (number) {
    return {

        /* Export direct. */
        round: Math.round(number),
        floor: Math.floor(number),
        ceil: Math.ceil(number),

        /* Export fonction `addition`. */
        addition: function (number1, number2) {
            return number1 + number2;
        },

        /* Export fonction `substraction`. */
        substraction: function (number1, number2) {
            return number1 - number2;
        },

        /* Export fonction `multiplication`. */
        multiplication: function (number1, number2) {
            return number1 * number2;
        },

        /* Export fonction `division`. */
        division: function (number1, number2) {
            return number1 / number2;
        }
    };
};

Puis nous les exécutons depuis le script d'appel :

javascripts/isomorphic.js (code source)

    /* Variables à tester. */
var number1 = 13,
    number2 = 7.7,

    /* Récupération du module direct depuis `module.exports` */
    tools = require('./operation.js'),
    operation = tools();

/* Utilisation des fonctions de notre import. */
console.log('addition', operation.addition(number1, number2));
console.log('substraction', operation.substraction(number1, number2));
console.log('multiplication', operation.multiplication(number1, number2));
console.log('division', operation.division(number1, number2));
console.log('round', tools(number2).round);
console.log('floor', tools(number2).floor);
console.log('ceil', tools(number2).ceil);

Nous obtenons alors avec la commande :

> node ./javascripts/isomorphic.js

le résultat suivant :

addition 20.7
substraction 5.3
multiplication 100.10000000000001
division 1.6883116883116882
round 8
floor 7
ceil 8

Côté client

Alimentons alors côté client notre fichier es5.htm :

es5.htm (code source)

<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="utf-8" />
        <title>ES5 example</title>
    </head>
    <body>
        <section class="main-content">
            <h1>Instructions:</h1>
            <p>Open console with F12.</p>
        </section>
        <script>var module = {};</script>
        <script src="javascripts/operation.js"></script>
        <script>var require = function () { return module.exports; }</script>
        <script src="javascripts/isomorphic.js"></script>
    </body>
</html>

Ce qui nous permet d'obtenir à l'adresse http://<your-local-domain-name>/es5.htm, en allant dans la console derrière F12 le résultat :

addition 20.7
substraction 5.3
multiplication 100.10000000000001
division 1.6883116883116882
round 8
floor 7
ceil 8

Nous avons alors « presque » à faire à de l'isomorphisme, car nous avons dû ajouter les morceaux de code :

<script>var module = {};</script>

et :

<script>var require = function () { return module.exports; }</script>

pour simuler le comportement de CommonJS côté client.

Vous pouvez aussi voir ce résultat en live en vous rendant à cette adresse qui se sert d'un serveur web GitHub Pages pour faire fonctionner l'exemple ES5.

Isomporphisme exploitable pour un site web avec Vanilla JS et Node.js

Vous aurez probablement remarqué que les trois premières parties de cet article vous font une belle jambe pour faire un site web. Certes, le résultat est exécuté de la même manière avec une commande node (côté serveur) qu'avec un appel depuis une balise <script> mais vous ne pouvez rien en faire. Effectivement, la grande différence entre client et serveur c'est que ce que vous ferez côté client consistera à manipuler le DOM alors que ce que vous ferez côté serveur consistera à générer une réponse HTTP à envoyer au client. On est donc loin des messages à afficher dans la console !

Partie cliente, partie serveur et partie isomorphique

Nous pouvons donc voir assez rapidement que la totalité du code ne pourra pas être isomorphique. Il y aura forcément :

  • coté serveur, du code dédié à faire le pont entre les fichiers et données stockées sur le serveur et leurs envois par réponse HTTP. Ce sera le code uniquement serveur. Et
  • côté client, du code dédié à faire le pont entre ce que l'on récupère en source HTML ou par requête XMLHttpRequest et le DOM. Ce sera le code uniquement client.

Cependant, hormis ces mécanismes, la totalité du code restant pourra être utilisée aussi bien pour générer côté serveur la réponse HTTP dont va se servir le client pour générer son DOM lors du premier affichage, que pour hydrater le code côté client ou générer toutes les nouvelles pages visitées sans solliciter le serveur.

C'est à cette condition que nous pourrons réellement estimer que l'on développe une application isomorphique.

Voyons cela par l'exemple côté navigateur sans bibliothèque avec Vanilla JS et côté serveur avec Node.js !

Le serveur HTTP

Nous allons donc créer un serveur Node.js dans le fichier server.js en utilisant l'API HTTP native de Node.js ainsi que le module communautaire JSDOM permettant de manipuler virtuellement le DOM côté serveur afin d'exploiter du code isomorphique. Nous aurions pu utiliser Express ou NodeAtlas pour faire cela avec facilité, mais ce sera un bon exercice de compréhension complète de A à Z sans zones d'ombres.

Partons de la structure actuelle à laquelle nous allons rajouter notre fichier server.js pour développer le code serveur non isomorphique servant les fichiers demandés au client ainsi que le fichier package.json pour permettre l'installation du DOM virtuel JSDOM. Nous allons également créer un fichier layout.htm qui va servir de base HTML pour tous les fichiers renvoyés par le serveur.

isomorphism/
├─ javascripts/
│  ├─ isomorphic.js
│  ├─ isomorphic.mjs
│  ├─ operation.js
│  ├─ operation.mjs
├─ es5.htm
├─ es6.htm
├─ layout.htm
├─ server.js
└─ package.json

Remplissons le fichier package.json avec :

package.json (code source)

{}

Puis exécutons la commande :

> npm install --save jsdom

Ce qui va remplir le fichier package.json ainsi :

package.json (code source)

{
  "dependencies": {
    "jsdom": "^11.5.1"
  }
}

et créer un fichier package-lock.json.

Grâce à cela, le module communautaire npm JSDOM et ses dépendances seront installés dans le dossier node_modules.

Basiquement, notre fichier layout.htm ressemblera à cela :

layout.htm (code source)

<!DOCTYPE html>
<html lang="en">
    <head>
        <base href="MyBase" />
        <meta charset="utf-8" />
        <title>Isomporphic example</title>
        <!-- Un peu de CSS pour faire changer les
             pages en changeant l'opacité. -->
        <style>
            // Chaque `div` `layout` va s'afficher
            // les unes sur les autres permettant
            // de faire disparaitre progressivement
            // celle du dessus…
            .layout {
                position: absolute;
                width: 100%;
                height: 100%;
                top: 0;
                left: 0;
                opacity: 1;
                background-color: #fff;
                -webkit-transition: opacity 1s ease;
                   -moz-transition: opacity 1s ease;
                    -ms-transition: opacity 1s ease;
                     -o-transition: opacity 1s ease;
                        transition: opacity 1s ease;
            }
            // …pour rendre visible celle
            // du dessous. Nous verrons cela
            // plus loin.
            .change {
                opacity: 0;
                z-index: 2;
            }
        </style>
    </head>
    <body>
        <!-- Ici sera montée la page demandée par le routeur
             côté serveur ou sera hydratée la page demandée
             côté client. -->
        <div class="layout"></div>
        <!-- Ici sera exécuté la partie cliente -->
        <script src="javascripts/client.js"></script>
    </body>
</html>

Remplissons maintenant le fichier server.js avec le code serveur dédié :

server.js (code source)

    /* Récupération de l'API native HTTP
     * pour faire des échanges client-server
     * (l'équivalent de APACHE). */
var http = require('http'),
    /* Récupération de l'API native File System
     * pour lire et écrire dans des fichiers
     * sur le serveur. */
    fs = require('fs'),
    /* Récupération de la bibliothèque JSDOM
     * pour manipuler le DOM « virtuel »
     * sur le serveur. */
    JSDOM = require('jsdom').JSDOM,

    /* Port d'écoute de notre site web. */
    httpPort = 8080,
    /* Nom de domaine de notre site web. */
    httpDomain = 'localhost';

/* Création du serveur web avec
 * récupération de toutes les requêtes faites
 * par le navigateur dans `request` et un objet
 * `response` pour renvoyer le contenu HTML demandé
 * au navigateur. */
http.createServer(function (request, response) {
    var router,
        file,
        statusCode,
        contentType;

    /* Cas des demandes d'adresse finissant par `/`. */
    if (/\/$/g.test(request.url)) {
        /* Le site répondra donc à :
         * - `http://localhost:8080/`
         * - `http://localhost:8080/about-us/`
         * - `http://localhost:8080/contact-us/`
         * ... */
        router = {
            '/': 'index',
            '/about-us/': 'overview',
            '/contact-us/': 'contact'
        };

        /* ...ou à n'importe quoi finissant par `/`
         * `http://localhost:8080/.+/`. */
        file = router[request.url] || 'error';

        /* Si l'adresse est trouvée dans `router`,
         * la `response` sera valide et en `200` sinon
         * ce sera une page inexistante d'erreur `404`. */
        statusCode = (router[request.url]) ? 200 : 404;

        /* Récupération de la structure globale de
         * chaque page dans le fichier `layout.htm`. */
        fs.readFile('layout.htm', function (err, layout) {
            if (err) {
                /* Information en cas d'erreur. */
                console.log('We cannot open layout file.', err);

                /* Renvoi d'une page serveur 500 en cas d'erreur. */
                response.writeHead(500, {});
                /* Fin de la transaction. */
                response.end('');
            }

            /* Ouverture du code isomorphique correspondant aux pages :
             * - `views/index.htm`    si `http://localhost:8080/`            est demandée
             * - `views/overview.htm` si `http://localhost:8080/about-us/`   est demandée
             * - `views/contact.htm`  si `http://localhost:8080/contact-us/` est demandée
             * - `views/error.htm`    si `http://localhost:8080/.+/`         est demandée. */
            fs.readFile('views/' + file + '.htm', 'utf-8', function (err, content) {
                var dom = new JSDOM(layout);

                if (err) {
                    /* Information en cas d'erreur. */
                    console.log('We cannot open ' + file + ' view file.', err);
                }

                /* Récupération de la balise `<base href="MyBase" />` */
                dom.window.document.getElementsByTagName('base')[0]
                    /* et changement en `<base href="http://localhost:8080/" />`. */
                    .setAttribute('href', 'http://' + httpDomain + ':' + httpPort + '/');

                /* Récupération de la balise `<div class="layout"></div>` */
                dom.window.document.getElementsByClassName('layout')[0]
                    /* et changement de leur contenu par le contenu
                     * généré à partir appels isomorphiques des fichiers :
                     * - `require('./views/index.js')(<contenu de `views/index.htm`>, <objet window virtuel>)` ou
                     * - `require('./views/overview.js')(<contenu de `views/overview.htm`>, <objet window virtuel>)` ou
                     * - `require('./views/contact.js')(<contenu de `views/contact.htm`>, <objet window virtuel>)` ou
                     * - `require('./views/error.js')(<contenu de `views/error.htm`>, <objet window virtuel>)` */
                    .innerHTML = require('./views/' + file + '.js')(content, dom.window)
                    /* et contenu dans la propriété `template`
                     * (par ex. : `'<div class="layout"><h1>Welco[...]</ul></div>'`). */
                    .template;

                /* Création des entêtes de réponse HTTP
                 * pour un fichier HTML
                 * soit en code `200` soit `404`. */
                response.writeHead(statusCode, {
                    'Content-Type': 'text/html; charset=utf-8'
                });

                /* Fin de la transaction avec envoi
                 * du fichier complet (par ex. `'<!DOCTYPE html><html lang="en"><head>[...]
                 * <div class="layout"><h1>Welco[...]</ul></div>[...]
                 * </body></html>'`). */
                response.end(dom.serialize());
            });
        });

    /* Cas de toutes les autres demandes du navigateur
     * fait pour récupérer directement les fichiers
     * de ressources statiques. */
    } else {
        /* Retrait du `/` de départ pour tentative
         * d'ouverture du fichier. (par ex.  la requête
         * `/javascripts/client.js` tentera d'ouvrir le
         * fichier `javascripts/client.js`). */
        file = request.url.slice(1);

        /* Ouverture du fichier statique demandé */
        fs.readFile(file, 'utf-8', function (err, content) {
            /* Par défaut on estime que le fichier est trouvé... */
            statusCode = 200;
            /* et n'a pas de `'Content-type'` particulier */
            contentType = {};

            /* Association d'un fichier de `'Content-type'`
             * par `application/javascript` si l'extension
             * du fichier est `'.js'`. */
            if (/\.js$/g.test(file)) {
                contentType = {
                    'Content-Type': 'application/javascript; charset=utf-8'
                };
            }

            /* Association d'un fichier de `'Content-type'`
             * par `text/html` si l'extension
             * du fichier est `'.htm'`. */
            if (/\.htm$/g.test(file)) {
                contentType = {
                    'Content-Type': 'text/html; charset=utf-8'
                };
            }

            if (err) {
                /* Si le ficher demandé n'existe pas
                 * on retourne un fichier en erreur
                 * 400 à contenu vide.*/
                statusCode = 404;
                contentType = {};
                content = '';

                /* Information en cas d'erreur */
                console.log('We cannot open ' + file + ' asset file.', err);
            }

            /* Création des entêtes de réponse HTTP
             * pour un fichier statique
             * soit en code `200` soit `404`. */
            response.writeHead(statusCode, contentType);

            /* Fin de la transaction avec envoi
             * du contenu du fichier s'il existe
             * ou d'un contenu vide s'il n'existe pas. */
            response.end(content);
        });
    }

/* Démarrage du serveur web */
}).listen(httpPort, function () {
    /* Envoi d'un message à la console côté serveur
     * quand le serveur est démarré et prêt à répondre
     * aux demandes du client. */
    console.log('Server listening on: http://' + httpDomain +':' + httpPort + '/');
});

Les fichiers isomporphiques

À ce stade, le fichier server.js va retourner une réponse HTTP différente à votre navigateur en fonction de l'adresse demandée.

  • Pour http://localhost:8080/, ce sont les fichiers de vue views/index.html et de modèle views/index.js qui vont être impliqués,
  • pour http://localhost:8080/about-us/, ce sont les fichiers de vue views/overview.html et de modèle views/overview.js qui vont être impliqués,
  • pour http://localhost:8080/contact-us/, ce sont les fichiers de vue views/contact.html et de modèle views/contact.js qui vont être impliqués et
  • pour http://localhost:8080/.+/ (n'importe quelle adresse finissant par /), ce sont les fichiers de vue views/error.html et de modèle views/error.js qui vont être impliqués.

Pour cela, nous allons créer ces fichiers dans notre structure existante :

isomorphism/
├─ javascripts/
│  ├─ isomorphic.js
│  ├─ isomorphic.mjs
│  ├─ operation.js
│  └─ operation.mjs
├─ node_molules/
│  ├─ ...
├─ views/
│  ├─ contact.htm
│  ├─ contact.js
│  ├─ error.htm
│  ├─ error.js
│  ├─ index.htm
│  ├─ index.js
│  ├─ overview.htm
│  └─ overview.js
├─ es5.htm
├─ es6.htm
├─ server.js
└─ package.json
└─ package-lock.json

et les remplir comme suit :

views/index.htm (code source)

<!-- Template HTML qui sera rempli par
     `views/index.js`. -->
<h1>MyTitle</h1>
<p>MyText</p>
<ul>
    <li><a href="MyLinkHref">MyLinkContent</a></li>
    <li><a href="MyLinkHref">MyLinkContent</a></li>
    <li><a href="MyLinkHref">MyLinkContent</a></li>
</ul>

views/index.js (code source)

/* Utilisation de l'export CommonJS de Node.js. */
module.exports = function (template, window) {

        /* Création d'un espace pour manipuler un fragment HTML... */
    var body = window.document.implementation.createHTMLDocument().body,

        /* Préparation des liens pour injection. */
        links = [{
            href: './about-us/',
            content: 'Go to about page'
        }, {
            href: './contact-us/',
            content: 'Go to contact page'
        }, {
            href: './error/',
            content: 'Try an error page'
        }];

    /* ...lu depuis le fichier `views/index.htm`. */
    body.innerHTML = template;

    /* Injection du titre. */
    body.getElementsByTagName('h1')[0].textContent = 'Welcome';

    /* Injection du contenu. */
    body.getElementsByTagName('p')[0].textContent = 'This is the welcome page!';

    /* Injection des liens. */
    Array.prototype.forEach.call(body.getElementsByTagName('a'), function (a, i) {
        a.textContent = links[i].content;
        a.setAttribute('href', links[i].href);
    });

    return {
        template: body.innerHTML
    };
};

views/overview.htm (code source)

<!-- Idem que pour `views/index.htm`. -->
<h1>MyTitle</h1>
<p>MyText</p>
<p><a href="MyLinkHref">MyLinkContent</a></p>

views/overview.js (code source)

/* Idem que pour `views/index.js`. */
module.exports = function (template, window) {
    var body = window.document.implementation.createHTMLDocument().body,
        a;

    body.innerHTML = template;

    body.getElementsByTagName('h1')[0].textContent = 'About this website';
    body.getElementsByTagName('p')[0].textContent = 'The goal of this website is to provide a way to run isomporphique from scratch!';
    a = body.getElementsByTagName('a')[0];
    a.textContent = 'Back to the home';
    a.setAttribute('href', './');

    return {
        template: body.innerHTML
    };
};

views/contact.htm (code source)

<!-- Idem que pour `views/index.htm`. -->
<h1>MyTitle</h1>
<p>MyText</p>
<p><a href="MyLinkHref">MyLinkContent</a></p>

views/contact.js (code source)

/* Idem que pour `views/index.js`. */
module.exports = function (template, window) {
    var body = window.document.implementation.createHTMLDocument().body,
        a;

    body.innerHTML = template;

    a = body.getElementsByTagName('a')[0];
    a.textContent = 'Back to the home';
    a.setAttribute('href', './');

    body.getElementsByTagName('h1')[0].textContent = 'Contact US';
    body.getElementsByTagName('p')[0].innerHTML = 'You can contact us by using the following email: <a href="mailto:bruno.lesieur@gmail.com">bruno.lesieur@gmail.com</a>';

    return {
        template: body.innerHTML
    };
};

views/error.htm (code source)

<!-- Idem que pour `views/index.htm`. -->
<h1>MyTitle</h1>
<p>MyText</p>
<p><a href="MyLinkHref">MyLinkContent</a></p>

views/error.js (code source)

/* Idem que pour `views/index.js`. */
module.exports = function (template, window) {
    var body = window.document.implementation.createHTMLDocument().body,
        a;

    body.innerHTML = template;

    body.getElementsByTagName('h1')[0].textContent = 'Error page';
    body.getElementsByTagName('p')[0].textContent = 'This is the error page...';
    a = body.getElementsByTagName('a')[0];
    a.textContent = 'Back to the home';
    a.setAttribute('href', './');

    return {
        template: body.innerHTML
    };
};

Comprenez bien qu'à ce stade, toutes les nouvelles pages que vous ajouterez se rempliront avec une partie vue représentée par le HTML pour l'affichage de la page et une partie modèle pour les actions que vous ferez sur cette vue (ici, ajouter des textes). Ce code fonctionne aussi bien en étant appelé depuis le serveur qu'en étant appelé depuis le client. Il est donc parfaitement isomorphique.

Le navigateur web

À partir d'ici, vous pouvez naviguer sur le site et le parcourir en utilisant les liens à l'adresse http://localhost:8080/. Si vous regardez dans la console de votre navigateur (F12 > Console), vous verrez juste que le fichier http://localhost:8080/javascripts/client.js n'est pas chargé.

GET http://localhost:8080/javascripts/client.js 404 (Not Found)

Vous constaterez également que changer de page se fait en rechargeant le navigateur pour chaque page.

C'est ici que va entrer en jeu la partie cliente dont le but va être d'exécuter les fichiers isomorphiques contenus dans le dossier views mais côté client. C'est grâce à cela que l'on sera capable de changer de page dynamiquement sans rechargement de page grâce aux appels XMLHttpRequest. Le fait de reprendre la main côté client sur la page courante s'appelle l'hydratation. Et en réalité, changer de page revient seulement à faire exécuter le couple .htm / .js directement dans le navigateur et simuler un changement de page avec pushState et l'évènement popstate.

Nous allons donc remplir le fichier client.js, qui lui, n'est compatible que du côté client :

isomorphism/
├─ javascripts/
│  ├─ client.js
│  ├─ isomorphic.js
│  ├─ isomorphic.mjs
│  ├─ operation.js
│  └─ operation.mjs
├─ node_molules/
│  ├─ ...
├─ views/
│  ├─ contact.htm
│  ├─ contact.js
│  ├─ error.htm
│  ├─ error.js
│  ├─ index.htm
│  ├─ index.js
│  ├─ overview.htm
│  └─ overview.js
├─ es5.htm
├─ es6.htm
├─ server.js
└─ package.json
└─ package-lock.json

avec le contenu suivant :

javascripts/client.js (code source)

/* Sortir de la portée globale
 * pour notre code personnel afin
 * d'éviter des conflits.
 */
;(function () {
    /* Les pages navigables sont :
     * - `http://localhost:8080/`
     * - `http://localhost:8080/about-us/`
     * - `http://localhost:8080/contact-us/`
     * ... */
    var router = {
        '/': 'index',
        '/about-us/': 'overview',
        '/contact-us/': 'contact'
    },

    /* ...et celle envoyant un contenu en erreur sont : `/`
     * `http://localhost:8080/.+/`. */
    file = router[location.pathname] || 'error';

    /* Compatiblilité CommonJS simple. */
    window.module = {};

    /* Gestion de la navigation dans l'historique
     * notamment en cliquant sur le bouton « Retour »
     * du navigateur. */
    window.addEventListener('popstate', function () {
        /* Récupération de l'URL après retour en
         * arrière ou en avant dans l'historique. */
        file = router[location.pathname] || 'error';

        /* Puis récupération du bon couple `.htm` / `.js`
         * en provenance de `views`. */
        changeRoute(file, true);
    });

    /* Gestion du changement de page sans rechargement
     * à partir du fichier de destination. `animate` permet
     * de savoir si l'hydratation va être faite avec un effet
     * d'animation ou non. */
    function changeRoute(file, animate) {

        /* Récupération de la vue et du modèle isomorphique.
         * `file` vaut soit `index`, `overview` ou `contact`
         * en fonction de ce que détecte `router` comme
         * page courante. */
        Promise.all([
            /* On fait une demande XMLHttpRequest
             * avec `fetch` qui retourne une promesse
             * puis on transforme le résultat au format texte. */
            fetch('views/' + file + '.htm').then(x => x.text()),
            fetch('views/' + file + '.js').then(x => x.text())
        ]).then(function (result) {

                /* Récupération d'une référence sur le contenu de
                 * la page à hydrater. */
            var layout = document.getElementsByClassName('layout'),

                /* Récupération des fichiers `.htm` et `.js`
                 * en retour de promesse. */
                view = result[0],
                model = result[1],

                /* Exécution côté client des fichiers
                 * en provenance du serveur. */
                content = eval(model)(view, window);

            /* Ajout du nouveau contenu dans une `div`
             * différente pour faire une animation si
             * demandé. */
            if (animate) {

                /* Ajout de la nouvelle page après la
                 * page courante. */
                layout = layout[layout.length - 1];
                layout.insertAdjacentHTML("afterend", '<div class="layout">' + content.template + "</div>");

                /* Ajout de la classe indiquant
                 * l'animation CSS3 a été exécutée. */
                layout.classList.add('change');

                /* Retrait de la page d'où l'on vient
                 * à la fin de l'animation. */
                setTimeout(function () {
                    layout.parentNode.removeChild(layout);
                }, 1000);

            /* Hydratation simple du contenu existant
             * si pas d'animation. */
            } else {
                layout.innerHTML = content.template;
            }

            /* Faire changer la page sans rechargement
             * à tous les liens présents dans la page. */
            Array.prototype.forEach.call(document.getElementsByTagName('a'), function (a) {

                /* Pour chaque lien, lors du clic. */
                a.addEventListener('click', function (e) {

                    /* Pas de changement de page... */
                    e.preventDefault();

                    /* ...mais changement d'URL de la page. */
                    history.pushState(null, 'Isomporphic example', document.getElementsByTagName('base')[0].getAttribute('href') + a.getAttribute('href'));

                    /* La page à charger étant choisie par le routeur */
                    /* en se basant sur la nouvelle URL. */
                    file = router[location.pathname] || 'error';

                    /* Changement de page. */
                    changeRoute(file, true);
                });
            });
        });
    }

    /* Hydratation de la page appelant `javascripts/client.js`
     * avec le bon couple `.htm` / `.js` */
    changeRoute(file, false);
}());

À partir de maintenant, depuis n'importe quelle page affichée en tapant l'URL dans la barre d'adresse, c'est le serveur qui répondra par retour HTTP grâce au fichier server.js. En fouillant la source HTML de votre page, vous constaterez que la page est correctement générée et peut donc être indexée par les moteurs de recherche. Une fois sur une page, le fichier javascripts/client.js va s'exécuter, hydratant le DOM actuel et permettant aux liens de changer de page sans rechargement, mais en appelant seulement les fragments isomorphiques pour les exécuter sur place (par le navigateur). Le résultat est un changement de page dynamique que vous pouvez apprécier grâce à l'animation de transition CSS mise en place dans layout.htm.

Vous pouvez tester l'hydratation cliente grâce à la mockup que vous trouverez en live grâce au système GitHub Pages : exemple d'hydratation côté client.

Conclusion

Vous en savez plus maintenant sur les mécanismes d'utilisation des Modules ECMAScript ou CommonJS / Node.js pour créer des applications web isomorphiques !

Bien entendu, le code actuel est loin d'être pratique pour la maintenance et n'est ni optimisé pour l'hydratation cliente (actuellement on jette le DOM et on le recrée au lieu de réellement l'hydrater), ni optimisé pour la charge serveur (on pourrait utiliser du cache côté serveur pour ne faire générer les rendus d'une page qu'une fois toutes les X secondes, minutes ou même heures en fonction des zones statiques ou dynamiques des pages).

On se demandera comment gérer plus simplement l'injection de texte dans les templates plutôt que de manipuler le DOM, comment faire fonctionner du code avec des évènements JavaScript côté serveur ? Si c'est possible ? Comment mélanger différents types de modules ?

Toutes ces solutions sont adressées plus ou moins simplement avec l'utilisation de l'écosystème Vue (par ex. webpack pour la partie cliente, Nuxt pour la partie serveur et Vue.js pour la partie isomorphique).

Pour ma part, pour passer à l'étape supérieure, tout en comprenant ce que vous faites (pour de l'isomorphisme aux petits oignons), je vous propose de vous tourner vers le couple Vue / NodeAtlas (un framework basé sur Express et Socket.io). Vue et NodeAtlas vous permettront de réaliser des sites réactifs et isomorphiques facilement et progressivement. Essayez avec l'article Vue + NodeAtlas : de l'art du SSR ou Rendu Côte Serveur avec JavaScript qui vous expliquera les bases.

Et cerise sur le gâteau, les documentations de Vue, Nuxt et NodeAtlas sont toutes en français, traduites par votre serviteur !

Vous pouvez obtenir l'intégralité des sources de cette article sur ce dépôt GitHub : Haeresis/import-export-require-isomorphism.