Un autre guide de réduction Redux Boilerplate (NGRX)


De quoi s'agit-il?


Nous parlerons de plusieurs (cinq, pour être précis) méthodes, astuces, sacrifices sanglants au Dieu de l'entreprise, qui semblent nous aider à écrire du code plus concis et expressif dans nos applications Redux (et NGRX!). Les voies sont en proie à la sueur et au café. Veuillez donner un coup de pied et critiquer fortement. Nous apprendrons à mieux coder ensemble.


Honnêtement, au début, je voulais juste parler au monde de ma nouvelle micro-bibliothèque (35 lignes de code!) Flux-action-class , mais, en regardant le nombre toujours croissant d'exclamations que Habr deviendra bientôt Twitter, et pour la plupart D'accord avec eux, j'ai décidé d'essayer de faire une lecture un peu plus ample. Donc, nous rencontrons 5 façons de mettre à jour votre application Redux!


Boilerplate come out


Prenons un exemple typique de la façon d'envoyer une demande AJAX à Redux. Imaginons que nous ayons vraiment besoin d'une liste de scellés du serveur.


import { createSelector } from 'reselect' const actionTypeCatsGetInit = 'CATS_GET_INIT' const actionTypeCatsGetSuccess = 'CATS_GET_SUCCESS' const actionTypeCatsGetError = 'CATS_GET_ERROR' const actionCatsGetInit = () => ({ type: actionTypeCatsGetInit }) const actionCatsGetSuccess = (payload) => ({ type: actionTypeCatsGetSuccess, payload, }) const actionCatsGetError = (error) => ({ type: actionTypeCatsGetError, payload: error, }) const reducerCatsInitialState = { error: undefined, data: undefined, loading: false, } const reducerCats = (state = reducerCatsInitialState, action) => { switch (action.type) { case actionTypeCatsGetInit: return { ...state, loading: true, } case actionCatsGetSuccess: return { error: undefined, data: action.payload, loading: false, } case actionCatsGetError: return { ...data, error: action.payload, loading: false, } default: return state } } const makeSelectorCatsData = () => createSelector( (state) => state.cats.data, (cats) => cats, ) const makeSelectorCatsLoading = () => createSelector( (state) => state.cats.loading, (loading) => loading, ) const makeSelectorCatsError = () => createSelector( (state) => state.cats.error, (error) => error, ) 

Si vous ne comprenez pas vraiment pourquoi des usines pour les sélecteurs sont nécessaires ici, vous pouvez en lire plus ici.


Je ne considère pas sciemment les effets secondaires ici. Ceci est un sujet pour un article séparé plein de colère chez les adolescents et de critique de l'écosystème existant: D


Il y a plusieurs points faibles dans ce code:


  • Les usines d'actions sont uniques en elles-mêmes, mais nous utilisons toujours des types d'actions.
  • À mesure que de nouvelles entités sont ajoutées, nous continuons de dupliquer la même logique pour définir l'indicateur de loading . Les données que nous stockons dans les data et leur forme peuvent varier considérablement d'une demande à l'autre, mais l'indicateur de téléchargement (indicateur de loading ) sera toujours le même.
  • Le temps d'exécution du commutateur est O (n) (enfin presque ). Ce n'est pas en soi un argument très fort, car Redux, en principe, ne concerne pas les performances. Cela me rend plus furieux que pour chaque case vous devez écrire quelques lignes supplémentaires de code de service, et qu'un switch ne peut pas être facilement et magnifiquement divisé en plusieurs.
  • Avons-nous vraiment besoin de stocker séparément l'état d'erreur pour chaque entité?
  • Les sélecteurs sont cool. Les sélecteurs mémorisés sont doublement cool. Ils nous donnent une abstraction de notre côté, de sorte que plus tard, nous n'avons pas à refaire la moitié de l'application lors du changement de forme. Nous changeons simplement le sélecteur lui-même. Ce qui n'est pas agréable à l'œil, c'est un ensemble d'usines primitives qui ne sont nécessaires qu'en raison des particularités de la mémorisation en resélection .

Méthode 1: se débarrasser des types d'action


Enfin, pas vraiment. Nous faisons simplement que JS les crée pour nous.


