Fermer

juin 13, 2019

Une plongée profonde dans Redux –


La création d'applications modernes avec état est complexe. À mesure que l'état évolue, l'application devient imprévisible et difficile à maintenir. C’est là que Redux entre en jeu. Redux est une bibliothèque légère qui traite de l’état. Imaginez-le comme une machine à états.

Dans cet article, je vais explorer le conteneur d’état de Redux en construisant un moteur de traitement de la paie. L'application va stocker des talons de paie, avec tous les extras – tels que les bonus et les options d'achat d'actions. Je vais garder la solution en JavaScript clair avec TypeScript pour la vérification de type. Comme Redux est super testable, j'utiliserai également Jest pour vérifier l'application.

Pour les besoins de ce tutoriel, je suppose un niveau de connaissance modéré de JavaScript, du nœud et de npm.

Pour commencer, vous pouvez initialiser cette application avec npm:

 npm init

Interrogé sur l'ordre de test, allez-y et mettez jest . Cela signifie que npm t lancera Jest et exécutera tous les tests unitaires. Le fichier principal sera index.js pour que cela reste simple et agréable. N'hésitez pas à répondre au reste des questions npm init .

J'utiliserai TypeScript pour la vérification du type et le clouage du modèle de données. Ceci aide à conceptualiser ce que nous essayons de construire.

Pour commencer avec TypeScript:

 npm i typescript --save-dev

Je conserverai les dépendances qui font partie du flux de travail de dev dans devDependencies . Cela montre clairement quelles dépendances sont pour les développeurs et lesquelles vont à prod. Avec TypeScript prêt, ajoutez un script start dans le fichier package.json :

 "start": "tsc && node .bin / index.js".

Créez un fichier index.ts dans le dossier src . Cela sépare les fichiers source du reste du projet. Si vous effectuez un npm start la solution ne pourra pas s'exécuter. C’est parce que vous devrez configurer TypeScript.

Créez un fichier tsconfig.json avec la configuration suivante:

 {
  "compilerOptions": {
    "strict": vrai,
    "lib": ["esnext", "dom"],
    "outDir": ".bin",
    "sourceMap": true
  },
  "fichiers": [
    "src/index"
  ]
}

J'aurais pu mettre cette configuration dans un argument de ligne de commande tsc . Par exemple, tsc src / index.ts --strict ... . Mais il est beaucoup plus propre d’aller de l’avant et de mettre tout cela dans un fichier séparé. Notez que le script start de package.json ne nécessite qu'une seule commande tsc .

Voici des options judicieuses du compilateur qui nous donneront un bon point de départ, et ce que chaque option signifie:

  • strict : active toutes les options de vérification de type strictes, c'est-à-dire - noImplicitAny - strictNullChecks etc.
  • lib : liste des fichiers de bibliothèque inclus dans la compilation
  • outDir : redirection de la sortie vers ce répertoire
  • sourceMap : générer un fichier de carte source utile pour le débogage de fichiers

    : fichiers d'entrée alimentés au compilateur

Comme je vais utiliser Jest pour les tests unitaires, je vais l’ajouter:

 npm jest ts-jest @ types / jest @ types / noeud --save-dev

La dépendance ts-jest ajoute la vérification de type au cadre de test. Il faut ajouter une configuration jest dans package.json :

 "jest": {
  "preset": "ts-jest"
}

Cela permet donc au framework de test de récupérer les fichiers TypeScript et de savoir comment les transpiler. Une fonctionnalité intéressante avec ceci est que vous obtenez une vérification de type lors de l'exécution de tests unitaires. Pour vous assurer que ce projet est prêt, créez un dossier __ tests __ contenant un fichier index.test.ts . Ensuite, faites un test de bon sens. Par exemple:

 it ('est vrai', () => {
  attendez (true) .toBe (true);
});

Faire npm start et npm t s'exécute maintenant sans erreur. Cela nous indique que nous sommes maintenant prêts à commencer à élaborer la solution. Mais avant cela, ajoutons Redux au projet:

 npm i redux --save

Cette dépendance va à prod. Donc, inutile de l'inclure dans - save-dev . Si vous inspectez votre package.json il apparaît dans dépendances .

Le moteur de paie en action

Le moteur de paie comportera les éléments suivants: paiement, remboursement, bonus et les options d'achat d'actions. Sous Redux, vous ne pouvez pas directement mettre à jour l’état. Au lieu de cela, les actions sont envoyées pour informer le magasin de toute nouvelle modification.

Cela nous laisse donc les types d'action suivants:

 const BASE_PAY = 'BASE_PAY';
