DIY 2 – Un routeur SPA en JavaScript
Lorsqu’on pense aux frameworks JavaScript modernes comme Angular, React ou Vue, on peut se demander comment l’architecture monopage intègre la navigation dans ses outils offerts aux développeurs. Pour ce deuxième article de la série DIY (Do It Yourself), je vous propose de regarder ensemble comment on peut créer un routeur de navigation comme celui des frameworks populaires.
Le code source de cet article est disponible sur GitLab.
Objectif
Créer un routeur configurable qui charge paresseusement (lazy loading) des composants selon certains paramètres de route de l’URL. Pour des fins académiques, la syntaxe JavaScript ES5 sera utilisée.
Plan
- Installer un serveur de test
- Créer la structure du projet
- Démontrer le module pattern
- Créer un chargeur de composant
- Créer un routeur
- Deux vues
- Initialiser et configurer le routeur dans un main
Prérequis
- Une base avec JavaScript
- Bonne compréhension de l’OO
- Node.js
Étape 1 – Installer un serveur de test
Un de mes packages npm favoris est http-server. Il permet de créer une instance de serveur web localement sans configuration. Installez-le via :
1 |
npm install -g http-server |
Avec cet outil, vous pourrez lancer un serveur dont la racine sera le répertoire en cours.
Étape 2 – Créer la structure du projet
Dans un premier temps, je crois qu’il est primordial de se dessiner une petite architecture sur une napkin. Voici ce que nous allons tenter d’implémenter :
Donc ici, on doit comprendre que l’utilisateur va demander une page spécifique à via l’URL. Puis, le routeur va réagir à l’URL pour déterminer la page à charger. Cette dernière le sera via un chargeur de composant et finalement injectée dans le DOM (Document Objet Model). Lorsque l’utilisateur demandera une nouvelle page, le precessus recommencera :
Dans un répertoire vide, créer les répertoires suivants :
- scripts
- views
- view-models
- styles
Puis créer un fichier index.html à la racine.
Étape 3 – Démonstration du module pattern
JavaScript n’a pas de concept de membre privé. L’encapsulation est donc légèrement plus difficile à implémenter, mais pas impossible. Pour ce faire, nous devons utiliser le module pattern.
Créer une fonction anonyme :
1 2 3 |
function () { } |
Vous le savez, les variables déclarées avec var sont de portée fonction, ce qui signifit qu’elles ne sont accessibles que de l’intérieur de la fonction. Ensuite, faites en sorte que cette fonction retourne un objet littéral :
1 2 3 4 5 |
function () { return { }; } |
Cet objet servira à exposer les variables et fonctions déclarées à l’intérieur de la fonction anonyme pour les rendre accessibles de l’extérieur. Par exemple :
1 2 3 4 5 6 7 8 9 10 11 |
function () { var _privateVariable = 'Hello'; var _someMethodToBeExposed = function () { console.log(_privateVariable); } return { someMethodToBeExposed: _someMethodToBeExposed }; } |
De cette façon, _privateVariable est encapsulée. Elle reste accessible lorsqu’on appelle someMethodToBeExposed() à cause du principe closure et de chaînage de portée (ang.), mais elle est complètement inaccessible de l’extérieur de la fonction anonyme.
On peut s’imaginer cette dernière comme un constructeur. La dernière étape au patron est de rendre cette fonction auto-exécutable ou immediately-invoked function expression (IIFE) (ang.). Pour ce faire, il faut simplement entourer la fonction anonyme de parenthèses, comme pour l’isoler, puis mettre deux autres parenthèses à la suite pour l’appeler.
L’objet JSON retourné peut alors être récupéré dans une variable.
1 2 3 4 5 6 7 8 9 10 11 |
var MonModule = (function () { var _privateVariable = 'Hello'; var _someMethodToBeExposed = function () { console.log(_privateVariable); } return { someMethodToBeExposed: _someMethodToBeExposed }; })(); |
De plus, on pourrait passer des dépendances comme jQuery dans ses paramètres comme ceci :
1 2 3 |
var MonModule = (function ($) { // Code qui utilise $() })(jQuery); |
Étape 4 – Créer un chargeur de composant
La responsabilité unique du chargeur de composant est, comme son nom l’indique, de charger une vue pour l’afficher dans le DOM. Les vues seront stockées dans le répertoire views en HTML.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 |
/** * Module ComponentLoader * Est appellé par ApplicationRouter (voir /scripts/application-router.js) * Ne fait que créer une requête AJAX pour obtenir le code * source HTML d'une vue à charger, puis l'injecte dans le DOM. */ var ComponentLoader = (function () { var _xhr = new XMLHttpRequest(); /** * Charge le source de la vue et l'injecte dans le DOM. * Le fichier doit se trouver dans le répertoire views. * * @param viewName Le nom du fichier HTML de la vue sans l'extension */ var _load = function (viewName) { console.log('ComponentLoader::load()'); _fetchViewSource(viewName, function (viewSource) { document.getElementById('app-root').innerHTML = viewSource; }); }; /** * Fait la requête pour charger le fichier HTML. * * @param viewName Nom du fichier HTML à charger sans l'extension * @param callback Fonction qui sera appellée au retour de la requête AJAX * @private */ var _fetchViewSource = function (viewName, callback) { _xhr.open('GET', '/views/' + viewName + '.html', true); _xhr.onload = function () { callback(_xhr.response.toString()); }; _xhr.send(); }; /** * Exposition des membres publiques */ return { load: _load }; })(); |
Étape 5 – Créer un routeur
La responsabilité du routeur est de simplement réagir aux changements de l’URL pour appeler le chargeur de composant avec la bonne vue. Pour ce faire, il faut créer une méthode d’initialisation qui accepte des configurations de route comme ceci :
1 2 3 4 5 6 |
{ path: 'Home', // http://localhost:8080/#Home view: 'home', // Vue = /views/home.html viewModel: HomeViewModel, // Voir /view-models/home-view-model.js isDefault: true // Si aucune route ne correspond, charger cette vue } |
Une fois la configuration des routes établie par l’appelant, le routeur peut ressembler à ceci :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 |
/** * Module ApplicationRouter * Permet de configurer des routes et d'écouter le changement * du hash de l'URL (navigation locale) pour charger une vue * spéficique au hash. */ var ApplicationRouter = (function (componentLoader) { var _routesConfig = null; var _lastRouteConfig = null; /** * Permet de configurer les routes et d'attacher le routeur * à l'événement de changement au hash de l'URL. * * @param routesConfig Un tableau de configuration des routes */ var _initializeRoutes = function (routesConfig) { _routesConfig = routesConfig; window.onhashchange = _handleLocationChange; _handleLocationChange(); }; /** * Fonction appellée lorsque le hash de l'URL change. * Elle appelle les méthodes du cycle de vie d'une vue, puis * appelle le component loader pour charger la vue dans le DOM. * @private */ var _handleLocationChange = function () { console.log('ApplicationRouter::_handleLocationChange()'); var requestedRouteConfig = _getRequestedRouteConfig(); _callViewModelLifecycleMethods(requestedRouteConfig); _lastRouteConfig = requestedRouteConfig; componentLoader.load(requestedRouteConfig.view); }; /** * Analyse l'URL pour déterminer la route demandée, puis * retrouve la configuration de route qui correspond au * chemin de l'URL. Retourne la route par défaut si * aucune configuration ne peut être trouvée. * * @returns la configuration de la route demandée dans l'URL * @private */ var _getRequestedRouteConfig = function () { var pathRequested = location.hash.substring(1); var routeConfigFound = null; var defaultRouteConfig = null; var i = 0; while (i < _routesConfig.length && !routeConfigFound) { var currentRouteConfig = _routesConfig[i]; if (currentRouteConfig.path === pathRequested) { routeConfigFound = currentRouteConfig; } else if (currentRouteConfig.isDefault === true) { defaultRouteConfig = currentRouteConfig; } i++; } return routeConfigFound || defaultRouteConfig; }; /** * Appelle les méthodes du cycle de vie d'une vue si elles sont définies. * onInit sera appellée sur le modèle de vue en cours * onDestroy sera appellée sur le modèle de vue qui est déchargé. * @param routeConfig * @private */ var _callViewModelLifecycleMethods = function (routeConfig) { if (_lastRouteConfig && typeof _lastRouteConfig.viewModel.onDestroy === 'function') { _lastRouteConfig.viewModel.onDestroy(); } if (typeof routeConfig.viewModel.onInit === 'function') { routeConfig.viewModel.onInit(); } }; /** * Exposition des membres publiques */ return { initializeRoutes: _initializeRoutes } })(ComponentLoader); |
Étape 6 – Créer deux vues
Chacune des vues de l’application aura une interface en HTML et un module JavaScript responsable de réagir aux comportements de l’utilisateur. Créer simplement une vue et un module ViewModel comme ceci :
1 2 3 4 |
<h1>Accueil</h1> <p> Cette page contient de l'information pertinante. </p> |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
/** * Module HomeViewModel * Objet permettant de stocker l'état de la vue home.html * et de réagir au comportement de l'utilisateur. */ var HomeViewModel = (function () { var _onInit = function () { console.log('HomeViewModel::onInit()'); }; var _onDestroy = function () { console.log('HomeViewModel::onDestroy()'); }; return { onInit: _onInit, onDestroy: _onDestroy }; })(); |
Répéter pour une autre vue, par exemple « about ».
Étape 7 – Initialiser et configurer le routeur dans un main
Pour lancer le routeur et l’application par le fait même, il nous faut un autre module. Ce module appelera uniquement la méthode de configuration du routeur.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
/** * main.js * Ce fichier sert à démarrer l'application. */ var routes = [ { path: 'Home', // http://localhost:8080/#Home view: 'home', // Vue = /views/home.html viewModel: HomeViewModel, // Voir /view-models/home-view-model.js isDefault: true // Si aucune route ne correspond, charger cette vue }, { path: 'About', view: 'about', viewModel: AboutViewModel } ]; /** * Module Application * Démarre l'application en initialisant le routeur */ var Application = (function (router, routes) { router.initializeRoutes(routes); /** * Exposition des membres publiques */ return { }; })(ApplicationRouter, routes); |
Puis, intégrer le tout dans index.html
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
<!DOCTYPE html> <html lang="fr"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>DIY - Un routeur JavaScript</title> <link href="https://fonts.googleapis.com/css?family=Lato" rel="stylesheet"> <link href="styles/theme.css" rel="stylesheet"> </head> <body> <header> <nav> <a href="#Home">Accueil</a> <a href="#About">À propos</a> </nav> </header> <section id="app-root"> <!-- Contenu injecté ici --> </section> <!-- Scripts à la fin pour laisser le DOM se charger --> <script src="scripts/component-loader.js"></script> <script src="scripts/application-router.js"></script> <script src="view-models/home-view-model.js"></script> <script src="view-models/about-view-model.js"></script> <!-- Bootstrap l'application --> <script src="scripts/main.js"></script> </body> </html> |
Conslusion
Gosso modo, voici comment fonctionne un routeur d’application monopage. J’espère sincèrement que vous appréciez cette série d’articles qui n’en est qu’à ses balbutiments. Si vous l’avez manqué, vous pouvez relire le premier article qui montre comment créer un module Angular et le publier sur npm de A à Z. Si ce n’est déjà fait, payez-nous une bière pour nous encourager à produire du contenu gratuit et sans pub pour vous 🙂
À la semaine prochaine!
Commentaires
Laisser un commentaire