
Hoje vamos analisar em detalhes um aplicativo angular reativo (
repositório github ), escrito inteiramente na estratégia
OnPush . Outro aplicativo usa formulários reativos, o que é bastante típico para um aplicativo corporativo.
Não usaremos Flux, Redux, NgRx e aproveitaremos os recursos já disponíveis em Typescript, Angular e RxJS. O fato é que essas ferramentas não são uma bala de prata e podem adicionar complexidade desnecessária até para aplicativos simples. Somos honestamente alertados sobre isso por
um dos autores do Flux , o
autor do Redux e o
autor do NgRx .
Mas essas ferramentas oferecem aos nossos aplicativos recursos muito agradáveis:
- Fluxo de dados previsível;
- Suporte OnPush por design;
- A imutabilidade dos dados, a falta de efeitos colaterais acumulados e outras coisas agradáveis.
Vamos tentar obter as mesmas características, mas sem introduzir complexidade adicional.
Como você verá no final do artigo, esta é uma tarefa bastante simples - se você remover os detalhes de Angular e OnPush do artigo, haverá apenas algumas idéias simples.
O artigo não oferece um novo padrão universal, mas apenas compartilha com o leitor várias idéias que, por toda a sua simplicidade, por algum motivo não vieram imediatamente à mente. Além disso, a solução desenvolvida não contradiz nem substitui o Flux / Redux / NgRx. Eles podem ser conectados, se isso for
realmente necessário .
Para uma leitura confortável do artigo, é necessária uma compreensão dos termos componentes inteligentes, de apresentação e de contêiner .Plano de ação
A lógica da aplicação, bem como a sequência de apresentação do material, pode ser descrita na forma das seguintes etapas:
- Dados separados para leitura (GET) e gravação (PUT / POST)
- Estado de carregamento como fluxo no componente de contêiner
- Distribuir State para uma hierarquia de componentes do OnPush
- Notificar o Angular sobre alterações de componentes
- Edição de dados encapsulados
Para implementar o OnPush, precisamos analisar todas as formas de executar a detecção de alterações no Angular. Existem apenas quatro desses métodos, e os consideraremos sucessivamente ao longo do artigo.
Então vamos lá.
Compartilhe dados para leitura e gravação
Normalmente, aplicativos de front-end e back-end usam contratos digitados (caso contrário, por que datilografado?).
O projeto de demonstração que estamos considerando não possui um back-end real, mas contém um arquivo de descrição pré-preparado
swagger.json . Com base nisso, contratos datilografados são gerados pelo utilitário
sw2dts .
Os contratos gerados têm duas propriedades importantes.
Em primeiro lugar, a leitura e a escrita são realizadas usando contratos diferentes. Usamos uma pequena convenção e nos referimos a ler contratos com o sufixo "State" e escrever contratos com o sufixo "Model".
Ao separar os contratos dessa maneira, estamos compartilhando o fluxo de dados no aplicativo. De cima para baixo, um estado somente leitura é propagado pela hierarquia de componentes. Para modificar os dados, é criado um modelo que é preenchido inicialmente com dados do estado, mas existe como um objeto separado. No final da edição, o modelo é enviado ao back-end como um comando.
O segundo ponto importante é que todos os campos de Estado são marcados com modificador somente leitura. Portanto, obtemos suporte de imunidade no nível de texto datilografado. Agora não poderemos alterar acidentalmente o estado no código ou vincular a ele usando [(ngModel)] - ao compilar o aplicativo no modo AOT, obteremos um erro.
Estado de carregamento como fluxo no componente de contêiner
Para carregar e inicializar o estado, usaremos serviços angulares comuns. Eles serão responsáveis pelos seguintes cenários:
- Um exemplo clássico é o carregamento via HttpClient usando o parâmetro id obtido pelo componente do roteador.
- Inicializando um estado vazio ao criar uma nova entidade. Por exemplo, se os campos tiverem valores padrão ou para inicializar, você precisará solicitar dados adicionais do back-end.
- Reiniciando um estado já carregado depois que o usuário executa uma operação que altera os dados para o back-end.
- Reinicializando o estado por notificação por push, por exemplo, ao co-editar dados. Nesse caso, o serviço mescla o estado local e o estado obtido do back-end.
No aplicativo de demonstração, consideraremos os dois primeiros cenários como os mais comuns. Além disso, esses cenários são simples e permitem que o serviço seja implementado como objetos simples sem estado e não se distraia com a complexidade, que não é o assunto deste artigo em particular.
Um exemplo de serviço pode ser encontrado no arquivo
some-entity.service.ts .
Resta obter o serviço através da DI no componente do contêiner e no estado de carga. Isso geralmente é feito assim:
route.params .pipe( pluck('id'), filter((id: any) => { return !!id; }), switchMap((id: string) => { return myFormService.get(id); }) ) .subscribe(state => { this.state = state; });
Mas com essa abordagem, dois problemas surgem:
- Você deve cancelar a assinatura manualmente da assinatura criada, caso contrário, ocorrerá um vazamento de memória.
- Se você alternar o componente para a estratégia OnPush, ele deixará de responder ao carregamento de dados.
Tubo assíncrono vem em socorro. Ele ouve diretamente o Observable e cancela a inscrição quando necessário. Além disso, ao usar o canal assíncrono, o Angular aciona automaticamente a detecção de alterações toda vez que o Observable publica um novo valor.
Um exemplo de uso de canal assíncrono pode ser encontrado no modelo para o
componente some-entity.component .
E no código do componente, removemos a lógica repetida nos operadores RxJS personalizados, adicionamos o script para criar um estado vazio, mesclando as duas fontes de estado em um fluxo com o operador de mesclagem e criando um formulário para edição, que discutiremos mais adiante:
this.state$ = merge( route.params.pipe( switchIfNotEmpty("id", (requestId: string) => requestService.get(requestId) ) ), route.params.pipe( switchIfEmpty("id", () => requestService.getEmptyState()) ) ).pipe( tap(state => { this.form = new SomeEntityFormGroup(state); }) );
Isso é tudo o que era necessário para ser feito no componente do contêiner. E colocamos no cofrinho a primeira maneira de chamar a detecção de alterações no componente OnPush - tubo assíncrono. Será útil para nós mais de uma vez.
Distribuir State para uma hierarquia de componentes do OnPush
Quando você precisa exibir um estado complexo, criamos uma hierarquia de pequenos componentes - é assim que lidamos com a complexidade.
Como regra, os componentes são divididos em uma hierarquia semelhante à hierarquia de dados e cada componente recebe seus próprios dados através dos parâmetros de Entrada para exibi-los no modelo.
Como vamos implementar todos os componentes como OnPush, vamos discutir por um momento e discutir o que é e como o Angular funciona com os componentes OnPush. Se você já conhece este material, fique à vontade para rolar até o final da seção.
Durante a compilação do aplicativo, o Angular gera um detector de alteração de classe especial para cada componente, que "lembra" todas as ligações usadas no modelo de componente. No tempo de execução, a classe gerada começa a verificar as expressões armazenadas em cada loop de detecção de alterações. Se a verificação mostrou que o resultado de qualquer expressão foi alterado, Angular redesenha o componente.
Por padrão, o Angular não sabe nada sobre nossos componentes e não pode determinar quais componentes afetarão, por exemplo, o setTimeout acionado ou uma solicitação AJAX que foi encerrada. Portanto, ele é forçado a verificar o aplicativo inteiro literalmente para todos os eventos dentro do aplicativo - mesmo um simples deslocamento da janela dispara repetidamente a detecção de alterações para toda a hierarquia dos componentes do aplicativo.
Aqui está uma fonte potencial de problemas de desempenho - quanto mais complexos os modelos de componentes, mais difíceis são as verificações do detector de alterações. E se houver muitos componentes e as verificações forem executadas com frequência, a detecção de alterações começará a levar um tempo considerável.
O que fazer?
Se o componente não depende de nenhum efeito global (a propósito, é melhor projetar componentes dessa maneira), então seu estado interno é determinado por:
- Parâmetros de entrada ( @Input );
- Eventos que ocorreram no próprio componente ( @Output ).
Adiaremos o segundo ponto por enquanto e suporemos que o estado do nosso componente depende apenas dos parâmetros de entrada.
Se todos os parâmetros de entrada do componente forem objetos imutáveis, podemos marcar o componente como OnPush. Então, antes de executar a detecção de alterações, o Angular verificará se os links para os parâmetros de Entrada do componente foram alterados desde a verificação anterior. E, se eles não foram alterados, o Angular ignorará a detecção de alterações do próprio componente e de todos os seus componentes filhos.
Assim, se construirmos todo o aplicativo de acordo com a estratégia OnPush, eliminaremos toda uma classe de problemas de desempenho desde o início.
Como o estado em nosso aplicativo já é imutável, objetos imutáveis também são transferidos para os parâmetros de entrada dos componentes filhos. Ou seja, estamos prontos para ativar o OnPush para componentes filhos e eles responderão às alterações de estado.
Por exemplo, esses são
componentes readonly-info.component e
nested-items.componentAgora vamos ver como implementar a mudança no estado dos componentes no paradigma OnPush.
Fale com a Angular sobre sua condição
Estado da apresentação - são os parâmetros responsáveis pela aparência do componente: carregar indicadores, sinalizadores de visibilidade de elementos ou acessibilidade ao usuário de uma ação específica, colados de três campos a uma linha do nome do usuário, etc.
Sempre que o estado de apresentação de um componente é alterado, é necessário notificar o Angular para que ele possa exibir as alterações na interface do usuário.
Dependendo da origem do estado do componente, existem várias maneiras de notificar o Angular.
Estado da apresentação, calculado com base nos parâmetros de entrada
Esta é a opção mais fácil. Colocamos a lógica de computação do estado de apresentação no gancho ngOnChanges. A detecção de alterações será iniciada alterando @ Input-parameters. Na demonstração, este é o
readonly-info.component .
export class ReadOnlyInfoComponent implements OnChanges { @Input() public state: Backend.SomeEntityState; public traits: ReadonlyInfoTraits; public ngOnChanges(changes: { state: SimpleChange }): void { this.traits = new ReadonlyInfoTraits(changes.state.currentValue); } }
Tudo é extremamente simples, mas há um ponto que deve ser prestado atenção.
Se o estado de apresentação do componente for complexo e, especialmente, se alguns de seus campos forem calculados com base em outros, também calculados pelos parâmetros de Entrada, coloque o estado do componente em uma classe separada, torne-o imutável e recrie ngOnChanges sempre que iniciar. Em um projeto de demonstração, um exemplo é a classe
ReadonlyInfoComponentTraits . Usando essa abordagem, você se protege da necessidade de sincronizar dados dependentes quando eles mudam.
Ao mesmo tempo, vale a pena considerar: talvez o componente tenha um estado difícil devido ao fato de haver muita lógica nele. Um exemplo típico é uma tentativa em um componente de ajustar representações para diferentes usuários que têm maneiras muito diferentes de trabalhar com o sistema.
Eventos Nativos do Componente
Para comunicação entre componentes de aplicativos, usamos eventos de saída. Essa também é a terceira maneira de executar a detecção de alterações. Angular pressupõe razoavelmente que, se um componente gerar um evento, algo poderá ter mudado em seu estado. Portanto, o Angular escuta todos os eventos de saída do componente e aciona a detecção de alterações quando eles ocorrem.
No projeto de demonstração, é completamente sintético, mas um exemplo é o componente
submit-button.component , que gera um evento
formSaved . O componente do contêiner se inscreve neste evento e exibe um alerta com uma notificação.
Use os eventos de saída para a finalidade pretendida, ou seja, crie-os para comunicação com os componentes-pai e não para acionar a detecção de alterações. Caso contrário, é provável que, depois de meses e anos, não se lembre por que esse evento é desnecessário para ninguém aqui e o exclua, quebrando tudo.
Mudanças nos componentes inteligentes
Às vezes, o estado de um componente é determinado por uma lógica complexa: chamar o serviço de forma assíncrona, conectar-se a um soquete da Web, verifica a execução de setInterval, mas você nunca sabe o que mais. Esses componentes são chamados de componentes inteligentes.
Em geral, quanto menos componentes inteligentes no aplicativo não forem componentes de contêiner, mais fácil será viver. Mas às vezes você não pode ficar sem eles.
A maneira mais simples de associar o estado de um componente inteligente à detecção de alterações é transformá-lo em um Observable e usar o
canal assíncrono já discutido acima. Por exemplo, se a origem das alterações for uma chamada de serviço ou status de formulário reativo, esse será um Observable pronto. Se o estado for formado a partir de algo mais complexo, você poderá usar
fromPromise ,
websocket ,
timer ,
interval da composição do RxJS. Ou gere um fluxo usando o
Assunto .
Se nenhuma das opções for adequada
Nos casos em que nenhum dos três métodos já estudados é adequado, ainda temos uma opção à prova de balas - usando o
ChangeDetectorRef diretamente. Estamos falando dos métodos detectChanges e markForCheck dessa classe.
A documentação abrangente responde a todas as perguntas, portanto, não iremos nos concentrar no seu trabalho. Mas observe que o uso de
ChangeDetectorRef deve ser limitado aos casos em que você entende claramente o que está fazendo, pois ainda é a cozinha Angular interna.
Durante todo o tempo, encontramos apenas alguns casos em que esse método pode ser necessário:
- Trabalho manual com detecção de alterações - usado na implementação de componentes de baixo nível e é apenas o caso "você entende claramente o que está fazendo".
- Relacionamentos complexos entre componentes - por exemplo, quando você precisa criar um link para um componente em um modelo e passá-lo como parâmetro para outro componente localizado mais alto na hierarquia ou mesmo em outra ramificação da hierarquia de componentes. Parece complicado? Assim é. E é melhor apenas refatorar esse código, porque trará dor não apenas com a detecção de alterações.
- As especificidades do comportamento do próprio Angular - por exemplo, ao implementar um ControlValueAccessor personalizado , você pode descobrir que o valor do controle é alterado pelo Angular de forma assíncrona e as alterações não são aplicadas ao ciclo de detecção de alterações desejado.
Como exemplos de uso no aplicativo de demonstração, existe a classe base
OnPushControlValueAccessor , que resolve o problema descrito no último parágrafo. Também no projeto, há um herdeiro dessa classe - custom
radio-button.component .
Agora, discutimos todas as quatro maneiras de executar as opções de implementação de detecção de alterações e OnPush para todos os três tipos de componentes: contêiner, inteligente e de apresentação. Passamos ao ponto final - editando dados com formas reativas.
Edição de dados encapsulados
As formas reativas têm várias limitações, mas ainda é uma das melhores coisas que aconteceram no ecossistema Angular.
Antes de tudo, eles encapsulam o trabalho bem com o estado e fornecem todas as ferramentas necessárias para responder às mudanças de maneira reativa.
De fato, a forma reativa é um tipo de mini-loja que encapsula o trabalho com o estado: dados e status desativados / válidos / pendentes.
Resta apoiar esse encapsulamento o máximo possível e evitar misturar a lógica de apresentação e a lógica do formulário.
No aplicativo de demonstração, é possível ver
classes de formulário individuais que encapsulam as especificidades de seu trabalho: validação, criação de FormGroups filhos, trabalhando com o estado desabilitado dos campos de entrada.
Criamos o formulário raiz no componente do contêiner no momento em que o estado é carregado e, a cada reinicialização do estado, o formulário é recriado. Isso não é um pré-requisito, mas, dessa maneira, podemos ter certeza de que não há efeitos acumulados na lógica do formulário que sobraram do estado carregado anterior.
Dentro do próprio formulário, construímos os controles e "empurramos" os dados que vêm deles, convertendo-os do contrato do Estado para o contrato Modelo. A estrutura dos formulários, na medida do possível, corresponde aos contratos dos modelos. Como resultado, a propriedade value do formulário fornece um modelo pronto para enviar para o back-end.
Se, no futuro, a estrutura do estado ou do modelo for alterada, obteremos um erro de compilação de texto datilografado exatamente no local em que precisamos adicionar / remover campos, o que é muito conveniente.
Além disso, se os objetos de estado e modelo tiverem uma estrutura absolutamente idêntica, a digitação estrutural usada no texto datilografado elimina a necessidade de criar um mapeamento sem sentido de um para o outro.
A lógica total de formulários é isolada da lógica de apresentação nos componentes e vive “por si só”, sem aumentar a complexidade do fluxo de dados de nosso aplicativo como um todo.
Isso é quase tudo. Existem casos de borda deixados quando não podemos isolar a lógica do formulário do restante do aplicativo:
- Alterações no formulário que levam a uma alteração no estado da apresentação - por exemplo, visibilidade de um bloco de dados, dependendo do valor digitado. Nós o implementamos no componente assinando eventos de formulário. Você pode fazer isso através das características imutáveis discutidas anteriormente.
- Se você precisar de um validador assíncrono que chame o back-end, construímos AsyncValidatorFn no componente e o passamos para o construtor de formulários, não para o serviço.
Assim, toda a lógica "limítrofe" permanece no lugar mais proeminente - nos componentes.
Conclusões
Vamos resumir o que obtivemos e que outros pontos existem para estudo e desenvolvimento.
Primeiro de tudo, o desenvolvimento da estratégia OnPush nos obriga a projetar cuidadosamente o fluxo de dados do aplicativo, já que agora estamos ditando as regras do jogo para Angular, e não para ele.
Existem duas consequências para esta situação.
Em primeiro lugar, temos uma agradável sensação de controle sobre o aplicativo. Não há mais mágica que "de alguma forma funcione". Você está claramente ciente do que está acontecendo a qualquer momento no seu aplicativo. A intuição está se desenvolvendo gradualmente, o que permite que você entenda o motivo do bug encontrado, mesmo antes de abrir o código.
Em segundo lugar, agora temos que gastar mais tempo projetando o aplicativo, mas o resultado será sempre o mais "direto" e, portanto, a solução mais simples. Isso leva a zero a probabilidade de uma situação em que, à medida que o aplicativo cresce, ele se torna um monstro de enorme complexidade, os desenvolvedores perderam o controle dessa complexidade e o desenvolvimento agora se parece mais com rituais místicos.
A complexidade controlada e a ausência de "mágica" reduzem a probabilidade de toda uma classe de problemas decorrentes, por exemplo, de atualizações cíclicas de dados ou efeitos colaterais acumulados. Em vez disso, estamos lidando com problemas já visíveis durante o desenvolvimento, quando o aplicativo simplesmente não funciona. E, forçosamente, você precisa fazer com que o aplicativo funcione de forma simples e clara.
Também mencionamos bons efeitos no desempenho. Agora, usando ferramentas muito simples, como
profiler.timeChangeDetection , podemos verificar a qualquer momento que nosso aplicativo ainda está em boas condições.
Agora também é pecado não tentar
desativar o NgZone . Primeiramente, permitirá que você não carregue toda a biblioteca na inicialização do aplicativo. Em segundo lugar, ele removerá uma quantidade razoável de magia do seu aplicativo.
É aí que terminamos nossa história.
Entraremos em contato!