const REIMBURSEMENT = 'REMBOURSEMENT';
const BONUS = 'BONUS';
const STOCK_OPTIONS = 'STOCK_OPTIONS';
const PAY_DAY = 'PAY_DAY';

Le type d'action PAY_DAY est utile pour extraire un chèque le jour de paye et conserver l'historique de la paie. Ces types d'action guident le reste de la conception à mesure que nous développons le moteur de la paie. Ils capturent des événements dans le cycle de vie de l'état, par exemple, en fixant un montant de salaire de base. Ces événements d'action peuvent être liés à n'importe quoi, qu'il s'agisse d'un événement de clic ou d'une mise à jour de données. Les types d’action Redux sont abstraits au point où l’importance de la répartition importe peu. Le conteneur d’état peut être exécuté à la fois sur le client et sur le serveur.

TypeScript

En utilisant la théorie des types, je vais préciser le modèle de données en termes de données d’état. Pour chaque action de paie, dites un type d’action et un montant optionnel. Le montant est facultatif, car PAY_DAY n’a pas besoin d’argent pour traiter un chèque de règlement. Je veux dire, il pourrait faire payer les clients mais le laisser pour l'instant (peut-être en l'introduisant dans la version deux).

Ainsi, par exemple, mettez ceci dans src / index.ts :

 interface PayrollAction {
  type: chaîne;
  montant ?: nombre;
}

Pour l'état du talon de la paie, nous avons besoin d'une propriété pour le salaire de base, les bonus, etc. Nous allons également utiliser cet état pour gérer un historique de paie.

Cette interface TypeScript devrait le faire:

 interface PayStubState {
  basePay: nombre;
  remboursement: nombre;
  bonus: nombre;
  stockOptions: nombre;
  totalPay: nombre;
  payHistory: Array ;
}

Le PayStubState est un type complexe, ce qui signifie qu'il dépend d'un autre type de contrat. Donc, définissez le tableau payHistory :

 interface PayHistoryState {
  totalPay: nombre;
  totalCompensation: nombre;
}

Remarque: avec chaque propriété, TypeScript spécifie le type à l'aide de deux points. Par exemple, : numéro . Cela règle le contrat de type et ajoute de la prévisibilité au vérificateur de type. Avoir un système de type avec des déclarations de type explicites améliore Redux. En effet, le conteneur d’état Redux est conçu pour un comportement prévisible.

Cette idée n’est ni folle ni radicale. En voici une bonne explication dans Learning Redux Chapitre 1 (membres de SitePoint Premium uniquement).

Comme l'application modifie, la vérification de type ajoute une valeur supplémentaire. couche de prévisibilité. La théorie des types facilite également la montée en puissance de l'application, car il est plus facile de refactoriser de grandes sections de code.

La conceptualisation du moteur avec des types permet désormais de créer les fonctions d'action suivantes:

 export const processBasePay = (amount: number): PayrollAction = >
  ({type: BASE_PAY, montant});
export const processReimbursement = (montant: nombre): PayrollAction =>
  ({type: REMBOURSEMENT, montant});
export const processBonus = (montant: nombre): PayrollAction =>
  ({type: BONUS, montant});
export const processStockOptions = (montant: nombre): PayrollAction =>
  ({type: STOCK_OPTIONS, amount});
export const processPayDay = (): PayrollAction =>
  ({type: PAY_DAY});

Ce qui est bien, c’est que, si vous essayez de faire processBasePay ('abc') le vérificateur de type vous aboie. La rupture d'un contrat de type ajoute l'imprévisibilité au conteneur d'état. J'utilise un contrat à action unique tel que PayrollAction pour améliorer la prévisibilité du traitement de la paie. Remarque Le montant est défini dans l'objet d'action via un raccourci de propriété ES6. L’approche la plus traditionnelle est montant: montant qui est longue. Une fonction de flèche, comme () => ({}) est un moyen succinct d’écrire des fonctions qui renvoient un littéral d’objet.

Réducteur en tant que fonction pure

Les fonctions de réducteur ont besoin de état et un paramètre d'action . L'état devrait avoir un état initial avec une valeur par défaut. Alors, pouvez-vous imaginer à quoi notre état initial pourrait ressembler? Je pense qu’il doit commencer à zéro avec une liste d’historique des salaires vide.

Par exemple:

 const initialState: PayStubState = {
  basePay: 0, remboursement: 0,
  bonus: 0, stockOptions: 0,
  totalPay: 0, payHistorique: []
};

