Organisation du développement d'applications React à grande échelle

Cet article est basé sur une série sur la modernisation du frontend jQuery avec React. Afin de mieux comprendre les raisons pour lesquelles ce matériel a été écrit, il est recommandé de jeter un œil au premier article de cette série. Il est très facile de nos jours d'organiser le développement d'une petite application React, ou de repartir de zéro. Surtout lors de l'utilisation de create-react-app . Certains projets n'auront probablement besoin que de quelques dépendances (par exemple, pour gérer l'état de l'application et pour internationaliser le projet) et d'un dossier contenant au moins un répertoire







srccomponents... Je pense que c'est la structure avec laquelle la plupart des projets React commencent. Cependant, à mesure que le nombre de dépendances de projet augmente, les programmeurs sont généralement confrontés à une augmentation du nombre de composants, de réducteurs et d'autres mécanismes réutilisables inclus dans sa composition. Parfois, tout devient très inconfortable et difficile à gérer. Que faire, par exemple, s'il n'est plus clair pourquoi certaines dépendances sont nécessaires et comment elles s'articulent? Ou, que se passe-t-il si le projet a accumulé tellement de composants qu'il devient difficile de trouver le bon parmi eux? Que faire si un programmeur a besoin de trouver un certain composant dont le nom a été oublié?



Ce ne sont que quelques exemples des questions auxquelles nous avons dû trouver des réponses lors de la refonte du frontend dans Karify . Nous savions que le nombre de dépendances et de composants de projet pourrait un jour devenir incontrôlable. Cela signifiait que nous devions tout planifier pour que, au fur et à mesure que le projet grandissait, nous puissions continuer à y travailler en toute confiance. Cette planification comprenait un accord sur la structure des fichiers et des dossiers et la qualité du code. Cela comprenait une description de l'architecture globale du projet. Et surtout, il fallait faire en sorte que tout cela puisse être facilement perçu par les nouveaux programmeurs qui viennent au projet, afin qu'ils, pour être inclus dans le travail, n'aient pas à étudier le projet trop longtemps, en comprenant toutes ses dépendances et le style de son code.



Au moment d'écrire ces lignes, notre projet contient environ 1200 fichiers JavaScript. 350 d'entre eux sont des composants. Le code est testé à 80% à l'unité. Comme nous adhérons toujours aux accords que nous avons établis et travaillons dans le cadre de l'architecture de projet précédemment créée, nous avons décidé qu'il serait bon de partager tout cela avec le grand public. C'est ainsi que cet article est né. Nous parlerons ici de l'organisation du développement d'une application React à grande échelle et des leçons que nous avons tirées de l'expérience de travailler dessus.



Comment organiser les fichiers et les dossiers?



Nous n'avons trouvé un moyen d'organiser commodément nos matériaux frontaux React qu'après avoir traversé plusieurs étapes du projet. Au départ, nous allions héberger les matériaux du projet dans le même référentiel où le code frontend basé sur jQuery était stocké. Cependant, en raison des exigences de la structure de dossiers imposées au projet par le framework backend que nous utilisons, cette option n'a pas fonctionné pour nous. Ensuite, nous avons pensé à déplacer le code du frontend vers un référentiel séparé. Au début, cette approche fonctionnait bien, mais au fil du temps, nous avons commencé à penser à créer d'autres parties clientes du projet, par exemple, une interface basée sur React Native. Cela nous a fait réfléchir à la bibliothèque de composants. En conséquence, nous avons divisé le nouveau référentiel en deux référentiels distincts. L'un était pour une bibliothèque de composants et l'autre pour la nouvelle interface React.Même si au début nous pensions que cette idée était réussie, sa mise en œuvre a conduit à une grave complication de la procédure de révision du code. La relation entre les changements dans nos deux référentiels est devenue floue. En conséquence, nous avons décidé de passer à nouveau au stockage du code dans un seul référentiel, mais maintenant c'était un référentiel mono.



