Réagir au développement du formulaire. Les principes KISS, YAGNI, DRY en pratique

Bonjour, dans ce tutoriel, nous verrons comment développer un formulaire très simple mais contrôlé dans React, en se concentrant sur la qualité du code.

Lors du développement de notre formulaire, nous suivrons les principes de "KISS", "YAGNI", "DRY". Pour réussir ce tutoriel, vous n'avez pas besoin de connaître ces principes, je vais les expliquer en cours de route. Cependant, je pense que vous maîtrisez bien le javascript moderne et que vous pouvez penser dans React .



Structure du didacticiel:







Pour la cause! Écrire un formulaire simple en utilisant KISS et YAGNI


Imaginons donc que nous ayons pour tâche de mettre en place un formulaire d'autorisation:
Copier le code
const logInData = {
  nickname: 'Vasya',
  email: 'pupkin@gmail.com',
  password: 'Reac5$$$',
};



Nous commençons notre développement en analysant les principes de KISS et YAGNI, en oubliant temporairement le reste des principes.

KISS - "Laissez le code simple et stupide." Je pense que vous connaissez le concept de code simple. Mais que signifie le code «stupide»? À ma connaissance, il s'agit d'un code qui résout le problème en utilisant le nombre minimum d'abstractions, tandis que l'imbrication de ces abstractions les unes dans les autres est également minime.

YAGNI - "Vous n'en aurez pas besoin." Le code ne doit pouvoir faire que ce pour quoi il est écrit. Nous ne créons pas de fonctionnalités qui pourraient être nécessaires ultérieurement ou qui améliorent l'application à notre avis. Nous ne faisons que ce qui est nécessaire spécifiquement pour la mise en œuvre de la tâche.

Suivons strictement ces principes, mais considérons également:

  • initialDataet onSubmitpour LogInFormvient d'en haut (c'est une technique utile, surtout quand le formulaire doit pouvoir être traité createet updateen même temps)
  • doit avoir pour chaque champ label

De plus, manquons la stylisation, car elle n'est pas intéressante pour nous, et valide, car c'est un sujet pour un tutoriel séparé.

Veuillez mettre en œuvre le formulaire vous-même, en suivant les principes décrits ci-dessus.

Implémentation de mon formulaire
const LogInForm = ({ initialData, onSubmit }) => {
  const [logInData, setLogInData] = useState(initialData);

  const handleSubmit = e => {
    e.preventDefault();
    onSubmit(logInData);
  };

  return (
    <form onSubmit={handleSubmit}>
      <label>
        Enter your nickname
        <input
          value={logInData.nickname}
          onChange={e => setLogInData({ ...logInData, nickname: e.target.value })}
        />
      </label>
      <label>
        Enter your email
        <input
          type="email"
          value={logInData.email}
          onChange={e => setLogInData({ ...logInData, email: e.target.value })}
        />
      </label>
      <label>
        Enter your password
        <input
          type="password"
          value={logInData.password}
          onChange={e => setLogInData({ ...logInData, password: e.target.value })}
        />
      </label>
      <button>Submit</button>
    </form>
  );
};
    






Votre décision est probablement quelque peu différente, car chaque développeur pense différemment. Vous pouvez créer votre propre état pour chaque champ ou placer des fonctions de gestionnaire dans une variable distincte, en tout cas ce n'est pas important.

Mais si vous avez immédiatement créé une fonction de gestionnaire pour tous les champs, ce n'est plus la solution «la plus stupide» au problème. Et notre objectif à ce stade est de créer le code le plus simple et le plus "stupide" afin que plus tard nous puissions regarder l'image entière et sélectionner la meilleure abstraction.

Si vous avez exactement le même code, c'est cool et signifie que notre pensée converge!

De plus, nous travaillerons avec ce code. C'est simple, mais encore loin d'être idéal.




Refactoring et SEC


Il est temps de traiter avec le principe DRY.

SEC, libellé simplifié - "ne dupliquez pas votre code." Le principe semble simple, mais il a un hic: pour se débarrasser de la duplication de code, il faut créer des abstractions. Si ces abstractions ne sont pas assez bonnes, nous violerons le principe KISS.