Le vérificateur de type s'assure que ces valeurs appartiennent à cet objet. Avec l’état initial en place, commencez à créer la fonction de réduction:

 export const payrollEngineReducer = (
  state: PayStubState = initialState,
  action: PayrollAction): PayStubState => {

Le réducteur Redux a un schéma dans lequel tous les types d'action sont gérés par une déclaration switch . Mais avant de passer en revue tous les cas de commutation, je vais créer une variable locale réutilisable:

 let totalPay: number = 0;

Notez qu’il est acceptable de muter les variables locales si vous ne mute pas l’état global. J'utilise un opérateur let pour communiquer cette variable va changer dans le futur. La mutation d'un état global, comme le paramètre d'action state ou rend le réducteur impur. Ce paradigme fonctionnel est essentiel car les fonctions de réducteur doivent rester pures. Si vous avez des difficultés avec ce paradigme, consultez cette explication dans JavaScript de novice à ninja chapitre 11 (membres de SitePoint Premium uniquement).

Activez le commutateur de réduction. instruction permettant de gérer le premier cas d'utilisation:

 switch (action.type) {
  case BASE_PAY:
    const {montant: basePay = 0} = action;
    totalPay = computeTotalPay ({... state, basePay});

    return {... state, basePay, totalPay};

J'utilise un opérateur ES6 rest pour conserver les propriétés d'état identiques. Par exemple, ... state . Vous pouvez remplacer toutes les propriétés après l'opérateur reste dans le nouvel objet. Le basePay provient de la déstructuration, ce qui ressemble beaucoup à la recherche de motifs dans d'autres langues. La fonction computeTotalPay est définie comme suit:

 const computeTotalPay = (payStub: PayStubState) =>
  payStub.basePay + payStub.reimbursement
  + payStub.bonus - payStub.stockOptions;

Notez que vous déduisez stockOptions car l'argent ira à l'achat d'actions de la société. Supposons que vous souhaitiez traiter un remboursement:

 affaire REMBOURSEMENT:
  const {montant: remboursement = 0} = action;
  totalPay = computeTotalPay ({... état, remboursement});

  return {... état, remboursement, totalPay};

Comme le montant est facultatif, assurez-vous qu'il a une valeur par défaut pour réduire les accidents. C'est ici que TypeScript brille, car le vérificateur de types relève cet écueil et aboie. Le système de typage connaît certains faits et peut donc faire de bonnes hypothèses. Supposons que vous souhaitiez traiter les bonus:

 case BONUS:
  const {montant: bonus = 0} = action;
  totalPay = computeTotalPay ({... state, bonus});

  return {... state, bonus, totalPay};

Ce motif rend le réducteur lisible car il ne fait que maintenir l'état. Vous saisissez le montant de l’action, calculez le salaire total et créez un nouveau littéral d’objet. Le traitement des options d'achat d'actions n'est pas très différent:

 case STOCK_OPTIONS:
  const {montant: stockOptions = 0} = action;
  totalPay = computeTotalPay ({... state, stockOptions});

  return {... state, stockOptions, totalPay};

Pour traiter un chèque de paie le jour de paie, il faut supprimer le bonus et le remboursement. Ces deux propriétés ne restent pas en état par chèque de paie. Et ajoutez une entrée à l'historique des paiements. Le salaire de base et les options d'achat d'actions peuvent rester en l'état car ils ne changent pas aussi souvent que le salaire. C’est dans cet esprit que PAY_DAY est libellé comme suit:

 cas PAY_DAY:
  const {payHistory} = state;
  totalPay = state.totalPay;

  const lastPayHistory = payHistory.slice (-1) .pop ();
  const lastTotalCompensation = (lastPayHistory
    && lastPayHistory.totalCompensation) || 0;
  const totalCompensation = totalPay + lastTotalCompensation;

  const newTotalPay = computeTotalPay ({... état,
    remboursement: 0, bonus: 0});
  const newPayHistory = [...payHistory, {totalPay, totalCompensation}];

  retour {... état, remboursement: 0, bonus: 0,
    totalPay: newTotalPay, payHistory: newPayHistory};

Dans un tableau comme newPayHistory utilisez un opérateur étendu qui est l'inverse de rest . Contrairement à rest, qui collecte les propriétés dans un objet, cela répartit les éléments. Ainsi, par exemple, [...payHistory]. Même si ces deux opérateurs se ressemblent, ils ne sont pas identiques. Regardez de près, car cela pourrait faire l’objet d’une question d’entrevue.

L’utilisation de pop () sur payHistory ne mute pas d’État. Pourquoi? Parce que slice () renvoie un nouveau tableau. Les tableaux en JavaScript sont copiés par référence. L’affectation d’un tableau à une nouvelle variable ne modifie pas l’objet sous-jacent. Donc, il faut être prudent quand on traite avec ce type d’objets.

Parce qu’il ya une chance que lastPayHistory ne soit pas défini, j’utilise la fusion nulle du pauvre pour l’initialiser à zéro. Notez le (o && o.property) || 0 motif à fusionner. Peut-être qu'une future version de JavaScript ou même de TypeScript aura un moyen plus élégant de le faire.

Chaque réducteur Redux doit définir une branche par défaut . Pour s’assurer que l’état ne devient pas undefined :

 par défaut:
  état de retour;

Test de la fonction réductrice

L’un des nombreux avantages de l’écriture de fonctions pures est qu’elles sont testables. Un test unitaire en est un où vous devez vous attendre à un comportement prévisible – au point où vous pouvez automatiser tous les tests dans le cadre d'une construction. Dans __ test __ / index.test.ts désactivez le test factice et importez toutes les fonctions qui vous intéressent:

 import {processBasePay,
  processusRemboursement,
  processBonus,
  processStockOptions,
  processPayDay,
  payrollEngineReducer} à partir de '../src/index';

Notez que toutes les fonctions ont été définies avec une exportation afin que vous puissiez les importer. Pour un salaire de base, activez le réducteur de moteur de paie et testez-le:

 il ("processus de traitement de base" , () => {
  action const = processBasePay (10);
  const result = payrollEngineReducer (undefined, action);

  expect (result.basePay) .toBe (10);
  attendez (resultat.totalPay) .toBe (10);
});

Redux définit l'état initial sur undefined . Par conséquent, il est toujours judicieux de fournir une valeur par défaut dans la fonction réducteur. Qu'en est-il du traitement d'un remboursement?

 it ('traiter le remboursement', () => {
  action const = processus de remboursement (10);
  const result = payrollEngineReducer (undefined, action);

  attendre (résultat. remboursement) .toBe (10);
  attendez (resultat.totalPay) .toBe (10);
});

Le modèle utilisé est le même pour le traitement des bonus:

 il ('bonus de traitement', () => {
  action const = processBonus (10);
  const result = payrollEngineReducer (undefined, action);

  attendre (résultat.bonus) .toBe (10);
  attendez (resultat.totalPay) .toBe (10);
});

Pour les options d'achat d'actions:

 it ('ignorer les options d'achat d'actions', () => {
  action const = processStockOptions (10);
  const result = payrollEngineReducer (undefined, action);

  expect (result.stockOptions) .toBe (0);
  attendez (resultat.totalPay) .toBe (0);
});

Remarque totalPay doit rester identique lorsque stockOptions est supérieur à totalPay . Comme cette entreprise hypothétique est éthique, elle ne veut pas prendre d’argent à ses employés. Si vous exécutez ce test, notez que totalPay est défini sur -10 car stockOptions est déduit. C'est pourquoi nous testons le code! Corrigeons cela là où il calcule le salaire total:

 const computeTotalPay = (payStub: PayStubState) =>
  payStub.totalPay> = payStub.stockOptions
  ? payStub.basePay + payStub.reimbursement
    + payStub.bonus - payStub.stockOptions
  : payStub.totalPay;

Si l’employé ne gagne pas assez d’argent pour acheter des actions de la société, ignorez la déduction. Assurez-vous également qu'il réinitialise stockOptions à zéro:

 case STOCK_OPTIONS:
  const {montant: stockOptions = 0} = action;
  totalPay = computeTotalPay ({... state, stockOptions});

  const newStockOptions = totalPay> = stockOptions
    ? stockOptions: 0;

  return {... state, stockOptions: newStockOptions, totalPay};

Le correctif détermine s'ils en ont assez dans newStockOptions . Avec cela, les tests unitaires réussissent et le code est correct et logique. Nous pouvons tester le cas d’utilisation positif où il ya assez d’argent pour une déduction:

 it ('process stock options', () => {
  const oldAction = processBasePay (10);
  const oldState = payrollEngineReducer (undefined, oldAction);
  action const = processStockOptions (4);
  const result = payrollEngineReducer (oldState, action);

  s'attendre (result.stockOptions) .toBe (4);
  attendez (resultat.totalPay) .toBe (6);
});

Pour le jour de paie, testez avec plusieurs états et assurez-vous que les transactions ponctuelles ne persistent pas:

 it ("traiter le jour de paie", () => {
  const oldAction = processBasePay (10);
  const oldState = payrollEngineReducer (undefined, oldAction);
  action const = processPayDay ();
  const result = payrollEngineReducer ({... oldState, bonus: 10,
    remboursement: 10}, action);

  attendez (resultat.totalPay) .toBe (10);
  expect (result.bonus) .toBe (0);
  attendez (résultat.reimbursement) .toBe (0);
  attendez (result.payHistory [0]). toBeDefined ();
  expect (result.payHistory [0] .Compensation totale) .toBe (10);
  expect (result.payHistory [0] .totalPay) .toBe (10);
});

Notez comment je modifie oldState pour vérifier le bonus et réinitialiser le remboursement

Qu'en est-il de la branche par défaut dans le réducteur? ] it ('gère la branche par défaut', () => {
  action const = {type: 'INIT_ACTION'};
  const result = payrollEngineReducer (undefined, action);

  attendre (résultat) .toBeDefined ();
});

