Como funciona o change detection do Angular
Um breve histórico da manipulação DOM
Quem desenvolvia para web nos anos 2000 sabe o quão trabalhoso era manipular o DOM em aplicações grandes e complexas. Libs e frameworks como o jQuery e PrototypeJS ajudavam bastante a escrever menos e produzir mais. Inclusive a frase "write less do more" (escreva menos, faça mais) está embaixo do logotipo da jQuery.
A título de exemplo, com e sem jQuery, veja o código abaixo e compare o tamanho das funções load
e loadjQuery
:
<table>
<tr>
<th>City</th>
<th>Country</th>
</tr>
<tr id="dados"></tr>
</table>
<button onclick="load()">
Random
</button>
<button onclick="loadjQuery()">
Random (jQuery)
</button>
var urlAPI = "https://random-data-api.com/api/address/random_address";
function load() {
var xhr = new XMLHttpRequest();
xhr.open("GET", urlAPI);
xhr.onreadystatechange = function () {
if (xhr.readyState !== 4) {
return;
}
var tr = document.getElementById("dados");
var dados = JSON.parse(xhr.response);
while (tr.firstChild) {
tr.removeChild(tr.firstChild);
}
var tdCity = document.createElement("td");
tdCity.innerHTML = dados.city;
tr.appendChild(tdCity);
var tdCountry = document.createElement("td");
tdCountry.innerHTML = dados.country;
tr.appendChild(tdCountry);
};
xhr.send();
}
function loadjQuery() {
$.getJSON(urlAPI, function (dados) {
var tr = $("#dados");
tr.empty();
tr.append("<td>" + dados.city + "</td>");
tr.append("<td>" + dados.country + "</td>");
});
}
A redução de código escrito é bem significante.
Com o passar dos anos novas soluções nasceram, como KnockoutJS, Vue.js, React, Angular, entre outras. Umas das grandes vantagens destas soluções é a abstração na manipulação do DOM. Basicamente usamos notações específicas de cada lib/framework dentro HTML para vincular variáveis ao DOM. Como por exemplo o JSX do React:
const name = "Josh Perez";
const element = <h1>Hello, {name}</h1>;
ReactDOM.render(
element,
document.getElementById("root")
);
...ou em Angular:
import { Component } from "@angular/core";
@Component({
template: `<h1>>Hello, {{ name }}</h1>`
})
export class ExemploComponent {
name: string = "Josh Perez";
}
O que quero deixar claro é que houve uma abstração da manipulação do DOM. O próprio framework/lib realiza essa tarefa. Basicamente quando os valores das variáveis mudam, o HTML "se atualiza sozinho".
Quando digo "se atualiza sozinho", me refiro a um mecanismo de cada lib/framework responsável por este trabalho, que antes era feito pelo próprio desenvolvedor. No caso do Angular este mecanismo chama-se change detection (CD) e este será o assunto abordado neste texto.
Como funciona o change detection (CD)
Quando ocorre algum evento que possa atualizar seu data model o CD entra em cena. Por padrão os eventos que disparam este processo são:
- todos os eventos do navegador (click, mouseover, keyup, etc.)
setTimeout()
esetInterval()
- Requisições HTTP (Ajax)
Sempre que o Angular identificar um destes eventos, o CD irá entrar em cena.
Agora que você já sabe qual é o gatilho do CD, ou seja, o que inicia seu processo, vamos analisar dois comportamentos deste mecanismo. Por padrão o Angular provê duas estratégias de detecção de mudanças:
ChangeDetectionStrategy.Default
ChangeDetectionStrategy.OnPush
ChangeDetectionStrategy.Default
Esta é a estratégia padrão, se você não informar nada nos metadados ( @Component(/* metadados*/)
) do seu componente, o Angular assumirá esta estratégia.
No modo ChangeDetectionStrategy.Default
, que é o padrão, o Angular decide se deve atualizar ou não a view analisando as expressões utilizadas no template de cada componente. Cada expressão retorna um valor. O Angular compara se houve alteração no valor que atualmente está view com o valor que a expressão retornou durante o CD. Caso haja alteração a DOM deve ser atualizado.
O processo em busca de mudanças, CD, ocorre em todos os componentes.
O GIF abaixo mostra este cenário mostrando que um evento foi disparado em um componente (canto inferior direito) e o CD foi executado em todos os componentes.
Vamos a um exemplo prático:
import { Component } from '@angular/core';
class Dados {
_nome: string = 'Nome A';
get nome(): string {
console.log('Dados.nome()');
return this._nome;
}
set nome(nome: string) {
this._nome = nome;
}
_idade: number = 10;
get idade(): number {
console.log('Dados.idade()');
return this._idade;
}
set idade(idade: number) {
this._idade = idade;
}
}
// ************************************
@Component({
selector: 'app-dados',
template: `
<div>{{ dados.nome }}</div>
<button (click)="atualizarDados()">
Atualizar
</button>
`,
})
export class DadosComponent {
dados = new Dados();
atualizarDados() {
this.dados.nome = 'Nome B';
this.dados.idade = 20;
}
}
Itens relevantes sobre o código acima:
- Sempre que os valores das variáveis
nome
eidade
forem lidos, será printada uma mensagem no console. Por isto adicionei 2 getters dentro da classeDados
- No template do componente temos uma única expressão:
<div>{{ dados.nome }}</div>
- O método
atualizarDados
atualiza o valor das variáveisnome
eidade
Faça a análise do GIF abaixo prestando atenção no que foi impresso no console do navegador após o clique. Depois siga para a explicação abaixo.
Analisando o código e o resultado (GIF acima), podemos chegar as seguintes conclusões:
- apesar de atualizarmos dois campos do objeto dados, o Angular apenas passou pelo
nome
durante o change detection (CD). Isto ocorre porque o Angular verifica se houve alterações nos valores retornados nas expressões que estão na view. Como neste exemplo temos apenas uma expressão (<div>{{ dados.nome }}</div>
), o Angular não verificou odados.idade
já que a informação não é utilizada na view. - internamente o Angular faz a seguinte comparação:
'Nome A' === 'Nome B'
. Esta comparação retornafalse
, logo o componente fica marcado para atualização para que a view seja atualizada com o novo valor. Vale ressaltar que internamente o Angular usa o comparador===
para comparar os valores, mas há um tratamento especial paraNaN
. Para mais detalhes sobre este comparador veja este link.
Vamos complicar um pouco mais o cenário, uma estrutura com 4 componentes com um botão em um dos componentes. Lembre-se de que este evento (clique do botão) irá iniciar o CD que passará por todos os componentes. Para provar isso adicionei um get texto()
em cada componente com um ponto de log e também adicionei uma expressão {{ texto }}
em cada template, veja a seguir:
AppComponent (root)
├── ComponenteAComponent
├── ComponenteBComponent
└── EnderecosComponent
Então quando o botão Adicionar endereço
é acionado o Angular identifica este evento e inicia o CD em cada componente. Repare nos prints que aparecem no console no GIF abaixo:
Observação: as mensagens estão duplicadas no console porque o Angular faz uma dupla verificação apenas no modo de desenvolvimento. Para mais detalhes acesse este link no tópico Avoiding change detection loops: Production vs Development mode.
O que é interessante neste cenário é que este evento de clique causou somente uma alteração na variável enderecos
. O que impacta diretamente seu próprio valor dentro do AppComponent
e também o EnderecosComponent
que a recebe através de um @Input()
, porém esta alteração não causará nenhum impacto no ComponenteAComponent
e ComponenteBComponent
. Mesmo sabendo disso, TODOS os componentes foram verificados em busca de alterações.
Essa verificação em todos os componentes normalmente não causa problemas de performance, mas em grandes aplicações ou em casos mais específicos este cenário pode ser um problema.
Neste nosso exemplo onde apenas atualizamos a variável enderecos
, será se não faz mais sentido apenas verificar as mudanças (change detection) no AppComponent
e EnderecosComponent
já que ambos são os únicos impactados? O ChangeDetectionStrategy.OnPush
pode nos ajudar com isso.
ChangeDetectionStrategy.OnPush
Quando o componente utiliza a estratégia OnPush
, o Angular só ativa o CD se uma das condições abaixo for satisfeita:
- alterações de valores primitivos ou referências de objetos no(s)
@Input()
do componente - nesta estratégia o conceito de imutabilidade conta bastante e será mostrado logo a seguir. - valores emitidos via subscrição de um Observable através do
async pipe
- por exemplo:<div *ngFor="let item of itens | async">{{ item }}</div>
Observação: É claro que se algum evento for identificado dentro do componente como os cliques, setTimeouts, entre outros, o change detection entrará em cena.
Então se um evento ocorrer dentro de um componente com o ChangeDetectionStrategy.Default
e uma das condições acima não for satisfeita, o change detection (CD) não é executado nos componentes configurados com o OnPush
. O GIF abaixo ilustra este cenário:
Por outro lado, caso ocorra algum evento (clique, setTimeout, etc.) dentro de um componente configurado com o OnPush
, o CD irá rodar em cima do próprio componente e em todos os componentes configurados com o Default
. Outros componentes com o OnPush
podem ser acionados caso alguma condição citada acima seja satisfeita.
Abaixo ilustrei alguns cenários mostrando onde o evento ocorre e quais são os componentes onde o CD será executado:
Para ficar mais claro vou alterar a estratégia do componente EnderecosComponent
(exemplo utilizado no tópico anterior) para ChangeDetectionStrategy.OnPush
:
Quando clicarmos no botão (está no template do AppComponent
) que aciona o método adicionarEndereco()
, o Angular irá incrementar o array enderecos
e irá disparar o CD em todos os componentes exceto no EnderecosComponent
.
Isso ocorre porque este componente não recebe um novo objeto no @Input() enderecos
, então o Angular considera que não houveram alterações no componente, logo, pode efetuar um "bypass" no CD do componente. Para provar isto podemos observar no console da imagem abaixo que o não temos o print EnderecosComponent.texto()
e também o novo item do array enderecos
não aparece na tela:
O OnPush
detecta as mudanças através de novas referências para objetos ou novos valores para tipos primitivos. Então para resolver este caso podemos alterar o adicionarEndereco
para:
Agora que o EnderecosComponente
recebe um novo objeto, o Angular detecta essa alteração e dispara o CD.
A grande vantagem desta estratégia é a performance. O Angular só ativa o CD para componentes que usam a estratégia OnPush
quando eles de fato recebem um novo objeto via @Input
ou via async pipe
no template. Novamente, quando o evento ocorre dentro do componente o CD é ativado normalmente.
Aqui vale uma nota: se a sua aplicação não trabalha com objetos imutáveis, tome muito cuidado ao optar pelo OnPush
, pois você até pode ganhar performance, mas ganhará uma alta complexidade na sua solução. Eventualmente este ganho de performance não paga a conta da complexidade. Então tome um tempo e reflita sobre a real necessidade.
Considerações
A ideia deste texto foi deixar claro como o mecânismo change detection (CD) funciona, quais são os eventos que disparam seu ciclo e quais são as estratégias que podemos adotar.
Recomendo fortemente a leitura do link abaixo, ele vai além do conteúdo abordado neste texto, falando sobre o ChangeDetectorRef
, NgZone
e outros pontos interessantes que abordarei no futuro: https://blog.angular-university.io/how-does-angular-2-change-detection-really-work/
Links interessantes: