Aller au contenu principal
Retour au blogue

Développement logiciel

Modèles réactifs de domaines riches en REACT + Typescript

Zack Therrien
16 mai ∙ 16 mins
Un ordinateur entrouvert sur un bureau, à coté d'un téléphone et d'une tasse de café

Au cours des dernières années, nous avons vu le monde des frameworks Web exploser. De nombreux frameworks ont émergé, tous essayant apparemment de résoudre le même problème. C'est-à-dire : comment mettre fin au problème de complexité dans nos applications frontend? Chaque framework traite cette question de manières différentes et possède ses propres avantages et inconvénients. Au fur et à mesure que les entreprises s'enferment dans des spécifiques frameworks stacks, elles commencent à embaucher des développeurs de logiciels «experts» dans ces frameworks. Une recherche rapide sur Linkedin révèle que Redux est un besoin quasi fondamental pour certaines entreprises au même titre que React.

Mon équipe et moi, nous avons exploré différentes manières de gérer la complexité croissante de notre stack. L'une des raisons derrière cette complexité est sans aucun doute l'étendue de la business logic de notre frontend. Nos applications frontend nécessitent un feedback immédiat aux utilisateurs. Nos utilisateurs se trouvent potentiellement dans des régions éloignées avec une connexion Internet plus lente, et il est impossible d'attendre les backends pour la business logic. L'application configure les données en temps réel et appelle les backends pour permettre de visualiser ces données. La configuration des données est rarement affectée au stockage et, par conséquent, l'aller-retour vers le backend n'est pas nécessaire. Avec notre interface contenant beaucoup de business logic, nous avons commencé à nous demander : pourquoi est-il si difficile d'ajouter de nouvelles fonctionnalités à nos besoins commerciaux croissants ?

Dans les ressources disponibles, résoudre la complexité de la business logic peut être faite de multiples façons. La conception pilotée par le domaine (DDD) semble être le chouchou de plusieurs. Surtout si vous avez un fort état d'esprit orienté objet. Comme je l'ai mentionné précédemment, une grande quantité de business logic se trouve dans notre frontend, en particulier une frontend basée sur React. Jusqu'à aujourd'hui, nous voulions tout coder dans l'écosystème React, avec très peu d'outils personnalisés, nous avons donc utilisé Redux en raison de son intégration avec React. Cette approche est correcte, cependant, vous vous retrouvez avec un modèle de domaine anémique, généralement uniquement un type interface. Bien que les modèles anémiques fonctionnent bien, vous vous retrouvez alors avec de nombreux «services» ou «fichiers d'assistance» Redux pour gérer la business logic de ces modèles. Ce qui a pour conséquence la répartition du business logic sur de nombreux fichiers.

Il y a une question que je me posais en boucle au cours des 6 derniers mois lors du développement d'un nouveau sous-ensemble de notre application. Avec une interface monolithique, enfermée dans un tech stack, 2 versions majeures derrière, et un spaghetti complexe de fichiers d'aide ; Je me suis demandé, pourquoi en sommes-nous rendu à ce point en premier lieu? Comment réduire l'enfer des versions ? Comment intégrons-nous la business logic répartie dans maintes fichiers d'assistance ? Certains de nos fichiers d'assistance comportaient 3 000 lignes, ils contenaient la business logic de nos 15+ entités. Comment s'est-on rendu ici? Après avoir fait un petit pas en arrière, notre équipe a identifié Redux comme étant l'une des causes. Redux suggère des modèles de domaine anémiques et donc de séparer la business logic en fichiers d'assistance. Nous voulions savoir s'il existait un moyen d'intégrer le rich domain dans React+Redux de manière à ne pas avoir à réécrire l'intégralité de notre frontend ou à créer nos propres frameworks. Nous avons parcouru Internet à la recherche de réponse, cependant ce fut en vain.

Permettez-moi de prendre du recul pour clarifier les modèles de domaine anémique par rapport aux modèles de domaine riches si vous n'êtes pas familier avec les principes DDD. Tout le monde a des définitions différentes de la conception pilotée par domaine. Pour les besoins de cet article, DDD est un moyen de mapper le code aux concepts du monde réel. Les principes importants de DDD incluent la création de modèles de domaine, c'est-à-dire une classe (généralement) qui définit de manière unique un concept du monde réel. Ci-dessous, j'aborde deux variations importantes de ces modèles de domaine.

Domaine anémiques