Il est également important de comprendre que DRY n'est pas nécessaire pour écrire du code plus rapidement. Sa tâche est de simplifier la lecture et le support de notre solution. Par conséquent, ne vous précipitez pas pour créer des abstractions tout de suite. Il est préférable de faire une implémentation simple d'une partie du code, puis d'analyser les abstractions que vous devez créer afin de simplifier la lecture du code et de réduire le nombre de places pour les modifications lorsqu'elles sont nécessaires.

Liste de contrôle de l'abstraction correcte:
  • le nom de l'abstraction est parfaitement conforme à son objectif
  • l'abstraction effectue une tâche spécifique et compréhensible
  • la lecture du code dont l'abstraction a été extraite s'est améliorée

Alors, passons à la refactorisation.
Et nous avons prononcé un code en double:
Copier le code
  <label>
    Enter your email
    <input
      type="email"
      value={logInData.email}
      onChange={e => setLogInData({ ...logInData, email: e.target.value })}
    />
  </label>
  <label>
    Enter your password
    <input
      type="password"
      value={logInData.password}
      onChange={e => setLogInData({ ...logInData, password: e.target.value })}
    />
  </label>



Ce code duplique une composition de 2 éléments: label, input. Fusionnons-les dans une nouvelle abstraction InputField:
Copier le code
  <label>
    Enter your email
    <input
      type="email"
      value={logInData.email}
      onChange={e => setLogInData({ ...logInData, email: e.target.value })}
    />
  </label>
  <label>
    Enter your password
    <input
      type="password"
      value={logInData.password}
      onChange={e => setLogInData({ ...logInData, password: e.target.value })}
    />
  </label>



Maintenant, le nôtre LogInFormressemble à ceci:
Copier le code
const LogInForm = ({ initialData, onSubmit }) => {
  const [logInData, setLogInData] = useState(initialData);

  const handleSubmit = e => {
    e.preventDefault();
    onSubmit(logInData);
  };

  return (
    <form onSubmit={handleSubmit}>
      <InputField
        label="Enter your nickname"
        value={logInData.nickname}
        onChange={e => setLogInData({ ...logInData, nickname: e.target.value })}
      />
      <InputField
        type="email"
        label="Enter your email"
        value={logInData.email}
        onChange={e => setLogInData({ ...logInData, email: e.target.value })}
      />
      <InputField
        type="password"
        label="Enter your password"
        value={logInData.password}
        onChange={e => setLogInData({ ...logInData, password: e.target.value })}
      />
      <button>Submit</button>
    </form>
  );
};



La lecture est devenue plus facile. Le nom de l'abstraction correspond à la tâche qu'elle résout. Le but du composant est évident. Le code est devenu moins. Nous allons donc dans la bonne direction!

Maintenant, il est clair que la InputField.onChangelogique est dupliquée.
Ce qui s'y passe peut être divisé en 2 étapes:

Copier le code
const stage1 = e => e.target.value;
const stage2 = password => setLogInData({ ...logInData, password });



La première fonction décrit les détails de l'obtention de la valeur de l'événement input. Nous avons le choix entre 2 abstractions dans lesquelles nous pouvons stocker cette logique: InputFieldet LogInForm.

Afin de déterminer correctement à laquelle des abstractions nous devons renvoyer notre code, nous devrons nous tourner vers la formulation complète du principe DRY: "Chaque partie de la connaissance devrait avoir une représentation unique, cohérente et faisant autorité au sein du système."

Une partie des connaissances de l'exemple concret consiste à savoir comment tirer le meilleur parti de l'événement input. Si nous stockons ces connaissances dans les nôtres LogInForm, il est évident que lorsque nous utilisons notreInputFieldsous une autre forme, nous devrons dupliquer nos connaissances, ou les mettre dans une abstraction distincte et les utiliser à partir de là. Et sur la base du principe KISS, nous devrions avoir le nombre minimum d'abstractions possible. En effet, pourquoi devons-nous créer une autre abstraction si nous pouvons simplement mettre cette logique dans la nôtre InputFieldet que le code externe ne saura rien sur le fonctionnement de l'entrée à l'intérieur InputField. Il prendra simplement un sens prêt à l'emploi, le même qu'il passe vers l'intérieur.

Si vous n'êtes pas certain d'avoir besoin d'un événement à l'avenir, n'oubliez pas le principe YAGNI. Vous pouvez toujours ajouter un accessoire supplémentaire onChangeEventà notre composant InputField.