Redux définit un type d'action tel que INIT_ACTION au début. Tout ce qui nous importe, c’est que notre réducteur crée un état initial.

En un tournemain

À ce stade, vous pouvez commencer à vous demander si Redux est plus un modèle de conception qu’autre chose. Si vous répondez que c’est à la fois un modèle et une bibliothèque légère, alors vous avez raison. Dans index.ts importez Redux:

 import {createStore} à partir de 'redux';

Le prochain exemple de code peut s'articuler autour de cette déclaration if . Il s’agit d’un palliatif. Par conséquent, les tests unitaires ne se perdent pas dans les tests d’intégration:

 if (! Process.env.JEST_WORKER_ID) {
}

Je ne recommande pas de faire cela dans un vrai projet. Les modules peuvent être placés dans des fichiers séparés pour isoler les composants. Cela le rend plus lisible et évite les fuites. Les tests unitaires bénéficient également du fait que les modules fonctionnent de manière isolée.

Lancez un magasin Redux avec le payrollEngineReducer :

 const store = createStore (payrollEngineReducer, initialState);
const unsubscribe = store.subscribe (() => console.log (store.getState ()));

Chaque store.subscribe () renvoie une fonction suivante unsubscribe () utile pour le nettoyage. Il désabonne les rappels lorsque des actions sont réparties dans le magasin. Ici, je sors l'état actuel sur la console avec store.getState () .

