Comment faire du routage strict avec Vue Router et Vue Server Renderer ?

L'URL www.example.com/foo n'est pas la même URL que www.example.com/foo/. Or, si cela n'est pas gênant dans une application monopage (plus loin SPA pour « Single Page Application »), cela devient critique pour de l'optimisation de moteur de recherche (plus loin SEO pour « Search Engine Optimization ») dès lors que le contenu est généré côté serveur.

Côté serveur, les routeurs comme celui d'Express possèdent un mode strict pour que l'adresse /foo ne soit pas la même que l'adresse /foo/. Mais qu'en est t-il de Vue Router ? Et surtout, comment faire concorder le routage client et le routage serveur pour que l'hydratation (la prise en main côté client d'un rendu côté serveur) concorde ?

Je vous le donne en mille : de base, là où Express en mode strict vous renverra une page 200 pour /foo/ et une page 404 pour /foo, Vue Router lui, en mode strict, vous renverra exactement l'inverse !

Comment dans ce cas utiliser Vue Router et Vue Server Renderer pour du routage strict dont les moteurs de recherche sont si friand pour une SEO à toute épreuve ? La réponse à la fin !

Cependant, afin que ce billet soit utile également pour ceux n'ayant pas (encore) ce problème et qui souhaitent découvrir par l'exemple comment fonctionne du SSR avec Vue (et de facto, qu'est-ce que c'est réellement), je vais élaborer un code pour vous accompagner dans cette compréhension, pas à pas. Seulement ensuite nous mettrons en évidence notre problème avant de le résoudre.

Pourquoi a t-on besoin d'un rendu côté serveur (SSR) ?

Ce terme de SSR (pour « Server-Side Rendering ») ne vous dit peut-être rien. C'est vrai, « du rendu côté serveur », vous faites ça depuis toujours avec PHP, C#, etc. non ? En réalité, quand on parle de SSR vis à vis de Vue, React and co., c'est pour parler du fait de faire le rendu de la page telle que ces outils le permettent dans un navigateur, mais du côté serveur. Bien entendu, cela signifie que votre serveur va devoir interpréter le JavaScript (puisque ces outils clients sont en JavaScript) avant de renvoyer le résultat au client. C'est cela que permet Node.js.

Bien. Pour répondre a cette question dans le détail, nous allons utiliser

  • le framework NodeAtlas qui fera office de serveur web évolutif Node.js pour faire du SSR sans peine (puisque la partie qui nous intéresse est surtout Vue). Nous allons également utiliser
  • la bibliothèque Vue qui fera office de moteur de template réactif, son extension Vue Router qui fera office de routeur client ainsi que Vue Server Renderer qui fera le pont entre Vue et Node.js.

Cet article contient tout le code utile à sa compréhension. Cependant, vous pouvez tester le code chez vous, pas à pas, pour améliorer votre compréhension, en suivant les instructions. Pour ce faire vous devez installer Node.js. Une fois celui-ci installé, lancez la commande npm install -g node-atlas pour utiliser l'outil côté serveur NodeAtlas utilisé dans cet article. NodeAtlas est ce qui fait actuellement tourner le blog sur lequel vous lisez cet article. Vous pouvez cependant adapter le code pour votre framework serveur préféré !

Créer du simple contenu en réponse serveur

Dans un contexte simple, nous allons créer une page d'accueil qui nous mènera à /foo/ qui sera une page existante. Elle nous mènera également à /foo qui retournera une page 404 (non existante pour le serveur). Ceci n'a rien de bien différent de l'utilisation d'un code serveur classique PHP couplé à Apache par exemple. Ici on utilisera NodeAtlas.

Créons nous donc un webconfig-www.json pour NodeAtlas comme suit.

webconfig-www.json

{
    "httpPort": 7778,
    "view": "layout.htm",
    "routes": {
        "/": "index.htm",
        "/foo/": "exist.htm",
        "/*": {
            "view": "error.htm",
            "statusCode": 404
        }
    }
}

NodeAtlas se configure progressivement en fonction du besoin. Aussi, ici, nous allons faire tourner un site en localhost sur le port 7778 de httpPort. Nous allons utiliser une page maître layout.htm (qui sera identique autour de la zone réelle de contenu autour de chaque page). Et nous allons rendre accessible le contenu de views/index.htm à l'adresse http://localhost/, le contenu de views/exist.htm à l'adresse http://localhost/foo/ et le contenu de views/error.htm (avec un statusCode d'erreur 404) pour toutes les autres pages (/*).

Notre jeu de fichiers est donc le suivant (par défaut NodeAtlas va checher les view dans le dossier views).

├─ views/
│  ├─ error.htm
│  ├─ exist.htm
│  ├─ index.htm
│  └─ layout.htm
└─ webconfig-www.json

Et les contenus pour chacun des fichiers les suivants.

views/layout.htm

<!DOCTYPE html>
<html lang="fr">
    <head>
        <meta charset="utf-8">
        <title>Vue Router + Vue Server Renderer = Problème</title>
    </head>
    <body>
        <!-- La zone si dessous contiendra les contenus
             `views/index.htm` pour `/`,
             `views/exist.htm` pour `/foo/` et
             `views/error.htm` pour les autres pages
             grâce à `include`. -->

        <?- include(routeParameters.view) ?>

        <!-- `routeParameters` représente, par exemple,
             pour la page `/existe-pas` l'objet
             `{ "view": "error.htm", "statusCode": 404 }`
             du webconfig. Donc `.view` retourne `"error.htm"`.
             Quand la valeur est une chaine de caractères
             comme pour `/foo/`, celle-ci est transformée en
             objet et la valeur est placée dans `.view` -->
    </body>
</html>

views/index.htm

<ul>
    <li><a href="/foo/">`/foo/` existe</a></li>
    <li><a href="/foo">`/foo` n'existe pas</a></li>