Jusque-là, cela InputFieldressemblera à ceci:
Copier le code
const InputField = ({ label, type, value, onChange }) => (
  <label>
    {label}
    <input
      type={type}
      value={value}
      onChange={e => onChange(e.target.value)}
    />
  </label>
);



Ainsi, l'uniformité du type est observée lors de l'entrée et de la sortie dans le composant et la vraie nature de ce qui se passe pour le code externe est cachée. Si, à l'avenir, nous avons besoin d'un autre composant ui, par exemple, une case à cocher ou une sélection, nous y maintiendrons également l'uniformité du type en entrée-sortie.

Cette approche nous donne une flexibilité supplémentaire, car notre formulaire peut fonctionner avec toutes les sources d'entrée-sortie sans avoir besoin de produire des gestionnaires uniques supplémentaires pour chacun.

Cette astuce heuristique est intégrée par défaut dans de nombreux frameworks. Par exemple, c'est l'idée principale v-modeldans Vuelaquelle beaucoup de gens aiment pour sa facilité à travailler avec des formulaires.

Revenons aux affaires, mettons à jour notre composant LogInFormen fonction des changements dans InputField:
Copier le code
const LogInForm = ({ initialData, onSubmit }) => {
  const [logInData, setLogInData] = useState(initialData);

  const handleSubmit = e => {
    e.preventDefault();
    onSubmit(logInData);
  };

  return (
    <form onSubmit={handleSubmit}>
      <InputField
        label="Enter your nickname"
        value={logInData.nickname}
        onChange={nickname => setLogInData({ ...logInData, nickname })}
      />
      <InputField
        type="email"
        label="Enter your email"
        value={logInData.email}
        onChange={email => setLogInData({ ...logInData, email })}
      />
      <InputField
        type="password"
        label="Enter your password"
        value={logInData.password}
        onChange={password => setLogInData({ ...logInData, password })}
      />
      <button>Submit</button>
    </form>
  );
};



Cela a déjà l'air plutôt bien, mais nous pouvons faire encore mieux!

Callbackqui est passé fait onChangetoujours la même chose. Seule la clé y est modifiée: mot de passe, email, pseudo. Ainsi, nous pouvons le remplacer par un appel à la fonction: handleChange('password').

Implémentons cette fonction:
Copier le code
  const handleChange = fieldName => fieldValue => {
    setLogInData({
      ...logInData,
      [fieldName]: fieldValue,
    });
  };



Comme vous pouvez le voir, la fonction reçoit l'argument et le stocke dans la fermeture du gestionnaire, et le gestionnaire retourne immédiatement au code externe. Les fonctions qui semblent prendre des arguments un par un pour un appel sont également appelées curry.

Regardons le code résultant:
Copier le code
  const LogInForm = ({ initialData, onSubmit }) => {
    const [logInData, setLogInData] = useState(initialData);
  
    const handleSubmit = e => {
      e.preventDefault();
      onSubmit(logInData);
    };
  
    const handleChange = fieldName => fieldValue => {
      setLogInData({
        ...logInData,
        [fieldName]: fieldValue,
      });
    };
  
    return (
      <form onSubmit={handleSubmit}>
        <InputField
          label="Enter your nickname"
          value={logInData.nickname}
          onChange={handleChange('nickname')}
        />
        <InputField
          type="email"
          label="Enter your email"
          value={logInData.email}
          onChange={handleChange('email')}
        />
        <InputField
          type="password"
          label="Enter your password"
          value={logInData.password}
          onChange={handleChange('password')}
        />
        <button>Submit</button>
      </form>
    );
  };
  
  // InputField.js
  const InputField = ({ type, label, value, onChange }) => (
    <label>
      {label}
      <input type={type} value={value} onChange={e => onChange(e.target.value)} />
    </label>
  );



Ce code est une implémentation courte, concise et modérément déclarative de la tâche. Une refactorisation plus poussée dans le contexte de la tâche, à mon avis, n'a pas de sens.




Que peut-on faire d'autre?