Les modèles de domaine anémique sont des objets sans aucune business logic associée à leurs définitions. Ils sont généralement représentés dans TypeScript par une interface unique, connue sous le nom d'objet de transfert de données (DTO). L'interface décrit la forme de l'objet JavaScript, en utilisant la vérification de type TypeScript, nous pouvons garantir une utilisation correcte. Cette interface n'est connectée à aucun autre modèle et n'a que peu ou pas de méthodes pour la business logic. Il n'a certainement pas ses propres transitions d'état. Voici un exemple typique en TypeScript :

export interface MyPetShopDTO {
   readonly id: number;
   readonly storeName: string;
   readonly pets: MyPetDTO[];
}

C'est parfait, cela fonctionnerait avec Redux et React tel quel. Cependant, il n'a aucune business logic. Que se passe-t-il si vous souhaitez mettre à jour le nom de ce modèle dans une application React+Redux typique ?

Pour propager vos modifications dans Redux, vous devez :

  • Créer un sélecteur Redux pour obtenir ce modèle dans vos composants
  • Ajouter un event listener à un input dans un composant
  • Envoyer un event à Redux pour mettre à jour ce nom dans son état
  • Attraper cet event dans Redux à l'aide d'une action Slice
  • Trouvez l'entité dans votre état Redux, généralement en utilisant un ID d'entité
  • Recréez un objet pour les entités (à l'aide d'opérateurs de propagation) dans votre état, sinon Redux ne laisse pas React re-render
  • React re-rend l'arbre entier, en passant par la réconciliation React pour savoir quels props ont changé et quel composant mettre à jour.
  • Si un props change, re-render tous les «enfants» jusqu'à votre composant. Peu importe si ce nom est utilisé partout ou dans une infime partie de votre application.

Ça sonne douloureux à écrire, n'est-ce pas! Dans ce cas, tout se travaille, pour un exemple de base d'un changement de nom. Comparons maintenant cela à la façon dont vous désignerez généralement un changement de variable en JavaScript sans framework.

myPetShop.storeName = 'Montreal Pets'

Une application React+Redux typique nécessite plus de 8 étapes pour accomplir quelque chose que JavaScript fait en une seule étape. Non seulement cela, mais les développeurs doivent être conscients de toutes les étapes mentionnées ci-dessus. En raison de leurs grandes complexités, l'industrie du logiciel crée souvent un passe-partout (boilerplate) à copier-coller de l'ensemble de la hiérarchie Redux. Les développeurs peuvent ensuite copier-coller les extraits dont ils ont besoin pour exécuter le code. Ils ne s'arrêtent pas pour analyser le flux de code complet, à travers ces bibliothèques tierces. Sans comprendre le flux complet, comment pouvons-nous attendre des développeurs qu'ils écrivent des tests unitaires ? Surtout lorsque vous faites des choses insignifiantes comme changer le nom d'un animal de compagnie. Dans React + Redux, cela nécessiterait près de 15 lignes de code de test unitaire avant chaque clause? Et c'est sans qu'aucune business logic n'ait encore été testée. C'est comme si nous nous plongions tête baissée dans un jargon de framework qui s'est accumulé au fil du temps sans prendre de recul et sans réfléchir à ce que serait la meilleur route à emprunter.

Modèles de domaine anémiques - Business logic

Ajoutons un peu de business logic à notre modèle anémique. Les employés de l'animalerie peuvent ajouter ou supprimer des animaux qui sont disponibles pour adoption. Ces animaux ont diverses options de configuration remplies via un modal.