</ul>

views/exist.htm

<div>
    <p><a href="/">Retour</a></p>
    <p><strong>200 : J'existe !</strong></p>
</div>

views/error.htm

<div>
    <p><a href="/">Retour</a></p>
    <p>404 : Je n'existe pas...</p>
</div>

Test site

Pour faire lire le webconfig à NodeAtlas, on lance alors la commande node-atlas --webconfig webconfig-www.json --browse depuis le dossier contenant webconfig-www.json et notre navigateur s'ouvre automatiquement (option --browse) à :

  • addresse : http://localhost:7778/
  • status : 200
  • réponse :
    <!DOCTYPE html>
    <html lang="fr">
        <head>
            <meta charset="utf-8">
            <title>Vue Router + Vue Server Renderer = Problème</title>
        </head>
        <body>
            <ul>
                <li><a href="/foo/">/foo/ existe</a></li>
                <li><a href="/foo">/foo n'existe pas</a></li>
            </ul>
        </body>
    </html>
    

    Note : les commentaires ont volontairement été omis.

Naviguez en cliquant sur /foo n'existe pas. Cela nous enverra droit sur une page inexistante (ce qui est le cas pour n'importe quels autres URL que / et /foo/) :

  • addresse : http://localhost:7778/foo
  • status : 404
  • réponse :
    <!DOCTYPE html>
    <html lang="fr">
        <head>
            <meta charset="utf-8">
            <title>Vue Router + Vue Server Renderer = Problème</title>
        </head>
        <body>
            <div>
                <p><a href="/">Retour</a></p>
                <p>404 : Je n'existe pas...</p>
            </div>
        </body>
    </html>
    

Retournez à l'accueil en cliquant sur Retour puis cliquez sur /foo/ existe. Cela nous affichera le contenu souhaité pour cette page.

  • addresse : http://localhost:7778/foo/
  • status : 200
  • réponse :
    <!DOCTYPE html>
    <html lang="fr">
        <head>
            <meta charset="utf-8">
            <title>Vue Router + Vue Server Renderer = Problème</title>
        </head>
        <body>
            <div>
                <p><a href="/">Retour</a></p>
                <p><strong>200 : J'existe !</strong></p>
            </div>
        </body>
    </html>
    

Tout ceci est donc parfait pour créer des sites indexables. Un défaut est que le site n'est pas réactif. De plus chaque changement de page rechargera le navigateur. Tous ces soucis pourraient être adressés à la main côté client mais, de la même manière que nous n'utilisons pas directement l'API HTTP de Node.js côté serveur, nous n'allons pas non plus utiliser directement l'API History des navigateurs côté client. Nous allons utiliser Vue.

Créer une application monopage ou SPA

Pour créer notre application, nous allons devoir jouer

  • avec Vue qui s'occupera de la réactivité et jouer
  • avec Vue Router qui s'occupera du changement de contenu sans recharger la page (mais en changeant bien l'URL !).

Pour cela, il nous suffit de toujours servir la même page côté client, et donc de créer un webconfig-spa.json comme suit :

webconfig-spa.json

{
    "httpPort": 7776,
    "routes": {
        "/*": "spa.htm"
    }
}

et de créer le nouveau fichier views/spa.htm

├─ views/
│  ├─ ...
│  ├─ spa.htm
│  ├─ ...
├─ ...
├─ webconfig-spa.json
├─ ...

contenant le code suivant.

views/spa.htm

<!DOCTYPE html>
<html lang="fr">
    <head>
        <meta charset="utf-8">
        <title>Vue Router + Vue Server Renderer = Problème</title>
        <style>
            /* On crée des règles pour la
               balise `<transition>` de Vue
               afin de bien voir que le changement
               de page ne recharge pas la page. */

            .fade-enter,
            .fade-leave-to {
                opacity: 0;
            }
            .fade-leave-active,
            .fade-enter-active {
                transition: opacity 1s;
            }
            .fade-enter-to,
            .fade-leave {
                opacity: 1;
            }
        </style>
    </head>
    <body>
        <!-- Voici le contenu qui va être pris en
             main par Vue. Il peut donc contenir des balises
             et attributs HTML non standard mais syntaxiquement
             valide qui seront interprétées par Vue. -->
        <div class="app">
            <transition name="fade">
                <router-view></router-view>
            </transition>
        </div>

        <!-- Chargement des bibliothèques de Vue pour la réactivité
             (`vue.js`) et le routage (`vue-router.js`). -->
        <script src="https://cdnjs.cloudflare.com/ajax/libs/vue/2.4.1/vue.js"></script>
        <script src="https://cdnjs.cloudflare.com/ajax/libs/vue-router/2.7.0/vue-router.js"></script>

        <!-- Utilisation des bibliothèque de Vue. -->
        <script>
            /* Pour commencer, nous créons 3 composants `Index`,
               `Err` et `Exist`. Chaque composant contient un template
               de page similaire à ceux de notre exemple plécédent avec
               l'utilisation unique de NodeAtlas.
               Notez également que les balises `<a>` sont remplacées
               par les balise `<router-link>` qui vont prendre en
               charge les changements de page. */
            var Index = { template: `<ul>
                    <li><router-link to="/foo/">/foo/ existe</router-link></li>
                    <li><router-link to="/foo">/foo existe aussi car pas strict</router-link></li>
                </ul>` },
                Exist = { template: `<div>
                    <p><router-link to="/">Retour</router-link></p>
                    <p><strong>200 : J'existe !</strong></p>
                </div>` },
                Err = { template: `<div>
                    <p><router-link to="/">Retour</router-link></p>
                    <p>404 : Je n'existe pas...</p>
                </div>` },

            /* Nous utilisons `vue-router.js` pour définir nos
               routes, et les composants à utiliser derrière
               chacune d'entre elle. Le `path` représente là
               route et le `component` le composant à charger. */
            router = new VueRouter({
                mode: 'history',
                routes: [
                    { path: '/', component: Index },
                    { path: '/foo/', component: Exist },
                    { path: '/*', component: Err }
                ]
            });

            /* Enfin nous utilisons `vue.js` pour assigner
               le routeur et monter l'application sur la zone
               du DOM sous la balise de classe `app`. */
            new Vue({
                router: router,
                el: '.app'
            });
        </script>
    </body>
