Vous lisez un extrait de mon livre sur le code propre, « Washing your code ». Disponible au format PDF, EPUB et en édition papier et Kindle. Obtenez votre exemplaire maintenant.
Savoir comment organiser le code en modules ou en fonctions, et quand le bon moment est d'introduire une abstraction au lieu de dupliquer le code, est une compétence importante. Écrire du code générique que d’autres peuvent utiliser efficacement est encore une autre compétence. Il existe autant de raisons de diviser le code que de le conserver ensemble. Dans ce chapitre, nous aborderons certaines de ces raisons.
Nous, développeurs, détestons faire deux fois le même travail. DRY est un mantra pour beaucoup. Cependant, lorsque nous avons deux ou trois morceaux de code qui font en quelque sorte la même chose, il est peut-être encore trop tôt pour introduire une abstraction, aussi tentante que cela puisse paraître.
Info : Le principe Ne vous répétez pas (DRY) exige que « chaque élément de connaissance doit avoir une représentation unique, sans ambiguïté et faisant autorité au sein d'un système », ce qui est souvent interprété comme toute duplication de code est strictement verboten.
Vivez avec la douleur de la duplication de code pendant un certain temps ; ce n’est peut-être pas si mal au final, et le code n’est en fait pas exactement le même. Un certain niveau de duplication de code est sain et nous permet d'itérer et de faire évoluer le code plus rapidement sans craindre de casser quelque chose.
Il est également difficile de proposer une bonne API lorsque l’on ne considère que quelques cas d’utilisation.
Gérer du code partagé dans de grands projets avec de nombreux développeurs et équipes est difficile. Les nouvelles exigences pour une équipe peuvent ne pas fonctionner pour une autre équipe et briser leur code, ou nous nous retrouvons avec un monstre spaghetti impossible à maintenir avec des dizaines de conditions.
Imaginez que l'équipe A ajoute un formulaire de commentaire à sa page : un nom, un message et un bouton d'envoi. Ensuite, l’équipe B a besoin d’un formulaire de commentaires pour trouver le composant de l’équipe A et essayer de le réutiliser. Ensuite, l'équipe A veut également un champ de courrier électronique, mais elle ne sait pas que l'équipe B utilise son composant, elle ajoute donc un champ de courrier électronique obligatoire et interrompt la fonctionnalité pour les utilisateurs de l'équipe B. Ensuite, l'équipe B a besoin d'un champ de numéro de téléphone, mais elle sait que l'équipe A utilise le composant sans celui-ci, elle ajoute donc une option pour afficher un champ de numéro de téléphone. Un an plus tard, les deux équipes se détestent pour avoir brisé le code de l'autre, et le composant est plein de conditions et impossible à maintenir. Les deux équipes gagneraient beaucoup de temps et auraient des relations plus saines entre elles si elles maintenaient des composants séparés composés de composants partagés de niveau inférieur, comme un champ de saisie ou un bouton.
Conseil : Cela pourrait être une bonne idée d'interdire à d'autres équipes d'utiliser notre code à moins qu'il ne soit conçu et marqué comme partagé. Le Dependency Cruiser est un outil qui pourrait aider à établir de telles règles.
Parfois, nous devons faire reculer une abstraction. Lorsque nous commençons à ajouter des conditions et des options, nous devons nous demander : s’agit-il toujours d’une variation de la même chose ou d’une nouvelle chose qui devrait être séparée ? Ajouter trop de conditions et de paramètres à un module peut rendre l'API difficile à utiliser et le code difficile à maintenir et à tester.
La duplication est moins chère et plus saine qu'une mauvaise abstraction.
Info : Voir l'article de Sandi Metz, The Wrong Abstraction, pour une excellente explication.
Plus le niveau du code est élevé, plus nous devons attendre avant de l'abstraire. Les abstractions d'utilitaires de bas niveau sont beaucoup plus évidentes et stables que la logique métier.
La réutilisation du code n'est pas la seule, ni même la raison la plus importante, pour extraire un morceau de code dans une fonction ou un module distinct.
LaLa longueur du code est souvent utilisée comme mesure pour savoir quand nous devons diviser un module ou une fonction, mais la taille à elle seule ne rend pas le code difficile à lire ou à maintenir.
Découper un algorithme linéaire, même long, en plusieurs fonctions puis les appeler les unes après les autres rend rarement le code plus lisible. Sauter entre les fonctions (et plus encore entre les fichiers) est plus difficile que faire défiler, et si nous devons examiner l'implémentation de chaque fonction pour comprendre le code, alors l'abstraction n'était pas la bonne.
Info : Egon Elbre a écrit un bel article sur la psychologie de la lisibilité du code.
Voici un exemple, adapté du blog Google Testing :
function createPizza(order) { const pizza = new Pizza({ base: order.size, sauce: order.sauce, cheese: 'Mozzarella' }); if (order.kind === 'Veg') { pizza.toppings = vegToppings; } else if (order.kind === 'Meat') { pizza.toppings = meatToppings; } const oven = new Oven(); if (oven.temp !== cookingTemp) { while (oven.temp < cookingTemp) { time.sleep(checkOvenInterval); oven.temp = getOvenTemp(oven); } } if (!pizza.baked) { oven.insert(pizza); time.sleep(cookTime); oven.remove(pizza); pizza.baked = true; } const box = new Box(); pizza.boxed = box.putIn(pizza); pizza.sliced = box.slicePizza(order.size); pizza.ready = box.close(); return pizza; }
J'ai tellement de questions sur l'API de la classe Pizza, mais voyons quelles améliorations les auteurs suggèrent :
function prepare(order) { const pizza = new Pizza({ base: order.size, sauce: order.sauce, cheese: 'Mozzarella' }); addToppings(pizza, order.kind); return pizza; } function addToppings(pizza, kind) { if (kind === 'Veg') { pizza.toppings = vegToppings; } else if (kind === 'Meat') { pizza.toppings = meatToppings; } } function bake(pizza) { const oven = new Oven(); heatOven(oven); bakePizza(pizza, oven); } function heatOven(oven) { if (oven.temp !== cookingTemp) { while (oven.temp < cookingTemp) { time.sleep(checkOvenInterval); oven.temp = getOvenTemp(oven); } } } function bakePizza(pizza, oven) { if (!pizza.baked) { oven.insert(pizza); time.sleep(cookTime); oven.remove(pizza); pizza.baked = true; } } function pack(pizza) { const box = new Box(); pizza.boxed = box.putIn(pizza); pizza.sliced = box.slicePizza(pizza.size); pizza.ready = box.close(); } function createPizza(order) { const pizza = prepare(order); bake(pizza); pack(pizza); return pizza; }
Ce qui était déjà complexe et alambiqué est maintenant encore plus complexe et alambiqué, et la moitié du code n'est constituée que d'appels de fonction. Cela ne rend pas le code plus facile à comprendre, mais cela rend son utilisation presque impossible. L'article ne montre pas le code complet de la version refactorisée, peut-être pour rendre le propos plus convaincant.
Pierre « catwell » Chapuis suggère dans son article de blog d'ajouter des commentaires au lieu de nouvelles fonctions :
function createPizza(order) { const pizza = new Pizza({ base: order.size, sauce: order.sauce, cheese: 'Mozzarella' }); if (order.kind === 'Veg') { pizza.toppings = vegToppings; } else if (order.kind === 'Meat') { pizza.toppings = meatToppings; } const oven = new Oven(); if (oven.temp !== cookingTemp) { while (oven.temp < cookingTemp) { time.sleep(checkOvenInterval); oven.temp = getOvenTemp(oven); } } if (!pizza.baked) { oven.insert(pizza); time.sleep(cookTime); oven.remove(pizza); pizza.baked = true; } const box = new Box(); pizza.boxed = box.putIn(pizza); pizza.sliced = box.slicePizza(order.size); pizza.ready = box.close(); return pizza; }
C'est déjà bien mieux que la version split. Une solution encore meilleure consisterait à améliorer les API et à rendre le code plus clair. Pierre suggère que le préchauffage du four ne devrait pas faire partie de la fonction createPizza() (et cuire moi-même de nombreuses pizzas, je suis tout à fait d'accord !) car dans la vraie vie, le four est déjà là et probablement déjà chaud à cause de la pizza précédente. Pierre suggère également que la fonction devrait renvoyer la boîte, pas la pizza, car dans le code original, la boîte disparaît en quelque sorte après toute la magie du tranchage et de l'emballage, et nous nous retrouvons avec la pizza tranchée dans nos mains.
Il existe de nombreuses façons de cuisiner une pizza, tout comme il existe de nombreuses façons de coder un problème. Le résultat peut sembler identique, mais certaines solutions sont plus faciles à comprendre, à modifier, à réutiliser et à supprimer que d'autres.
La dénomination peut également être un problème lorsque toutes les fonctions extraites font partie du même algorithme. Nous devons inventer des noms plus clairs que le code et plus courts que les commentaires – ce n’est pas une tâche facile.
Info : Nous parlons de commenter le code dans le chapitre Éviter les commentaires, et de nommer dans le chapitre Naming is hard.
Vous ne trouverez probablement pas beaucoup de petites fonctions dans mon code. D'après mon expérience, les raisons les plus utiles pour diviser le code sont la fréquence de changement et la raison du changement.
Commençons par changer la fréquence. La logique métier change beaucoup plus souvent que les fonctions utilitaires. Il est logique de séparer le code qui change souvent du code qui est très stable.
Le formulaire de commentaire dont nous avons parlé plus tôt dans ce chapitre est un exemple du premier ; une fonction qui convertit les chaînes camelCase en kebab-case est un exemple de cette dernière. Le formulaire de commentaires est susceptible de changer et de diverger au fil du temps lorsque de nouvelles exigences commerciales surviennent ; Il est peu probable que la fonction de conversion de cas change et elle peut être réutilisée en toute sécurité dans de nombreux endroits.
Imaginez que nous créons un joli tableau pour afficher des données. Nous pensons peut-être que nous n'aurons plus jamais besoin de cette conception de table, nous décidons donc de conserver tout le code de la table dans un seul module.
Au sprint suivant, nous obtenons une tâche pour ajouter une autre colonne au tableau, nous copions donc le code d'une colonne existante et y modifions quelques lignes. Au prochain sprint, nous devons ajouter une autre table avec le même design. Au prochain sprint, il faudra changer le design des tables…
Notre module de table a au moins trois raisons de changer, ou responsabilités :
Cela rend le module plus difficile à comprendre et plus difficile à modifier. Le code de présentation ajoute beaucoup de verbosité, ce qui rend plus difficile la compréhension de la logique métier. Pour modifier l'une des responsabilités, nous devons lire et modifier davantage de code. Cela rend l'itération plus difficile et plus lente sur l'un ou l'autre.
Avoir une table générique en tant que module séparé résout ce problème. Désormais, pour ajouter une autre colonne à un tableau, il suffit de comprendre et de modifier l'un des deux modules. Nous n'avons pas besoin de savoir quoi que ce soit sur le module de table générique, à l'exception de son API publique. Pour modifier la conception de toutes les tables, il nous suffit de modifier le code du module de table générique et nous n'avons probablement pas besoin de toucher aux tables individuelles.
Cependant, selon la complexité du problème, il est correct, et souvent préférable, de commencer par une approche monolithique et d'en extraire une abstraction plus tard.
Même la réutilisation du code peut être une raison valable pour séparer le code : si nous utilisons un composant sur une page, nous en aurons probablement bientôt besoin sur une autre page.
Il pourrait être tentant d'extraire chaque fonction dans son propre module. Cependant, cela présente aussi des inconvénients :
Je préfère garder les petites fonctions qui ne sont utilisées que dans un seul module au début du module. De cette façon, nous n'avons pas besoin de les importer pour les utiliser dans le même module, mais les réutiliser ailleurs serait gênant.
function createPizza(order) { const pizza = new Pizza({ base: order.size, sauce: order.sauce, cheese: 'Mozzarella' }); if (order.kind === 'Veg') { pizza.toppings = vegToppings; } else if (order.kind === 'Meat') { pizza.toppings = meatToppings; } const oven = new Oven(); if (oven.temp !== cookingTemp) { while (oven.temp < cookingTemp) { time.sleep(checkOvenInterval); oven.temp = getOvenTemp(oven); } } if (!pizza.baked) { oven.insert(pizza); time.sleep(cookTime); oven.remove(pizza); pizza.baked = true; } const box = new Box(); pizza.boxed = box.putIn(pizza); pizza.sliced = box.slicePizza(order.size); pizza.ready = box.close(); return pizza; }
Dans le code ci-dessus, nous avons un composant (FormattedAddress) et une fonction (getMapLink()) qui ne sont utilisés que dans ce module, ils sont donc définis en haut du fichier.
Si nous devons tester ces fonctions (et nous devrions le faire !), nous pouvons les exporter depuis le module et les tester avec la fonction principale du module.
Il en va de même pour les fonctions destinées à être utilisées uniquement avec une certaine fonction ou un certain composant. Les conserver dans le même module montre plus clairement que toutes les fonctions vont ensemble et rend ces fonctions plus visibles.
Un autre avantage est que lorsque nous supprimons un module, nous supprimons automatiquement ses dépendances. Le code des modules partagés reste souvent pour toujours dans la base de code car il est difficile de savoir s'il est toujours utilisé (bien que TypeScript facilite cela).
Info : De tels modules sont parfois appelés modules profonds : des modules relativement volumineux qui encapsulent des problèmes complexes mais possèdent des API simples. Le contraire des modules profonds sont les modules peu profonds : de nombreux petits modules qui doivent interagir les uns avec les autres.
Si l'on doit souvent modifier plusieurs modules ou fonctions en même temps, il serait peut-être préférable de les fusionner en un seul module ou fonction. Cette approche est parfois appelée colocation.
Voici quelques exemples de colocation :
Voici comment l'arborescence des fichiers change avec la colocation :
Separated | Colocated |
---|---|
React components | |
src/components/Button.tsx | src/components/Button.tsx |
styles/Button.css | |
Tests | |
src/util/formatDate.ts | src/util/formatDate.ts |
tests/formatDate.ts | src/util/formatDate.test.ts |
Ducks | |
src/actions/feature.js | src/ducks/feature.js |
src/actionCreators/feature.js | |
src/reducers/feature.js |
Info : Pour en savoir plus sur la colocation, lisez l'article de Kent C. Dodds.
Une plainte courante concernant la colocation est qu'elle rend les composants trop volumineux. Dans de tels cas, il est préférable d'extraire certaines parties dans leurs propres composants, ainsi que le balisage, les styles et la logique.
L'idée de colocation entre également en conflit avec la séparation des préoccupations — une idée dépassée qui a conduit les développeurs Web à conserver HTML, CSS et JavaScript dans des fichiers séparés (et souvent dans des parties distinctes de l'arborescence des fichiers) pour trop long, nous obligeant à modifier trois fichiers en même temps pour apporter les modifications les plus élémentaires aux pages Web.
Info : La raison du changement est également connue sous le nom de principe de responsabilité unique, qui stipule que « chaque module, classe ou fonction doit avoir la responsabilité d'une seule partie de la fonctionnalité. fourni par le logiciel, et cette responsabilité doit être entièrement assumée par la classe. »
Parfois, nous devons travailler avec une API particulièrement difficile à utiliser ou sujette aux erreurs. Par exemple, cela nécessite plusieurs étapes dans un ordre spécifique ou l’appel d’une fonction avec plusieurs paramètres toujours identiques. C'est une bonne raison de créer une fonction utilitaire pour nous assurer que nous faisons toujours les choses correctement. En bonus, nous pouvons désormais écrire des tests pour ce bout de code.
Les manipulations de chaînes, telles que les URL, les noms de fichiers, la conversion de casse ou le formatage, sont de bons candidats pour l'abstraction. Très probablement, il existe déjà une bibliothèque pour ce que nous essayons de faire.
Considérez cet exemple :
function createPizza(order) { const pizza = new Pizza({ base: order.size, sauce: order.sauce, cheese: 'Mozzarella' }); if (order.kind === 'Veg') { pizza.toppings = vegToppings; } else if (order.kind === 'Meat') { pizza.toppings = meatToppings; } const oven = new Oven(); if (oven.temp !== cookingTemp) { while (oven.temp < cookingTemp) { time.sleep(checkOvenInterval); oven.temp = getOvenTemp(oven); } } if (!pizza.baked) { oven.insert(pizza); time.sleep(cookTime); oven.remove(pizza); pizza.baked = true; } const box = new Box(); pizza.boxed = box.putIn(pizza); pizza.sliced = box.slicePizza(order.size); pizza.ready = box.close(); return pizza; }
Il faut un certain temps pour se rendre compte que ce code supprime l'extension du fichier et renvoie le nom de base. Non seulement c'est inutile et difficile à lire, mais cela suppose également que l'extension comporte toujours trois caractères, ce qui pourrait ne pas être le cas.
Réécrivons-le à l'aide d'une bibliothèque, le module path intégré de Node.js :
function prepare(order) { const pizza = new Pizza({ base: order.size, sauce: order.sauce, cheese: 'Mozzarella' }); addToppings(pizza, order.kind); return pizza; } function addToppings(pizza, kind) { if (kind === 'Veg') { pizza.toppings = vegToppings; } else if (kind === 'Meat') { pizza.toppings = meatToppings; } } function bake(pizza) { const oven = new Oven(); heatOven(oven); bakePizza(pizza, oven); } function heatOven(oven) { if (oven.temp !== cookingTemp) { while (oven.temp < cookingTemp) { time.sleep(checkOvenInterval); oven.temp = getOvenTemp(oven); } } } function bakePizza(pizza, oven) { if (!pizza.baked) { oven.insert(pizza); time.sleep(cookTime); oven.remove(pizza); pizza.baked = true; } } function pack(pizza) { const box = new Box(); pizza.boxed = box.putIn(pizza); pizza.sliced = box.slicePizza(pizza.size); pizza.ready = box.close(); } function createPizza(order) { const pizza = prepare(order); bake(pizza); pack(pizza); return pizza; }
Maintenant, ce qui se passe est clair, il n’y a pas de chiffres magiques et cela fonctionne avec des extensions de fichiers de n’importe quelle longueur.
Les autres candidats à l'abstraction incluent les dates, les capacités de l'appareil, les formulaires, la validation des données, l'internationalisation, etc. Je recommande de rechercher une bibliothèque existante avant d'écrire une nouvelle fonction utilitaire. Nous sous-estimons souvent la complexité de fonctions apparemment simples.
Voici quelques exemples de telles bibliothèques :
Parfois, on se laisse emporter et créons des abstractions qui ne simplifient ni le code ni ne le raccourcissent :
function createPizza(order) { // Prepare pizza const pizza = new Pizza({ base: order.size, sauce: order.sauce, cheese: 'Mozzarella' }); // Add toppings if (order.kind == 'Veg') { pizza.toppings = vegToppings; } else if (order.kind == 'Meat') { pizza.toppings = meatToppings; } const oven = new Oven(); if (oven.temp !== cookingTemp) { // Heat oven while (oven.temp < cookingTemp) { time.sleep(checkOvenInterval); oven.temp = getOvenTemp(oven); } } if (!pizza.baked) { // Bake pizza oven.insert(pizza); time.sleep(cookTime); oven.remove(pizza); pizza.baked = true; } // Box and slice const box = new Box(); pizza.boxed = box.putIn(pizza); pizza.sliced = box.slicePizza(order.size); pizza.ready = box.close(); return pizza; }
Autre exemple :
function createPizza(order) { const pizza = new Pizza({ base: order.size, sauce: order.sauce, cheese: 'Mozzarella' }); if (order.kind === 'Veg') { pizza.toppings = vegToppings; } else if (order.kind === 'Meat') { pizza.toppings = meatToppings; } const oven = new Oven(); if (oven.temp !== cookingTemp) { while (oven.temp < cookingTemp) { time.sleep(checkOvenInterval); oven.temp = getOvenTemp(oven); } } if (!pizza.baked) { oven.insert(pizza); time.sleep(cookTime); oven.remove(pizza); pizza.baked = true; } const box = new Box(); pizza.boxed = box.putIn(pizza); pizza.sliced = box.slicePizza(order.size); pizza.ready = box.close(); return pizza; }
La meilleure chose que nous puissions faire dans de tels cas est d'appliquer le tout-puissant refactoring en ligne : remplacer chaque appel de fonction par son corps. Pas d'abstraction, pas de problème.
Le premier exemple devient :
function prepare(order) { const pizza = new Pizza({ base: order.size, sauce: order.sauce, cheese: 'Mozzarella' }); addToppings(pizza, order.kind); return pizza; } function addToppings(pizza, kind) { if (kind === 'Veg') { pizza.toppings = vegToppings; } else if (kind === 'Meat') { pizza.toppings = meatToppings; } } function bake(pizza) { const oven = new Oven(); heatOven(oven); bakePizza(pizza, oven); } function heatOven(oven) { if (oven.temp !== cookingTemp) { while (oven.temp < cookingTemp) { time.sleep(checkOvenInterval); oven.temp = getOvenTemp(oven); } } } function bakePizza(pizza, oven) { if (!pizza.baked) { oven.insert(pizza); time.sleep(cookTime); oven.remove(pizza); pizza.baked = true; } } function pack(pizza) { const box = new Box(); pizza.boxed = box.putIn(pizza); pizza.sliced = box.slicePizza(pizza.size); pizza.ready = box.close(); } function createPizza(order) { const pizza = prepare(order); bake(pizza); pack(pizza); return pizza; }
Et le deuxième exemple devient :
function createPizza(order) { // Prepare pizza const pizza = new Pizza({ base: order.size, sauce: order.sauce, cheese: 'Mozzarella' }); // Add toppings if (order.kind == 'Veg') { pizza.toppings = vegToppings; } else if (order.kind == 'Meat') { pizza.toppings = meatToppings; } const oven = new Oven(); if (oven.temp !== cookingTemp) { // Heat oven while (oven.temp < cookingTemp) { time.sleep(checkOvenInterval); oven.temp = getOvenTemp(oven); } } if (!pizza.baked) { // Bake pizza oven.insert(pizza); time.sleep(cookTime); oven.remove(pizza); pizza.baked = true; } // Box and slice const box = new Box(); pizza.boxed = box.putIn(pizza); pizza.sliced = box.slicePizza(order.size); pizza.ready = box.close(); return pizza; }
Le résultat n'est pas seulement plus court et plus lisible ; désormais, les lecteurs n'ont plus besoin de deviner ce que font ces fonctions, car nous utilisons désormais des fonctions et fonctionnalités natives JavaScript sans abstractions maison.
Dans de nombreux cas, un peu de répétition fait du bien. Prenons cet exemple :
function FormattedAddress({ address, city, country, district, zip }) { return [address, zip, district, city, country] .filter(Boolean) .join(', '); } function getMapLink({ name, address, city, country, zip }) { return `https://www.google.com/maps/?q=${encodeURIComponent( [name, address, zip, city, country].filter(Boolean).join(', ') )}`; } function ShopsPage({ url, title, shops }) { return ( <PageWithTitle url={url} title={title}> <Stack as="ul" gap="l"> {shops.map(shop => ( <Stack key={shop.name} as="li" gap="m"> <Heading level={2}> <Link href={shop.url}>{shop.name}</Link> </Heading> {shop.address && ( <Text variant="small"> <Link href={getMapLink(shop)}> <FormattedAddress {...shop} /> </Link> </Text> )} </Stack> ))} </Stack> </PageWithTitle> ); }
Cela semble parfaitement bien et ne soulèvera aucune question lors de la révision du code. Cependant, lorsque nous essayons d'utiliser ces valeurs, la saisie semi-automatique affiche uniquement le nombre au lieu des valeurs réelles (voir une illustration). Cela rend plus difficile le choix de la bonne valeur.
Nous pourrions intégrer la constante baseSpacing :
const file = 'pizza.jpg'; const prefix = file.slice(0, -4); // → 'pizza'
Maintenant, nous avons moins de code, c'est tout aussi simple à comprendre, et la saisie semi-automatique montre les valeurs réelles (voir l'illustration). Et je ne pense pas que ce code changera souvent – probablement jamais.
Considérez cet extrait d'une fonction de validation de formulaire :
const file = 'pizza.jpg'; const prefix = path.parse(file).name; // → 'pizza'
Il est assez difficile de comprendre ce qui se passe ici : la logique de validation se mêle aux messages d’erreur, de nombreuses vérifications sont répétées…
Nous pouvons diviser cette fonction en plusieurs parties, chacune responsable d'une seule chose :
Nous pouvons décrire les validations de manière déclarative sous forme de tableau :
// my_feature_util.js const noop = () => {}; export const Utility = { noop // Many more functions… }; // MyComponent.js function MyComponent({ onClick }) { return <button onClick={onClick}>Hola!</button>; } MyComponent.defaultProps = { onClick: Utility.noop };
Chaque fonction de validation et la fonction qui exécute les validations sont assez génériques, nous pouvons donc soit les faire abstraction, soit utiliser une bibliothèque tierce.
Maintenant, nous pouvons ajouter une validation pour n'importe quel formulaire en décrivant quels champs nécessitent quelles validations et quelle erreur afficher lorsqu'une certaine vérification échoue.
Info : Voir le chapitre Conditions à éviter pour le code complet et une explication plus détaillée de cet exemple.
J'appelle ce processus séparation du « quoi » et du « comment » :
Les avantages sont :
De nombreux projets ont un fichier appelé utils.js, helpers.js ou misc.js dans lequel les développeurs ajoutent des fonctions utilitaires lorsqu'ils ne trouvent pas de meilleur emplacement pour elles. Souvent, ces fonctions ne sont jamais réutilisées ailleurs et restent pour toujours dans le fichier utilitaire, celui-ci continue donc de croître. C'est ainsi que naissent les fichiers utilitaires monstres.
Les fichiers de l'utilitaire Monster présentent plusieurs problèmes :
Voici mes règles empiriques :
Les modules JavaScript ont deux types d'exports. Le premier est les exportations nommées :
function createPizza(order) { const pizza = new Pizza({ base: order.size, sauce: order.sauce, cheese: 'Mozzarella' }); if (order.kind === 'Veg') { pizza.toppings = vegToppings; } else if (order.kind === 'Meat') { pizza.toppings = meatToppings; } const oven = new Oven(); if (oven.temp !== cookingTemp) { while (oven.temp < cookingTemp) { time.sleep(checkOvenInterval); oven.temp = getOvenTemp(oven); } } if (!pizza.baked) { oven.insert(pizza); time.sleep(cookTime); oven.remove(pizza); pizza.baked = true; } const box = new Box(); pizza.boxed = box.putIn(pizza); pizza.sliced = box.slicePizza(order.size); pizza.ready = box.close(); return pizza; }
Qui peut être importé comme ceci :
function prepare(order) { const pizza = new Pizza({ base: order.size, sauce: order.sauce, cheese: 'Mozzarella' }); addToppings(pizza, order.kind); return pizza; } function addToppings(pizza, kind) { if (kind === 'Veg') { pizza.toppings = vegToppings; } else if (kind === 'Meat') { pizza.toppings = meatToppings; } } function bake(pizza) { const oven = new Oven(); heatOven(oven); bakePizza(pizza, oven); } function heatOven(oven) { if (oven.temp !== cookingTemp) { while (oven.temp < cookingTemp) { time.sleep(checkOvenInterval); oven.temp = getOvenTemp(oven); } } } function bakePizza(pizza, oven) { if (!pizza.baked) { oven.insert(pizza); time.sleep(cookTime); oven.remove(pizza); pizza.baked = true; } } function pack(pizza) { const box = new Box(); pizza.boxed = box.putIn(pizza); pizza.sliced = box.slicePizza(pizza.size); pizza.ready = box.close(); } function createPizza(order) { const pizza = prepare(order); bake(pizza); pack(pizza); return pizza; }
Et le deuxième est les exportations par défaut :
function createPizza(order) { // Prepare pizza const pizza = new Pizza({ base: order.size, sauce: order.sauce, cheese: 'Mozzarella' }); // Add toppings if (order.kind == 'Veg') { pizza.toppings = vegToppings; } else if (order.kind == 'Meat') { pizza.toppings = meatToppings; } const oven = new Oven(); if (oven.temp !== cookingTemp) { // Heat oven while (oven.temp < cookingTemp) { time.sleep(checkOvenInterval); oven.temp = getOvenTemp(oven); } } if (!pizza.baked) { // Bake pizza oven.insert(pizza); time.sleep(cookTime); oven.remove(pizza); pizza.baked = true; } // Box and slice const box = new Box(); pizza.boxed = box.putIn(pizza); pizza.sliced = box.slicePizza(order.size); pizza.ready = box.close(); return pizza; }
Qui peut être importé comme ceci :
function FormattedAddress({ address, city, country, district, zip }) { return [address, zip, district, city, country] .filter(Boolean) .join(', '); } function getMapLink({ name, address, city, country, zip }) { return `https://www.google.com/maps/?q=${encodeURIComponent( [name, address, zip, city, country].filter(Boolean).join(', ') )}`; } function ShopsPage({ url, title, shops }) { return ( <PageWithTitle url={url} title={title}> <Stack as="ul" gap="l"> {shops.map(shop => ( <Stack key={shop.name} as="li" gap="m"> <Heading level={2}> <Link href={shop.url}>{shop.name}</Link> </Heading> {shop.address && ( <Text variant="small"> <Link href={getMapLink(shop)}> <FormattedAddress {...shop} /> </Link> </Text> )} </Stack> ))} </Stack> </PageWithTitle> ); }
Je ne vois pas vraiment d’avantages aux exportations par défaut, mais elles présentent plusieurs problèmes :
Info : Nous parlons davantage de la grépabilité dans la section Écrire du code gréptable du chapitre Autres techniques.
Malheureusement, certaines API tierces, comme React.lazy() nécessitent des exportations par défaut, mais pour tous les autres cas, je m'en tiens aux exportations nommées.
Un fichier baril est un module (généralement nommé index.js ou index.ts) qui réexporte un tas d'autres modules :
function createPizza(order) { const pizza = new Pizza({ base: order.size, sauce: order.sauce, cheese: 'Mozzarella' }); if (order.kind === 'Veg') { pizza.toppings = vegToppings; } else if (order.kind === 'Meat') { pizza.toppings = meatToppings; } const oven = new Oven(); if (oven.temp !== cookingTemp) { while (oven.temp < cookingTemp) { time.sleep(checkOvenInterval); oven.temp = getOvenTemp(oven); } } if (!pizza.baked) { oven.insert(pizza); time.sleep(cookTime); oven.remove(pizza); pizza.baked = true; } const box = new Box(); pizza.boxed = box.putIn(pizza); pizza.sliced = box.slicePizza(order.size); pizza.ready = box.close(); return pizza; }
Le principal avantage réside dans des importations plus propres. Au lieu d'importer chaque module individuellement :
function prepare(order) { const pizza = new Pizza({ base: order.size, sauce: order.sauce, cheese: 'Mozzarella' }); addToppings(pizza, order.kind); return pizza; } function addToppings(pizza, kind) { if (kind === 'Veg') { pizza.toppings = vegToppings; } else if (kind === 'Meat') { pizza.toppings = meatToppings; } } function bake(pizza) { const oven = new Oven(); heatOven(oven); bakePizza(pizza, oven); } function heatOven(oven) { if (oven.temp !== cookingTemp) { while (oven.temp < cookingTemp) { time.sleep(checkOvenInterval); oven.temp = getOvenTemp(oven); } } } function bakePizza(pizza, oven) { if (!pizza.baked) { oven.insert(pizza); time.sleep(cookTime); oven.remove(pizza); pizza.baked = true; } } function pack(pizza) { const box = new Box(); pizza.boxed = box.putIn(pizza); pizza.sliced = box.slicePizza(pizza.size); pizza.ready = box.close(); } function createPizza(order) { const pizza = prepare(order); bake(pizza); pack(pizza); return pizza; }
Nous pouvons importer tous les composants à partir d'un fichier tonneau :
function createPizza(order) { // Prepare pizza const pizza = new Pizza({ base: order.size, sauce: order.sauce, cheese: 'Mozzarella' }); // Add toppings if (order.kind == 'Veg') { pizza.toppings = vegToppings; } else if (order.kind == 'Meat') { pizza.toppings = meatToppings; } const oven = new Oven(); if (oven.temp !== cookingTemp) { // Heat oven while (oven.temp < cookingTemp) { time.sleep(checkOvenInterval); oven.temp = getOvenTemp(oven); } } if (!pizza.baked) { // Bake pizza oven.insert(pizza); time.sleep(cookTime); oven.remove(pizza); pizza.baked = true; } // Box and slice const box = new Box(); pizza.boxed = box.putIn(pizza); pizza.sliced = box.slicePizza(order.size); pizza.ready = box.close(); return pizza; }
Cependant, les limes tonneau présentent plusieurs problèmes :
Info : TkDodo explique en détail les inconvénients des limes à barillet.
Les avantages des limes cylindriques sont trop mineurs pour justifier leur utilisation, je recommande donc de les éviter.
Un type de fichiers Barrel que je n'aime pas particulièrement est celui qui exporte un seul composant juste pour permettre de l'importer en tant que ./components/button au lieu de ./components/button/button.
Pour troller les DRYers (développeurs qui ne répètent jamais leur code), quelqu'un a inventé un autre terme : WET, écrivons tout deux fois, ou nous aimons taper, suggérant que nous devrions dupliquer le code à au moins deux fois jusqu'à ce que nous le remplaçons par une abstraction. C'est une blague, et je ne suis pas entièrement d'accord avec l'idée (parfois, il est acceptable de dupliquer du code plus de deux fois), mais c'est un bon rappel que toutes les bonnes choses sont meilleures avec modération.
Considérez cet exemple :
function createPizza(order) { const pizza = new Pizza({ base: order.size, sauce: order.sauce, cheese: 'Mozzarella' }); if (order.kind === 'Veg') { pizza.toppings = vegToppings; } else if (order.kind === 'Meat') { pizza.toppings = meatToppings; } const oven = new Oven(); if (oven.temp !== cookingTemp) { while (oven.temp < cookingTemp) { time.sleep(checkOvenInterval); oven.temp = getOvenTemp(oven); } } if (!pizza.baked) { oven.insert(pizza); time.sleep(cookTime); oven.remove(pizza); pizza.baked = true; } const box = new Box(); pizza.boxed = box.putIn(pizza); pizza.sliced = box.slicePizza(order.size); pizza.ready = box.close(); return pizza; }
Il s'agit d'un exemple extrême de séchage de code, qui ne rend pas le code plus lisible ou maintenable, surtout lorsque la plupart de ces constantes ne sont utilisées qu'une seule fois. Voir les noms de variables au lieu des chaînes réelles ici n'est d'aucune utilité.
Incorporons toutes ces variables supplémentaires. (Malheureusement, la refactorisation en ligne dans Visual Studio Code ne prend pas en charge les propriétés des objets en ligne, nous devons donc le faire manuellement.)
function prepare(order) { const pizza = new Pizza({ base: order.size, sauce: order.sauce, cheese: 'Mozzarella' }); addToppings(pizza, order.kind); return pizza; } function addToppings(pizza, kind) { if (kind === 'Veg') { pizza.toppings = vegToppings; } else if (kind === 'Meat') { pizza.toppings = meatToppings; } } function bake(pizza) { const oven = new Oven(); heatOven(oven); bakePizza(pizza, oven); } function heatOven(oven) { if (oven.temp !== cookingTemp) { while (oven.temp < cookingTemp) { time.sleep(checkOvenInterval); oven.temp = getOvenTemp(oven); } } } function bakePizza(pizza, oven) { if (!pizza.baked) { oven.insert(pizza); time.sleep(cookTime); oven.remove(pizza); pizza.baked = true; } } function pack(pizza) { const box = new Box(); pizza.boxed = box.putIn(pizza); pizza.sliced = box.slicePizza(pizza.size); pizza.ready = box.close(); } function createPizza(order) { const pizza = prepare(order); bake(pizza); pack(pizza); return pizza; }
Maintenant, nous avons beaucoup moins de code, et il est plus facile de comprendre ce qui se passe et de mettre à jour ou de supprimer des tests.
J’ai rencontré tellement d’abstractions désespérées dans les tests. Par exemple, ce modèle est très courant :
function createPizza(order) { // Prepare pizza const pizza = new Pizza({ base: order.size, sauce: order.sauce, cheese: 'Mozzarella' }); // Add toppings if (order.kind == 'Veg') { pizza.toppings = vegToppings; } else if (order.kind == 'Meat') { pizza.toppings = meatToppings; } const oven = new Oven(); if (oven.temp !== cookingTemp) { // Heat oven while (oven.temp < cookingTemp) { time.sleep(checkOvenInterval); oven.temp = getOvenTemp(oven); } } if (!pizza.baked) { // Bake pizza oven.insert(pizza); time.sleep(cookTime); oven.remove(pizza); pizza.baked = true; } // Box and slice const box = new Box(); pizza.boxed = box.putIn(pizza); pizza.sliced = box.slicePizza(order.size); pizza.ready = box.close(); return pizza; }
Ce modèle tente d'éviter de répéter les appels mount(...) dans chaque scénario de test, mais il rend les tests plus déroutants qu'ils ne devraient l'être. Incorporons les appels mount() :
function FormattedAddress({ address, city, country, district, zip }) { return [address, zip, district, city, country] .filter(Boolean) .join(', '); } function getMapLink({ name, address, city, country, zip }) { return `https://www.google.com/maps/?q=${encodeURIComponent( [name, address, zip, city, country].filter(Boolean).join(', ') )}`; } function ShopsPage({ url, title, shops }) { return ( <PageWithTitle url={url} title={title}> <Stack as="ul" gap="l"> {shops.map(shop => ( <Stack key={shop.name} as="li" gap="m"> <Heading level={2}> <Link href={shop.url}>{shop.name}</Link> </Heading> {shop.address && ( <Text variant="small"> <Link href={getMapLink(shop)}> <FormattedAddress {...shop} /> </Link> </Text> )} </Stack> ))} </Stack> </PageWithTitle> ); }
De plus, le modèle beforeEach ne fonctionne que lorsque l'on souhaite initialiser chaque scénario de test avec les mêmes valeurs, ce qui est rarement le cas :
const file = 'pizza.jpg'; const prefix = file.slice(0, -4); // → 'pizza'
Pour éviter certaines duplications lors du test des composants React, j'ajoute souvent un objet defaultProps et je le répartis dans chaque scénario de test :
const file = 'pizza.jpg'; const prefix = path.parse(file).name; // → 'pizza'
De cette façon, nous n’avons pas trop de duplication, mais en même temps, chaque cas de test est isolé et lisible. La différence entre les cas de test est désormais plus claire car il est plus facile de voir les accessoires uniques de chaque cas de test.
Voici une variante plus extrême du même problème :
// my_feature_util.js const noop = () => {}; export const Utility = { noop // Many more functions… }; // MyComponent.js function MyComponent({ onClick }) { return <button onClick={onClick}>Hola!</button>; } MyComponent.defaultProps = { onClick: Utility.noop };
Nous pouvons intégrer la fonction beforeEach() de la même manière que nous l'avons fait dans l'exemple précédent :
const findByReference = (wrapper, reference) => wrapper.find(reference); const favoriteTaco = findByReference( ['Al pastor', 'Cochinita pibil', 'Barbacoa'], x => x === 'Cochinita pibil' ); // → 'Cochinita pibil'
J'irais encore plus loin et utiliserais la méthode test.each() car nous exécutons le même test avec un tas d'entrées différentes :
function MyComponent({ onClick }) { return <button onClick={onClick}>Hola!</button>; } MyComponent.defaultProps = { onClick: () => {} };
Maintenant, nous avons rassemblé toutes les entrées de test avec leurs résultats attendus en un seul endroit, ce qui facilite l'ajout de nouveaux cas de test.
Info : Consultez mes aide-mémoire Jest et Vitest.
Le plus grand défi avec les abstractions est de trouver un équilibre entre être trop rigide et trop flexible, et savoir quand commencer à abstraire les choses et quand s'arrêter. Cela vaut souvent la peine d’attendre pour voir si nous avons vraiment besoin d’abstraire quelque chose – bien souvent, il vaut mieux ne pas le faire.
C'est bien d'avoir un composant bouton global, mais s'il est trop flexible et comporte une douzaine d'accessoires booléens pour basculer entre différentes variantes, il sera difficile à utiliser. Cependant, si c'est trop rigide, les développeurs créeront leurs propres composants de bouton au lieu d'en utiliser un partagé.
Nous devons être vigilants quant à laisser les autres réutiliser notre code. Trop souvent, cela crée un couplage étroit entre des parties de la base de code qui devraient être indépendantes, ce qui ralentit le développement et entraîne des bugs.
Commencez à penser à :
Si vous avez des commentaires, mastodontez-moi, tweetez-moi, ouvrez un ticket sur GitHub ou envoyez-moi un e-mail à artem@sapegin.ru. Obtenez votre exemplaire.
Ce qui précède est le contenu détaillé de. pour plus d'informations, suivez d'autres articles connexes sur le site Web de PHP en chinois!