Artigo original: Best practices for a clean and performant Angular application
Tradução em português europeu
Escrito por: Vamsi Vempati
Tenho vindo a trabalhar numa aplicação de grande escala em Angular na Trade Me, na Nova Zelândia, há um par de anos. Ao longo dos últimos anos, a nossa equipa tem vindo a refinar a nossa aplicação tanto em termos de normas de programação como em desempenho, para que esteja no melhor estado possível.
Este artigo descreve as práticas que utilizamos na nossa aplicação e está relacionado com Angular, Typescript, RxJs e @ngrx/store. Também vamos abordar algumas diretrizes gerais de programação para ajudar a aplicação a ficar mais limpa.
1) trackBy
Ao utilizar ngFor
para iterar sobre um array em templates, utiliza-o com uma função trackBy
, que retornará um identificador único por cada item.
Por quê?
Quando um array muda, o Angular renderiza novamente toda a árvore do DOM. Se, no entanto, utilizares trackBy
, o Angular saberá que elemento foi alterado e realizará alterações à árvore do DOM apenas para esse elemento em particular.
Para uma explicação detalhada sobre isso, consulta este artigo por Netanel Basal (em inglês).
Antes
<li *ngFor="let item of items;">{{ item }}</li>
Depois
// no template
<li *ngFor="let item of items; trackBy: trackByFn">{{ item }}</li>
// no componente
trackByFn(index, item) {
return item.id; // correspondência de id único do item
}
2) const x let
Ao declarar variáveis, utiliza const nos casos em que o valor não será redefinido.
Por quê?
Utilizar let
e const
quando for adequado clarifica a intenção das declarações. Também ajudará a identificar problemas quando um valor é acidentalmente reatribuído a uma constante, ao gerar um erro de compilação. Também ajuda a melhorar a legibilidade do código.
Antes
let car = 'ludicrous car';
let myCar = `My ${car}`;
let yourCar = `Your ${car};
if (iHaveMoreThanOneCar) {
myCar = `${myCar}s`;
}
if (youHaveMoreThanOneCar) {
yourCar = `${youCar}s`;
}
Depois
// o valor de car não é redefinido, por isso podemos torná-lo numa const
const car = 'ludicrous car';
let myCar = `My ${car}`;
let yourCar = `Your ${car};
if (iHaveMoreThanOneCar) {
myCar = `${myCar}s`;
}
if (youHaveMoreThanOneCar) {
yourCar = `${youCar}s`;
}
3) Operadores canalizáveis
Utiliza operadores canalizáveis ao utilizar operadores RxJs.
Por quê?
Os operadores canalizáveis são "tree-shakeable", o que significa que apenas o código de cuja execução precisamos será incluído quando os operadores são importados.
Isso também faz com que seja mais fácil identificar operadores inutilizados nos ficheiros.
Nota: isso requer a versão 5.5+ do Angular.
Antes
import 'rxjs/add/operator/map';
import 'rxjs/add/operator/take';
iAmAnObservable
.map(value => value.item)
.take(1);
Depois
import { map, take } from 'rxjs/operators';
iAmAnObservable
.pipe(
map(value => value.item),
take(1)
);
4) Truques isolados de API
Nem todas as APIs são à prova de bala — por vezes, precisamos de adicionar alguma lógica no código para compensar os erros nas APIs. Em vez de ter os truques em componentes onde eles são necessários, é melhor isolá-los num local — como, por exemplo, um serviço e utilizar o servido a partir do componente.
Por quê?
Isso ajuda a manter os truques "mais próximos da API", ou seja, tão próximo do local onde o pedido de rede é realizado quanto possível. Desse modo, existe menos código a lidar com erros. Além disso, existe um local onde residem todos os truques e é mais fácil encontrá-los. Ao corrigir erros nas APIs, é mais fácil procurar os truques num ficheiro, em vez de procurar os truques que poderiam estar espalhados pela base de código.
Também podes criar tags personalizadas, como API_FIX, semelhante a TODO e anexar as correções à tag para que seja mais fácil de encontrar.
5) Subscrever em template
Evita subscrever a observáveis a partir de componente. Em vez disso, subscreve aos observáveis a partir do template.
Por quê?
Os canais async
removem a sua subscrição automaticamente e tornam o código mais simples ao eliminar a necessidade de gerir manualmente subscrições. Também reduz o risco de acidentalmente esquecer a remoção da subscrição no componente, que causaria uma fuga de memória. Esse risco também pode ser mitigado ao utilizar uma regra de lint para detetar observáveis não subscritos.
Isto também impede que os componentes tenham estado e introduzam erros onde os dados são alterados fora da subscrição.
Antes
// template
<p>{{ textToDisplay }}</p>
// componente
iAmAnObservable
.pipe(
map(value => value.item),
takeUntil(this._destroyed$)
)
.subscribe(item => this.textToDisplay = item);
Depois
// template
<p>{{ textToDisplay$ | async }}</p>
// componente
this.textToDisplay$ = iAmAnObservable
.pipe(
map(value => value.item)
);
6) Limpar subscrições
Ao fazer subscrição a observáveis, certifica-te sempre que removes a subscrição a eles adequadamente ao utilizar operadores como take
, takeUntil
etc.
Por quê?
Não remover a subscrição a observáveis causará fugas de memória indesejáveis, pois o fluxo de observáveis é deixado aberto, potencialmente, até mesmo após a destruição de um componente/o utilizador ter navegado para outra página.
Melhor ainda, cria uma regra lint para detetar observáveis cuja subscrição não tenha sido removida.
Antes
iAmAnObservable
.pipe(
map(value => value.item)
)
.subscribe(item => this.textToDisplay = item);
Depois
Utilizar takeUntil
quando desejas aguardar por alterações até que outro observável emita um valor:
private _destroyed$ = new Subject();
public ngOnInit (): void {
iAmAnObservable
.pipe(
map(value => value.item)
// Queremos aguardar por iAmAnObservable até que o componente seja destruído,
takeUntil(this._destroyed$)
)
.subscribe(item => this.textToDisplay = item);
}
public ngOnDestroy (): void {
this._destroyed$.next();
this._destroyed$.complete();
}
Utilizar um sujeito privado como esse é um padrão para gerir a remoção da subscrição de vários observáveis no componente.
Utilizar take
quando queres apenas o primeiro valor emitido pelo observável:
iAmAnObservable
.pipe(
map(value => value.item),
take(1),
takeUntil(this._destroyed$)
)
.subscribe(item => this.textToDisplay = item);
Observa aqui a utilização de takeUntil
com take
. Isso é para evitar fugas de memória quando a subscrição não tiver recebido um valor antes do componente ter sido destruído. Sem takeUntil
aqui, a subscrição ainda permaneceria até obter o primeiro valor, mas visto que o componente já foi destruído, ele nunca obterá um valor — levando a uma fuga de memória.
7) Utilizar operadores apropriados
Ao utilizar operadores de flattening com os teus observáveis, utiliza o operador adequado para a situação.
switchMap: quando desejas ignorar as emissões anteriores quando existe uma nova emissão.
mergeMap: quando desejas lidar simultaneamente com todas as emissões.
concatMap: quando desejas lidar com as emissões uma após a outra, à medida que são emitidas.
exhaustMap: quando desejas cancelar todas as emissões novas enquanto processas a emissão anterior.
Para uma explicação detalhada disso, observa este artigo de Nicholas Jamieson (em inglês).
Por quê?
Utilizar um único operador quando possível, em vez de encadear vários outros operadores para alcançar o mesmo efeito, pode fazer com que seja enviado menos código para o client. Utilizar os operadores errados pode levar a comportamentos indesejados, visto que operadores diferentes lidam com observáveis de maneiras diferentes.
8) Carregamento lento (lazy loading)
Quando possível, tenta carregar lentamente os módulos na tua aplicação em Angular. Carregamento lento é quando carregas algo apenas quando é utilizado – por exemplo, carregar um componente apenas quando ele tiver de ser visto.
Por quê?
Isso reduzirá o tamanho da aplicação a ser carregada e pode melhorar o tempo de iniciação da aplicação ao não carregar os módulos que não são utilizados.
Antes
// app.routing.ts
{ path: 'not-lazy-loaded', component: NotLazyLoadedComponent }
Depois
// app.routing.ts
{
path: 'lazy-load',
loadChildren: 'lazy-load.module#LazyLoadModule'
}
// lazy-load.module.ts
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterModule } from '@angular/router';
import { LazyLoadComponent } from './lazy-load.component';
@NgModule({
imports: [
CommonModule,
RouterModule.forChild([
{
path: '',
component: LazyLoadComponent
}
])
],
declarations: [
LazyLoadComponent
]
})
export class LazyModule {}
9) Evitar ter subscrições dentro de subscrições
Por vezes, podes querer que os valores de mais do que um observável realizem uma ação. Nesse caso, evita subscrever um observável no bloco de subscrição de outro observável. Em vez disso, utiliza operadores de encadeamento adequados. Os operadores de encadeamento correm nos observáveis do operador antes deles. Alguns operadores são: withLatestFrom
, combineLatest
etc.
Antes
firstObservable$.pipe(
take(1)
)
.subscribe(firstValue => {
secondObservable$.pipe(
take(1)
)
.subscribe(secondValue => {
console.log(`Os valores combinados são: ${firstValue} e ${secondValue}`);
});
});
Depois
firstObservable$.pipe(
withLatestFrom(secondObservable$),
first()
)
.subscribe(([firstValue, secondValue]) => {
console.log(`Os valores combinados são: ${firstValue} e ${secondValue}`);
});
Por quê?
"Code smell"/legibilidade/complexidade: não utilizar RxJs em toda a sua extensão, sugere que o programador não está familiarizado com a área de superfície da API RxJs API.
Desempenho: se os observáveis estiverem inativos, o código subscreverá para firstObservable, esperará que ele termine e DEPOIS iniciará o trabalho com o segundo observável. Se esses fossem pedidos de rede, seria mostrado como assíncrono/cascata.
10) Evitar "any" e atribuir tipo a tudo
Declara sempre variáveis ou constantes com um tipo diferente de any
.
Por quê?
Ao declarar variáveis ou constantes em Typescript sem uma tipologia, o tipo da variável/constante será deduzido pelo valor que lhe é atribuído. Isso causará problemas não pretendidos. Um exemplo clássico é:
const x = 1;
const y = 'a';
const z = x + y;
console.log(`O valor de z é: ${z}`
// Resultado
O valor de z é 1a
Isso pode causar problemas indesejados quando esperas que y também seja um número. Esses problemas podem ser evitados ao escrever as variáveis de maneira apropriada.
const x: number = 1;
const y: number = 'a';
const z: number = x + y;
// Isto gerará um erro de compilação a dizer:
Type '"a"' is not assignable to type 'number'.
const y:number
Dessa forma, podemos evitar erros causados pela ausência de tipos.
Outra vantagem de ter boas tipologias na tua aplicação é que torna a refatorização mais fácil e segura.
Considera estes exemplos:
public ngOnInit (): void {
let myFlashObject = {
name: 'My cool name',
age: 'My cool age',
loc: 'My cool location'
}
this.processObject(myFlashObject);
}
public processObject(myObject: any): void {
console.log(`Name: ${myObject.name}`);
console.log(`Age: ${myObject.age}`);
console.log(`Location: ${myObject.loc}`);
}
// Resultado
Name: My cool name
Age: My cool age
Location: My cool location
Digamos que queremos renomear a propriedade loc
para location
em myFlashObject
:
public ngOnInit (): void {
let myFlashObject = {
name: 'My cool name',
age: 'My cool age',
location: 'My cool location'
}
this.processObject(myFlashObject);
}
public processObject(myObject: any): void {
console.log(`Name: ${myObject.name}`);
console.log(`Age: ${myObject.age}`);
console.log(`Location: ${myObject.loc}`);
}
// Resultado
Name: My cool name
Age: My cool age
Location: undefined
Se não tivermos um tipo no myFlashObject
, este pensará que a propriedade loc
em myFlashObject
é apenas indefinida, em vez de não ser uma propriedade válida.
Se tivéssemos um tipo para myFlashObject
, obteríamos um bom erro de tempo de compilação como mostrado abaixo:
type FlashObject = {
name: string,
age: string,
location: string
}
public ngOnInit (): void {
let myFlashObject: FlashObject = {
name: 'My cool name',
age: 'My cool age',
// Erro de compilação
Type '{ name: string; age: string; loc: string; }' is not assignable to type 'FlashObjectType'.
Object literal may only specify known properties, and 'loc' does not exist in type 'FlashObjectType'.
loc: 'My cool location'
}
this.processObject(myFlashObject);
}
public processObject(myObject: FlashObject): void {
console.log(`Name: ${myObject.name}`);
console.log(`Age: ${myObject.age}`)
// Erro de compilação
Property 'loc' does not exist on type 'FlashObjectType'.
console.log(`Location: ${myObject.loc}`);
}
Se estiveres a iniciar um novo projeto, vale a pena definir strict:true
no ficheiro tsconfig.json
para ativar todas as opções rigorosas de verificação de tipo.
11) Faz uso das regras de lint
O tslint
tem várias opções já incorporadas como no-any
, no-magic-numbers
, no-console
etc, que podes configurar no teu tslint.json
para impor certas regras na tua base de código.
Por quê?
Ter regras de lint aplicadas significa que obterás um erro útil caso estejas a fazer algo que não deverias estar a fazer. Isso garantirá consistência e legibilidade. Por favor, consulta aqui (em inglês) mais regras que podes configurar.
Alguma regras de lint até têm correções para resolver o erro de lint. Se desejares configurar a tua própria regra de lint personalizada, também podes fazê-lo. Consulta, por favor, este artigo de Craig Spence (em inglês) sobre como escrever as tuas próprias regras de lint personalizadas utilizando o TSQuery.
Antes
public ngOnInit (): void {
console.log('Eu sou uma mensagem do console');
console.warn('Eu sou uma mensagem de aviso do console');
console.error('Eu sou uma mensagem de erro do console');
}
// Resultado - sem erros, imprimirá o que vemos abaixo na janela do console:
Eu sou uma mensagem do console
Eu sou uma mensagem de aviso do console
Eu sou uma mensagem de erro do console
Depois
// tslint.json
{
"rules": {
.......
"no-console": [
true,
"log", // console.log não é permitido
"warn" // console.warn não é permitido
]
}
}
// ..component.ts
public ngOnInit (): void {
console.log('Eu sou uma mensagem do console');
console.warn('Eu sou uma mensagem de aviso do console');
console.error('Eu sou uma mensagem de erro do console');
}
// Resultado - erros de lint para as instruções de console.log e console.warn, mas sem erro para console.error, já que não foi mencionado no config
Calls to 'console.log' are not allowed.
Calls to 'console.warn' are not allowed.
12) Pequenos componentes reutilizáveis
Extrai as partes que podem ser reutilizadas num componente e cria outro. Torna o componente tão "burro" quanto possível, visto que isso fará com que funcione em mais cenários. Tornar um componente "burro" quer dizer que o componente não tem nenhuma lógica em especial e opera puramente com base nos inputs e outputs que lhe forem fornecidos.
Como regra geral, o último descendente na árvore de componentes será o mais burro de todos.
Por quê?
Os componentes reutilizáveis reduzem a repetição de código, tornando-o assim mais fácil de gerir e de receber alterações.
Os componentes "burros" são mais simples e, por isso, têm menos probabilidade de terem erros. Os componentes "burros" fazem-te pensar mais sobre a API de componente pública e ajudam-te a detetar preocupações contraditórias.
13) Os componentes devem lidar apenas com lógica de exibição
Evita ter qualquer outra lógica para além da lógica de exibição no teu componente sempre que puderes e faz com que o componente lide apenas com a lógica de exibição.
Por quê?
Os componentes são projetados para fins de exibição e controlam o que a vista deve fazer. Qualquer lógica empresarial deve ser extraída nos seus próprios métodos/serviços, onde for apropriado, separando a lógica empresarial da lógica de exibição.
A lógica empresarial é geralmente mais fácil de testar quando é extraída para um serviço. Ela pode ser reutilizada por quaisquer outros componentes que precisem da mesma lógica empresarial aplicada.
14) Evita métodos longos
Métodos longos geralmente indicam que estão a fazer muitas coisas. Tenta utilizar o Princípio de Responsabilidade Única. O método por si só pode estar a fazer uma coisa, mas, dentro dele, existem algumas operações que podem estar a acontecer. Podemos extrair esses métodos para o seu próprio método e fazer com que façam cada um uma coisa e utilizá-los.
Por quê?
Os métodos longos são difíceis de ler, compreender e manter. Também são susceptíveis a erros, visto que alterar uma coisa pode afetar diversas outras coisas nesse método. Também tornam a refatorização (que é uma coisa essencial em qualquer aplicação) difícil.
Isso é por vezes medido como "complexidade ciclomática". Também existem algumas regras do TSLint para detetar complexidade ciclomática/cognitiva, que podes utilizar no teu projeto para evitar erros e detetar "code smells" e problemas de manutenção.
15) DRY (Não te repitas)
Do not Repeat Yourself (Não te repitas). Certifica-te de que não tens o mesmo código copiado para locais diferentes na base de código. Extrai o código repetido e utiliza isso em vez do código repetido.
Por quê?
Ter o mesmo código em vários locais significa que, se quisermos fazer uma alteração à lógica nesse código, temos de fazê-la em vários locais. Isso torna-o difícil de gerir e também é susceptível a erros. Podemos falhar e não alterar em todas as ocorrências. É mais demorado realizar alterações à lógica e testar o código também é um processo demorado.
Nesses casos, extrai o código repetido e utiliza essa alternativa em vez disso. Isso quer dizer que existe apenas um local para alterar e uma coisa para testar. Ter menos código duplicado enviado para os utilizadores quer dizer que a aplicação será mais rápida.
16) Adicionar mecanismos de cache
Ao realizar chamadas de API, as respostas delas não mudam com frequência. Nesses casos, podes adicionar um mecanismo de cache e armazenar o valor da API. Quando for realizado outro pedido à mesma API, verifica se existe um valor para ele na cache. Caso exista, utiliza-o. Caso contrário, realiza a chamada da API e coloca o resultado em cache.
Se os valores alterarem, mas não regularmente, podes introduzir uma cache de tempo onde podes verificar quando foi colocado algo em cache pela última vez e decidir se chamas ou não a API.
Por quê?
Ter um mecanismo de cache significa evitar chamadas de API indesejadas. Ao realizar apenas as chamadas de API quando necessário e evitar código duplicado, a velocidade da aplicação melhora pois não temos de esperar pela rede. Também significa que não descarregamos a mesma informação vezes sem conta.
17) Evitar lógica em templates
Se tiveres qualquer tipo de lógica nos teus templates, mesmo que seja uma simples cláusula &&
, é bom extraí-la para o seu próprio componente.
Por quê?
Ter lógica no template significa que não é possível realizar testes unitários ao template e é, então, mais susceptível a erros quando alteramos o código do template.
Antes
// template
<p *ngIf="role==='developer'"> Status: Developer </p>
// component
public ngOnInit (): void {
this.role = 'developer';
}
Depois
// template
<p *ngIf="showDeveloperStatus"> Status: Developer </p>
// componente
public ngOnInit (): void {
this.role = 'developer';
this.showDeveloperStatus = true;
}
18) Strings devem ser seguras
Se tiveres uma variável do tipo string que possa ter apenas um conjunto de valores, em vez de declará-la com o tipo string, podes declarar a lista de valores possíveis como o tipo.
Por quê?
Ao declarar o tipo da variável adequadamente, podemos evitar erros ao escrever o código durante a compilação em vez de durante a execução.
Antes
private myStringValue: string;
if (itShouldHaveFirstValue) {
myStringValue = 'First';
} else {
myStringValue = 'Second'
}
Depois
private myStringValue: 'First' | 'Second';
if (itShouldHaveFirstValue) {
myStringValue = 'First';
} else {
myStringValue = 'Other'
}
// Isso gerará o erro abaixo
Type '"Other"' is not assignable to type '"First" | "Second"'
(property) AppComponent.myValue: "First" | "Second"
Visão geral
Gestão de estado
Considera utilizar @ngrx/store para gerir o estado da tua aplicação e @ngrx/effects como modelo de efeito secundário para armazenamento. As alterações de estados são descritas pelas ações e as alterações são realizadas por funções puras chamadas reducers(redutores).
Por quê?
@ngrx/store isola toda a lógica relacionada com estados num só local e torna-a consistente ao longo da aplicação. Também tem um mecanismo de memorização ao aceder à informação armazenada, levando a uma aplicação com melhor desempenho. @ngrx/store combinado com a estratégia de deteção de alterações do Angular resulta numa aplicação mais rápida.
Estado imutável
Ao utilizar @ngrx/store, considera utilizar ngrx-store-freeze para tornar o estado imutável. ngrx-store-freeze impede que o estado seja alterado ao lançar uma excepção. Isso evita alterações acidentais do estado, o que leva a consequências indesejadas.
Por quê?
Alterar o estado em componentes faz com que a aplicação se comporte de modo inconsistente com base na ordem de carregamento dos componentes. Quebra o modelo mental do padrão redux. As alterações podem acabar sobrepostas caso o estado de armazenamento altere e seja emitido novamente. Separa as responsabilidades/preocupações — componentes são camadas de vista, não devem saber como alterar o estado.
Jest
O Jest é uma framework de testes unitários da Facebook para JavaScript. Torna os testes unitários mais rápidos ao colocar em paralelo as execuções de testes ao longo da base de código. Com o seu modo de observação, são executados apenas os testes relacionados com as alterações, o que torna o ciclo de resposta para os testes muito mais curto. Ele também fornece cobertura de código dos testes e é suportado no VS Code e Webstorm.
Podias utilizar um preset (texto em inglês) para Jest que fará a maior parte do trabalho por ti quando estiveres a configurar a Jest no teu projeto.
Karma
Karma é um executante de testes desenvolvido pela equipa do AngularJS. Este requer um browser real/DOM para executar os testes. Também pode ser executado em browsers diferentes. O Jest não precisa do Chrome headless/phantomjs para executar os testes e é executado em Node puro.
Universal
Se não tiveres tornado a tua aplicação numa aplicação Universal, agora é uma boa altura para fazê-lo. O Angular Universal (link em inglês) permite-te executar a tua aplicação do Angular no servidor e faz renderização no lado do servidor (SSR), que serve páginas html estáticas pré-renderizadas. Isso torna a aplicação muito rápida, pois apresenta o conteúdo no ecrã quase instantaneamente, sem ter de esperar que os JS bundles carreguem e analisem, ou pela iniciação do Angular.
Também é bom em termos de SEO, visto que o Angular Universal gera conteúdo estático e torna mais fácil a indexação da aplicação por parte dos rastreadores da web e tornam a aplicação pesquisável sem executar JavaScript.
Por quê?
O Universal melhora drasticamente o desempenho da tua aplicação. Atualizamos recentemente a nossa aplicação para realizar renderização do lado do servidor e o tempo de carregamento do site passou de vários segundos para dezenas de milésimos de segundo!
Também permite que o teu site apareça corretamente nos modelos de pré-visualização das redes sociais. O primeiro carregamento significativo é muito rápido e torna o conteúdo visível aos utilizadores sem quaisquer atrasos indesejados.
Conclusão
Criar aplicações é uma jornada constante e existe sempre espaço para melhorar coisas. Esta lista de optimizações é um bom local para começar, e aplicar de maneira consistente esses padrões fará a tua equipa feliz. Os teus utilizadores também vão adorar-te pela boa experiência na tua aplicação com menos problemas e com melhor desempenho.
Obrigada pela leitura! Se gostaste deste artigo, fica à vontade para partilhar e ajudar outras pessoas a encontrá-lo. Segue a autora no Medium ou Twitter para ver mais artigos. Boa programação, pessoal! ☕️