</html>

Test SPA

Exécutons alors la commande node-atlas --webconfig webconfig-spa.json --browse depuis le dossier contenant webconfig-spa.json et notre navigateur s'ouvre à :

  • addresse : http://localhost:7776/
  • status : 200
  • réponse :

    <!DOCTYPE html>
    <html lang="fr">
        <head>
            <meta charset="utf-8">
            <title>Vue Router + Vue Server Renderer = Problème</title>
            <style>
                .fade-enter,
                .fade-leave-to {
                    opacity: 0;
                }
                .fade-leave-active,
                .fade-enter-active {
                    transition: opacity 1s;
                }
                .fade-enter-to,
                .fade-leave {
                    opacity: 1;
                }
            </style>
        </head>
        <body>
            <div class="app">
                <transition name="fade">
                    <router-view></router-view>
                </transition>
            </div>
            <script src="https://cdnjs.cloudflare.com/ajax/libs/vue/2.4.1/vue.js"></script>
            <script src="https://cdnjs.cloudflare.com/ajax/libs/vue-router/2.7.0/vue-router.js"></script>
            <script>
                var Index = { template: `<ul>
                        <li><router-link to="/foo/">/foo/ existe</router-link></li>
                        <li><router-link to="/foo">/foo existe aussi car pas strict</router-link></li>
                    </ul>` },
                    Exist = { template: `<div>
                        <p><router-link to="/">Retour</router-link></p>
                        <p><strong>200 : J'existe !</strong></p>
                    </div>` },
                    Err = { template: `<div>
                        <p><router-link to="/">Retour</router-link></p>
                        <p>404 : Je n'existe pas...</p>
                    </div>` },
    
              router = new VueRouter({
                  mode: 'history',
                  routes: [
                      { path: '/', component: Index },
                      { path: '/foo/', component: Exist },
                      { path: '/*', component: Err }
                  ]
              });
    
                new Vue({
                    router: router,
                    el: '.app'
                });
            </script>
        </body>
    </html>
    

    Note : les commentaires ont volontairement été omis.

Naviguez en cliquant sur /foo/ existe. Cela nous affichera le contenu existant 200 : J'existe ! et nous fera changer d'adresse sans rechargement de page (vous constaterez d'ailleurs que celle-ci est animée grâce à la balise <transition>).

  • addresse : http://localhost:7776/foo/
  • status : — (pas de rechargement de page)
  • réponse : — (pas de rechargement de page)

Retournez à l'accueil en cliquant sur Retour puis cliquez sur /foo existe aussi car pas strict. Cela aura exactement le même effet, c.-à-d. affichera 200 : J'existe ! alors que vous n'avez pas spécifiquement indiqué au routeur que /foo était une route valide. C'est parce que le routeur de Vue n'est pas strict.

  • addresse : http://localhost:7776/foo
  • status : — (pas de rechargement de page)
  • réponse : — (pas de rechargement de page)

Cependant, si vous tapez dans votre bar d'adresse /bar/. Il y aura un rechargement de page qui renverra une réponse identique au page existante et même... un code 200 !

  • addresse : http://localhost:7776/bar/
  • status : 200
  • réponse :

    <!DOCTYPE html>
    <html lang="fr">
        <head>
            <meta charset="utf-8">
            <title>Vue Router + Vue Server Renderer = Problème</title>
            <style>
                .fade-enter,
                .fade-leave-to {
                    opacity: 0;
                }
                .fade-leave-active,
                .fade-enter-active {
                    transition: opacity 1s;
                }
                .fade-enter-to,
                .fade-leave {
                    opacity: 1;
                }
            </style>
        </head>
        <body>
            <div class="app">
                <transition name="fade">
                    <router-view></router-view>
                </transition>
            </div>
            <script src="https://cdnjs.cloudflare.com/ajax/libs/vue/2.4.1/vue.js"></script>
            <script src="https://cdnjs.cloudflare.com/ajax/libs/vue-router/2.7.0/vue-router.js"></script>
            <script>
                var Index = { template: `<ul>
                        <li><router-link to="/foo/">/foo/ existe</router-link></li>
                        <li><router-link to="/foo">/foo existe aussi car pas strict</router-link></li>
                    </ul>` },
                    Exist = { template: `<div>
                        <p><router-link to="/">Retour</router-link></p>
                        <p><strong>200 : J'existe !</strong></p>
                    </div>` },
                    Err = { template: `<div>
                        <p><router-link to="/">Retour</router-link></p>
                        <p>404 : Je n'existe pas...</p>
                    </div>` },
    
              router = new VueRouter({
                  mode: 'history',
                  routes: [
                      { path: '/', component: Index },
                      { path: '/foo/', component: Exist },
                      { path: '/*', component: Err }
                  ]
              });
    
                new Vue({
                    router: router,
                    el: '.app'
                });
            </script>
        </body>
    </html>
    

Pourtant, visuellement, vous constaterez que le message affiché est cette fois 404 : Je n'existe pas... et que c'est bien le composant Err qui cette fois aura été chargé. Cliquez ensuite sur Retour pour constater que le routeur côté client a bien repris la main puisque vous retournez à l'accueil avec une animation et sans rechargement de page.

  • addresse : http://localhost:7776/
  • status : — (pas de rechargement de page)
  • réponse : — (pas de rechargement de page)

Cela nous montre exactement les limitations de Vue pour du routage côté client (qui sont les mêmes limitations que Angular, React, le framework MVVM de votre choix). Puisque toutes les pages de votre serveur renvoi votre SPA et que c'est le côté client qui la gère.

Dans le cas ou votre SPA doit être référencée, ce comportement est problématique car :

  • les moteurs d'indexation prendront les pages inexistantes pour du contenu existant (car renvoi d'un status 200),
  • les moteurs d'indexation indexeront toujours la même page illisible car le contenu est dans du JavaScript,
  • l'indexation de cette page ne sera pas garantie puisque la page n'est pas du HTML valide (balise <transition> et <router-view>). Autant vous dire qu'en réalité, si vous n'avez pas un site frontal envoyant votre utilisateur vers votre SPA, celle-ci ne sera pas facilement référencée (même si les moteurs font de gros efforts pour les pages à forts trafiques).