export const slice = createSlice({
  name: 'petStoreSlice',
  initialState,
  reducers: {
    addPet(state, action: PayloadAction<MyPetDTO>) {
      const hasPet = state.petStore.pets.find(
        (pet) => pet.id === action.payload.data.id
      );
      if(!hasPet) {
        const defaults = getPetDefaults(action.payload.data);
        const newPet = {...defaults, ...action.payload.data);
        state.petStore = { 
          ...state.petStore, 
          pets: [...state.petStore.pets, newPet] 
        }
      }
    },

Modèles de domaine riches (rich domain models)

Et si nous vivions dans un monde où nos données et notre business logic sont au même endroit. Agnostique de tout framework. La possibilité de les importer dans des stacks d'applications frontend. JavaScript est JavaScript, il peut être utilisé quel que soit le framework de rendu utilisé. Les tests unitaires de notre application deviennent beaucoup plus faciles, vous n'avez plus besoin de gérer tous ces frameworks. La séparation des préoccupations est plus facile à appliquer si vous planifiez correctement vos modèles en fonction de la séparation des préoccupations et des limites du contexte.

Une bonne compréhension de vos entités et des interactions entre elles est nécessaire, en plus du Contexte qui les entoure… Mais c'est une discussion pour un autre jour. Généralement, un responsable technique/développeur d'un projet aura effectué l'analyse en amont et déterminé les modèles, leurs interactions et leur business logique. Même si ce n'est pas le cas, une approche orientée objet contribuera considérablement à la flexibilité future de vos applications grâce à l'encapsulation et à l'héritage.

À quoi ressemblerait un modèle de domaine riche dans TypeScript?

export interface PetStoreDTO {
   id: number;
   storeName: string;
   pets: PetDTO[];
}
 
export interface IPetStore extends PetStoreDTO {
   pets: IPet[];
   addPet: (newPet: IPet) => void;
   removePet: (pet: IPet) => void;
   createOffspring: (parent1: IPet, parent2: IPet) => IPet;
}

Remarquez comment nous gardons le PetStoreDTO du modèle de domaine anémique, mais nous créons une nouvelle interface qui l'étend. Dans cette nouvelle interface, nous indiquons que les implémentations concrètes contiendront 3 fonctions. Ajoutez un animal de compagnie, supprimez un animal de compagnie et créez une progéniture. Notez que le tableau `pets` a été remplacé dans cette interface par un nouveau type : le type IPet. Le DTO de l'animalerie ne contient que le JSON brut des animaux, mais pas les modèles basés sur les classes. Cependant, l'interface de l'animalerie (et/ou le modèle basé sur les classes de l'animalerie) a des animaux de compagnie comme modèles. Pour remédier à ce changement entre les DTO et les modèles, les propriétés de l'interface sont remplacées.

L'implémentation concrète de cette classe pourrait ressembler à :

class PetStore implements IPetStore {
   id: number;
   storeName: string;
   pets: IPet[] = [];
 
   addPet(newPet: IPet) {
       this.pets.push(newPet);
   }
}

Remarquez à quel point c'est simple! Vous pouvez le compiler de TypeScript à JS et l'importer dans n'importe quel autre projet JS.

Que du plaisir, mais à quoi cela ressemblerait-il avec React ?

Eh bien, c'est là que les choses deviennent intéressantes. React n'aime pas les instances de classe. React utilise un algorithme appelé «Réconciliation» pour savoir quels accessoires ont changé et quels composants doivent être rendus (render) à nouveau. Il sait quels props ont changé en examinant les références d'objets (similaires aux pointeurs C) dans leurs props, c'est-à-dire une comparaison superficielle. Lors de l'utilisation d'entités basées sur des classes, la modification des valeurs au sein de ces classes ne modifie pas la référence de l'entité de classe. Par conséquent, React ne considérait pas cela comme un changement d'accessoire et ne le restituera donc pas.

Jetons un coup d'œil à cet exemple :

// Instantiated somewhere else
const petStore = new PetStore({
   id: uuidv4(),
   storeName: 'Montreal Pets',
   pets: []
});
 
// React Component:
const PetStoreHome: React.FC = () => {
   const onAddPet = () => {
       petStore.addPet(new Pet());
   }
   return (
       <div>
           <p>Pet store: ${petStore.storeName}</p>
           <PetList petStore={petStore} />
           <button onClick={onAddPet}>Add Pet</button>
       </div>
   );
 
};

Dans ce cas, nous instancons une nouvelle classe pour contenir le petStore, potentiellement dans un autre fichier (ex : un fichier de service, un référentiel, peut-être Redux). Si nous apportons des modifications à petStore, React ne restituera pas ce composant. De plus, PetList ne restituera pas même si nous appelons addPet sur petStore. Les instances basées sur les classes dans React sont problématiques car l'algorithme de réconciliation ne vérifie que l'égalité des références.

Le modèle PetStore est un modèle de domaine riche car il contient un business logic (ex. addPet) mais il n'est pas « réactif » dans React. La modification de l'une des valeurs du modèle n'entraîne aucun nouveau rendu. Nous avons trouvé un moyen d'utiliser des modèles de domaine riches et de faire en sorte que React… réagisse aux changements.

Modèles réactifs de domaine riche dans React

Pour ce faire, nous avions besoin d'un moyen de connaître toutes les modifications apportées à une classe de modèle. La modification d'une propriété unique ou d'une myriade de propriétés sur une classe devrait entraîner le rendu de tout ou partie des composants de réaction pour refléter ces modifications. Cependant, tous les composants de l'arborescence DOM/React n'auront pas besoin d'être rendus à nouveau. Un changement dans le nom du magasin ne devrait pas provoquer de nouveaux rendus dans la PetList. Pour tenir compte des changements effectués sur une classe de modèle, nous utilisons l'API Proxy et une classe de base qui sera étendue par nos modèles.

class PetStore extends BaseModel implements IPetStore {
   ...
   constructor(petStore: PetStoreDTO) {
       super();
      
       ...
   }

La seule chose qui change est la directiveextends et le super appel dans le constructeur. Pour le reste, tout semble identique.

Nous avons également créé un React Hook qui se lierait au modèle de base et enregistrerait un rappel sur les modifications de propriétés :

// React Component:
const PetStoreHome: React.FC = () => {
   useModelUpdate(petStore, 'storeName');
   ...
}

Le hook ne met à jour ce composant que si la propriété 'storeName' du modèle petStore est modifiée. Étant donné que le PetList ne nécessite que le petStore en tant qu'accessoire, qui ne change jamais de référence entre les rendus, le composant PetList n'est pas restitué, ce qui améliore considérablement les performances.

Si nous voulons ajouter un animal de compagnie, le composant PetStoreHome n'a pas besoin d'être rendu à nouveau, seul le composant PetList doit être rendu à nouveau. Cela peut être accompli en ajoutant un nouvel enregistrement de hook dans le composant PetList:

const PetList: React.FC<PetListProps> = ({petStore}) => {
   useModelUpdate(petStore, 'pets'); // array changes (ex: push, pop, splice, etc)
   return (
       <ul>
           {petStore.pets.map((pet) => <PetDetails pet={pet} />)}
       </ul>
   );
};

Si un animal change de nom, seuls les PetDetails doivent être restitués à l'aide d'un nouvel hook enregistrement. Toute autre modification apportée à «animal de compagnie» (par exemple, les modifications d'identifiant) ne provoquerait pas de nouveau rendu :

const PetDetails: React.FC<{ pet: IPet}> = ({ pet }) => {
   useModelUpdate(pet, 'name');
   return (
       <li>{pet.name}</li>
   );
}

Comme vous pouvez l'imaginer, il s'agit d'un moyen extrêmement puissant de rendre vos composants React en contrôlant explicitement le flux de mise à jour de React. Exiger uniquement que les composants soient restitués si les propriétés qu'ils utilisent changent. Peut-être que parfois vous n'aurez même pas besoin d'un nouveau rendu si les propriétés d'une classe changent. Quelques propriétés de certains modèles n'ont pas de représentation visuelle et ne sont utilisées qu'en interne pour les calculs de propriétés dérivées, ou elles ne doivent être appliquées que lors d'un event.

Un monde sans Redux en React

Bien qu'il existe différentes manières de gérer l'état dans React, un package populaire est React-Redux. Il s'agit d'un package de gestion d'état global. Son objectif principal est de rationaliser la gestion de l'État. C'est un paquet assez gros qui nous oblige à nous développer de certaines manières. Dans une application React + Redux typique, vous pouvez trouver des centaines, voire des milliers de hooks useDispatch et useSelectoréparpillés dans le code. Chaque modèle anémique a des dizaines d'actions, de réducteurs d'état, de thunks et de fonctions de sélection.

Tester les Slices devient extrêmement difficile car nous devons nous souvenir de la séquence useSelector pour simuler correctement l'implémentation ou nous devons créer un magasin entier pour nos tests unitaires. Mon équipe a pris un peu de temps pour maîtriser les tests des Slices et nous avons fini par abandonner les Slices pour les tests unitaires au profit des tests Cypress end-to-MockAPI.

Avec des modèles de domaine riches, nous pouvons nous débarrasser des actions, des réducteurs d'état et des fonctions de sélection. Après avoir supprimé la plupart des principes de base de redux, il ne nous restait plus que des thunks. Cela m'a laissé deux questions : comment puis-je me débarrasser de ces thunks et où gardons-nous nos modèles de domaine riches ?

J'ai trouvé une réponse à cette question dans certains principes de conception axés sur le domaine : le repository. Comme pour une Slice, le repository contient l'état d'un modèle et définit les thunks. Une seule classe pour gérer toutes les communications backend et les modèles de magasin. Le OG BFF.

class PetStoreRepository extends BaseRepository {
   myPetStore: IPetStore | null = null;
   isFetching = false;
   lastError: unknown = null;
 
   async fetchPetStore() {
       if (this.isFetching) {
           return;
       }
 
       this.isFetching = true;
 
       try {
           const response = await backend.get<PetStoreDTO | null>(
               'localhost:8888/api/pet-store',
           );
           if (response.data) {
               this.myPetStore = new PetStore(response.data);
           }
           this.lastError = null;
       } catch (e: unknown) {
           this.isFetching = false;
           this.lastError = e;
       } finally {
           this.isFetching = false;
       }
   }
}
const petStoreRepository = new PetStoreRepository();
export default petStoreRepository;

Alors, comment l'utilisons-nous dans une application React ?

// React Component:
import petStoreRepository from ‘repositories/petStoreRepository’;
const PetStoreHome: React.FC = () => {
   useRepositoryUpdates(petStoreRepository); // Re-renders if any property changes
   const petStore = petStoreRepository.myPetStore;
   const isLoading = petStoreRepository.isFetching;
 
  useEffect(() => {
       if(!petStore && !isLoading) {
           petStoreRepository.fetchPetStore();
       }
    }, [petStore, isLoading]);
 
   if(isLoading) {
       return <p>Loading...</p>;
   }
   if(!petStore || petStoreRepository.lastError) {
       return <p>An error occurred</p>;
   }
   ...
}

Ceci est très similaire à la façon dont vous le feriez avec Redux avec un appel de répartition. Sauf que nous n'avons pas d'appel de répartition. Cela réduit nos sélecteurs puisque nous utilisons directement le repository, mocking ne nécessite pas d’effort pour les tests. Le code est beaucoup plus lisible. La mise à jour de l'une des propriétés de l'animal dans les gestionnaires d'événements est plus simple. Vous n'avez plus besoin de poursuivre une action de répartition dans le code pour trouver le changement d'état ultime. Le débuggage est simple avec les entités basées sur les classes, le débogueur React affiche les variables de classe dans l'arborescence de débuggage. La seule perte était la capacité de « voyager dans le temps » du package Redux.

Conclusion

D'autres tests des modèles de domaine riche + React nécessiteraient un mélange d'étendue et de profondeur dans une application utilisant cette approche. Les exemples utilisés dans cet article étaient d'une complexité extrêmement limitée. Cependant, notre application semblait vraiment bénéficier de ces modèles de domaine riches à la fois en termes de performances, de simplification de la complexité du code et de lisibilité. Cette approche peut également être plus stable au fil du temps : les mises à jour dans les packages étaient relativement simples à mettre en œuvre, la vraie réactivité vient de la classe BaseModel et des hooks useModelUpdate. L'approche détaillée dans cet article fonctionne à la fois avec React basé sur les classes et React basé sur le hook, espérons que la prochaine version majeure de React sera aussi facile à intégrer dans cette stratégie de modèle réactif. Changer les implémentations pour les futures mises à jour dans React ne nécessitent que 2 modifications de fichiers.

La programmation orientée objet n'est pas trop populaire dans les frameworks frontend et les packages nous découragent d'utiliser ce modèle. Alors que nos applications deviennent de plus en plus complexes et que la business logic est détenue par le frontend, nos outils et frameworks doivent s'adapter à cela. React et Redux ne font pas de la POO un modèle viable, et pourtant c'est un moyen extrêmement puissant d'écrire des applications. Je vous suggère d'essayer cette solution si vous avez beaucoup de business logic et que vous connaissez les frontières entre vos entités. Si vous avez besoin d'une validation et d'une interactivité immédiate dans le frontend, cela peut également être une option viable pour vos applications. Si vous souhaitez avoir un modèle de programmation orientée objet dans vos applications React sans perdre les avantages de React et sans trop de tracas, cette approche est peut-être pour vous.

Vous pouvez trouver le hook et le modèle de base: GitHub gists

Un de mes collègues a écrit un blog sur comment Gérer l'état global React en 2022, dans lequel il détaille les différentes bibliothèques disponibles sur le registre NPM pour la gestion globale d’état.

Remarque : Cette approche n'a pas été étalonnée. Je n'ai aucune idée de ce à quoi ressemblent les performances en fonction de l'étendue et de la profondeur de votre application par rapport à d'autres packages populaires. Je ne peux pas non plus garantir que les points essentiels de GitHub fonctionnent pour toutes les entités et tous les cas (mises à jour d'objets imbriquées très profondément dans les tableaux et les objets ?). Tous les commentaires, suggestions et commentaires sont les bienvenus dans GitHub gists et si vous avez des questions, n'hésitez pas à nous contacter!

Crédit Photo: Christopher Gower