Organização redutora - dando um passo adiante


O que vamos cobrir aqui?


Vamos apresentar a evolução dos redutores nos meus aplicativos Redux / NGRX que ocorreram nos últimos dois anos. Começando pela switch-case chaves de baunilha, indo para selecionar um redutor de um objeto por chave, finalmente estabelecendo-se com redutores baseados em classe. Não vamos falar apenas sobre como, mas também sobre o porquê.


Se você estiver interessado em trabalhar com muita clichê no Redux / NGRX, consulte este artigo .

Se você já está familiarizado com a seleção de um redutor em uma técnica de mapa, pense em pular direto para redutores baseados em classe .

Caixa de distribuição de baunilha


Então, vamos dar uma olhada em uma tarefa cotidiana de criar uma entidade no servidor de forma assíncrona. Desta vez, sugiro que descrevamos como poderíamos criar um novo 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 } } 

Deixe-me ser honesto, nunca usei esse tipo de redutor na produção. Meu raciocínio é triplo:


  • switch-case apresenta alguns pontos de tensão, tubos com vazamentos, que podemos esquecer de corrigir em algum momento. Sempre poderíamos esquecer de break se não return imediatamente, sempre poderíamos esquecer de adicionar o default , que precisamos adicionar a cada redutor.
  • switch-case possui algum código padrão, que não adiciona nenhum contexto.
  • switch-case é O (n), mais ou menos. Não é um argumento sólido por si só, porque o Redux não tem muito desempenho, mas deixa meu perfeccionista interior louco.

O próximo passo lógico sugerido pela documentação oficial do Redux é escolher um redutor de um objeto por chave.


Selecionando um redutor de um objeto por chave


A ideia é simples. Cada transformação de estado é uma função do estado e da ação e possui um tipo de ação correspondente. Considerando que cada tipo de ação é uma string, poderíamos criar um objeto, onde cada chave é um tipo de ação e cada valor é uma função que transforma o estado (um redutor). Então, poderíamos escolher um redutor necessário desse objeto por chave, que é O (1), quando recebermos uma nova ação.


 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) } 

O legal aqui é que a lógica dentro do reducerJedi permanece a mesma para qualquer redutor, o que significa que podemos reutilizá-lo. Existe até uma pequena biblioteca, chamada redux-create-reducer , que faz exatamente isso. Faz com que o código fique assim:


 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, }), }) 

Bonito e bonito, hein? Embora isso ainda tenha algumas ressalvas:


  • No caso de redutores complexos, temos que deixar muitos comentários descrevendo o que esse redutor faz e por quê.
  • Mapas redutores enormes são difíceis de ler.
  • Cada redutor possui apenas um tipo de ação correspondente. E se eu quiser executar o mesmo redutor para várias ações?

O redutor baseado em classe tornou-se meu galpão de luz no reino da noite.


Redutores baseados em classe


Desta vez, deixe-me começar com por que dessa abordagem:


  • Os métodos de classe serão nossos redutores e os métodos terão nomes, o que é uma meta-informação útil, e poderíamos abandonar comentários em 90% dos casos.
  • Os métodos da classe podem ser decorados, o que é uma maneira declarativa de fácil leitura para combinar ações e redutores.
  • Ainda poderíamos usar um mapa de ações sob o capô para ter complexidade O (1).

Se isso soa como uma lista razoável de razões para você, vamos nos aprofundar!


Antes de mais, gostaria de definir o que queremos obter 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, } } } 

Agora, como vemos onde queremos chegar, podemos fazê-lo passo a passo.


Etapa 1. Decorador de ação .


O que queremos fazer aqui é aceitar qualquer número de tipos de ação e armazená-los como meta-informações para que o método de uma classe possa ser usado posteriormente. Para fazer isso, podemos utilizar o polyfill reflect-metadata , que traz funcionalidade de metadados ao objeto Reflect . Depois disso, esse decorador apenas anexaria seus argumentos (tipos de ação) a um método como meta-dados.


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

Etapa 2. Criando uma função redutora a partir de uma classe redutora


Como sabemos, cada redutor é uma função pura que aceita um estado e uma ação e retorna um novo estado. Bem, classe também é uma função, mas as classes ES6 não podem ser invocadas sem new e temos que fazer um redutor real de uma classe com alguns métodos de qualquer maneira. Então, precisamos transformá-lo de alguma forma.


Precisamos de uma função que leve nossa classe, percorra cada método, colete metadados com tipos de ação, construa um mapa redutor e crie um redutor final a partir desse mapa redutor.


Veja como poderíamos examinar cada método de uma classe.


 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 } 

Agora queremos processar a coleção recebida em um mapa redutor.


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

Portanto, a função final pode se parecer com isso.


 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 } 

E poderíamos aplicá-lo à nossa classe ReducerJedi assim.


 const reducerJedi = createClassReducer(ReducerJedi) 

Etapa 3. Mesclando tudo.


 // 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óximas etapas


Aqui está o que perdemos:


  • E se a mesma ação corresponder a vários métodos? A lógica atual não lida com isso.
  • Podemos adicionar immer ?
  • E se eu usar ações baseadas em classe? Como eu poderia passar um criador de ações, não um tipo de ação?

Tudo isso com exemplos e exemplos de código adicionais é coberto pela classe redutor .


Devo dizer que usar classes para redutores não é um pensamento original. O @amcdnl surgiu com incríveis ações do ngrx há algum tempo, mas parece que agora ele está focado no NGXS , sem mencionar que eu queria uma digitação e dissociação mais estritas da lógica específica do Angular. Aqui está uma lista das principais diferenças entre classe redutora e ações ngrx.


Se você gosta da ideia de usar classes para seus redutores, pode fazer o mesmo com seus criadores de ações. Dê uma olhada na classe de ação e fluxo .

Felizmente, você encontrou algo útil para o seu projeto. Sinta-se livre para me comunicar seus comentários! Certamente aprecio qualquer crítica e pergunta.

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


All Articles