
¿De qué se tratará?
Hablaremos sobre varios (cinco, para ser específicos) métodos, trucos, sacrificios sangrientos al Dios de la Empresa, que parecen ayudarnos a escribir un código más conciso y expresivo en nuestras aplicaciones Redux (¡y NGRX!). Las formas están plagadas de sudor y café. Por favor patea y critica fuertemente. Aprenderemos a codificar mejor juntos.
Honestamente, al principio solo quería contarle al mundo sobre mi nueva microbiblioteca (¡35 líneas de código!) Flux-action-class , pero mirando el número cada vez mayor de exclamaciones de que Habr pronto se convertirá en Twitter, y en su mayor parte De acuerdo con ellos, decidí intentar hacer una lectura algo más amplia. ¡Entonces, conocemos 5 formas de actualizar su aplicación Redux!
Boilerplate sale
Considere un ejemplo típico de cómo enviar una solicitud AJAX a Redux. Imaginemos que realmente necesitamos una lista de sellos del servidor.
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 no comprende por qué se necesitan fábricas para selectores aquí, puede leer sobre esto aquí.
No considero deliberadamente los efectos secundarios aquí. Este es un tema para un artículo separado lleno de ira adolescente y críticas al ecosistema existente: D
Hay varios puntos débiles en este código:
- Las fábricas de acción son únicas por derecho propio, pero aún utilizamos tipos de acción.
- A medida que se agregan nuevas entidades, continuamos duplicando la misma lógica para establecer el indicador de
loading
. Los datos que almacenamos en los data
y su forma pueden variar significativamente de una solicitud a otra, pero el indicador de descarga (indicador de loading
) seguirá siendo el mismo. - El tiempo de ejecución del interruptor es O (n) (bueno, casi ). Esto en sí mismo no es un argumento muy fuerte, porque Redux, en principio, no se trata de rendimiento. Me enfurece más que para cada
case
necesites escribir un par de líneas adicionales de código de publicación, y que un switch
no se puede dividir fácilmente en varios. - ¿Realmente necesitamos almacenar el estado de error para cada entidad por separado?
- Los selectores son geniales. Los selectores memorizados son doblemente geniales. Nos dan una abstracción de nuestro lado, para que luego no tengamos que rehacer la mitad de la aplicación al cambiar la forma de la misma. Simplemente cambiamos el selector en sí. Lo que no es agradable a la vista es un conjunto de fábricas primitivas que solo se necesitan debido a las peculiaridades de la memorización en la reflexión .
Método 1: deshacerse de los tipos de acción
Bueno, en realidad no. Simplemente hacemos que JS los cree para nosotros.
Pensemos por un segundo sobre por qué generalmente necesitamos tipos de acción. Bueno, obviamente, para iniciar la rama lógica deseada en nuestro reductor y cambiar el estado de la aplicación en consecuencia. La verdadera pregunta es, ¿un tipo tiene que ser una cadena? Pero, ¿qué pasa si usamos clases y cambiamos por tipo?
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 } }
Todo parece ser genial, pero hay un problema: perdimos la serialización de nuestras acciones. Estos ya no son objetos simples que podemos convertir en una cadena y viceversa. Ahora confiamos en el hecho de que cada acción tiene su propio prototipo único, que, de hecho, permite que funcione un diseño como un switch
en action.constructor
. Sabes, me gusta mucho la idea de serializar mis acciones en una cadena y enviarlas junto con un informe de error, y no estoy listo para rechazarlo.
Por lo tanto, cada acción debe tener un campo de type
( aquí puede ver qué más debe tener cada acción respetuosa con la acción). Afortunadamente, cada clase tiene un nombre que es como una cadena. Agreguemos un type
getter type
cada clase que devolverá el nombre de esta clase.
class CatsGetInit { constructor() { this.type = this.constructor.name } } const reducerCats = (state, action) => { switch (action.type) { case CatsGetInit.name: return { ...state, loading: true, }
Incluso funciona, pero me gustaría pegar un prefijo para cada tipo, como sugiere el Sr. Eric en ducks-modular-redux (recomiendo mirar el tenedor de re-ducks , que es aún más genial, en cuanto a mí). Para agregar un prefijo, tendremos que dejar de usar el nombre de la clase directamente y agregar otro getter. Ahora estático
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, }
Peinemos todo esto un poco. Reduzca el copiar y pegar al mínimo y agregue otra condición: si la acción presenta un error, entonces su payload
debe ser del tipo 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 } }
En esta etapa, este código funciona bien con NGRX, pero Redux no es capaz de masticarlo. Jura que la acción debería ser objetos simples. Afortunadamente, JS nos permite devolver casi cualquier cosa del diseñador, pero realmente no necesitamos una cadena de prototipos después de crear la acción.
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 } }
En base a las consideraciones anteriores, se escribió la micro biblioteca de clase de acción de flujo . Hay pruebas, 100% de cobertura de prueba y casi la misma clase ActionStandard
con ActionStandard
a genéricos para las necesidades de TypeScript. Funciona con TypeScript y JavaScript.
Método 2: No tenemos miedo de usar CombineReducers
La idea es fácil de deshonrar: use combineReducers no solo para los reductores de nivel superior, sino también para romper aún más la lógica y crear un reductor separado para la 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étodo 3: deshacerse del interruptor
Y una vez más, una idea extremadamente simple: en lugar de switch-case
use un objeto para seleccionar el campo deseado por tecla. El acceso al campo del objeto por clave es O (1), y se ve un poco más limpio en mi humilde opinión.
const createReducer = (initialState, reducerMap) => ( state = initialState, action, ) => {
Refactoricemos el reducerLoading
. Ahora, conociendo los mapas (objetos) para reductores, podemos devolver este mapa desde reducerLoading
, en lugar de devolver un reductor completo. Potencialmente, esto abre un alcance ilimitado para expandir la funcionalidad.
const createReducer = (initialState, reducerMap) => ( state = initialState, action, ) => {
La documentación oficial sobre Redux también habla sobre este enfoque , sin embargo, por alguna razón desconocida, sigo viendo muchos proyectos usando switch-case
. Basado en el código de la documentación oficial, el Sr. Moshe ha compilado una biblioteca para nosotros para createReducer
.
Método 4: utilice el controlador de errores global
No tenemos que guardar el error para cada entidad por separado. En la mayoría de los casos, solo queremos mostrar el diálogo. El mismo diálogo con texto dinámico para todas las entidades.
Crea un controlador de errores global. En el caso más simple, podría verse así:
class GlobalErrorInit extends ActionStandard {} class GlobalErrorClear extends ActionStandard {} const reducerError = createReducer(undefined, { [GlobalErrorInit.type]: (state, action) => action.payload, [GlobalErrorClear.type]: (state, action) => undefined, })
Luego, en nuestro efecto secundario, enviaremos la acción ErrorInit
en el catch
. Puede verse algo así cuando se usa 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)) } }
Ahora podemos deshacernos del campo de error
en nuestra tienda de gatos y usar CatsGetError
solo para cambiar la bandera 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étodo 5: Piensa antes de memorizar
Veamos nuevamente un montón de fábricas para selectores.
Lancé makeSelectorCatsError
porque ya no es necesario, como encontramos en el capítulo anterior.
const makeSelectorCatsData = () => createSelector( (state) => state.cats.data, (cats) => cats, ) const makeSelectorCatsLoading = () => createSelector( (state) => state.cats.loading, (loading) => loading, )
¿Por qué necesitamos selectores memorizados aquí? ¿Qué es exactamente lo que estamos tratando de memorizar? El acceso al campo de objeto por clave, que es lo que sucede aquí, es O (1). Podemos usar funciones ordinarias no memorizadas. Use la memorización solo cuando desee cambiar los datos de la tienda antes de entregarlos al componente.
const selectorCatsData = (state) => state.cats.data const selectorCatsLoading = (state) => state.cats.loading
La memorización tiene sentido en el caso de calcular el resultado sobre la marcha. Para el siguiente ejemplo, imaginemos que cada gato es un objeto con el campo de name
, y queremos obtener una cadena que contenga los nombres de todos los gatos.
const makeSelectorCatNames = () => createSelector( (state) => state.cats.data, (cats) => cats.data.reduce((accum, { name }) => `${accum} ${name}`, ''), )
Conclusión
Veamos de nuevo dónde empezamos:
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, )
Y lo que vino a:
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
Espero que no hayas perdido el tiempo en vano, y el artículo fue al menos un poco útil para ti. Como dije al principio, por favor patea y critica fuerte. Aprenderemos a codificar mejor juntos.