Le Server-Side Rendering est la solution

Le rendu côté serveur ou SSR est le rassemblement des deux premières parties pour tirer profit d'un site réactif et sans rechargement de page qui soit également indexable. Cela est possible car le contenu renvoyé est toujours différent et valide en source de chaque page. C'est ensuite l'application Vue qui prend la main côté client une fois la page chargée. Voyons cela dès à présent !

Vue Router + SSR + NodeAtlas

Nous allons de ce pas fusionner les deux approches précédentes. Nous allons montrer comment créer une application capable de faire du SSR et de produire une partie cliente réactive avec Vue et NodeAtlas. Nous résoudrons ensuite le conflit de fonctionnement des serveurs stricts qui font la différence entre /foo/ et /foo et Vue Router qui ne la fait pas : ce qui mène a une hydratation ratée ! Nous verrons d'ailleurs par l'exemple ce qu'est exactement l'hydratation.

Partie serveur

Créons à présent un fichier webconfig.json original correspondant à notre application finale :

webconfig.json

{
    "httpPort": 7777,
    "view": "common.htm",
    "controller": "common.js",
    "routes": "routes.json"
}

Dans ce webconfig, nous avons décidez d'ajouter un contrôleur commun à toutes les pages, comme c'était le cas de view. NodeAtlas ira donc lire ce contrôleur dans controllers/common.js avant d'effectuer le rendu de chaque page. Nous avons également décidé de placer les routes dans un fichier séparé routes.json, ce qui nous permettra plus loin de fournir la liste des routes à la partie cliente sans donner le reste du webconfig.

Nous allons affecter au fichier de routes externes routes.json, basé sur les routes de notre premier exemple, les routes suivantes.

routes.json

{
    "index": {
        "url": "/",
        "view": "index"
    },
    "exist": {
        "url": "/foo/",
        "view": "exist"
    },
    "error": {
        "url": "/*",
        "view": "error",
        "statusCode": 404
    }
}

Notez que nous ne précisons cette fois pas d'extension dans la view de chaque route, ce qui va nous permettre de charger au choix un fichier .htm ou .js. Nous allons voir cela plus loin.

Nous allons utiliser Vue et Vue Router côté serveur. Nous allons donc avoir besoin de Vue Server Renderer qui s'occupera d’exécuter le moteur de template de Vue en mode serveur. Pour ce faire nous allons les ajouter en tant que dépendances de notre projet NodeAtlas dans un fichier package.json.

package.json

{
  "dependencies": {
    "vue": "2.3.x",
    "vue-router": "2.7.x",
    "vue-server-renderer": "2.3.x"
  }
}

Cela va nous donner l'architecture suivante.

├─ controllers/
│  └─ common.js
├─ views/
│  ├─ ...
├─ ...
├─ package.json
├─ routes.json
└─ webconfig.json

Puis nous allons installer ces dépendances avec la commande npm install depuis le dossier ou est placé le fichier package.json.

Nous allons à présent prendre la main sur le cycle de création d'une page de NodeAtlas. Pour cela nous allons nous placer dans le point d'ancrage changeDom afin d'utiliser notre propre moteur de rendu qui sera Vue Server Renderer. Nous allons pour cela utiliser le contrôleur global common.js dans le nouveau dossier controllers dont le contenu est le suivant.

controllers/common.js

/* `changeDom` permet a NodeAtlas de manipuler le DOM virtuel
    complètement rendu côté serveur avec, au besoin, les informations
    de la requête qui a demandé la page dans `request` ainsi que des
    informations complémentaires (comme le `DOM` généré) dans `local`.
    Nous pourrions faire nos modifications puis appeler `next` pour
    renvoyer le DOM modifié. Nous n'allons cependant pas faire cela.
    Nous allons nous même renvoyé ce DOM en utilisant l'objet `response`.
    Le DOM virtuel NodeAtlas servira alors de page maître au Vue Server Renderer,
    qui se chargera d'injecter les contenus correcte en fonction de la page.
 */
