Article écrit par Tom
Dans notre article traitant de rendering avancé avec Vue.js, nous avions exploré la possibilité, pour un composant écrit pour ce framework, de tirer parti de JSX, le langage de templating initialement prévu pour être utilisé avec React. Si vous êtes aussi tordu·e curieux·se que moi, vous vous êtes peut-être demandé si, et dans quelle mesure, il était possible de réaliser un composant pouvant être exploité par les deux, en réutilisant un maximum de code.
Que ce soit le cas ou non, c’est la question à laquelle nous allons tâcher de répondre aujourd’hui, alors accrochez-vous !
Un peu de plomberie
Compte tenu de la nature plus qu’expérimentale du sujet du jour, nous allons nous fixer un objectif simple : réaliser un composant effectuant le rendu d’un objet JavaScript en tant que liste imbriquée, dont chaque élément possédera une propriété booléenne active
. Lors du clic sur un élément, la valeur de cette propriété sera inversée, la valeur courante étant visible sur la page, en barrant les éléments dont la propriété susmentionnée vaut false
.
Pour construire la petite application qui nous servira de terrain de jeu, nous allons privilégier là encore la simplicité ; je vous propose donc d’utiliser Browserify avec quelques bibliothèques complémentaires, le tout étant géré via des scripts NPM, plutôt que de dégainer l’artillerie lourde à base de Webpack et consorts :
$ mkdir jsx && cd $_ $ npm init
Répondez aux questions d’usage posées par la ligne de commande, puis installez les paquets dont nous allons avoir besoin :
# Browserify et Babel $ npm i browserify babelify babel-core babel-preset-env # Vue.js $ npm i vue vueify babel-plugin-transform-vue-jsx babel-helper-vue-jsx-merge-props # React $ npm i react react-dom babel-plugin-transform-react-jsx
Mettons ensuite en place la structure du projet :
$ mkdir app-react app-vue common public
Configurons Babel en créant un fichier .babelrc
à la racine du projet :
{ "presets": ["env"] }
Tu n’aurais pas oublié les plugins que tu nous as fait installer plus haut ?
Non, justement : les plugins à utiliser étant différents entre les builds de chacun des frameworks, nous allons plutôt les spécifier directement dans chacun des scripts NPM dont je parlais tantôt, et que voici :
{ "scripts": { "vue": "browserify -t [ babelify --plugins [ transform-vue-jsx ] ] -t vueify -e app-vue/main.js -o public/app-vue.js", "react": "browserify -t [ babelify --plugins [ transform-react-jsx ] ] app-react/main.js -o public/app-react.js", "start": "npm run vue && npm run react" } }
Avec cette configuration, vous pourrez construire le code pour Vue.js avec npm run vue
, celui pour React avec npm run react
, et les deux d’un coup avec npm start
.
Pour finir cette introduction, créez le fichier public/index.html
qui servira de double réceptacle :
<!DOCTYPE html> <html> <body> <div id="app-vue"></div> <div id="app-react"></div> <script src="app-vue.js"></script> <script src="app-react.js"></script> </body> </html>
Au commencement, Dieu écrivit « Hello world »
Notre première véritable étape sera, sans grande surprise, le sempiternel « Hello world », avec toutefois une légère variation qui nous permettra de valider le fait que notre composant peut être rendu par Vue.js comme par React.
Pour commencer, créons le fichier common/renderDataList.js
: ce module sera chargé d’exposer une fonction effectuant le rendu du template de notre composant. Pour l’heure, son nom sera quelque peu trompeur puisqu’il se contentera d’afficher un bête h1
:
import React from "react"; export default function renderDataList(props, h) { return <div> <h1>Hello from {props.renderer}!</h1> </div>; }
Trois choses sont d’ores et déjà à noter ici :
- Cette fonction prendra en paramètre les props du composant duquel elle effectuera le rendu ; nous voyons ici que nous aurons besoin d’une prop
renderer
, qui contiendra une chaîne de caractères - Pour utiliser JSX, Vue.js a besoin que sa fonction
createElement
soit disponible dans le contexte courant sous le nomh
— c’est une convention, et pour respecter celle-ci, nous allons devoir passer cette fonction en argument lorsque nous appelleronsrenderDataList
- De son côté, React a sa propre exigence : être importé dans tout fichier contenant du JSX
Heu, on ne risque pas de récupérer la source de React dans le fichier compilé pour Vue.js ?
Absolument ! Il n’existe pas de façon « propre » de remédier à cet inconvénient, je vous propose donc de ruser un brin :
$ npm i browserify-replace
Modifions package.json
en conséquence :
{ "scripts": { "vue": "browserify -t [ browserify-replace --replace '{ ""from"": ""import React from \""react\"""", ""to"": """" }' ] -t [ babelify --plugins [ transform-vue-jsx ] ] -t vueify -e app-vue/main.js -o public/app-vue.js" } }
En faisant disparaître l’instruction import
concernée avant de lancer Babel, nous évitons d’inclure tout ce code mort dans notre build. Ouf !
Poursuivons en mettant en place les composants eux-mêmes, avec tout d’abord app-vue/DataList.js
:
import renderDataList from "../common/renderDataList"; export default { props: { renderer: String }, render(h) { return renderDataList(this.$props, h); } };
La fonction createElement
(ou h
, pour suivre la convention dont nous avons parlé) est passée par défaut en paramètre à render
, nous pouvons donc procéder comme prévu. Une petite particularité : pour pouvoir accéder à la prop renderer
, nous devons la déclarer explicitement.
Passons à notre composant alter ego, à savoir app-react/DataList.js
:
import { Component } from "react"; import renderDataList from "../common/renderDataList"; export default class DataList extends Component { render() { return renderDataList(this.props); } }
Il ne nous reste plus qu’à brancher le tout ensemble, en mettant en place les deux points d’entrée correspondants, respectivement app-vue/main.js
:
import Vue from "vue"; import DataList from "./DataList"; new Vue({ el: "#app-vue", render: h => h(DataList, { props: { renderer: "vue" } }) });
Et app-react/main.js
:
import React from "react"; import { render } from "react-dom"; import DataList from "./DataList"; render( <DataList renderer="react" />, document.getElementById("app-react") );
Lancez donc npm start
et naviguez vers index.html
pour valider la bonne marche de ce projet démoniaque (pas besoin de serveur web, le protocole file://
suffira) :
Passons la seconde
Nous allons maintenant essayer d’effectuer le rendu d’un template un peu plus complexe, car contenant de la logique. Modifions donc common/renderDataList.js
comme suit :
import React from "react"; function renderList(data, h) { return <ul> {Object.keys(data).map(key => <li key={key}> <b>{key}</b>: {typeof data[key] === "object" ? renderList(data[key], h) : data[key]} </li>)} </ul>; } export default function renderDataList(props, h) { return <div> <h1>Hello from {props.renderer}!</h1> {renderList(props.data, h)} </div>; }
Nous utilisons ici la fonction récursive renderList
, chargée d’effectuer le rendu d’un niveau de profondeur de notre objet de données (lequel sera fourni à nos composants via une nouvelle prop data
). En parlant de données, créons le fichier data.json
à la racine du projet, lequel contiendra ces dernières :
{ "plain": "Some value", "object": { "plain": "Other value", "more": { "keys": "And values" } } }
Vous pouvez évidemment utiliser d’autres données si le cœur vous en dit !
Il nous faut maintenant faire en sorte de lire le contenu de ce fichier et de le passer en prop à nos composants, dans app-vue/main.js
:
// ... import data from "../data"; new Vue({ el: "#app-vue", render: h => h(DataList, { props: { renderer: "vue", data } }) });
Et dans app-react/main.js
:
// ... import data from "../data"; render( <DataList renderer="react" data={data} />, document.getElementById("app-react") );
Étant donné que nous passons l’intégralité des props à notre fonction renderDataList
, aucune modification n’est nécessaire dans le composant React ! Concernant Vue.js, il nous faudra simplement déclarer explicitement la nouvelle prop :
import renderDataList from "../common/renderDataList"; export default { props: { renderer: String, data: Object }, // ... };
Vous commencez à connaître la chanson… lancez npm start
, et admirez le résultat :
Sinon, on fait un truc utile, ou comment ça se passe ?
Jusqu’ici, c’était facile : nous nous sommes contentés d’afficher des données, même un minimum complexes. Créer un composant « universel » et avec lequel l’utilisateur pourra interagir risque de s’avérer être une autre paire de manches…
Il nous faut tout d’abord faire en sorte de pouvoir gérer la fameuse propriété active
pour chaque élément de notre jeu de données. Pour ce faire, nous allons mettre en place un nouveau service générique dans common/prepareData.js
, qui sera chargé de transformer chacun des éléments en question en un objet avec une clé value
et une clé active
(de fait), dont la valeur par défaut sera true
:
import clone from "clone"; function prepareDatum(datum) { Object.keys(datum).forEach(key => { datum[key] = { value: typeof datum[key] === "object" ? prepareDatum(datum[key]) : datum[key], active: true }; }); return datum; } export default function prepareData(data) { return prepareDatum(clone(data)); }
N’oubliez pas de lancer npm install clone
pour récupérer la nouvelle dépendance.
Cette nouvelle version de notre objet de données étant stateful, nous allons logiquement devoir l’intégrer à l’état de nos composants. Côté Vue.js, cela implique de déclarer une méthode data
:
// ... import prepareData from "../common/prepareData"; export default { // ... data() { return { preparedData: prepareData(this.data) }; }, render(h) { return renderDataList({ renderer: this.renderer, data: this.preparedData }, { h }); } };
Les plus attentifs d’entre vous auront sûrement remarqué que le second argument de renderDataList
est désormais un objet ; pour comprendre pourquoi, voyons l’équivalent côté React :
// ... import prepareData from "../common/prepareData"; export default class DataList extends Component { constructor(props) { super(props); this.state = { data: prepareData(props.data) }; } render() { return renderDataList({ renderer: this.props.renderer, data: this.state.data }, { setState: this.setState.bind(this) }); } }
En effet, React nécessite l’utilisation de setState
pour mettre à jour l’état d’un composant, ce qui nous met dans une situation où nous avons un paramètre totalement différent à passer pour chacun des deux contextes ; j’ai donc choisi de faire de ce second paramètre un objet, afin de tirer parti de la syntaxe de destructuring qui nous permet d’alléger et clarifier un peu le code de renderDataList
. Voyons ce que cela donne :
import React from "react"; function toggleItem(event, data, key, setState) { event.stopPropagation(); data[key].active = !data[key].active; if (typeof setState === "function") { setState({}); } } function renderList(data, { h, setState }) { return <ul> {Object.keys(data).map(key => <li key={key} onClick={event => toggleItem(event, data, key, setState)} style={{ textDecoration: data[key].active ? "none" : "line-through" }} > <b>{key}</b>: {typeof data[key].value === "object" ? renderList(data[key].value, { h, setState }) : data[key].value} </li>)} </ul>; } export default function renderDataList(props, { h, setState }) { return <div> <h1>Hello from {props.renderer}!</h1> {renderList(props.data, { h, setState })} </div>; }
Le fichier devenant un tant soit peu volumineux, décomposons ensemble son fonctionnement de bas en haut :
renderDataList
est chargée d’effectuer le rendu de base, en appelant comme précédemment la fonction récursiverenderList
, à laquelle elle passe l’objet de données (props.data
) et son objet de fonctions « propriétaires » (h
pour Vue.js,setState
pour React)renderList
itère sur les clés de cet objet et, pour chacune d’entre elles, crée un élémentli
qui sera barré ou non en fonction de la valeur de la propriétéactive
correspondante ; elle s’appelle également elle-même si la valeur courante est un objet, afin de répéter le processus à un niveau de profondeur inférieur, et, chose nouvelle, déclare un handler destiné à gérer le clic pour faire varier la valeur de la propriétéactive
, en l’occurrencetoggleItem
toggleItem
, enfin, reçoit en paramètres :- l’évènement JavaScript, afin de pouvoir empêcher sa propagation native
- l’objet (ou sous-objet) de données courant, c’est-à-dire une partie de l’état du composant, dans laquelle on doit trouver et modifier la bonne propriété
active
- la clé de l’élément recherché
- la méthode
setState
, si elle est présente (concrètement, dans le cas de React)
Deux points importants sont également à noter :
- On tire parti du fait que les objets, en JavaScript, sont passés par référence : modifier un sous-objet localement le modifiera également dans l’objet global, ce qui nous permet de nous contenter, pour les niveaux inférieurs, du sous-objet et de la clé correspondante
- Le revers de la médaille du point précédent est que dans le cas de React,
setState
référence l’état du composant depuis sa racine ; c’est pour cette raison que nous devons tricher en modifiant directement l’objet (qui correspond ici à un morceau dethis.state
) avant d’appelersetState
avec une valeur vide — c’est en théorie strictement prohibé, car l’absence d’effets de bord ne peut être garantie, mais cela nous évite de devoir manipuler tout l’objet ainsi que la liste des clés pour le traverser
Aucune modification n’étant nécessaire dans nos deux fichiers main.js
, lançons une ultime fois npm start
et amusons-nous à cliquer sur les deux listes pour constater que cela fonctionne !
Et concrètement, ça sert à quoi ?
Ainsi que je l’ai sous-entendu en début d’article, la démonstration proposée ici a avant tout une valeur d’expérimentation : Vue.js et React partageant une grande partie de leur philosophie de base et pouvant exploiter le même moteur de rendu, il était à mon sens intéressant d’étudier la possibilité de partager du code entre les deux, afin de tenter de déterminer quel serait le coût supplémentaire dédié au fait de rendre disponible un composant autonome aux deux communautés, par exemple. Le code « propriétaire » requis au fonctionnement de ce proof of concept reste relativement restreint, et le code commun pourrait sûrement être factorisé, voire isolé dans un module séparé.
Dans les faits, nous constatons évidemment que bien que fonctionnelle, cette solution requiert un nombre non négligeable de hacks pour fonctionner ; une sorte de standard d’interopérabilité, s’il existait, pourrait rendre les choses plus faciles et permettre aux deux écosystèmes de se mélanger, à l’instar de ce qui existe côté PHP avec les normes PSR. Bien sûr, ce standard a peu de chances de voir le jour, puisque l’existence même des Web Components le rend par définition caduc.
Pour pousser l’expérience plus loin, on pourrait toutefois envisager d’utiliser Redux (ou un outil équivalent) pour gérer l’état de manière décentralisée, afin de ne plus avoir besoin de passer setState
en paramètre un peu partout.
J’espère en tout cas que cet article vous a plu, et vous donne rendez-vous très prochainement pour un nouveau. À la revoyure !