React Isomorphique en pratique
5ème et dernière semaine d'intégration et me voilà confronté à un projet peu commun mais ô combien intéressant. La mise en place d'un jeu d'Othello (et oui, encore lui!) dans le navigateur, en React.. et en isomorphique !
Au travers cet article, je vais vous présenter dans les grandes lignes qu'est ce que l'isomorphisme, pourquoi il peut être utile, ainsi que ses avantages et ses inconvénients.
Qu'est ce que l'isomorphisme ?
Le mot isomorphisme vient des racines grecques isos pour égal et morph pour forme. L'isomorphisme désigne donc deux entités de même forme dans un contexte différent.
En informatique, et plus particulièrement dans le domaine du développement web, on dit qu'une application est isomorphique lorsqu'elle partage le même code coté client (navigateur) et coté serveur.
Comme chacun de nous le sait, le principal langage (encore actif) capable de s'éxécuter dans un navigateur web est le Javascript. Il est par conséquent tout à fait naturel de faire le rapprochement avec une application Javascript lorsqu'il s'agit de parler de développement isomorphique pour le web.
L'isomorphisme, d'accord, mais pourquoi ? Cette question est digne d'être posée. En effet, jusqu'à récemment, personne n'avait ressenti le besoin d'appliquer ce principe pour développer des sites internet ou des applications. Il n'était d'ailleurs pas possible de le faire jusqu'à l'arrivée des premiers moteurs JS coté serveur.
En réalité, l'avénement de l'isomorphisme est dû à plusieurs facteurs dans le prolongement des évolutions du web et des technologies. Laissez moi vous faire un petit résumé approximatif des dates clés qui ont permis d'en arriver là:
- 1990: Premier site web, basique, pas de Javascript à cette époque
- 1995: Première version de Javascript (nom commercial donné à EcmaScript)
- 2005: "Web 2.0", intéractivité "basique", appels Ajax (XMLHttpRequest) sans rechargement
- 2006: Les frameworks Prototype, jQuery apparaissent.. Les pages deviennent "vivantes"
- 2009: Les SPA voient le jour, avec elles les frameworks BackboneJS, EmberJS, AngularJS..
- 2010: Les premiers moteurs JS coté serveur émergent (NodeJS, SpiderMonkey, ...)
- 2011: Besoin de partager le même langage client / serveur (MeteorJS, LoopBack, React...)
Comme vous pouvez le voir, l'isomorphisme est le résultat d'une longue évolution des mentalités. Au fur et à mesure du temps, le gros de la logique applicative est passé du serveur vers le client, pour ne laisser place au final qu'à un langage unique, Javascript!
Concrètement, les avantages de l'isomorphisme sont multiples:
- Un seul code, pour toute une application (serveur et client)
- Les moteurs de recherche voient le contenu (plus totalement vrai: Google interprète JS)
- Rapide, plus nécessaire d'attendre le téléchargement du JS pour voir la page
- Plus facile à maintenir (une seule codebase)
- Un état identique (partagé) entre client et serveur = un debug plus simple
Cependant, mettre en place de l'isomorphisme "from-scratch" n'est pas simple, c'est pourquoi il est souvent nécessaire d'utiliser un framework qui supporte ce mode de fonctionnement.
React et l'isomorphisme
Afin de développer cette application isomorphique, j'ai utilisé ReactJS, une librairie Javascript créée en 2013 par Facebook. Elle se définie elle même comme étant une librairie permettant de faciliter le développement de SPA à travers un système de composants visuels. Par "visuel", j'entends qu'elle n'est dédiée qu'au rendu, pour le reste de la logique il sera nécessaire de passer par un autre framework.
React se démarque des autres frameworks SPA par son utilisation d'un DOM Virtuel (stocké en mémoire) permettant de réduire les intéractions avec le DOM du navigateur (qui s'avérent très couteûses en terme de performance). Les éléments de ce Virtual DOM peuvent être déclarés par l'utilisation d'un langage intermédiaire créé lui aussi par Facebook et appelé JSX.
Il n'est pas rare pour un débutant de confondre le JSX avec de l'HTML tant le langage est proche; cependant, JSX (dans le cas d'une utilisation web) ne représente pas directement le DOM, mais une représentation de celui-ci, qui sera transformée en un objet javascript lors de la transpilation. Voici un exemple d'un code en JSX et de son équivalent en utilisant la notation classique de React (en ES6).
// Création de l'élément en JSX
// J'ai volontairement mis des {} autour de la classe "button"
// Des objets peuvent être passés en attribut des composants
// Les "strings" sont assez particulières
// Elles ne nécessitent pas de crochets englobants habituellement
class Button extends React.Component {
render() {
return (
<button className={"button"}>
<b>OK!</b>
</button>
);
}
}
// Création de l'élément à l'aide des "helpers" React
class Button extends React.Component {
render() {
return React.createElement(
"button",
{ className: "button" },
React.createElement("b", {}, "OK!")
);
}
}
// Création de l'élément en "brut" (objet JS)
class Button extends React.Component {
render() {
return {
type: "button",
props: {
className: "button",
children: {
type: "b",
props: {
children: "OK!",
},
},
},
};
}
}
Comme vous pouvez le voir, pour avoir le même rendu, il est possible d'utiliser 3 notations bien distinctes. Une possibilité qui montre bien le détachement total de React avec le DOM.
Très bien, mais ne parlons-nous pas d'isomorphisme ? Pourquoi dériver sur le fonctionnement de React ? Vous allez le comprendre juste après.
Jusqu'à maintenant, pour faire de "l'isomorphisme" (vous comprendrez bientôt pourquoi il est entre quotes), il était nécessaire de "capturer" le code HTML construit par Javascript coté client afin de le servir à la prochaine requête depuis le serveur (cela grâce à un système de navigateur "headless" type phantomJS).
En effet, aucun framework n'était capable de générer du code HTML à la fois du coté client et du coté serveur. C'est pourquoi de nombreux services se sont mis à fleurir pour offrir un tel système (le plus connu étant prerender.io).
Avec React, nul besoin d'un service externe et tout cela grâce à l'utilisation du DOM virtuel ! L'utilisation d'un "DOM" virtuel permet à React d'être totalement "context agnostic", ce qui lui permet de générer un rendu coté serveur... ou même sur mobile ! A ce propos, je vous invite à lire l'article de Florian sur ReactNative, une implémentation de React pour le développement d'applications mobiles.
De la théorie à la pratique
Pour le développement de cette application, et par défi personnel, j'ai fais le choix du "total" isomorphique. C'est à dire que j'ai voulu rendre l'application totalement fonctionnelle à la fois avec et sans Javascript activé dans mon navigateur.
Bien souvent, selon les besoins, les applications ne sont pas totalement isomorphiques. Il est par exemple impossible d'intéragir avec certains formulaires sans Javascript... Le but étant seulement de rendre le site plus rapide aux yeux des utilisateurs ou des moteurs de recherche pour augmenter le Rank SEO.
Organisation
Afin d'organiser au mieux mon application, j'ai séparé mon code dans trois dossiers distincts "client", "server" et "shared" pour le code partagé entre les environnements.
├── config
│ └── default.js
├── src
│ ├── client
│ │ └── client.js
│ ├── server
│ │ ├── api
│ │ │ └── games.js
│ │ ├── middleware
│ │ │ ├── api.js
│ │ │ └── app.js
│ │ ├── server.js
│ │ └── views
│ │ └── index.ejs
│ └── shared
│ ├── app
│ │ ├── actions
│ │ ├── components
│ │ ├── containers
│ │ ├── reducers
│ │ ├── routes
│ │ ├── sagas
│ │ ├── propTypes.js
│ │ ├── App.js
│ │ └── store.js
│ ├── reversi
│ │ ├── board
│ │ ├── cell
│ │ ├── game
│ │ ├── matrix
│ │ ├── player
│ │ └── vector
│ ├── routes.js
│ └── utils
│ ├── hashGenerator.js
│ └── StyleProvider.js
├── README.md
├── LICENSE
├── Makefile
├── package.json
└── webpack.config.js
Comme vous pouvez le voir, le code client ne consiste qu'en un seul fichier (client.js) utilisant tous les fichiers de "shared". Il est compilé à l'aide d'une commande Webpack. Le résultat est alors stocké dans un fichier "bundle.js" qui est ensuite servi tel quel au navigateur après le chargement de la page.
Le dossier serveur quand à lui, contient à la fois le code responsable du rendu de l'application React en isomorphique (middleware/app.js) et le code de l'api responsable de la manipulation des parties (interaction avec la base de données), des placements de pions, ... (middleware/api.js). Le point d'entrée du serveur étant le fichier "server.js".
Par facilité, j'ai choisi d'utiliser un stockage en mémoire pour mes parties (voir api/games.js). Si j'avais eu plus de temps, j'aurais également plus utiliser n'importe quelle base de donnée, qu'elle soit relationnelle (PostgreSQL, mySQL, ...) ou orientée document (mongoDB, ...). Cela n'a que peu d'importance étant donné que c'est l'api coté serveur qui est chargée d'intéragir avec la base. En revanche, l'utilisation d'un stockage "in-memory" implique qu'à chaque redémarrage du serveur nodeJS, l'ensemble des parties sont perdues.
Quand à la logique de l'application, presque tout est placé dans "shared". Le dossier "shared/app" contient l'implémentation du jeu (composants React, reducers et actions redux, sagas, ...) alors que le dossier "shared/reversi" contient la logique propre à Othello (totalement générique) que j'ai repris de mon précédent défi, le développement d'un jeu d'Othello sur mobile en ReactNative.
Principe de fonctionnement
D'accord, mais concrètement, comment se présente le workflow de cette application isomorphique ? Un petit schéma vaut mieux qu'un long discours.
Concrètement, lorsque l'utilisateur accède à une page de l'application (1), le code HTML est généré coté serveur à partir des composants partagés (shared/app) puis renvoyé au navigateur (2).
Ensuite, le navigateur télécharge le fichier Javascript de l'application déclaré dans le code HTML (3). Une fois téléchargé, React s'éxécute et construit l'arbre du DOM virtuel à partir de l'état qui a été fournit en Json dans le code HTML (via une variable globale window.__INITIAL_STATE__).
À partir de son algorithme de "diff", React compare l'arbre de DOM virtuel généré avec celui déjà en place dans le DOM, il résout les éventuels conflits et "patch" le DOM en conséquence.
Enfin, la personne peut jouer et naviguer dans l'application sans aucun rafraîchissement de page, le tout grâce au routeur intégré à l'application (react-router) et à des appels Ajax directement effectués sur l'API (4).
Note: Afin de réaliser des appels HTTP aussi bien depuis le serveur que le client sans se soucier de l'environnement, une excellente librairie appelée axios a été utilisée.
Où est la magie ?
Nous avons la structure, nous avons les principes et les grandes lignes du fonctionnement, mais objectivement, où est-ce que cette application isomorphique se démarque d'une application classique en React ?
Afin de clarifier les choses, voici un extrait (épuré en conséquence) du code utilisé pour générer le code HTML à partir des composants React du coté serveur et du coté client.
// Rendu coté client (client.js)
// Comme on peut le voir, le store est initialisé
// à partir du state défini en global sur la page html
const store = configureStore(window.__INITIAL_STATE__);
render(
<Provider store={store}>
<Router history={browserHistory} routes={routes} />
</Provider>,
document.getElementById("react-root")
);
// Rendu coté serveur (app.js)
// "match" permet d'associer une route à partir d'une url
// C'est une fonction directement intégrée à react-router
// Attention, JSON.stringify est utilisé pour l'exemple
// Il expose des risques de failles XSS. + d'infos: https://goo.gl/qxisOq
match({ routes, location: req.url }, (error, redirectLocation, props) => {
const store = configureStore();
const rootComponent = (
<Provider store={store}>
<RouterContext {...props} />
</Provider>
);
store.runSaga(sagaFactory(config)).done.then(() => {
return res.render("index", {
state: JSON.stringify(store.getState()),
app: renderToString(rootComponent),
});
});
renderToString(rootComponent);
store.close();
});
Comme vous pouvez le voir, du coté client, le principe de fonctionnement reste fondamentalement le même que dans une application classique. Nous utilisons la fonction render de react-dom à laquelle nous fournissons le composant de base ainsi qu'un point de montage dans le DOM. La seule différence réside dans l'hydratation du store redux. Ici, nous utilisons le state fournit sous la forme d'une variable globale par le rendu serveur pour synchroniser les deux environnements.
Coté serveur en revanche, de nombreux acteurs rentrent en jeux. Laissez moi vous présenter en détails leurs rôles et caractéristiques:
La fonction match (fournie par "react-router") permet à partir des routes de l'application et de l'url courante de définir un context de routing et donc de rendre l'élément adéquat.
store.runSaga démarre l'ensemble des sagas et retourne une promesse qui permettra, lorsque résolue (à la fermeture du store "écouté"), de déclencher le rendu et l'envoi de la réponse.
La fonction renderToString de "react-dom/server" permet comme son homologue "render" (cf code client) de rendre un composant React. En revanche, au lieu d'appliquer ce rendu sur le DOM, elle se charge de retourner le code HTML généré sous forme de chaine de texte.
"res.render" permet de générer à partir du template "index.ejs" et des variables nécessaires le code HTML complet de la page et de le retourner dans la réponse du serveur.
"store.close()" déclenche la fermeture du store, et ainsi le déclenchement de la promesse décrite ci-avant, et donc le rendu de la page.
Pour satisfaire le besoin d'hydratation asynchrone du store redux, j'ai utilisé une technique assez particulière mais qui mériterait d'être d'avantage creusée.
Dans le cas présent, nous effectuons un premier renderToString afin de déclencher un "componentWillMount" sur l'ensemble des components qui eux, vont se charger de déclencher des actions redux afin de remplir le store (à partir des sagas).
Un second renderToString est effectué ensuite dans le callback de la promesse afin d'effectivement rendre les components avec le state correctement hydraté. Cette méthode n'est pas la plus optimale car elle requiert la double exécution du rendu.
Des librairies permettant de gérer le rendu asynchrone de composants existent, mais la plupart ne permettent pas (ou difficilement) l'accès à un store redux (react-resolver, async-props, react-async, react-refetch, redux-async-connect..).
Dans cette application, ce sont respectivement expressJS et eJS qui ont été utilisés comme serveur web et moteur de template. Ils ont étés choisi pour leur forte communauté et leur simplicité d'utilisation, mais il en existe biensur de nombreux autres.
Et comme cet article ne serait pas complet sans un aperçu du résultat, voici une petite démonstration de l'application dans le navigateur et avec Javascript activé (le rendu sans JS est quasiment semblable, sans les logs d'état sur la droite).
Conclusion
Cette dernière semaine d'intégration m'a permis de renouer une seconde fois avec l'isomorphisme et React en général. Elle a également été l'occasion de me rendre compte qu'il est très difficile de faire de l'isomorphisme "total" et entièrement fonctionnel sans Javascript activé au niveau du navigateur (création de "hacks" à base de redirections vers le referer, utilisation de meta refresh pour déclencher le placement de pion de l'ordinateur...) .
L'expérience m'a également permis de faire la connaissance de JSS (et son implémentation React), une librairie permettant de styliser ses composants en Javascript. Je ne reste cependant pas convaincu de l'utilité de ce genre de pratique qui ne permet pas à un intégrateur de travailler séparément et qui manque de souplesse au niveau des règles CSS.
Petit regret cependant, je n'ai pas eu le temps de tester unitairement mon application. Ce qui aurait d'ailleurs été assez intéressant étant donné la nature isomorphique du projet.
Concernant mes PR, j'ai fais des efforts pour séparer au mieux mes modifications sur le code et cela s'est plutôt bien fait ressentir dans mes échanges avec mon mentor (échanges plus brefs, plus fréquents, et donc plus constructifs). Je tâcherais de continuer sur cette voie pour mes futurs projets, car c'est un fondement de l'agilité.
Pour conclure au sujet de l'isomorphisme, bien que les débits augmentent, l'isomorphisme à encore de beaux jours devant lui. Les mobiles étant de plus en plus utilisés et les utilisateurs de plus en plus impatients, la vitesse de rendu d'une page reste un intérêt majeur aujourd'hui.
Je vous invite à retrouver l'ensemble du code sur Github où le projet est totalement open-sourcé comme nous en avons l'habitude chez Marmelab.