Organización reductora: dando un paso más


¿Qué vamos a cubrir aquí?


Vamos a ver la evolución general de los reductores en mis aplicaciones Redux / NGRX que tuvieron lugar en los últimos dos años. Comenzando desde switch-case vainilla, yendo a seleccionar un reductor de un objeto por clave, finalmente estableciéndonos con reductores basados ​​en clases. No vamos a hablar solo de cómo, sino también de por qué.


Si está interesado en trabajar con demasiadas repeticiones en Redux / NGRX, puede consultar este artículo .

Si ya está familiarizado con la selección de un reductor de una técnica de mapa, considere saltar directamente a los reductores basados en la clase .

Caja de interruptor de vainilla


Así que echemos un vistazo a una tarea cotidiana de crear una entidad en el servidor de forma asincrónica. Esta vez sugiero que describamos cómo podríamos crear un nuevo jedi.


 const actionTypeJediCreateInit = 'jedi-app/jedi-create-init' const actionTypeJediCreateSuccess = 'jedi-app/jedi-create-success' const actionTypeJediCreateError = 'jedi-app/jedi-create-error' const reducerJediInitialState = { loading: false, // List of our jedi data: [], error: undefined, } const reducerJedi = (state = reducerJediInitialState, action) => { switch (action.type) { case actionTypeJediCreateInit: return { ...state, loading: true, } case actionTypeJediCreateSuccess: return { loading: false, data: [...state.data, action.payload], error: undefined, } case actionTypeJediCreateError: return { ...state, loading: false, error: action.payload, } default: return state } } 

Permítanme ser sincero, nunca he usado este tipo de reductores en la producción. Mi razonamiento es triple:


  • switch-case introduce algunos puntos de tensión, tuberías con fugas, que podríamos olvidar arreglar en algún momento. Siempre podríamos olvidar poner un break si no hacemos un return inmediato, siempre podríamos olvidar agregar el default , que tenemos que agregar a cada reductor.
  • switch-case tiene un código repetitivo en sí mismo que no agrega ningún contexto.
  • switch-case es O (n), más o menos . No es un argumento sólido en sí mismo porque Redux no es muy eficiente de todos modos, pero enloquece a mi perfeccionista interno.

El siguiente paso lógico que sugiere la documentación oficial de Redux es elegir un reductor de un objeto por clave.


Seleccionar un reductor de un objeto por clave


La idea es simple. Cada transformación de estado es una función de estado y acción y tiene un tipo de acción correspondiente. Teniendo en cuenta que cada tipo de acción es una cadena, podríamos crear un objeto, donde cada clave es un tipo de acción y cada valor es una función que transforma el estado (un reductor). Entonces podríamos elegir un reductor requerido de ese objeto por clave, que es O (1), cuando recibamos una nueva acción.


 const actionTypeJediCreateInit = 'jedi-app/jedi-create-init' const actionTypeJediCreateSuccess = 'jedi-app/jedi-create-success' const actionTypeJediCreateError = 'jedi-app/jedi-create-error' const reducerJediInitialState = { loading: false, data: [], error: undefined, } const reducerJediMap = { [actionTypeJediCreateInit]: (state) => ({ ...state, loading: true, }), [actionTypeJediCreateSuccess]: (state, action) => ({ loading: false, data: [...state.data, action.payload], error: undefined, }), [actionTypeJediCreateError]: (state, action) => ({ ...state, loading: false, error: action.payload, }), } const reducerJedi = (state = reducerJediInitialState, action) => { // Pick a reducer by action type const reducer = reducerJediMap[action.type] if (!reducer) { // Return state unchanged if we did not find a suitable reducer return state } // Run suitable reducer if found one return reducer(state, action) } 

Lo bueno aquí es que la lógica dentro del reducerJedi permanece igual para cualquier reductor, lo que significa que podemos reutilizarlo. Incluso hay una pequeña biblioteca, llamada redux-create-reducer , que hace exactamente eso. Hace que el código se vea así:


 import { createReducer } from 'redux-create-reducer' const actionTypeJediCreateInit = 'jedi-app/jedi-create-init' const actionTypeJediCreateSuccess = 'jedi-app/jedi-create-success' const actionTypeJediCreateError = 'jedi-app/jedi-create-error' const reducerJediInitialState = { loading: false, data: [], error: undefined, } const reducerJedi = createReducer(reducerJediInitialState, { [actionTypeJediCreateInit]: (state) => ({ ...state, loading: true, }), [actionTypeJediCreateSuccess]: (state, action) => ({ loading: false, data: [...state.data, action.payload], error: undefined, }), [actionTypeJediCreateError]: (state, action) => ({ ...state, loading: false, error: action.payload, }), }) 

Bonita y bonita, ¿eh? Aunque esto todavía tiene algunas advertencias:


  • En el caso de los reductores complejos, tenemos que dejar muchos comentarios que describan qué hace este reductor y por qué.
  • Los mapas reductores enormes son difíciles de leer.
  • Cada reductor tiene solo un tipo de acción correspondiente. ¿Qué sucede si quiero ejecutar el mismo reductor para varias acciones?

El reductor basado en clases se convirtió en mi cobertizo de luz en el reino de la noche.


Reductores de clase


Esta vez permítanme comenzar con los porqués de este enfoque:


  • Los métodos de la clase serán nuestros reductores y los métodos tienen nombres, lo cual es una metainformación útil, y podríamos abandonar los comentarios en el 90% de los casos.
  • Se podrían decorar los métodos de la clase, que es una forma declarativa fácil de leer para combinar acciones y reductores.
  • Todavía podríamos usar un mapa de acciones bajo el capó para tener una complejidad O (1).

Si eso suena como una lista razonable de razones para usted, ¡profundicemos!


En primer lugar, me gustaría definir qué queremos obtener como resultado.


 const actionTypeJediCreateInit = 'jedi-app/jedi-create-init' const actionTypeJediCreateSuccess = 'jedi-app/jedi-create-success' const actionTypeJediCreateError = 'jedi-app/jedi-create-error' class ReducerJedi { // Take a look at "Class field delcaratrions" proposal, which is now at Stage 3. // https://github.com/tc39/proposal-class-fields initialState = { loading: false, data: [], error: undefined, } @Action(actionTypeJediCreateInit) startLoading(state) { return { ...state, loading: true, } } @Action(actionTypeJediCreateSuccess) addNewJedi(state, action) { return { loading: false, data: [...state.data, action.payload], error: undefined, } } @Action(actionTypeJediCreateError) error(state, action) { return { ...state, loading: false, error: action.payload, } } } 

Ahora, cuando vemos a dónde queremos llegar, podemos hacerlo paso a paso.


Paso 1. Decorador de acciones .


Lo que queremos hacer aquí es aceptar cualquier número de tipos de acción y almacenarlos como metainformación para que el método de una clase lo use más adelante. Para hacer eso, podríamos utilizar polyfill reflect -metadata , que trae la funcionalidad de metadatos al objeto Reflect . Después de eso, este decorador simplemente adjuntaría sus argumentos (tipos de acción) a un método como metadatos.


 const METADATA_KEY_ACTION = 'reducer-class-action-metadata' export const Action = (...actionTypes) => (target, propertyKey, descriptor) => { Reflect.defineMetadata(METADATA_KEY_ACTION, actionTypes, target, propertyKey) } 

Paso 2. Crear una función reductora a partir de una clase reductora


Como sabemos, cada reductor es una función pura que acepta un estado y una acción y devuelve un nuevo estado. Bueno, la clase también es una función, pero las clases ES6 no se pueden invocar sin new y de todos modos tenemos que hacer un reductor real de una clase con algunos métodos. Entonces necesitamos transformarlo de alguna manera.


Necesitamos una función que tome nuestra clase, recorra cada método, recopile metadatos con tipos de acción, cree un mapa reductor y cree un reductor final a partir de ese mapa reductor.


Así es como podríamos examinar cada método de una clase.


 const getReducerClassMethodsWthActionTypes = (instance) => { // Get method names from class' prototype const proto = Object.getPrototypeOf(instance) const methodNames = Object.getOwnPropertyNames(proto).filter( (name) => name !== 'constructor', ) // We want to get back a collection with action types and corresponding reducers const res = [] methodNames.forEach((methodName) => { const actionTypes = Reflect.getMetadata( METADATA_KEY_ACTION, instance, methodName, ) // We want to bind each method to class' instance not to lose `this` context const method = instance[methodName].bind(instance) // We might have many action types associated with a reducer actionTypes.forEach((actionType) => res.push({ actionType, method, }), ) }) return res } 

Ahora queremos procesar la colección recibida en un mapa reductor.


 const getReducerMap = (methodsWithActionTypes) => methodsWithActionTypes.reduce((reducerMap, { method, actionType }) => { reducerMap[actionType] = method return reducerMap }, {}) 

Entonces la función final podría verse más o menos así.


 import { createReducer } from 'redux-create-reducer' const createClassReducer = (ReducerClass) => { const reducerClass = new ReducerClass() const methodsWithActionTypes = getReducerClassMethodsWthActionTypes( reducerClass, ) const reducerMap = getReducerMap(methodsWithActionTypes) const initialState = reducerClass.initialState const reducer = createReducer(initialState, reducerMap) return reducer } 

Y podríamos aplicarlo a nuestra clase ReducerJedi esta manera.


 const reducerJedi = createClassReducer(ReducerJedi) 

Paso 3. Fusionarlo todo junto.


 // We move that generic code to a dedicated module import { Action, createClassReducer } from 'utils/reducer-class' const actionTypeJediCreateInit = 'jedi-app/jedi-create-init' const actionTypeJediCreateSuccess = 'jedi-app/jedi-create-success' const actionTypeJediCreateError = 'jedi-app/jedi-create-error' class ReducerJedi { // Take a look at "Class field delcaratrions" proposal, which is now at Stage 3. // https://github.com/tc39/proposal-class-fields initialState = { loading: false, data: [], error: undefined, } @Action(actionTypeJediCreateInit) startLoading(state) { return { ...state, loading: true, } } @Action(actionTypeJediCreateSuccess) addNewJedi(state, action) { return { loading: false, data: [...state.data, action.payload], error: undefined, } } @Action(actionTypeJediCreateError) error(state, action) { return { ...state, loading: false, error: action.payload, } } } export const reducerJedi = createClassReducer(ReducerJedi) 

Próximos pasos


Esto es lo que nos perdimos:


  • ¿Qué pasa si la misma acción corresponde a varios métodos? La lógica actual no maneja esto.
  • ¿Podríamos agregar immer ?
  • ¿Qué pasa si uso acciones basadas en clases? ¿Cómo podría pasar un creador de acción, no un tipo de acción?

Todo ello con ejemplos de código adicionales y ejemplos está cubierto con una clase reductora .


Debo decir que usar clases para reductores no es un pensamiento original. A @amcdnl se le ocurrieron increíbles acciones ngrx hace bastante tiempo, pero parece que ahora está enfocado en NGXS , sin mencionar que quería una escritura y un desacoplamiento más estrictos de la lógica específica de Angular. Aquí hay una lista de diferencias clave entre reducer-class y ngrx-actions.


Si te gusta la idea de usar clases para tus reductores, quizás quieras hacer lo mismo con tus creadores de acción. Echa un vistazo a flux-action-class .

Con suerte, encontraste algo útil para tu proyecto. ¡No dudes en comunicarme tus comentarios! Aprecio mucho cualquier crítica y pregunta.

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


All Articles