Réfléchissons un instant à la raison pour laquelle nous avons généralement besoin de types d'action. Eh bien, évidemment, pour démarrer la branche de logique souhaitée dans notre réducteur et changer l'état de l'application en conséquence. La vraie question est: un type doit-il être une chaîne? Mais que se passe-t-il si nous utilisons des classes et switch de type?


 class CatsGetInit {} class CatsGetSuccess { constructor(responseData) { this.payload = responseData } } class CatsGetError { constructor(error) { this.payload = error this.error = true } } const reducerCatsInitialState = { error: undefined, data: undefined, loading: false, } const reducerCats = (state = reducerCatsInitialState, action) => { switch (action.constructor) { case CatsGetInit: return { ...state, loading: true, } case CatsGetSuccess: return { error: undefined, data: action.payload, loading: false, } case CatsGetError: return { ...data, error: action.payload, loading: false, } default: return state } } 

Tout semble bien se passer, mais il y a un problème: nous avons perdu la sérialisation de nos actions. Ce ne sont plus des objets simples que nous pouvons convertir en chaîne et vice versa. Maintenant, nous nous appuyons sur le fait que chaque action a son propre prototype unique, ce qui, en fait, permet à une conception telle qu'un switch sur action.constructor de fonctionner. Vous savez, j'aime vraiment l'idée de sérialiser mes actions en une chaîne et de les envoyer avec un rapport de bogue, et je ne suis pas prêt à la refuser.


