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
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.
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.
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
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.
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
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.
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.
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.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
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.
2.
3.
4.
5.
6.
7.
8.
9.
<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.
IV. Intégration côté client▲
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
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.
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 :
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.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
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'
));
}
);
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.