Nous avons opté pour un référentiel mono car nous voulions introduire une séparation entre la bibliothèque de composants et l'application frontend dans le projet. La différence entre notre référentiel mono et d'autres référentiels similaires est que nous n'avons pas besoin de publier de packages dans notre référentiel. Dans notre cas, les packages n'étaient qu'un moyen d'assurer la modularité du développement et un outil de séparation des préoccupations. Il est particulièrement utile d'avoir différents packages pour différentes variantes de votre application, car cela vous permet de définir différentes dépendances pour chacun et d'appliquer différents scripts à chacun.



Nous avons configuré notre référentiel mono à l'aide d' espaces de travail yarn en utilisant la configuration suivante dans le fichier racine package.json:



"workspaces": [
    "app/*",
    "lib/*",
    "tool/*"
]


Maintenant, certains d'entre vous se demandent peut-être pourquoi nous n'avons tout simplement pas utilisé les dossiers de paquets, faisant la même chose que dans d'autres monorépôts. Ceci est principalement dû au fait que nous voulions séparer l'application et la bibliothèque de composants. De plus, nous savions que nous devions créer certains de nos propres outils. En conséquence, nous sommes arrivés à la structure de dossiers ci-dessus. Voici comment ces dossiers sont lus dans un projet:



  • app: tous les packages de ce dossier sont liés à des applications frontales telles que l'interface Karify et d'autres interfaces internes. Notre matériel Storybook est également stocké ici .
  • lib: -, , . , , . , , typography, media primitive.
  • tool: , , Node.js. , , , . , , webpack, , ( « »).


Tous nos packages, quel que soit le dossier dans lequel ils sont stockés, ont un sous-dossier srcet éventuellement un dossier bin. Les dossiers de srcpackages, stockés dans les répertoires appet lib, peuvent contenir certains des sous-dossiers suivants:



  • actions: Contient des fonctions pour créer des actions dont les valeurs de retour peuvent être transmises aux fonctions de répartition depuis reduxou useReducer.
  • components: contient des dossiers de composants avec leur code, traductions, tests unitaires, instantanés, historiques (si applicable à un composant spécifique).
  • constants: ce dossier stocke des valeurs inchangées dans différents environnements. Les utilitaires sont également stockés ici.
  • fetch: c'est là que les définitions de type sont stockées pour le traitement des données reçues de notre API, ainsi que les actions asynchrones correspondantes utilisées pour recevoir ces données.
  • helpers: , .
  • reducers: , redux useReducer.
  • routes: , react-router history.
  • selectors: , redux-, , API.


Cette structure de dossiers nous permet d'écrire du code véritablement modulaire, car elle crée un système clair pour répartir les responsabilités entre les différents concepts définis par nos dépendances. Cela nous aide à rechercher dans le référentiel des variables, des fonctions et des composants, et, de plus, que celui qui les recherche connaisse ou non leur existence. De plus, cela nous aide à conserver le minimum de contenu dans des dossiers séparés, ce qui, à son tour, facilite leur travail.



Lorsque nous avons commencé à appliquer cette structure de dossiers, nous avons été confrontés au défi d'assurer une application cohérente d'une telle structure. Lorsqu'il travaille avec différents packages, le développeur peut souhaiter créer différents dossiers dans les dossiers de ces packages, organiser les fichiers dans ces dossiers de différentes manières. Bien que ce ne soit pas toujours une mauvaise chose, une telle approche désorganisée prêterait à confusion. Pour nous aider à appliquer systématiquement la structure ci-dessus, nous avons créé ce que l'on peut appeler un «système de fichiers linter». Nous en parlerons maintenant.



Comment vous assurez-vous de l'application obligatoire du guide de style?



Nous avons cherché à uniformiser la structure des fichiers et des dossiers dans notre projet. Nous voulions obtenir la même chose pour le code. À ce moment-là, nous avions déjà une expérience réussie de résolution d'un problème similaire dans la version jQuery du projet, mais nous avions beaucoup à améliorer, en particulier en ce qui concerne CSS. En conséquence, nous avons décidé de créer un guide de style à partir de zéro et de nous assurer de l'utiliser avec un linter. Les règles qui ne pouvaient pas être appliquées avec un linter ont été contrôlées lors de la révision du code.



