Tutoriel pour apprendre l'isomorphisme avec React et Node.js

Olivier Boisse

L'utilisation de frameworks JavaScript est une pratique de plus en plus répandue à l'heure actuelle. Ces derniers nous permettent de mieux organiser nos projets et d'augmenter notre productivité. On peut toutefois se retrouver coincé lorsque l'on se penche sur la question du référencement. La plupart des moteurs de recherche ne liront pas le JavaScript et, par conséquent, ne pourront pas indexer correctement les pages de votre site.

On entend parler aujourd'hui d'application isomorphe (ou universelle) dont la particularité est de pouvoir générer le rendu HTML à la fois côté client et côté serveur. Cette technique est accessible avec l'utilisation de Node.js qui nous permet de tirer profit du JavaScript côté serveur.

Dans ce tutoriel, j'illustrerai mes propos à travers un exemple en utilisant React et Node.js. React est une bibliothèque JavaScript, développée par Facebook, permettant de créer des composants qui constitueront l'interface du site. Sa particularité est de manipuler le DOM de façon intelligente en ne modifiant que le strict minimum lors du rafraîchissement des données. Cette notion apparaîtra plus clairement dans la suite de ce tutoriel.

Commentez ♪

- Titre : Tutoriel pour apprendre l'isomorphisme avec React et Node.js

- Auteur : Olivier Boisse

- Parution : 8 juin 2017

- Licence : Copyright ® 2016 Olivier Boisse. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.

Sommaire

- I. Pourquoi avoir recours à l'isomorphisme ?

- II. Un exemple concret

- II-A. Implémentation des composants

- II-A-1. Signification de state

- III. Intégration côté serveur

- IV. Intégration côté client

- V. Remarques

- VI. La phase de build

- VI-A. Transformation JSX

- VI-B. Génération du bundle

- VII. Conclusion

- VIII. Remerciements

I - Pourquoi avoir recours à l'isomorphisme ?

Comme je l'ai dit précédemment, un des motifs majeurs concerne le référencement. On peut également trouver un autre avantage concernant la rapidité d'affichage des pages.

Traditionnellement, avant la visualisation d'une page, le framework JavaScript doit s'initialiser, éventuellement récupérer des données en exécutant une requête AJAX, puis procéder à la génération du rendu HTML.

En générant la vue côté serveur, l'utilisateur bénéficiera d'un accès immédiat aux données. Les interactions avec l'interface resteront disponibles une fois le framework JavaScript chargé dans le navigateur.

II - Un exemple concret

Le but sera d'afficher une liste de produits avec un champ de recherche. La première étape consiste à découper la vue en un ensemble de composants.

Le composant global (ProductListComponent) permettra d'assurer la synchronisation des données entre la barre de recherche (SearchBar) et le tableau des produits (ProductTable).

II-A - Implémentation des composants

Les composants seront créés en utilisant la dernière version de JavaScript. Pour introduire React brièvement, le rendu du composant est spécifié dans la méthode render. Le langage JSX est utilisé dans cette méthode permettant de garder une syntaxe similaire au HTML.

ProductListComponent

1. class ProductListComponent extends React.Component {

3. constructor(props) {

4. super(props);

5. //On stocke les produits dans l'objet state

6. this.state = { products: props.products };

7. //Permet de lier la fonction au composant React

8. this.handleSearch = this.handleSearch.bind(this);

9. }

11. //Méthode appelée lors d'une recherche

12. handleSearch(productName) {

13. const url = '/products?productName=' + productName;

14. $.get(url,  (data) => {

15. this.setState({

16. products: data

17. });

18. });

19. }

21. render() {

22. return (

23. <div>

24. <SearchBar onSearch={this.handleSearch} />

25. <ProductTable products={this.state.products}/>

26. </div>

27. );

28. }

29. }

Le composant global définit la barre de recherche ainsi que le tableau des produits dans sa méthode render. Lorsque l'utilisateur lancera une recherche, la fonction handleSearch sera appelée, une requête AJAX permettra de récupérer les produits correspondants, puis de les transmettre au composant ProductTable grâce à la méthode setState.

II-A-1 - Signification de state

Lorsque l'on a des données susceptibles de changer, on les stocke dans la variable this.state. À chaque appel de la méthode setState, la fonction render sera appelée, entraînant un rafraîchissement du composant. Concernant les données immuables, elles seront définies en tant que propriétés du composant, et on y aura accès grâce à this.props.

SearchBar

