
Sobre o que será?
Falaremos sobre vários (cinco, para ser específico) métodos, truques, sacrifícios sangrentos ao Deus da Empresa, que parecem nos ajudar a escrever código mais conciso e expressivo em nossos Aplicativos Redux (e NGRX!). Maneiras são atormentadas por suor e café. Por favor, chute e critique fortemente. Vamos aprender a codificar melhor juntos.
Honestamente, no começo eu só queria contar ao mundo sobre minha nova micro-biblioteca (35 linhas de código!) Flux-action-class , mas olhando para o crescente número de exclamações de que Habr em breve se tornaria o Twitter e, em grande parte, Concordando com eles, decidi tentar fazer uma leitura um pouco mais ampla. Portanto, encontramos 5 maneiras de atualizar seu aplicativo Redux!
Boilerplate sair
Considere um exemplo típico de como enviar uma solicitação AJAX ao Redux. Vamos imaginar que realmente precisamos de uma lista de selos do 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, )
Se você não entende por que as fábricas de seletores são necessárias aqui, você pode ler sobre isso aqui.
Eu não considero conscientemente os efeitos colaterais aqui. Este é um tópico para um artigo separado, cheio de raiva dos adolescentes e críticas ao ecossistema existente: D
Existem vários pontos fracos nesse código:
- As fábricas de ação são únicas por si só, mas ainda usamos tipos de ação.
- À medida que novas entidades são adicionadas, continuamos duplicando a mesma lógica para definir o sinalizador de
loading
. Os dados que armazenamos nos data
e seu formato podem variar significativamente de solicitação para solicitação, mas o indicador de download (sinalizador de loading
) ainda será o mesmo. - O tempo de execução do comutador é O (n) (bem, quase ). Isso por si só não é um argumento muito forte, porque o Redux, em princípio, não é sobre desempenho. Isso me enfurece mais que, para cada
case
você precisa escrever algumas linhas extras de código de veiculação, e que uma switch
não pode ser fácil e lindamente dividida em várias. - Realmente precisamos armazenar o estado de erro para cada entidade separadamente?
- Seletores são legais. Seletores memorizados são duplamente legais. Eles nos dão uma abstração do nosso lado, para que mais tarde não tenhamos que refazer metade da aplicação ao alterar sua forma. Apenas mudamos o seletor em si. O que não é agradável aos olhos é um conjunto de fábricas primitivas que são necessárias apenas por causa dos recursos da memorização no re-ajuste .
Método 1: livrar-se de tipos de ação
Bem, na verdade não. Nós apenas fazemos o JS criá-los para nós.
Vamos pensar por um segundo sobre por que geralmente precisamos de tipos de ação. Obviamente, para iniciar o ramo lógico desejado em nosso redutor e alterar o estado do aplicativo de acordo. A verdadeira questão é: um tipo precisa ser uma string? Mas e se usássemos classes e switch
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 } }
Tudo parece estar ótimo, mas há um problema: perdemos a serialização de nossas ações. Estes não são mais objetos simples que podemos converter em uma string e vice-versa. Agora, contamos com o fato de que cada ação tem seu próprio protótipo exclusivo, o que, de fato, permite que um design como uma switch
no action.constructor
funcione. Você sabe, eu realmente gosto da ideia de serializar minhas ações em uma string e enviá-las juntamente com um relatório de erro, e não estou pronto para recusar.
Portanto, cada ação deve ter um campo de type
( aqui você pode ver o que mais toda ação que respeita a ação deve ter). Felizmente, cada classe tem um nome que é como uma string. Vamos adicionar um type
getter type
cada classe que retornará o nome dessa classe.
class CatsGetInit { constructor() { this.type = this.constructor.name } } const reducerCats = (state, action) => { switch (action.type) { case CatsGetInit.name: return { ...state, loading: true, }
Até funciona, mas eu gostaria de fixar um prefixo para cada tipo, como o Sr. Eric sugere em ducks-modular-redux (eu recomendo olhar para o garfo de re-ducks , que é ainda mais legal quanto a mim). Para adicionar um prefixo, teremos que parar de usar o nome da classe diretamente e adicionar outro getter. Agora 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, }
Vamos pentear tudo isso um pouco. Reduza a copiar e colar ao mínimo e adicione outra condição: se a ação apresentar um erro, sua payload
deverá ser do 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 } }
Nesse estágio, esse código funciona bem com o NGRX, mas o Redux não é capaz de mastigá-lo. Ele jura que a ação deve ser objetos simples. Felizmente, o JS nos permite retornar quase tudo do construtor, mas realmente não precisamos de uma cadeia de protótipos depois de criar a ação.
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 } }
Com base nas considerações acima, a micro biblioteca da classe de ação e fluxo foi escrita. Existem testes, 100% de cobertura e quase a mesma classe ActionStandard
com ActionStandard
genéricos para as necessidades do TypeScript. Funciona com TypeScript e JavaScript.
Método 2: Não temos medo de usar CombineReducers
A idéia é simples de desonrar: use o combineReducers não apenas para redutores de nível superior, mas também para quebrar ainda mais a lógica e criar um redutor separado para 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: Livre-se da opção
E, novamente, uma idéia extremamente simples: em vez de switch-case
de switch-case
use um objeto para selecionar o campo desejado por chave. O acesso ao campo do objeto por chave é O (1) e parece um pouco mais limpo na minha humilde opinião.
const createReducer = (initialState, reducerMap) => ( state = initialState, action, ) => {
Vamos refatorar reducerLoading
. Agora, sabendo sobre mapas (objetos) para redutores, podemos retornar este mapa de reducerLoading
, em vez de retornar um redutor inteiro. Potencialmente, isso abre um escopo ilimitado para expandir a funcionalidade.
const createReducer = (initialState, reducerMap) => ( state = initialState, action, ) => {
A documentação oficial do Redux também fala sobre essa abordagem , no entanto, por algum motivo desconhecido, continuo vendo muitos projetos usando casos de switch-case
. Com base no código da documentação oficial, o Sr. Moshe compilou uma biblioteca para nós para o createReducer
.
Método 4: usar o manipulador de erro global
Não precisamos absolutamente manter o erro de cada entidade separadamente. Na maioria dos casos, apenas queremos mostrar o diálogo. O mesmo diálogo com texto dinâmico para todas as entidades.
Crie um manipulador de erros global. No caso mais simples, pode ser assim:
class GlobalErrorInit extends ActionStandard {} class GlobalErrorClear extends ActionStandard {} const reducerError = createReducer(undefined, { [GlobalErrorInit.type]: (state, action) => action.payload, [GlobalErrorClear.type]: (state, action) => undefined, })
Então, em nosso efeito colateral, enviaremos a ação ErrorInit
no catch
. Pode parecer algo assim ao usar 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)) } }
Agora podemos nos livrar do campo de error
em nossa loja de gatos e usar CatsGetError
apenas para mudar o sinalizador 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: Pense antes de memorizar
Vejamos uma pilha de fábricas para seletores novamente.
makeSelectorCatsError
porque não é mais necessário, como descobrimos no capítulo anterior.
const makeSelectorCatsData = () => createSelector( (state) => state.cats.data, (cats) => cats, ) const makeSelectorCatsLoading = () => createSelector( (state) => state.cats.loading, (loading) => loading, )
Por que precisamos de seletores memorizados aqui? O que exatamente estamos tentando memorizar? O acesso ao campo de objeto por chave, que é o que acontece aqui, é O (1). Podemos usar funções comuns não memorizadas. Use a memorização apenas quando desejar alterar os dados do armazenamento antes de entregá-los ao componente.
const selectorCatsData = (state) => state.cats.data const selectorCatsLoading = (state) => state.cats.loading
Memoização faz sentido no caso de calcular o resultado em tempo real. Para o exemplo abaixo, vamos imaginar que cada gato é um objeto com o campo de name
e queremos obter uma string contendo os nomes de todos os gatos.
const makeSelectorCatNames = () => createSelector( (state) => state.cats.data, (cats) => cats.data.reduce((accum, { name }) => `${accum} ${name}`, ''), )
Conclusão
Vamos ver novamente onde começamos:
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, )
E o que aconteceu:
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 você não tenha perdido tempo em vão, e o artigo tenha sido pelo menos um pouco útil para você. Como eu disse no começo, por favor, chute e critique bastante. Vamos aprender a codificar melhor juntos.