exports.changeDom = function (next, locals, request, response) {
    /* Nous récupérons tous les outils fournis par NodeAtlas. */
    var NA = this,

        /* Nous récupérons de quoi lire un fichier sur l'OS
           courant et de quoi « merger » des fragments d'adresse
           de fichier. */
        readFile = NA.modules.fs.readFile,
        join = NA.modules.path.join,

        /* Nous chargeons les dépendances installés grâce à notre
           `package.json`. */
        Vue = require("vue"),
        VueRouter = require("vue-router"),
        VueServerRenderer = require("vue-server-renderer"),

        /* Nous générons le chemin depuis la racine de l'OS
           jusqu'aux fichiers du dossier `views`. */
        path = join(NA.serverPath, NA.webconfig.viewsRelativePath),

        /* Puis nous utilisons ce chemin pour créer le chemin complet
           des fichiers de vue et modèle pour l'application complète,
           ainsi que celui du composant utile pour la page actuellement
           demandée. */
        view = join(path, locals.routeParameters.view + ".htm"),
        model = join(path, locals.routeParameters.view + ".js"),
        appModel = join(path, "app.js"),
        appView = join(path, "app.htm"),

        /* Nous générons notre application Vue côté serveur.
           Celle-ci sera injecté dans le DOM déjà crée par
           NodeAtlas au niveau de la balise `<!--vue-ssr-outlet-->`
           (voir dans `views/common.htm` plus loin). */
        renderer = VueServerRenderer.createRenderer({
            template: locals.dom
        });

    /* On ajoute le plugin `VueRouter` à `Vue` (ce qui est automatiquement fait
       côté client si les bibliothèques sont chargé via une balise `<script>`). */
    Vue.use(VueRouter);

    /* On ouvre la vue du composant de la page courante. Si par exemple
       on demande l'URL `http://localhost:7777/foo/`, alors la variable `view`
       vaudra `{chemin_depuis_la_racine_de_l_OS}/views/exist.htm`. */
    readFile(view, "utf-8", function (error, template) {
        /* On charge le composant en question en lui donnant comme nom
           (en premier paramètre) la valeur de l'option `view` de l'objet,
           par exemple, `{ "url": "/foo/", "view": "exist" }`
           pour l'adresse `http://localhost:7777/foo/` et en second
           paramètre le fichier modèle `{chemin_depuis_la_racine_de_l_OS}/views/exist.js`. */
        var component = Vue.component(locals.routeParameters.view, require(model)(template));

        /* On ouvre la vue de la page maître. */
        readFile(appView, "utf-8", function (error, template) {
            /* On fournit au routeur l'unique composant utile pour la
               page courante, à savoir celui de `exist`. */
            var router = new VueRouter({
                    routes: [{
                        /* En fournissant la route... */
                        path: locals.routeParameters.url,
                        /* et le composant */
                        component: component
                    }]
                }),
                /* On permet à l'application d'avoir connaissance de la liste
                   complète des routes. */
                webconfig = {
                    routes: NA.webconfig.routes
                },
                /* On génère la réponse sous forme de flux. Cela signifie que
                   les fragments de la réponse seront retournés au client dès que
                   Vue Server Renderer les aura compilés avec Vue. */
                stream = renderer.renderToStream(new Vue(require(appModel)(template, router, webconfig)));

            /* On explique au serveur que le composant à rendre est
               celui qui correspond à la route courante. */
            router.push(locals.routeParameters.url);

            /* On envoit les fragments aussitôt qu'ils sont disponibles. */
            stream.on('data', function (chunk) {
                response.write(chunk);
            });

            /* On confirme au client que la réponse est terminée. */
            stream.on('end', function () {
                response.end();
            });
        });
    });
};

Nous allons ensuite créer nos paires de fichier vue / modèle dans le dossier views pour chaque composant de route, créer une paire de fichier d'application, et créer une page globale views/common.htm :

├─ controllers/
│  └─ ...
├─ views/
│  ├─ app.htm
│  ├─ app.js
│  ├─ common.htm
│  ├─ ...
│  ├─ error.js
│  ├─ ...
│  ├─ exist.js
│  ├─ ...
│  ├─ index.js
│  ├─ ...
├─ ...

On explique dans le fichier common.htm a quel endroit notre application se trouve avec <!--vue-ssr-outlet--> pour permettre l'injection côté serveur, et l'hydratation côté client.

views/common.htm

<!DOCTYPE html>
<html lang="fr">
    <head>
        <meta charset="utf-8">
        <title>Vue Router + Vue Server Renderer = Problème</title>
    </head>
    <body>
        <!--vue-ssr-outlet-->
    </body>
</html>

Ensuite nous créons l'application principale qui va être injectée sur toutes les pages à la place de <!--vue-ssr-outlet--> :

views/app.htm

<div class="app">
    <transition name="fade">
        <router-view></router-view>
    </transition>
</div>

views/app.js

/* Nous retournons un objet avec `return` lorsque la fonction
   `function (template, router, webconfig)` sera exécutée.
   Cette fonction est exécuté depuis `controllers/common.js` vu plus
   haut lors de cet appel `new Vue(require(appModel)(template, router, webconfig))`,
   les paramètres envoyé dans la `new Vue` étant ceux disponible dans le code
   ci-dessous. C'est le même principe pour chaque futur composant. */
module.exports = function (template, router, webconfig) {
    return {
        name: 'app',
        /* `template` contient le contenu de `views/app.htm` */
        template: template,
        router: router,
        data: {
            webconfig: webconfig
        }
    };
};

Puis on affecte a chaque vue déjà existante un modèle :

views/index.js

/* Cette fonction `function (template)` est exécutée quand
   `Vue.component(locals.routeParameters.view, require(model)(template))`
   est exécuté depuis `controllers/common.js` vu plus
   haut si la route en question est `/`. */
module.exports = function (template) {
    return {
        name: 'index',
        /* `template` contient le contenu de `views/index.htm` */
        template: template,
        data: function () {
            return {};
        }
    };
};

views/exist.js

module.exports = function (template) {
    return {
        name: 'exist',
        /* `template` contient le contenu de `views/exist.htm` */
        template: template,
        data: function () {
            return {};
        }
    };
};

views/error.js

module.exports = function (template) {
    return {
        name: 'error',
        /* `template` contient le contenu de `views/error.htm` */
        template: template,
        data: function () {
            return {};
        }
    };
};

Test serveur

