Webapp isomorphique avec Webpack, React, Router & Redux

L’isomorphisme ou JavaScript universal c’est cool ! Cela permet de rendre une Single Page Application JavaScript aussi rapide au premier chargement qu’un site web standard. De plus, cela permet l’indexation du contenu par un moteur de recherche et on sait à quel point c’est important.
Voici comment le mettre en oeuvre concrètement dans le contexte d’une application React & Redux.

L’isomorphisme, ce n’est pas “juste” une fonctionnalité de plus pour les framework JavaScript modernes. C’est ce qui leur permet aujourd’hui de sortir du domaine unique de l’application et permet d’adresser n’importe quel type de site, notamment les sites institutionnels dont le SEO est un enjeu majeur.
Côté framework, Angular 1 ne permet pas cette fonctionnalité, Angular 2 le propose via le projet encore jeune Angular Universal et React le propose déjà depuis maintenant quelque temps.

Principe général

Pour faire de l’isomorphisme avec React, il faut réaliser plusieurs étapes.

  • Tout d’abord, rendre le code executable dans Node. La transpilation s’effectue facilement avec le système du babel-register. Ensuite, il n’y a normalement pas grand chose à changer, mais pour peu que l’on utilise des fonctionnalités spéciales de Webpack, il peut être nécessaire de faire des adaptations. Exemple typique, l’import de fichiers de style. Il est possible par contre d’ajouter une variable d’environnement uniquement dans Webpack afin de conditionner le code spécifique à webpack.
  • Créer un serveur Node / Express relativement standard sans oublier de mapper les routes des ressources statiques type images, styles et JavaScript. Oui oui, JavaScript, en effet, le code de l’appli React sera en fait lu deux fois, une fois dans le serveur Node et une fois dans le navigateur.
  • Créer un middleware Express qui correspond au rendu côté serveur. Basiquement, cela correspond à utiliser la fonction React.renderToString et de positionner le code HTML généré dans un template. Il faudra retrouver quelle route de l’application est demandée. Pour cela React Router est bien instrumenté et propose une fonction qui prépare tout à partir de l’URL.
  • Les premières difficultés sont rencontrées avec la gestion des éléments asynchrones, comme les données d’une requête Ajax, il va falloir attendre que les données soient arrivées. Pour cela, React ne propose rien de spécial, les frameworks Flux sont plus ou moins industrialisés pour répondre à ce besoin. Redux en particulier ne propose rien de concret mais sa nature permet de mettre en place quelque chose assez facilement (plus de détails dans Mise en oeuvre).
  • Le code client ne change pas vraiment. On demande au React côté client de démarrer sur le même élément du DOM que ce qui a été généré côté serveur et il se rendra compte qu’il y a déjà du contenu.
  • Un nouveau problème arrive alors. Si l’état de l’application React n’est pas le même au démarrage du client qu’à la génération du contenu côté serveur, React loggera simplement qu’il n’y a pas correspondance et qu’il ne peut donc pas réutiliser le markup. A ce niveau là, concrètement, ça ne sert encore à rien. Il faut donc un mécanisme qui va extraire l’état de l’application lors de la génération côté serveur pour l’ajouter dans le contenu de la page pour qu’ensuite il soit chargé par React avant son démarrage. Là encore cette étape est plus ou moins industrialisée dans les frameworks Flux. Une fois la synchro de l’état réussie, vous aurez simplement le plaisir de voir le log disparaitre.

Une fois toutes ces étapes réalisées. Vous avez l’url demandée par le client pré-générée côté serveur avec le même code que votre application cliente et la page s’affiche immédiatement. Ensuite, votre code est chargé par le navigateur, React est initialisé au même état qui a été préparé puis lancé sur le même endroit du DOM. React va alors reprendre là où il en était et être capable de continuer côté client comme si de rien n’était.
Redux iso

Mise en oeuvre

Adaptation de l’application

Dans le contexte de cette application React / Router / Redux / Webpack, voici les adaptations qu’il faut mettre en oeuvre.

  • Retirer les imports de fichiers de style. En effet ils seront lus de façon littérale par Node et cela va planter. Pour contourner le problème, on ajoute une variable d’environnement dans Webpack avec cette conf :
new webpack.DefinePlugin({
  'process.env': {BROWSER: JSON.stringify(true)}
})
  • Puis on remplace les imports de style avec cette formule :