La configuration d'un linter dans un référentiel mono se fait de la même manière que dans tout autre référentiel. C'est bien car cela vous permet d'extraire tout le référentiel en une seule exécution linter. Si vous n'êtes pas familier avec les linters, je vous recommande de jeter un œil à ESLint et Stylelint . Nous les utilisons exactement.



Le linter JavaScript s'est avéré particulièrement utile dans les situations suivantes:



  • Assurer l'utilisation de composants construits avec l'accessibilité du contenu à l'esprit, au lieu de leurs homologues HTML. Lors de la création du guide de style, nous avons introduit plusieurs règles concernant l'accessibilité des liens, des boutons, des images et des icônes. Ensuite, nous devions appliquer ces règles dans le code et nous assurer qu'à l'avenir, nous ne les oublierions pas. Nous l'avons fait en utilisant la règle react / interdire-éléments de eslint-plugin-react .


Voici un exemple de ce à quoi il ressemble:



'react/forbid-elements': [
    'error',
    {
        forbid: [
            {
                element: 'img',
                message: 'Use "<Image>" instead. This is important for accessibility reasons.',
            },
        ],
    },
],






En plus du linting JavaScript et CSS, nous avons également notre propre «système de fichiers linter». C'est lui qui assure une utilisation uniforme de la structure de dossiers que nous avons choisie. Puisqu'il s'agit d'un outil que nous avons créé nous-mêmes, si nous décidons de passer à une structure de dossiers différente, nous pouvons toujours la modifier en conséquence. Voici des exemples de règles que nous contrôlons lorsque nous travaillons avec des fichiers et des dossiers:



  • Vérifier la structure des dossiers des composants: s'assurer qu'il y a toujours un fichier index.tset un .tsxfichier avec le même nom que le dossier.
  • Validation de fichier package.json: s'assurer qu'il existe un fichier de ce type par package et que la propriété privateest définie sur truepour éviter la publication accidentelle du package.


Quel système de typage devriez-vous choisir?



De nos jours, la réponse à la question dans le titre de cette section est probablement assez simple pour beaucoup. Il vous suffit d'utiliser TypeScript . Dans certains cas, quelle que soit la taille du projet, la mise en œuvre de TypeScript peut ralentir le développement. Mais nous pensons que c'est un prix raisonnable à payer pour améliorer la qualité et la rigueur du code.



Malheureusement, au moment où nous avons commencé à travailler sur le projet, le système des types d'accessoires était encore très largement utilisé.... Au début de notre travail, cela nous suffisait, mais à mesure que le projet grandissait, nous avons commencé à manquer la possibilité de déclarer des types pour des entités qui ne sont pas des composants. Nous avons vu que cela nous aidera à améliorer, par exemple, les réducteurs et les sélecteurs. Mais l'introduction d'un système de typage différent dans un projet nécessiterait beaucoup de refactorisation de code pour taper toute la base de code.



Au final, nous avons toujours équipé notre projet de support de type, mais avons commis l'erreur d'essayer Flow en premier.... Il nous a semblé que Flow était plus facile à intégrer dans le projet. Même si c'était le cas, nous avions régulièrement toutes sortes de problèmes avec Flow. Ce système ne s'intégrait pas très bien à notre IDE, parfois pour une raison inconnue, il ne détectait pas certains bogues, et la création de types génériques était un véritable cauchemar. Pour ces raisons, nous avons fini par tout migrer vers TypeScript. Si nous savions alors ce que nous savons maintenant, nous choisirions immédiatement TypeScript.



En raison de la direction dans laquelle TypeScript a évolué ces dernières années, cette transition a été assez facile pour nous. La transition de TSLint vers ESLint nous a été particulièrement utile .



Comment tester le code?



Lorsque nous avons commencé à travailler sur le projet, nous ne savions pas très bien quels outils de test choisir. Si j'y pensais maintenant, je dirais que, pour les tests unitaires et d'intégration, il est préférable d'utiliser respectivement jest et cypress . Ces outils sont bien documentés et faciles à utiliser. Le seul dommage est que cypress ne prend pas en charge l'API Fetch , le problème est que l'API de cet outil n'est pas conçue pour utiliser la construction async / await . Nous, après avoir commencé à utiliser le cyprès, n'avons pas tout de suite compris cela. Mais j'espère que la situation s'améliorera dans un proche avenir.



