(le titre est vraiment à chier 😅)
Aujourd'hui, je travaille sur un projet consistant à produire une codebase capable de générer des dizaines de sites très semblables au niveau de la structure mais qui possèdent chacun leurs particularité. Jusqu'à maintenant, ce travail s'est fait essentiellement en forkant le précédent site et donc en produisant des sites jetables très difficiles à maintenir. React m'a aidé à mettre en place des composants configurables à plusieurs niveaux et ainsi à générer un site contenant simplement un composant et une configuration. Ce composant génèrera toutes les pages communes à tous les sites, les routes, etc tout en injectant les composants spécifiques au site. Mais comment arriver à injecter tout ça dans les tréfonds de cet énorme composant ?
Ce que je trouve vraiment très puissant avec React, c'est l'usage quasi illimité qu'on peut faire avec les props
. Les props sont les entrées du composant qui vont être utilisées pour générer une sortie. Ces props sont passées au composant lors de son implémentation, mais là où ça devient très puissant, c'est qu'on peut aussi passer ces props par le biais de HOC, des fonctions qui vont se placer de façon transparente au dessus du composant ou alors via la définition des props par défaut (defaultProps
). Avec ces 3 outils, on est capable de faire des composants à l'implémentation ultra simpliste mais pourtant très facilement réutilisable ou configurables.
Injection par les props
La grosse difficulté que j'ai pu découvrir lors de l'apprentissage de React est venue de la volonté de vouloir reproduire ce que je connaissais d'autres framework plus proches de la spec des WebComponent. Notamment, mon premier gros pédicode a été de vouloir reproduire des slots pour pouvoir passer plusieurs éléments enfants à mon composant. Quelqu'un avait cherché à faire la même chose et avait obtenu une réponse sur StackOverflow, j'ai donc pensé que c'était légitime de procéder de la sorte. Puis plus tard, j'ai compris qu'il était possible et surtout bien plus pratique de passer des composants directement via les props. Il devenait ainsi possible d'injecter absolument ce qu'on voulait à un composant à condition que celui-ci soit conçu de façon à être à l'écoute de ces injections.
Un autre des défauts que je trouvais à React était sa non séparation du rendu et de la logique dans la plupart des tutos. Et c'est ici que j'ai pu trouver une réponse à cette problématique et concevoir tous mes composants sous la forme de deux fichiers : un index.js et un render.js. Le premier s'occupant de récupérer toutes les données nécessaires à passer au render qui lui n'était qu'une bête fonction retournant directement et sans la moindre logique du VDOM. J'injecte ce render à mon index via les props, ce qui me permet de pouvoir changer le rendu du composant simplement en passant une autre fonction à la prop render de mon composant.
Voici donc le modèle de composant que j'utilise :
// MonComposant/index.js
import React from 'react';
import render from './render';
export class MonComposant extends React.Component {
static defaultProps = {
render,
};
render() {
const { render: Render, ...nextProps } = this.props;
// On peut ici générer ou altérer des props à passer au render
const label = 'World';
const props = {
...nextProps,
// On ajoute ici les props générés à passer au render
label,
};
return <Render {...props} />;
}
};
export default MonComposant;
// MonComposant/render.js
import React from 'react';
export const MonComposantRender = ({ label }) => (
<p>Hello {label}</p>
);
export default MonComposantRender;
Premier avantage : on se retrouve avec un modèle de séparation rassurant quand on vient du MVC, le template est à part du code. Mais le second avantage, c'est que cette fonction de rendu devient très facilement remplaçable. Ainsi, je pourrais implémenter mon composant de ces multiples façons pour avoir autant de résultats différents sans toucher au code du composant :
<MonComposant />
<MonComposant
render={({ label }) => (
<h1>Yo {label}</h1>
)} />
<MonComposant
render={({ prefix, label }) => (
<h1>{prefix} {label}</h1>
)}
prefix="Yo" />
<MonComposant
render={HelloComponent} />
Toute la logique est préservée, et je peux même injecter d'autres props à mon render personnalisé via le scope parent.
Je peux aussi utiliser ce modèle pour injecter des composants à mon render tout en facilitant leur modification ou leur mocking. En effet, dans le cas suivant où mon render va implémenter le composant MonHeader
, je vais avoir tendance à importer MonHeader
dans le render. Ce composant est alors figé dans le marbre, rendant la personnalisation impossible :
// render.js
import React from 'react';
import MonHeader from './MonHeader;
export default () => (
<div>
<MonHeader />
</div>
)
Alors qu'il est tout à fait possible d'injecter ce composant via l'index.js et même de le rendre configurable :
// index.js
import React from 'react';
import MonHeader from './MonHeader;
import render from './render;
export default class MonComposant extends React.Component {
static defaultProps = {
render,
HeaderComponent: {
component: MonHeader,
title: 'Foo bar',
},
};
render() {
const { render: Render, HeaderComponent, ...nextProps } = this.props;
const Header = () => <HeaderComponent.component {...{...HeaderComponent, ...this.props}};
// Là c'est la version complexe dans le cas où on veut faire transiter directement des props depuis MonComposant jusqu'à HeaderComponent
// Mais si on veut juste injecter un composant, on peut faire beaucoup plus simplement :
// const Header = HeaderComponent.component;
const props = {
...nextProps,
Header,
};
return <Render {...props} />;
}
}
// render.js
import React from 'react';
export default ({ Header }) => (
<div>
<Header />
</div>
)
// app.js
// Remplace complètement le composant
<MonComposant
HeaderComponent={{
component: CustomHeader
}} />
// Remplace uniquement la valeur du `title` passé au composant par défaut
<MonComposant
HeaderComponent={{
title="Bar foo"
}} />
// Remplace uniquement la fonction de render du composant par défaut, qui va donc prendre en props les props par défaut
<MonComposant
HeaderComponent={{
render={({ title }) => <p>{title}</p>},
}} />
Je peux juste tester mon render.js en lui passant un mock de Header en prop, je peux tester le composant entier en passant aussi un mock de Header en prop, et je peux passer un Header personnalisé si j'ai besoin d'afficher quelque chose de différent dans certains cas.
Configuration par contexte
Maintenant, allons plus loin avec les hoc et context. Le plan pour que mon objectif soit atteint était d'arriver à générer un site avec seulement ceci :
render() {
return (
<SiteComponent
config={config} />
);
}
Rien de plus, rien de moins. Cependant, comment faire si sur l'un des sites, on ne veut pas afficher l'avatar dans le cadre des informations utilisateurs de la vue Dashboard accessible depuis la route /dashboard ? Grâce au context, et aux hoc.
Mon composant est accompagné d'un Provider qui se charge de récupérer la configuration et de la ranger dans un Context. Ainsi, chacun de mes composants passe par un hoc withSiteConfig
donnant accès à la configuration. Mais j'ai aussi un hoc withAutoConfig(path)
prenant en paramètre le chemin vers la configuration du composant. Les props du composant seront alors automatiquement remplies en piochant dans la config passée au Provider.
Ainsi, si je veux personnaliser le composant UserCard de la vue Dashboard, je pourrais implémenter mon site de la sorte :
render() {
return (
<Provider
config={{
Dashboard: {
UserCardComponent: {
component: CustomUserCard,
},
},
}}>
<SiteComponent />
</Provider>
);
}
ou bien plus simplement :
render() {
return (
<Provider
config={{
Dashboard: {
UserCardComponent: {
render: ({ user }) => <p>Hello {user.name}</p>,
},
},
}}>
<SiteComponent />
</Provider>
);
}
ou encore plus simplement, si le composant le permet, en modifiant ses props :
render() {
return (
<Provider
config={{
Dashboard: {
UserCardComponent: {
displayAvatar: false,
},
},
}}>
<SiteComponent />
</Provider>
);
}
Mon composant UserCard, lui, sera implémenté de la sorte :
import React from 'react';
import { withAutoConfig } from '../config';
export class UserCard extends React.Component {
/**/
}
export default
withAutoConfig('views.Dashboard.UserCardComponent')(
UserCard
);
Je ne vous présente pas le code de withAutoConfig
, mais en gros son fonctionnement est le suivant : il récupère la config du contexte, les propTypes
et les defaultProps
du composant passé en paramètre, et pour chaque propType
, il va ajouter la valeur correspondant à la prop prise par ordre de priorité dans la config, dans la config par défaut du projet puis dans le defaultProps
.
En conclusion de cet article, je voudrais saluer la créativité que permet React. C'est probablement pour cet aspect que je m'y suis plu si rapidement. Il est possible d'architecturer ses projets de pleins de façons différentes, que ça soit par des hocs, par des renderProps, des context, autour de redux, etc. Il n'y a pas qu'un seul modèle à suivre bêtement comme une IA. Avec les effets négatifs qui sont qu'on peut aussi produire un énorme plat de pâtes impossible à maintenir 😅