Si vous avez plusieurs formulaires dans le projet, vous pouvez placer le calcul handleChange dans un crochet distinct useFieldChange:
Copier le code
  // hooks/useFieldChange.js
  const useFieldChange = setState => fieldName => fieldValue => {
    setState(state => ({
      ...state,
      [fieldName]: fieldValue,
    }));
  };
  // LogInForm.js
  const handleChange = useFieldChange(setLogInData);



Puisqu'il s'agit d'une fonction pure (c'est-à-dire la première fois qu'elle est appelée car elle renvoie toujours la même fonction), elle n'a pas besoin d'être un crochet. Mais le crochet semble conceptuellement une solution plus correcte et naturelle pour React.

Vous pouvez également ajouter une prise callbacken charge fieldValuesur place pour reproduire complètement le comportement habituel à setStatepartir de React:
Copier le code
  const isFunc = val => typeof val === "function";

  const useFieldChange = setState => fieldName => fieldValue => {
    setState(state => ({
      ...state,
      [fieldName]: isFunc(fieldValue) ? fieldValue(state[fieldName]) : fieldValue,
    }));
  };



Un exemple d'utilisation avec notre formulaire:
Copier le code
  const LogInForm = ({ initialData, onSubmit }) => {
    const [logInData, setLogInData] = useState(initialData);
    const handleChange = useFieldChange(setLogInData);
  
    const handleSubmit = e => {
      e.preventDefault();
      onSubmit(logInData);
    };
  
    return (
      <form onSubmit={handleSubmit}>
        <InputField
          label="Enter your nickname"
          value={logInData.nickname}
          onChange={handleChange('nickname')}
        />
        <InputField
          type="email"
          label="Enter your email"
          value={logInData.email}
          onChange={handleChange('email')}
        />
        <InputField
          type="password"
          label="Enter your password"
          value={logInData.password}
          onChange={handleChange('password')}
        />
        <button>Submit</button>
      </form>
    );
  };



Mais si votre candidature ne comporte qu'un seul formulaire, vous n'avez pas besoin de faire tout cela! Parce qu'il contredit le principe YAGNI, selon lequel nous ne devons pas faire ce dont nous n'avons pas besoin pour résoudre un problème spécifique. Si vous n'avez qu'une seule forme, les avantages réels de tels mouvements ne suffisent pas. Après tout, nous n'avons réduit le code de notre composant que de 3 lignes, mais avons introduit une abstraction supplémentaire, cachant une certaine logique de la forme, qu'il vaut mieux garder en surface.




Oh non! Ne le faites pas, s'il vous plaît!



Formulaires de configuration en forme


Les configurations verrouillées sont comme une configuration webpack, uniquement pour la forme.

Mieux avec un exemple, regardez ce code:
Copier le code
  const Form = () => (
    <form onSubmit={handleSubmit}>
      <InputField
        label="Enter your nickname"
        value={state.nickname}
        onChange={handleChange('nickname')}
      />
      <InputField
        type="email"
        label="Enter your email"
        value={state.email}
        onChange={handleChange('email')}
      />
      <InputField
        type="password"
        label="Enter your password"
        value={state.password}
        onChange={handleChange('password')}
      />
      <button>Submit</button>
    </form>
  );



Il peut sembler à certains que le code est dupliqué ici, car nous appelons le même composant InputField, en y passant les mêmes paramètres label, value et onChange. Et ils commencent à DRY-yav leur propre code pour éviter la duplication imaginaire.
Souvent, ils font ça comme ceci:
Copier le code
const fields = [
  {
    name: 'nickname',
    label: 'Enter your nickname',
  },
  {
    type: 'email',
    name: 'email',
    label: 'Enter your email',
  },
  {
    type: 'password',
    name: 'password',
    label: 'Enter your password',
  },
];

const Form = () => (
  <form onSubmit={handleSubmit}>
    {fields.map(({ type, name, label }) => (
      <InputField
        type={type}
        label={label}
        value={state[name]}
        onChange={handleChange(name)}
      />
    ))}
    <button>Submit</button>
  </form>
);



En conséquence, avec 17 lignes de code jsx, nous obtenons 16 lignes de la configuration. Bravo! C'est ce que je comprends SEC. Si nous avons 100 de ces entrées ici, nous obtenons respectivement 605 et 506 lignes.