Au début, il nous était difficile de trouver la meilleure façon d'écrire des tests unitaires. Au fil du temps, nous avons essayé des approches telles que l' instantané test , renderer test , renderer peu profonde . Nous avons essayé Testing Library . Nous nous sommes retrouvés avec un rendu superficiel, utilisé pour tester la sortie du composant, et avons utilisé le rendu de test pour tester la logique interne des composants.



Nous pensons que la bibliothèque de tests est une bonne solution pour les petits projets. Mais le fait que ce système repose sur le rendu DOM a un impact important sur les performances de référence. De plus, nous pensons que la critiqueles tests d'instantanés utilisant le rendu de surface ne sont pas pertinents lorsqu'il s'agit de composants très "profonds". Pour nous, les instantanés se sont avérés très utiles pour vérifier toutes les options possibles pour la sortie des composants. Cependant, le code du composant ne doit pas être trop compliqué; vous devez vous efforcer de le rendre facile à lire. Cela peut être toJSONréalisé en rendant les composants petits et en définissant une méthode pour les entrées de composant qui ne sont pas pertinentes pour l'instantané.



Afin de ne pas oublier les tests unitaires, nous avons mis en place le seuil de couverture du code par des tests... Avec blague, c'est très facile à faire et il n'y a pas grand chose à penser. Il suffit de définir un indicateur de couverture globale du code par des tests. Donc, au début des travaux, nous avons fixé ce chiffre à 60%. Au fil du temps, à mesure que la couverture des tests de notre base de code augmentait, nous l'avons augmentée à 80%. Nous sommes satisfaits de cet indicateur, car nous ne pensons pas qu'il soit nécessaire de viser une couverture de code à 100% avec des tests. Atteindre ce niveau de couverture de code avec des tests ne nous semble pas réaliste.



Comment simplifier la création de nouveaux projets?