if (process.env.BROWSER) {
  require('../styles/component.scss');
}
  • Au niveau de la récupération des données via les actions Redux, il y a également des adaptations à faire. Côté serveur, on n’aura accès qu’aux composants racines liés au Router. Ce sont ces composants qui doivent déclencher les actions pour récupérer les données. Il faut également tester si les données ne sont pas déjà présentes, en effet, lors d’une reprise sur un état déjà construit, il ne faut pas relancer la requête.
  • Voici un exemple de code d’un composant lié au router :
componentDidMount() {
  if (_.isEmpty(this.props.trainings)) {
    this.props.fetchTrainingsList();
  }
}
  • Il va également falloir être capable de récupérer facilement les actions à lancer pour le chargement des données à partir de la définition du composant. On utilise pour cela une propriété statique du composant, par exemple ici needs:
Catalogue.needs = [fetchTrainingsList];
  • Ces actions devront être des actions thunk (redux-thunk) qui rendent une promesse (ou redux-promise). En effet, le backend doit pouvoir être capable de savoir quand elle sont terminées.

Middleware Express

Voici les principales étapes de génération du rendu côté serveur dans un middleware Express.

  • La première étape est l’analyse de l’URL demandée. Pour cela, react-router propose une fonction faite pour ça qui de façon schématique prend l’URL en argument et rend les composants déclenchés par le router.
match({routes, location: req.url}, (error, redirectLocation, renderProps) => {
  //...
}
  • Ensuite, l’objectif va être d’initialiser le store Redux, de lancer les actions nécessaires pour les composants identifiés par le router et faire bien attention à attendre la fin de leur traitement.
const store = configureStore();
// fetchComponentData parcourt tous les needs des composants en arguments
// dispatch les actions et rend une promesse résolue quand ils sont tous finis
fetchComponentData(store.dispatch, renderProps.components, renderProps.params)
  .then(() => {
    //...
  });
  • Enfin, nous avons un store prêt et les composants à rendre, il faut donc simplement demander à React de réaliser le rendu et de positionner la valeur du state dans le contexte de la page pour que le React côté client puisse redémarrer avec le même state.
const html = renderToString(
  <Provider store={store}>
    <RouterContext {...renderProps}/>
  </Provider>
);
const initialState = store.getState();
// renderFullPage insère l'HTML et le state dans le template du index.html
res.send(renderFullPage(html, initialState));

Contraintes dans les développements

Il y a des contraintes à prendre en compte pour le développement d’une application React qui sera isomorphique. On peut tout de même dire qu’elles sont tout à fait raisonnables par rapport à la complexité de l’opération et les gains qu’elle peut apporter.

Au niveau du code

Les contraintes pour le développeur ont été abordées au cours des explications précédentes mais pour les résumer:

  • Isoler les commandes liées à Webpack derrière des conditions s’ assurant qu’on est côté client
  • Déclencher les actions chargeant les données depuis les composants liés au router
  • Ne déclencher les actions chargeant les données que si les données ne sont pas encore dans le state.
  • Toujours dans les composants liés au router, identifier les actions nécessaires pour charger les données dans une propriété needs du composant.
  • Positionner le routage dans un fichier à part. Il sera lu également par le serveur.
  • Les actions de chargement asynchrones doivent être des fonctions thunk qui rendent une promesse qui sera résolue quand tous les traitements sont terminés.

Au niveau tooling

Lorsque vous lancerez votre application avec le serveur de prérendu, Webpack ne sera pas mis en oeuvre et vous n’aurez aucune fonctionnalité de recompilation à chaud de votre code.
Si vous accompagnez votre serveur de la fonctionnalité watch de Webpack, vous aurez une situation un peu délicate. En effet, il va recompiler les sources automatiquement pour le client mais côté serveur, les sources sont chargées dans Node au démarrage, il n’y a pas de rechargement à chaud. S’il y a des développements de fait pendant que le serveur est démarré, il y aura un décalage entre le code client et le code serveur. Il faut redémarrer le serveur pour prendre les mise à jour en compte.
Il faudra encore ajouter nodemon pour que le serveur redémarre automatiquement sur tout changement de source pour bénéficier du rechargement à chaud des sources côté serveur et côté client.

Laisser un commentaire

Ce site utilise Akismet pour réduire les indésirables. En savoir plus sur comment les données de vos commentaires sont utilisées.

%d blogueurs aiment cette page :