Ainsi, chaque action doit avoir un champ type ( ici vous pouvez voir ce que chaque action respectant l'action devrait avoir). Heureusement, chaque classe a un nom qui ressemble à une chaîne. Ajoutons un type getter type chaque classe qui renverra le nom de cette classe.


 class CatsGetInit { constructor() { this.type = this.constructor.name } } const reducerCats = (state, action) => { switch (action.type) { case CatsGetInit.name: return { ...state, loading: true, } //... } } 

Cela fonctionne même, mais je voudrais coller un préfixe à chaque type, comme M. Eric le suggère dans ducks-modular-redux (je recommande de regarder la fourche de re-ducks , qui est encore plus cool, comme pour moi). Pour ajouter un préfixe, nous devrons arrêter d'utiliser directement le nom de la classe et ajouter un autre getter. Maintenant statique.


 class CatsGetInit { get static type () { return `prefix/${this.name}` } constructor () { this.type = this.constructor.type } } const reducerCats = (state, action) => { switch (action.type) { case CatsGetInit.type: return { ...state, loading: true, } //... } } 

Peignons un peu tout ça. Réduisez le copier-coller au minimum et ajoutez une autre condition: si l'action présente une erreur, alors sa payload doit être de type Error .


 class ActionStandard { get static type () { return `prefix/${this.name}` } constructor(payload) { this.type = this.constructor.type this.payload = payload this.error = payload instanceof Error } } class CatsGetInit extends ActionStandard {} class CatsGetSuccess extends ActionStandard {} class CatsGetError extends ActionStandard {} const reducerCatsInitialState = { error: undefined, data: undefined, loading: false, } const reducerCats = (state = reducerCatsInitialState, action) => { switch (action.type) { case CatsGetInit.type: return { ...state, loading: true, } case CatsGetSuccess.type: return { error: undefined, data: action.payload, loading: false, } case CatsGetError.type: return { ...data, error: action.payload, loading: false, } default: return state } } 

À ce stade, ce code fonctionne bien avec NGRX, mais Redux n'est pas capable de le mâcher. Il jure que l'action doit être de simples objets. Heureusement, JS nous permet de retourner presque n'importe quoi du concepteur, mais nous n'avons vraiment pas vraiment besoin d'une chaîne de prototype après avoir créé l'action.


 class ActionStandard { get static type () { return `prefix/${this.name}` } constructor(payload) { return { type: this.constructor.type, payload, error: payload instanceof Error } } } class CatsGetInit extends ActionStandard {} class CatsGetSuccess extends ActionStandard {} class CatsGetError extends ActionStandard {} const reducerCatsInitialState = { error: undefined, data: undefined, loading: false, } const reducerCats = (state = reducerCatsInitialState, action) => { switch (action.type) { case CatsGetInit.type: return { ...state, loading: true, } case CatsGetSuccess.type: return { error: undefined, data: action.payload, loading: false, } case CatsGetError.type: return { ...data, error: action.payload, loading: false, } default: return state } } 

Sur la base des considérations ci-dessus, la micro-bibliothèque de classe flux-action a été écrite. Il existe des tests, une couverture de test à 100% et presque la même classe ActionStandard avec des génériques pour les besoins de TypeScript. Fonctionne avec TypeScript et JavaScript.


Méthode 2: nous n'avons pas peur d'utiliser des réducteurs combinés


L'idée est simple à déshonorer: utilisez combineReducers non seulement pour les réducteurs de niveau supérieur, mais aussi pour briser davantage la logique et créer un réducteur séparé pour le loading .


 const reducerLoading = (actionInit, actionSuccess, actionError) => ( state = false, action, ) => { switch (action.type) { case actionInit.type: return true case actionSuccess.type: return false case actionError.type: return false } } class CatsGetInit extends ActionStandard {} class CatsGetSuccess extends ActionStandard {} class CatsGetError extends ActionStandard {} const reducerCatsData = (state = undefined, action) => { switch (action.type) { case CatsGetSuccess.type: return action.payload default: return state } } const reducerCatsError = (state = undefined, action) => { switch (action.type) { case CatsGetError.type: return action.payload default: return state } } const reducerCats = combineReducers({ data: reducerCatsData, loading: reducerLoading(CatsGetInit, CatsGetSuccess, CatsGetError), error: reducerCatsError, }) 

Méthode 3: se débarrasser de l'interrupteur


Et encore une idée extrêmement simple: au lieu de switch-case utilisez un objet à partir duquel sélectionner le champ souhaité par clé. L'accès au champ de l'objet par clé est O (1), et il semble un peu plus propre à mon humble avis.


 const createReducer = (initialState, reducerMap) => ( state = initialState, action, ) => { //       const reducer = state[action.type] if (!reducer) { return state } //  ,    return reducer(state, action) } const reducerLoading = (actionInit, actionSuccess, actionError) => createReducer(false, { [actionInit.type]: () => true, [actionSuccess.type]: () => false, [actionError.type]: () => false, }) class CatsGetInit extends ActionStandard {} class CatsGetSuccess extends ActionStandard {} class CatsGetError extends ActionStandard {} const reducerCatsData = createReducer(undefined, { [CatsGetSuccess.type]: () => action.payload, }) const reducerCatsError = createReducer(undefined, { [CatsGetError.type]: () => action.payload, }) const reducerCats = combineReducers({ data: reducerCatsData, loading: reducerLoading(CatsGetInit, CatsGetSuccess, CatsGetError), error: reducerCatsError, }) 

Refactorisons le reducerLoading Maintenant, en connaissant les cartes (objets) pour les réducteurs, nous pouvons retourner cette carte à partir de reducerLoading , au lieu de renvoyer un réducteur entier. Potentiellement, cela ouvre une portée illimitée pour étendre la fonctionnalité.


 const createReducer = (initialState, reducerMap) => ( state = initialState, action, ) => { //       const reducer = state[action.type] if (!reducer) { return state } //  ,    return reducer(state, action) } const reducerLoadingMap = (actionInit, actionSuccess, actionError) => ({ [actionInit.type]: () => true, [actionSuccess.type]: () => false, [actionError.type]: () => false, }) class CatsGetInit extends ActionStandard {} class CatsGetSuccess extends ActionStandard {} class CatsGetError extends ActionStandard {} const reducerCatsLoading = createReducer( false, reducerLoadingMap(CatsGetInit, CatsGetSuccess, CatsGetError), ) /*       reducerCatsLoading: const reducerCatsLoading = createReducer( false, { ...reducerLoadingMap(CatsGetInit, CatsGetSuccess, CatsGetError), ... some custom stuff } ) */ const reducerCatsData = createReducer(undefined, { [CatsGetSuccess.type]: () => action.payload, }) const reducerCatsError = createReducer(undefined, { [CatsGetError.type]: () => action.payload, }) const reducerCats = combineReducers({ data: reducerCatsData, loading: reducerCatsLoading), error: reducerCatsError, }) 

La documentation officielle sur Redux parle également de cette approche , cependant, pour une raison inconnue, je continue de voir beaucoup de projets utilisant switch-case . Basé sur le code de la documentation officielle, M. Moshe a compilé une bibliothèque pour nous pour createReducer .


Méthode 4: utilisez le gestionnaire d'erreur global


Nous ne devons absolument pas conserver l'erreur pour chaque entité séparément. Dans la plupart des cas, nous voulons simplement montrer le dialogue. La même boîte de dialogue avec du texte dynamique pour toutes les entités.


Créez un gestionnaire d'erreurs global. Dans le cas le plus simple, cela pourrait ressembler à ceci:


 class GlobalErrorInit extends ActionStandard {} class GlobalErrorClear extends ActionStandard {} const reducerError = createReducer(undefined, { [GlobalErrorInit.type]: (state, action) => action.payload, [GlobalErrorClear.type]: (state, action) => undefined, }) 

Ensuite, dans notre effet secondaire, nous enverrons l'action ErrorInit dans le catch . Cela pourrait ressembler à ceci lors de l'utilisation de redux-thunk :


 const catsGetAsync = async (dispatch) => { dispatch(new CatsGetInit()) try { const res = await fetch('https://cats.com/api/v1/cats') const body = await res.json() dispatch(new CatsGetSuccess(body)) } catch (error) { dispatch(new CatsGetError(error)) dispatch(new GlobalErrorInit(error)) } } 

Maintenant, nous pouvons nous débarrasser du champ d' error dans notre magasin de chats et utiliser CatsGetError uniquement pour changer le drapeau de loading .


 class CatsGetInit extends ActionStandard {} class CatsGetSuccess extends ActionStandard {} class CatsGetError extends ActionStandard {} const reducerCatsLoading = createReducer( false, reducerLoadingMap(CatsGetInit, CatsGetSuccess, CatsGetError), ) const reducerCatsData = createReducer(undefined, { [CatsGetSuccess.type]: () => action.payload, }) const reducerCats = combineReducers({ data: reducerCatsData, loading: reducerCatsLoading) }) 

Méthode 5: Réfléchissez avant de mémoriser


Regardons à nouveau un tas d'usines pour les sélecteurs.


J'ai lancé makeSelectorCatsError car il n'est plus nécessaire, comme nous l'avons trouvé dans le chapitre précédent.


 const makeSelectorCatsData = () => createSelector( (state) => state.cats.data, (cats) => cats, ) const makeSelectorCatsLoading = () => createSelector( (state) => state.cats.loading, (loading) => loading, ) 

Pourquoi avons-nous besoin de sélecteurs mémorisés ici? Qu'essayons-nous exactement de mémoriser? L'accès au champ objet par clé, ce qui se passe ici, est O (1). Nous pouvons utiliser des fonctions ordinaires non mémorisées. Utilisez la mémorisation uniquement lorsque vous souhaitez modifier les données du magasin avant de les transmettre au composant.


 const selectorCatsData = (state) => state.cats.data const selectorCatsLoading = (state) => state.cats.loading 

La mémorisation est logique dans le cas du calcul du résultat à la volée. Pour l'exemple ci-dessous, imaginons que chaque chat est un objet avec le champ de name , et nous voulons obtenir une chaîne contenant les noms de tous les chats.


 const makeSelectorCatNames = () => createSelector( (state) => state.cats.data, (cats) => cats.data.reduce((accum, { name }) => `${accum} ${name}`, ''), ) 

Conclusion


Voyons à nouveau où nous avons commencé:


 import { createSelector } from 'reselect' const actionTypeCatsGetInit = 'CATS_GET_INIT' const actionTypeCatsGetSuccess = 'CATS_GET_SUCCESS' const actionTypeCatsGetError = 'CATS_GET_ERROR' const actionCatsGetInit = () => ({ type: actionTypeCatsGetInit }) const actionCatsGetSuccess = () => ({ type: actionTypeCatsGetSuccess }) const actionCatsGetError = () => ({ type: actionTypeCatsGetError }) const reducerCatsInitialState = { error: undefined, data: undefined, loading: false, } const reducerCats = (state = reducerCatsInitialState, action) => { switch (action.type) { case actionTypeCatsGetInit: return { ...state, loading: true, } case actionCatsGetSuccess: return { error: undefined, data: action.payload, loading: false, } case actionCatsGetError: return { ...data, error: action.payload, loading: false, } default: return state } } const makeSelectorCatsData = () => createSelector( (state) => state.cats.data, (cats) => cats, ) const makeSelectorCatsLoading = () => createSelector( (state) => state.cats.loading, (loading) => loading, ) const makeSelectorCatsError = () => createSelector( (state) => state.cats.error, (error) => error, ) 

Et ce qui est arrivé à:


 class CatsGetInit extends ActionStandard {} class CatsGetSuccess extends ActionStandard {} class CatsGetError extends ActionStandard {} const reducerCatsLoading = createReducer( false, reducerLoadingMap(CatsGetInit, CatsGetSuccess, CatsGetError), ) const reducerCatsData = createReducer(undefined, { [CatsGetSuccess.type]: () => action.payload, }) const reducerCats = combineReducers({ data: reducerCatsData, loading: reducerCatsLoading) }) const selectorCatsData = (state) => state.cats.data const selectorCatsLoading = (state) => state.cats.loading 

J'espère que vous n'avez pas perdu de temps en vain, et l'article vous a au moins été un peu utile. Comme je l'ai dit au tout début, veuillez donner un coup de pied et critiquer dur. Nous apprendrons à mieux coder ensemble.

Source: https://habr.com/ru/post/fr435322/


All Articles