Accueil Nos publications Blog Introduction à l’isomorphisme avec React et Node.js

Introduction à l’isomorphisme avec React et Node.js

nodereactL’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é lorsqu’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 isomorphique (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 billet, j’illustrerai mes propos à travers un exemple en utilisant React et Node.js. React est une librairie 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 cet article.

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.

classic_cycle

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.

isomorphic_cycle

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.

view_components

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

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


class ProductListComponent extends React.Component {

    constructor(props) {
        super(props);
        //On stocke les produits dans l'objet state
        this.state = { products: props.products };
        //Permet de lier la fonction au composant React
        this.handleSearch = this.handleSearch.bind(this);
    }

    //Méthode appelée lors d'une recherche
    handleSearch(productName) {
        const url = '/products?productName=' + productName;
        $.get(url,  (data) => {
            this.setState({
                products: data
            });
        });
    }

    render() {
        return (
            <div>
                <SearchBar onSearch={this.handleSearch} />
                <ProductTable products={this.state.products}/>
            </div>
        );
    }
}

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.

Signification de state

Lorsqu’on a des données susceptibles de changer, on les stocke dans la variable this.state. A 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


class SearchBar extends React.Component {

    constructor() {
        super();
        this.searchProduct = this.searchProduct.bind(this);
        this.keyDownHandler = this.keyDownHandler.bind(this);
    }

  //Méthode appelée dès que le composant est chargé 
    //dans le navigateur
    componentDidMount() {
        this.textInput.focus();
    }

    searchProduct() {
        const productName = this.textInput.value;
        this.props.onSearch(productName);
    }

    keyDownHandler(event) {
        //On lance la recherche si l'utilisateur 
        //saisit la touche Enter
        if(event.keyCode == 13){
            this.searchProduct();
        }
    }

    render() {
        return (
            <div className="form-group">
                <div className="input-group col-md-6">
                    <input type="text"
                        className="form-control" 
                        onKeyDown={this.keyDownHandler} 
                        //Permet de stocker la référence du champ 
                        //texte dans la variable textInput
                        ref={(ref) => this.textInput = ref} 
                        placeholder="Search...." />

              <span className="input-group-btn">
                <button type="button"
                        onClick={this.searchProduct} 
                        className="btn btn-primary">
                        <span className="glyphicon glyphicon-search">
                        </span>
                </button>
                </span>
                </div>
            </div>
        );
    }
}

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


class ProductTable extends React.Component {

    render() {
        const rows = [];
        this.props.products
        .forEach( 
            (p) => rows.push(<ProductItem key={p.id} product={p} />) 
        );

        return (
            <table className="table table-hover">
                <thead>
                    <tr>
                        <th>Nom</th>
                        <th>Quantité</th>
                        <th>Lieu de fabrication</th>
                    </tr>
                </thead>
                <tbody>
                    {rows}
                </tbody>
            </table>
        );

    }
}

class ProductItem extends React.Component {
    render() {
        return (
            <tr>
                <td>{this.props.product.name}</td>
                <td>{this.props.product.quantity}</td>
                <td>
                    <img src="img/blank.gif" 
                       className={"flag flag-" + 
                         this.props.product.from.suffix} 
                      alt={this.props.product.from.title} 
                      title={this.props.product.from.title} />
                </td>
            </tr>
        );
    }
}

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.

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.


const data = require('./data/products.js');
const ProductListComponent = 
  React.createFactory(require('./components/ProductListComponent'));
const React = require('react');
const ReactDOMServer = require('react-dom/server');
const express = require('express');

const app = express();

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

  const products = {'products' : data};

  const htmlProductListComponent = ReactDOMServer.renderToString( 
    ProductListComponent(products)
  );

  res.render("index", {
    component: htmlProductListComponent,
    context: JSON.stringify(products)
  });

});

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.


<body>
        <div class="container">
              <div id="product-parent"><%- component %></div>
        </div>

        <script id='context' type='application/json'>
            <%= context %>
        </script>
</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.

Intégration côté client


const React = require('react');
const ReactDOM = require('react-dom');
const ProductListComponent = 
  React.createFactory(require('../components/ProductListComponent'));

const context = JSON.parse(
decodeHTML(document.getElementById('context').textContent)
);

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

ReactDOM.render(
  ProductListComponent({'products': context.products}), 
  mountNode
);

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.

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. Au final, nos composants React pourront être utilisés de part et d’autre.

components_client_server

Si vous avez bien suivi, vous pouvez vous demandez si ce n’est pas une mauvaise chose de réafficher le même composant côté client, le DOM sera inutilement modifé 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érationnel la gestion des interactions avec l’utilisateur.

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.

Transformation JSX

Le code JSX est converti en code javasctipt


gulp.task('transpile-jsx', function() {
    return gulp.src('./jsx/**/*.jsx')
      .pipe(plumber())
      .pipe(babel({
            presets: ['react']
        }))
    .pipe(gulp.dest('./components'));
});

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.


gulp.task('generate-bundle', ['transpile-jsx'], function(){
//'ProductListClient.js' correspond au fichier présenté 
//dans la partie 'Intégration  Côté Client'
return browserify({ entries: ['./client/ProductListClient.js'] })
            .transform('babelify', {presets: ['es2015']})
            .bundle()
            .pipe(plumber())                        
            .pipe(source('ProductListClient.bundle.js'))
            .pipe(buffer())
            .pipe(gulp.dest('./public/js'));
});

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 est 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 cet article sur github.

© SOAT
Toute reproduction interdite sans autorisation de la société SOAT