Habituellement , le début des travaux sur le React-application est très simple: ReactDOM.render(<App />, document.getElementById(‘#root’));. Mais dans le cas où vous devez prendre en charge SSR (Server-Side Rendering, Server Rendering), cette tâche devient plus compliquée. En outre, si les dépendances de votre application incluent plus que React, votre code client et serveur devra peut-être utiliser des paramètres différents. Par exemple, nous utilisons react-intl pour l'internationalisation, react-redux pour la gestion globale de l'état , react-router pour le routage et redux-saga pour gérer les actions asynchrones . Ces dépendances nécessitent quelques ajustements. Le processus de configuration de ces dépendances peut être complexe.



Notre solution à ce problème était basée sur les modèles de conception « Stratégie » et « Usine abstraite ». Nous avions l'habitude de créer deux classes différentes (deux stratégies différentes): une pour la configuration du client et une pour la configuration du serveur. Ces deux classes ont reçu les paramètres de l'application créée, qui comprenaient le nom, le logo, les réducteurs, les routes, la langue par défaut, les sagas (pour redux-saga), etc. Les réducteurs, routes et sagas peuvent être extraits de différents packages de notre mono-référentiel. Cette configuration est ensuite utilisée pour créer le magasin redux, le middleware sagas, l'objet historique du routeur. Il est également utilisé pour charger des traductions et pour rendre l'application. Voici, par exemple, les signatures des stratégies client et serveur:



type BootstrapConfiguration = {
  logo: string,
  name: string,
  reducers: ReducersMapObject,
  routes: Route[],
  sagas: Saga[],
};
class AbstractBootstrap {
  configuration: BootstrapConfiguration;
  intl: IntlShape;
  store: Store;
  rootSaga: Task;
abstract public run(): void;
  abstract public render<T>(): T;
  abstract protected createIntl(): IntlShape;
  abstract protected createRootSaga(): Task;
  abstract protected createStore(): Store;
}
//   
class WebBootstrap extends AbstractBootstrap {
  constructor(config: BootstrapConfiguration);
  public render<ReactNode>(): ReactNode;
}
//   
class ServerBootstrap extends AbstractBootstrap {
  constructor(config: BootstrapConfiguration);
  public render<string>(): string;
}


Nous avons trouvé cette séparation des stratégies utile, car il existe des différences dans la configuration du stockage, des sagas, des objets d'internationalisation et de l'historique, en fonction de l'environnement dans lequel le code est exécuté. Par exemple, un magasin redux sur le client est créé à l'aide de données préchargées à partir du serveur et à l'aide de l' extension redux-devtools- . Rien de tout cela n'est nécessaire sur le serveur. Un autre exemple est un objet d'internationalisation qui, sur le client, obtient la langue actuelle à partir de navigator.languages , et sur le serveur à partir de l'en - tête HTTP Accept-Language .



Il est important de noter que nous avons pris cette décision il y a longtemps. Alors que les classes étaient encore largement utilisées dans les applications React, il n'existait pas d'outils simples pour effectuer le rendu des applications côté serveur. Au fil du temps, la bibliothèque React a fait un pas vers un style fonctionnel et des projets comme Next.js sont apparus . Dans cet esprit, si vous recherchez une solution à un problème similaire, nous vous recommandons de rechercher les technologies actuelles. Ceci, très probablement, nous permettra de trouver quelque chose qui sera plus simple et plus fonctionnel que ce que nous utilisons.



Comment maintenir la qualité de votre code à un niveau élevé?



Linters, tests, vérification de type - tout cela a un effet bénéfique sur la qualité du code. Mais un programmeur peut facilement oublier d'exécuter les vérifications appropriées avant d'inclure du code dans une branche master. La meilleure chose à faire est de faire exécuter ces vérifications automatiquement. Certaines personnes préfèrent faire cela à chaque commit en utilisant des hooks Git., qui ne vous permet pas de vous engager tant que le code n'a pas passé toutes les vérifications. Mais nous pensons qu'avec cette approche, le système interfère trop avec le travail du programmeur. Après tout, par exemple, le travail sur une certaine branche peut prendre plusieurs jours, et tous ces jours-là, il ne sera pas reconnu comme apte à être envoyé au référentiel. Par conséquent, nous vérifions les commits en utilisant le système d'intégration continue. Seul le code des branches associées aux demandes de fusion est vérifié. Cela nous permet d'éviter d'exécuter des vérifications dont on a la garantie de ne pas réussir, car nous demandons le plus souvent d'inclure les résultats de nos travaux dans le code principal du projet lorsque nous sommes sûrs que ces résultats sont capables de passer tous les contrôles.



Le flux de validation automatique du code commence par l'installation des dépendances. Ceci est suivi par la vérification de type, l'exécution de linters, l'exécution de tests unitaires, la création d'une application, l'exécution de tests de cyprès. Presque toutes ces tâches sont effectuées en parallèle. Si une erreur se produit à l'une de ces étapes, l'ensemble du processus de paiement échouera et la branche correspondante ne pourra pas être incluse dans le code principal du projet. Voici un exemple de système de révision de code fonctionnel.





Vérification automatique du code La



principale difficulté que nous avons rencontrée lors de la mise en place de ce système a été d'accélérer l'exécution des contrôles. Cette tâche est toujours d'actualité. Nous avons effectué de nombreuses optimisations et maintenant tous ces contrôles sont stables en environ 20 minutes. Peut-être que cet indicateur peut être amélioré en parallélisant l'exécution de certains tests de cyprès, mais pour l'instant cela nous convient.



Résultat



Organiser le développement d'une application React à grande échelle n'est pas une tâche facile. Pour le résoudre, un programmeur doit prendre de nombreuses décisions, de nombreux outils doivent être configurés. Dans le même temps, il n'y a pas de réponse unique et correcte à la question de savoir comment développer de telles applications.



Notre système nous convient jusqu'à présent. Nous espérons qu'en parler aidera d'autres programmeurs qui sont confrontés aux mêmes tâches que nous avons affrontées. Si vous décidez de suivre notre exemple, assurez-vous d'abord que ce qui a été discuté ici est bon pour vous et votre entreprise. Plus important encore, aspirez au minimalisme. Ne compliquez pas trop vos applications et boîtes à outils utilisées pour les créer.



Comment aborderiez-vous l'organisation du développement d'un projet React à grande échelle?






All Articles