1. class SearchBar extends React.Component {

3. constructor() {

4. super();

5. this.searchProduct = this.searchProduct.bind(this);

6. this.keyDownHandler = this.keyDownHandler.bind(this);

7. }

9. //Méthode appelée dès que le composant est chargé

10. //dans le navigateur

11. componentDidMount() {

12. this.textInput.focus();

13. }

15. searchProduct() {

16. const productName = this.textInput.value;

17. this.props.onSearch(productName);

18. }

20. keyDownHandler(event) {

21. //On lance la recherche si l'utilisateur

22. //saisit la touche Enter

23. if(event.keyCode == 13){

24. this.searchProduct();

25. }

26. }

28. render() {

29. return (

30. <div className="form-group">

31. <div className="input-group col-md-6">

32. <input type="text"

33. className="form-control"

34. onKeyDown={this.keyDownHandler}

35. //Permet de stocker la référence du champ

36. //texte dans la variable textInput

37. ref={(ref) => this.textInput = ref}

38. placeholder="Search...." />

40. <span className="input-group-btn">

41. <button type="button"

42. onClick={this.searchProduct}

43. className="btn btn-primary">

44. <span className="glyphicon glyphicon-search">

45. </span>

46. </button>

47. </span>

48. </div>

49. </div>

50. );

51. }

52. }

Petite subtilité ici, pour procéder à la recherche, on utilise la propriété this.props.onSearch. Rappelez-vous que dans le composant précédent, nous avions associé la fonction handleSearch à cette propriété. La méthode componentDidMount sera appelée dès que le composant sera chargé dans le navigateur. C'est à partir de ce moment-là que l'on pourra manipuler le DOM. Ici on s'en sert pour donner le focus sur le champ texte.

ProductTable

1. class ProductTable extends React.Component {

3. render() {

4. const rows = [];

5. this.props.products

6. .forEach(

7. (p) => rows.push(<ProductItem key={p.id} product={p} />)

8. );

10. return (

11. <table className="table table-hover">

12. <thead>

13. <tr>

14. <th>Nom</th>

15. <th>Quantité</th>

16. <th>Lieu de fabrication</th>

17. </tr>

18. </thead>

19. <tbody>

20. {rows}

21. </tbody>

22. </table>

23. );

25. }

26. }

28. class ProductItem extends React.Component {

29. render() {

30. return (

31. <tr>

32. <td>{this.props.product.name}</td>

33. <td>{this.props.product.quantity}</td>

34. <td>

35. <img src="img/blank.gif"

36. className={"flag flag-" +

37. this.props.product.from.suffix}

38. alt={this.props.product.from.title}

39. title={this.props.product.from.title} />

40. </td>

41. </tr>

42. );

43. }

44. }

Rien de bien compliqué, les lignes du tableau correspondent à un ensemble de ProductItem. La liste des produits à afficher est contenue dans la propriété this.props.products.

III - Intégration côté serveur

Node.js combiné à Express nous permet de coder la partie serveur, la méthode renderToString se chargera de convertir le composant React en chaîne de caractères contenant le rendu HTML.

1. const data = require('./data/products.js');

2. const ProductListComponent =

3. React.createFactory(require('./components/ProductListComponent'));

4. const React = require('react');

5. const ReactDOMServer = require('react-dom/server');

6. const express = require('express');

8. const app = express();

10. app.get('/', function (req, res) {

12. const products = {'products' : data};

14. const htmlProductListComponent = ReactDOMServer.renderToString(

15. ProductListComponent(products)

16. );

18. res.render("index", {

19. component: htmlProductListComponent,

20. context: JSON.stringify(products)

21. });

23. });

25. app.listen(8080);

Lors de l'accès à la page d'accueil, on récupère les produits (stockés dans un fichier statique). On génère le rendu HTML du composant, puis on redirige vers la page de template index. On lui passera la propriété component qui contient le rendu HTML du composant, ainsi que la propriété context permettant de transmettre au client les produits chargés.

1. <body>

2. <div class="container">

3. <div id="product-parent"><%- component %></div>

4. </div>

6. <script id='context' type='application/json'>

7. <%= context %>

8. </script>

9. </body>