Mais, en conséquence, nous avons obtenu un code plus complexe, violant le principe KISS. En effet, il se compose désormais de 2 abstractions: des champs et un algorithme (oui, un algorithme est aussi une abstraction), ce qui en fait un arbre d'éléments React. Lors de la lecture de ce code, nous devrons constamment sauter entre ces abstractions.

Mais le plus gros problème avec ce code est son support et son extension.
Imaginez ce qui lui arrivera si:
  • d'autres types de champs avec différentes propriétés possibles seront ajoutés
  • les champs sont rendus ou non rendus en fonction de la saisie d'autres champs
  • certains champs nécessitent un traitement supplémentaire lors du changement
  • les valeurs dans les sélections dépendent des sélections précédentes

Cette liste peut être prolongée pendant longtemps ...

Pour l'implémenter, vous devrez toujours stocker certaines fonctions dans votre configuration, et le code qui rend la configuration se transformera progressivement en Frankenstein, en prenant un tas d'accessoires différents et en les jetant sur différents composants.

Le code qui a été écrit auparavant, basé sur la composition des composants, se développera discrètement, changera comme vous le souhaitez, sans devenir très compliqué en raison du manque d'abstraction intermédiaire - un algorithme qui construit un arbre d'éléments React à partir de notre configuration. Ce code n'est pas en double. Il suit simplement un certain modèle de conception, et par conséquent, il semble "à motifs".

Optimisation inutile


Après l'apparition des crochets dans React, il y avait une tendance à envelopper tous les gestionnaires et composants sans discrimination dans useCallbacket memo. S'il te plaît ne fait pas ça! Ces crochets n'ont pas été fournis par les développeurs de React car React est lent et tout doit être optimisé. Ils permettent d'optimiser votre application en cas de problèmes de performances. Et même si vous rencontrez de tels problèmes, vous n'avez pas besoin d'envelopper l'ensemble du projet dans memoet useCallback. Utilisez le profileur pour identifier les problèmes et ne mémorisez ensuite qu'au bon endroit.

La mémorisation rendra toujours votre code plus compliqué, mais pas toujours plus productif.

Regardons le code d'un vrai projet. Comparez à quoi ressemble la fonction avec useCallbacket sans:
Copier le code
  const applyFilters = useCallback(() => {
    const newSelectedMetrics = Object.keys(selectedMetricsStatus).filter(
      metric => selectedMetricsStatus[metric],
    );
    onApplyFilterClick(newSelectedMetrics);
  }, [selectedMetricsStatus, onApplyFilterClick]);

  const applyFilters = () => {
    const newSelectedMetrics = Object.keys(selectedMetricsStatus).filter(
      metric => selectedMetricsStatus[metric],
    );
    onApplyFilterClick(newSelectedMetrics);
  };



La lisibilité du code a clairement augmenté après le retrait du wrapper, car le code idéal est son manque.

Les performances de ce code n'ont pas augmenté, car cette fonction est utilisée comme ceci:
Copier le code
  <RightFooterButton onClick={applyFilters}>APPLY</RightFooterButton>



D'où RightFooterButton- c'est juste styled.buttonde styled-components, qui sera mis à jour très rapidement. Mais la consommation de mémoire augmente notre application car React gardera toujours en mémoire selectedMetricsStatus, onApplyFilterClicket la version de la fonction applyFilters, pertinente pour ces dépendances.

Si ces arguments ne vous suffisent pas, lisez l' article qui aborde ce sujet plus largement.




résultats


  • Les formulaires de réaction sont faciles. Des problèmes avec eux surviennent en raison des développeurs eux-mêmes et de la documentation React, dans laquelle ce sujet n'est pas divulgué de manière suffisamment détaillée.
  • aka value onChange . useFieldChange, , , v-model Vue .
  • KISS YAGNI. DRY, , .
  • , , React- .
  • , .




P.S.


Au départ, j'avais prévu d'écrire sur la validation déclarative des formulaires imbriqués complexes. Mais, finalement, j'ai décidé de préparer le public à cela, afin que l'image soit aussi complète que possible.
Le tutoriel suivant portera sur la validation du formulaire simple implémenté dans ce tutoriel.
Un grand merci à tous ceux qui ont lu jusqu'à la fin!

Peu importe comment vous écrivez du code ou ce que vous faites, l'essentiel est d'en profiter.

All Articles