Testons ce code côté serveur avec la commande node-atlas --browse (car l'option --webconfig n'est pas utile si le webconfig s'appel webconfig.json). Nous remarquerons alors que notre application se comporte comme dans le cas de notre premier exemple avec webconfig-www.json. Vous pouvez effectuer les mêmes actions, vous obtiendrez les mêmes résultat sauf que nous n'utilisons plus le moteur initial EJS fourni par NodeAtlas mais nous utilisons celui de Vue Server Renderer qui utilise Vue. Voyons la différence avec http://localhost:7777/.

  • addresse : http://localhost:7777/
  • status : 200
  • réponse :
    <!DOCTYPE html>
    <html lang="fr">
        <head>
            <meta charset="UTF-8">
            <title>Vue Router + Vue Server Renderer = Problème</title>
        </head>
        <body>
            <div data-server-rendered="true" class="app"><ul><li><a href="/foo/">/foo/ existe</a></li> <li><a href="/foo">/foo n&#x27;existe pas</a></li></ul></div>
        </body>
    </html>
    

Vous remarquerez la balise <div data-server-rendered="true" class="app"> qui indiquera à Vue côté client où commencer son hydratation pour prendre la relève. Nous allons voir cela dès maintenant dans la suite car actuellement, Vue n'est pas utilisé côté client.

Partie cliente

Maintenant que notre site est généré avec Vue côté serveur, nous allons faire des modifications pour que la partie cliente prenne ensuite la main. Quand une page est affichée depuis le serveur, et que la partie cliente prend la main, les futures pages visitées seront générées côté client. Votre serveur se contentera tout au plus d'envoyer seulement les petits fragments manquants nécessaires pour le rendu de la page.

À partir d'ici l'exemple utilisant webconfig-www.json risque de ne plus fonctionner car nous allons modifier les fichiers index.htm, exist.htm et error.htm qui sont également utilisés par celui-ci. Si vous le souhaitez, vous pouvez les renommer en index-www.htm, exist-www.htm et error-www.htm et changer les noms dans le webconfig-www.json :

{
    "httpPort": 7778,
    "view": "layout.htm",
    "routes": {
        "/": "index-www.htm",
        "/foo/": "exist-www.htm",
        "/*": {
            "view": "error-www.htm",
            "statusCode": 404
        }
    }
}

afin que l'exemple marche toujours.

Nous allons donc changer les liens standards par des liens d'utilisation du routeur de Vue :

views/index.htm

<ul>
    <li><router-link to="/foo/">/foo/ existe</router-link></li>
    <li><router-link to="/foo">/foo n'existe pas</router-link></li>
</ul>

views/exist.htm

<div>
    <p><router-link to="/">Retour</router-link></p>
    <p><strong>200 : J'existe !</strong></p>
</div>

views/error.htm

<div>
    <p><router-link to="/">Retour</router-link></p>
    <p>404 : Je n'existe pas...</p>
</div>

À ce niveau là, si vous naviguez par exemple en cliquant sur /foo/ existe, vous constaterez que, même si vous avez remplacez les balises <a> par des balises <router-link>, votre source renvoyée en réponse par le serveur est valide W3C et une crème pour la SEO :

  • addresse : http://localhost:7777/foo/
  • status : 200
  • réponse :
    <!DOCTYPE html>
    <html lang="fr">
        <head>
            <meta charset="UTF-8">
            <title>Vue Router + Vue Server Renderer = Problème</title>
        </head>
        <body>
            <div data-server-rendered="true" class="app"><div><p><a href="/" class="router-link-active">Retour</a></p> <p><strong>200 : J&#x27;existe !</strong></p></div></div>
        </body>
    </html>
    

Ajoutons à présent à notre modèle de page global les animations CSS nécessaires au changement de page ainsi que le code client permettant à Vue d'hydrater la page courante en allant chercher les bons fichiers.

views/common.htm

<html lang="fr">
    <head>
        <meta charset="UTF-8">
        <title>Vue Router + Vue Server Renderer = Problème</title>
        <style>
            .fade-enter, .fade-leave-to { opacity: 0; }
            .fade-leave-active, .fade-enter-active { transition: opacity 1s; }
            .fade-enter-to, .fade-leave { opacity: 1; }
        </style>
    </head>
    <body>
        <!--vue-ssr-outlet-->
        <script src="https://cdnjs.cloudflare.com/ajax/libs/vue/2.4.1/vue.js"></script>
        <script src="https://cdnjs.cloudflare.com/ajax/libs/vue-router/2.7.0/vue-router.js"></script>
        <script src="<?- urlBasePath ?>/javascript/common.js"></script>
    </body>
</html>

Nous allons aménager notre code servi par le client en permettant à nos fichiers dans views d'être accessibles côté client grâce à l'ajout de fichiers statiques dans webconfig.json :

webconfig.json

{
    "httpPort": 7777,
    "view": "common.htm",
    "controller": "common.js",
    "routes": "routes.json",
    "statics": {
        "/views-models": "views/",
        "/routes.json": "routes.json"
    }
}

Nous avons donc décidez, en plus des routes existantes dans routes.json, de fournir côté client le contenu du dossier views derririère les URL http//localhost:7777/views-models/. Ainsi demander, par exemple, http//localhost:7777/views-models/index.htm nous retournera le contenu, côté client, du fichier views/index.htm. Nous avons également mis à disposition derrière l'adresse http//localhost:7777/routes.json le contenu du fichier routes.json, ce qui va nous permettre d'alimenter les routes de Vue Router avec les mêmes informations que notre serveur NodeAtlas.

Nous allons maintenant rendre disponible un fichier http//localhost:7777/javascript/common.js côté client en ajoutant un fichier statique dans le dossier assets de NodeAtlas qui est prévu pour cela.

├─ assets/
│  └─ javascript/
│     └─ common.js
├─ controllers/
│  └─ ...
├─ views/
│  ├─ ...
├─ ...

Nous aurons alors, côté client, accès au contenu de assets/javascript/common.js qui contient tout le code nécessaire pour hydrater le site :

assets/javascript/common.js

/* Nous créons à la main une fonction pour aller chercher en AJAX
   des fichiers à des adresses données. Cette fonction utilise les promesses,
   aussi vous ne pourrez tester ce code que dans les navigateurs les supportants.
   http://caniuse.com/#feat=promises */
window.module = {};
function xhr(url) {
  return new Promise(function (resolve, reject) {
    var request = new XMLHttpRequest(),
        type = url.match(/\.(js(on)?|html?)$/g, '$0')[0];

    request.addEventListener("load", function () {
      if (request.status < 200 && request.status >= 400) {
        reject(new Error("We reached our target server, but it returned an error."));
      }

      if (type === '.js') {
        resolve(eval(request.responseText));
      } else if (type === '.json') {
        resolve(JSON.parse(request.responseText));
      } else {
        resolve(request.responseText);
      }

    });

    request.addEventListener("error", function () {
      reject(new Error("There was a connection error of some sort."));
    });

    request.open("GET", location.origin + '/' + url, true);
    request.send();
  });
}

/* Le code qui nous intéresse commence ici.
   Nous commençons par charger la vue et le modèle
   de l'application en elle même, ainsi que les routes
   qu'il va falloir donner au routeur client. */
Promise.all([
    xhr("views-models/app.js"),
    xhr("views-models/app.htm"),
    xhr("routes.json")
]).then(function (files) {
    /* `files` contient le contenu de ces 3 fichiers. */
    var vm,
        routes = [],
        router,
        model = files[0],
        template = files[1],
        webconfig = {
            routes: files[2]
        },
        /* Nous créons un objet basé sur toutes les routes
           que nous allons parcourir pour alimeter le routeur. */
        keys = Object.keys(webconfig.routes);

    /* Pour chaque route de `routes.json`... */
    keys.forEach(function (key) {
        var route = {},
            name = webconfig.routes[key].view,
            model,
            template;

        /* ...nous associons un nom,... */
        route.name = name;
        /* ...nous associons un chemin de route et... */
        route.path = webconfig.routes[key].url;

        /* ...nous associons un composant. */
        route.component = function (resolve) {
            /* Nous chargeons alors la vue et le modèle
               du composant de la route en question. */
            Promise.all([
                xhr("views-models/" + name + ".js", "js"),
                xhr("views-models/" + name + ".htm", "htm"),
            ]).then(function (files) {
                model = files[0];
                template = files[1];

                /* Puis ceux-ci seront récupérés UNIQUEMENT
                   si la page est demandée. Cela permet de
                   ne demandé que le composant courant à la
                   page pour l'hydratation. */
                resolve(model(template));
            });
        };

        /* Nous créons notre objet de
           routes pour Vue Router. */
        routes.push(route);
    });

    /* Nous créons à présent notre routeur. */
    router = new VueRouter({
        mode: 'history',
        fallback: false,
        base: '/',
        routes: routes
    });

    /* Puis nous attribuons le routeur `router`, le `webconfig`
       et la vue `template` à notre modèle `model`. */
    vm = new Vue(model(template, router, webconfig));

    /* Quand le routeur a tout ce qui lui faut pour
       la page courante... */
    router.onReady(function () {
        /* Vue monte l'application sur le DOM en dessous
           de la balise avec la classe `app`. Comme cette
           balise contient l'attribut `data-server-rendered="true"`
           Vue ne recompile pas tout le contenu mais hydrate celui déjà
           en place. Dans notre cas, cela ce résume à ne rien faire
           puisque le rendu demandé côté client est identique à celui côté
           serveur (pas de variables qui diffèrent). */
        vm.$mount('.app');
    });
});

Test client

Puisque vous avez modifié le webconfig.json, vous devez quitter NodeAtlas en utilisant « ctrl + c » et relancer le site avec la commande node-atlas --browse.

Vous accéderez à :

  • addresse : http://localhost:7777/
  • status : 200
  • réponse :
    <html lang="fr">
        <head>
            <meta charset="UTF-8">
            <title>Vue Router + Vue Server Renderer = Problème</title>
            <style>
                .fade-enter, .fade-leave-to { opacity: 0; }
                .fade-leave-active, .fade-enter-active { transition: opacity 1s; }
                .fade-enter-to, .fade-leave { opacity: 1; }
            </style>
        </head>
        <body>
            <div data-server-rendered="true" class="app"><ul><li><a href="/foo/">/foo/ existe</a></li> <li><a href="/foo">/foo n&#x27;existe pas</a></li></ul></div>
            <script src="https://cdnjs.cloudflare.com/ajax/libs/vue/2.4.1/vue.js"></script>
            <script src="https://cdnjs.cloudflare.com/ajax/libs/vue-router/2.7.0/vue-router.js"></script>
            <script src="http://localhost:7777/javascript/common.js"></script>
        </body>
    </html>
    

Puis Vue prendra la main en hydratant le code côté client ! Aussi en cliquant sur /foo/ existe vous n'enclencherez pas de nouvelles réponses HTTP, vous récupérerez seulement les fragments http://localhost:7777/views-models/exist.htm et http://localhost:7777/views-models/exist.js nécessaires à l'affichage de la nouvelle page !

  • addresse : http://localhost:7777/foo/
  • status : — (pas de rechargement de page)
  • réponse : — (pas de rechargement de page)

Par contre, si vous actualisez la page avec le bouton actualiser, alors vous obtiendrez votre rendu courant depuis le serveur :

  • addresse : http://localhost:7777/foo/
  • status : 200
  • réponse :
    <html lang="fr">
        <head>
            <meta charset="UTF-8">
            <title>Vue Router + Vue Server Renderer = Problème</title>
            <style>
                .fade-enter, .fade-leave-to { opacity: 0; }
                .fade-leave-active, .fade-enter-active { transition: opacity 1s; }
                .fade-enter-to, .fade-leave { opacity: 1; }
            </style>
        </head>
        <body>
            <div data-server-rendered="true" class="app"><div><p><a href="/" class="router-link-active">Retour</a></p> <p><strong>200 : J&#x27;existe !</strong></p></div></div>
            <script src="https://cdnjs.cloudflare.com/ajax/libs/vue/2.4.1/vue.js"></script>
            <script src="https://cdnjs.cloudflare.com/ajax/libs/vue-router/2.7.0/vue-router.js"></script>
            <script src="http://localhost:7777/javascript/common.js"></script>
        </body>
    </html>
    

Mais en cliquant sur Retour, vous naviguerez de nouveau depuis l'hydratation cliente en récupérant les fragments http://localhost:7777/views-models/index.htm et http://localhost:7777/views-models/index.js nécessaires à votre navigation :

  • addresse : http://localhost:7777/
  • status : — (pas de rechargement de page)
  • réponse : — (pas de rechargement de page)

VueRouter + SSR = Problème de contenu dupliqué

Bien, nous voici arrivé au réel problème de cet article.

Problème

Avec le site que nous avons monté de toute pièce, voici ce qu'il va se passer si nous affichons la page https://localhost:7777/foo :

  • addresse : http://localhost:7777/foo
  • status : 404
  • réponse :
    <html lang="fr">
        <head>
            <meta charset="UTF-8">
            <title>Vue Router + Vue Server Renderer = Problème</title>
            <style>
                .fade-enter, .fade-leave-to { opacity: 0; }
                .fade-leave-active, .fade-enter-active { transition: opacity 1s; }
                .fade-enter-to, .fade-leave { opacity: 1; }
            </style>
        </head>
        <body>
            <div data-server-rendered="true" class="app"><div><p><a href="/" class="router-link-active">Retour</a></p> <p>404 : Je n&#x27;existe pas...</p></div></div>
            <script src="https://cdnjs.cloudflare.com/ajax/libs/vue/2.4.1/vue.js"></script>
            <script src="https://cdnjs.cloudflare.com/ajax/libs/vue-router/2.7.0/vue-router.js"></script>
            <script src="http://localhost:7777/javascript/common.js"></script>
        </body>
    </html>
    

Notre serveur renvoi une page 404 (avec un code d'erreur 404) généré avec Vue côté serveur en suivant les ordres de routage stricte de NodeAtlas. La page ainsi affichée avant que le JavaScript client ne s'exécute, et que Vue hydrate la page est donc générée depuis views/error.htm et views/error.js.

Cependant, une fois la main reprise côté client par le Vue Router, cette fois se sont les fragments http//localhost:7777/views-models/exist.htm et http//localhost:7777/views-models/exist.js qui sont appelés ! La page ne s'hydrate pas, mais voit son contenu changer ! Normalement, se sont les fichiers http//localhost:7777/views-models/error.htm et http//localhost:7777/views-models/error.js qui auraient dû être chargés, et la page aurait alors simplement été hydratée au lieu que le DOM complet de la partie applicative ne soit changé.

Ce problème est dû au fait que Vue Router ne possède pas de routage strict car initialement, il est plus pratique qu'une application rende la même page derrière http//localhost:7777/views-models/foo/ et `http//localhost:7777/foo puisqu'il n'y a pas à se soucier du référencement.

Dans notre cas c'est critique, et même l'options strict de Vue Router n'y change rien (actuellement).

Solution

En attendant que Vue Router puisse prendre en compte cette possibilité sans effets secondaires, un moyen de contournement simple est de manuellement rediriger l'utilisateur sur la page avec / s'il atterrit sur la page sans : c.-à-d. par exemple aller de /foo à /foo/. Ainsi, le fichier assets/javascript/common.js pourrait simplement être modifié de la sorte :

assets/javascript/common.js

if (window.location.pathname.slice(-1) !== '/') {
    window.location = window.location.pathname + '/';
}

window.module = {};
function xhr(url) {
    return new Promise(function (resolve, reject) {
/* ... reste du fichier ... */

De cette manière, Vue n'est même pas chargé côté client, et le navigateur redirige l'utilisateur sur la bonne page. De cette page, tout le processus reprend : Le serveur renvoi une page en 200, et Vue hydrate la page avec le bon composant.

Bien entendu, il reste à votre charge de ne pas fournir de lien vers /foo dans vos <router-link> car ça ne gènera pas votre application d'y emmener l'utilisateur.

Et maintenant ?

J'espère que cet article vous aura permis de comprendre et/ou d'expérimenter le SSR ! Vous avez maintenant une meilleure compréhension de la différence entre /foo/ et /foo et pouvez solutionner le problème de routage non strict de Vue Router.

Vous pouvez tester ce code chez vous en téléchargeant cette archive qui contient déjà la solution. Pour mettre en évidence le problème, commentez les 3 premières lignes du fichier assets/javascript/common.js. Pour lancer l'application, exécutez d'abord npm install dans le dossier, puis installez NodeAtlas npm install -g node-atlas et enfin faites tourner le projet avec la commande node-atlas --browse. Les deux autres exemples sont également fournis avec les commandes respectives node-atlas --browse --webconfig webconfig-www.json et node-atlas --browse --webconfig webconfig-spa.json.

Vous pouvez également remplacer NodeAtlas par le framework serveur de votre choix comme Express ou même du Node.js natif si vous trouvez que l'abstraction offerte par NodeAtlas est trop simple. Pour ma part, je vous conseillerais de l'adopter car il répondra simplement à la quasis totalité de vos besoins de création de site web et application web. Le site officiel se trouve ici, mais n'hésitez pas à venir sur le chat pour une information rapide ou même de l'aide sur cet article ci par exemple.