Le langage de template utilisé est EJS. La balise <%- permet d'afficher une valeur non échappée, on l'utilise pour afficher le composant étant donné que React nous protège de la faille XSS. La balise <%= échappera les caractères spéciaux afin de se prémunir de cette attaque (injection de code JavaScript dans une donnée d'un produit, par exemple), on l'utilisera donc pour stocker les produits au format JSON.

IV - Intégration côté client

1. const React = require('react');

2. const ReactDOM = require('react-dom');

3. const ProductListComponent =

4. React.createFactory(require('../components/ProductListComponent'));

6. const context = JSON.parse(

7. decodeHTML(document.getElementById('context').textContent)

8. );

10. const mountNode = document.getElementById("product-parent");

12. ReactDOM.render(

13. ProductListComponent({'products': context.products}),

14. mountNode

15. );

On récupère le nœud parent, auquel on ajoute notre composant en lui passant les produits transmis par le serveur. On s'aperçoit ici de l'utilité de la balise script id='context' définie dans le template : elle nous permet de récupérer l'objet products afin d'initialiser notre composant.

Je rappelle que c'est uniquement une fois le composant chargé côté navigateur que les interactions utilisateurs seront accessibles. Par exemple, si l'utilisateur clique sur le bouton de recherche avant la fin du chargement, l'évènement ne sera pas pris en compte. On pourrait très bien décider par défaut de désactiver le bouton, pour ensuite l'activer à l'intérieur de la méthode componentDidMount. Le rendu côté serveur est donc utile pour visualiser le contenu plus rapidement.

V - Remarques

Côté serveur, il faut bien penser à transmettre au client toutes les données nécessaires à la création des composants afin d'obtenir exactement le même rendu des deux côtés. Finalement, nos composants React pourront être utilisés de part et d'autre.

Si vous avez bien suivi, vous pouvez vous demander si ce n'est pas une mauvaise chose de réafficher le même composant côté client, le DOM sera inutilement modifié puisqu'on va insérer exactement le même contenu HTML que celui généré par le serveur.

Prenons, par exemple, le cas où l'utilisateur saisit du texte dans le champ de recherche avant que le composant ne soit chargé dans le navigateur. On pourrait penser qu'à l'appel de la fonction ReactDOM.render, le DOM serait modifié et que, par conséquent, le champ texte serait recréé et les données saisies perdues.

C'est là que React tire son épingle du jeu. Souvenez-vous, j'avais évoqué le fait que ce dernier ne modifie le DOM que lorsque c'est nécessaire. Le rendu du composant généré côté client est identique à celui déjà présent dans la page, ainsi React n'altérera pas le DOM. Pour vous en persuader, vous pouvez faire un test en appelant la méthode ReactDOM.render à l'intérieur d'un setTimeout afin de pouvoir saisir du texte avant le chargement du composant dans le navigateur.
La création du composant côté client permettra de rendre opérationnelle la gestion des interactions avec l'utilisateur.

VI - La phase de build

Le code JSX devra être converti en JavaScript pour pouvoir être interprété. D'autre part, seuls les navigateurs récents sont compatibles avec ECMAScript 6, et le chargement de modules avec la fonction require n'est pas disponible côté client. Nous utiliserons donc conjointement browserify et babel, afin de transformer nos composants de manière à pouvoir les utiliser avec les principaux navigateurs du marché (IE >= 9).

Enfin, Gulp nous permettra d'automatiser toutes ces tâches de construction.

VI-A - Transformation JSX

Le code JSX est converti en code JavaScript :

1. gulp.task('transpile-jsx', function() {

2. return gulp.src('./jsx/**/*.jsx')

3. .pipe(plumber())

4. .pipe(babel({

5. presets: ['react']

6. }))

7. .pipe(gulp.dest('./components'));

8. });

VI-B - Génération du bundle

On génère un fichier, compatible avec ECMAScript 5, qui contiendra tous les éléments permettant de charger le composant dans le navigateur.

1. gulp.task('generate-bundle', ['transpile-jsx'], function(){

2. //'ProductListClient.js' correspond au fichier présenté

3. //dans la partie 'Intégration  Côté Client'

4. return browserify({ entries: ['./client/ProductListClient.js'] })

5. .transform('babelify', {presets: ['es2015']})

6. .bundle()

7. .pipe(plumber())

8. .pipe(source('ProductListClient.bundle.js'))

9. .pipe(buffer())

10. .pipe(gulp.dest('./public/js'));

11. });

VII - Conclusion

Le sujet est assez récent, mais il fait peu à peu son chemin, même si le framework phare du moment (AngularJs) ne permet pas d'utiliser cette technique efficacement. Son successeur, AngularJs 2, a été conçu pour pouvoir être utilisé côté serveur. On trouve dès à présent des exemples mettant en pratique l'isomorphisme avec cette deuxième version du framework). C'est un sujet prometteur, puisqu'il permet de développer une SPA (Single Page Application) en évitant les inconvénients du référencement et du temps de chargement côté client. L'avenir nous dira si cette approche se démocratise.

En attendant, pour les plus curieux, vous pouvez retrouver l'exemple présenté dans ce tutoriel sur github.

VIII - Remerciements

Nous tenons à remercier la société Soat qui nous a autorisés à publier ce tutoriel.

Nous remercions également Winjerome pour la mise au gabarit et Jacques_jean pour la relecture orthographique.