Disons que cet employé gagne 300 bénéficie d'un remboursement 50 100 bonus et 15 vers le stock de la société:

 store.dispatch (processBasePay (300));
store.dispatch (processReimbursement (50));
store.dispatch (processBonus (100));
store.dispatch (processStockOptions (15));
store.dispatch (processPayDay ());

Pour le rendre plus intéressant, effectuez un autre 50 remboursement et traitez un autre chèque de paie:

 store.dispatch (processReimbursement (50));
store.dispatch (processPayDay ());

Enfin, lancez un autre chèque de paie et désabonnez-vous du magasin Redux:

 store.dispatch (processPayDay ());

Se désabonner();

Le résultat final ressemble à ceci:

 {"basePay": 300,
  "remboursement": 0,
  "bonus": 0,
  "stockOptions": 15,
  "totalPay": 285,
  "payHistory":
   [ { "totalPay": 435, "totalCompensation": 435 },
     { "totalPay": 335, "totalCompensation": 770 },
     { "totalPay": 285, "totalCompensation": 1055 } ]}

Comme indiqué, Redux maintient l'état, mute et informe les abonnés dans un petit paquet. Pensez à Redux comme une machine d'état qui est la source de la vérité pour les données d'état. Tout cela, tout en intégrant le meilleur codage possible, tel qu'un bon paradigme fonctionnel.

Conclusion

Redux apporte une solution simple au problème complexe de la gestion par l'état. Il repose sur un paradigme fonctionnel visant à réduire l'imprévisibilité. Les réducteurs étant des fonctions pures, il est très facile de procéder à des tests unitaires. J'ai décidé d'utiliser Jest, mais tout framework de test prenant en charge les assertions de base fonctionnera également.

TypeScript ajoute une couche supplémentaire de protection avec la théorie des types. Contrôlez le type de couple avec la programmation fonctionnelle et vous obtenez un code sonore qui ne se casse presque pas. Le meilleur de tous, TypeScript reste à l'écart tout en ajoutant de la valeur. Si vous remarquez, il y a peu de codage supplémentaire une fois que les contrats de type sont en place. Le vérificateur de type effectue le reste du travail. Comme tout bon outil, TypeScript automatise la discipline de codage tout en restant invisible. TypeScript est livré avec une aboiement fort mais une morsure douce.

Si vous vouliez vous amuser avec ce projet (et j'espère que vous le ferez), vous pouvez trouver le code source de cet article sur GitHub .




Source link