Como funciona o change detection do Angular

Veja como funciona o change detection (CD) do Angular e qual a melhor estratégia para o seu componente: Default ou OnPush.

Como funciona o change detection do Angular
Como funciona o change detection - 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.

Edit fiddle - JSFiddle - Code Playground
Test your JavaScript, CSS, HTML or CoffeeScript online with JSFiddle code editor.
Link para visualizar o código acima

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() e setInterval()
  • 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.

Animação mostrando a execução do change detection na árvore de componentes
Animação mostrando a execução do change detection na árvore de 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 e idade forem lidos, será printada uma mensagem no console. Por isto adicionei 2 getters dentro da classe Dados
  • No template do componente temos uma única expressão:
    <div>{{ dados.nome }}</div>
  • O método atualizarDados atualiza o valor das variáveis nome e idade

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.

Resultado da execução do código acima no navegador
Resultado da execução do código acima

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 o dados.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 retorna false, 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 para NaN. 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
import { Component } from '@angular/core';

@Component({ selector: 'app-componente-a', template: `{{ texto }}` })
export class ComponenteAComponent {
  _texto: string = 'Olá, sou o componente A';
  get texto(): string {
    console.log('ComponenteA.texto()');
    return this._texto;
  }
}
ComponenteAComponent
import { Component } from '@angular/core';

@Component({ selector: 'app-componente-b', template: `{{ texto }}` })
export class ComponenteBComponent {
  _texto: string = 'Olá, sou o componente B';
  get texto(): string {
    console.log('ComponenteB.texto()');
    return this._texto;
  }
}
ComponenteBComponent
import { Component, Input } from '@angular/core';
import { Endereco } from './endereco';

@Component({
  selector: 'app-enderecos',
  template: `
    <div>{{ texto }}</div>
    <div *ngFor="let endereco of enderecos">{{ endereco.logradouro }}</div>
  `,
})
export class EnderecosComponent {
  @Input() enderecos: Endereco[] = [];

  _texto: string = 'Olá, sou o EnderecosComponent';
  get texto(): string {
    console.log('EnderecosComponent.texto()');
    return this._texto;
  }
}
EnderecosComponent
import { Component } from '@angular/core';
import { Endereco } from './componentes/enderecos/endereco';

@Component({
  selector: 'app-root',
  template: `
    {{ texto }}
    <br />

    <app-componente-a></app-componente-a>
    <br />

    <app-componente-b></app-componente-b>
    <br />

    <app-enderecos
      [enderecos]="enderecos"></app-enderecos>
    <br />

    <button (click)="adicionarEndereco()">
      Adicionar endereço
    </button>
  `,
})
export class AppComponent {
  _texto: string = 'Olá, sou o AppComponent';
  get texto(): string {
    console.log('AppComponent.texto()');
    return this._texto;
  }

  enderecos: Endereco[] = [
    { logradouro: 'Logradouro 0' },
    { logradouro: 'Logradouro 1' },
    { logradouro: 'Logradouro 2' },
    { logradouro: 'Logradouro 3' },
    { logradouro: 'Logradouro 4' },
  ];

  adicionarEndereco() {
    this.enderecos.push({ logradouro: 'Logradouro 5' });
  }
}
AppComponent

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:

Resultado da execução do código acima no navegador
Resultado da execução do código acima no navegador

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:

Ilustração de como o change detection é ativado quando o evento se inicia em um componente com a estratégia Default
Ilustração de como o change detection é ativado quando o evento se inicia em um componente com a estratégia Default

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:

AppComponent (root)
├── ComponenteAComponent  (Default)
├── ComponenteBComponent  (Default)
├── EnderecosComponent    (OnPush) -> (Evento)
│   └── DetalhesComponent (OnPush)
└── ComponenteCComponent  (OnPush)

Após o clique:

AppComponent (root)       (Executa o CD)
├── ComponenteAComponent  (Executa o CD)
├── ComponenteBComponent  (Executa o CD)
├── EnderecosComponent    (Executa o CD)
│   └── DetalhesComponent (------------)
└── ComponenteCComponent  (------------)
Cenário onde o clique (<button (click)="0">botão</button>) dispara o CD dentro do EnderecosComponente
AppComponent (root)
├── ComponenteAComponent  (Default)
├── ComponenteBComponent  (Default)
├── EnderecosComponent    (OnPush) -> (Evento: altera o valor
│   │                                  de um @Input() do 
│   └── DetalhesComponent (OnPush)     DetalhesComponent)
└── ComponenteCComponent  (OnPush)

Após o clique:

AppComponent (root)       (Executa o CD)
├── ComponenteAComponent  (Executa o CD)
├── ComponenteBComponent  (Executa o CD)
├── EnderecosComponent    (Executa o CD)
│   └── DetalhesComponent (Executa o CD)
└── ComponenteCComponent  (------------)
Cenário onde o clique dispara o CD e atualiza um objeto que é passado via @Input para o DetalhesComponent
AppComponent (root)
├── ComponenteAComponent  (Default) -> (Evento: atualiza dados
│                                       dentro do próprio componente)
├── ComponenteBComponent  (Default)
├── EnderecosComponent    (OnPush)
│   └── DetalhesComponent (OnPush)
└── ComponenteCComponent  (OnPush)

Após o clique:

AppComponent (root)       (Executa o CD)
├── ComponenteAComponent  (Executa o CD)
├── ComponenteBComponent  (Executa o CD)
├── EnderecosComponent    (------------)
│   └── DetalhesComponent (------------)
└── ComponenteCComponent  (------------)
Cenário onde o clique ocorre dentro de um componente com a estratégia Default. O clique apenas atualiza dados que impactam o próprio componente
AppComponent (root) ---------------> (Evento: atualiza a lista
│                                     endereços que é passada
│                                     via @Input() para o
│                                     EnderecosComponente)
├── ComponenteAComponent  (Default)
├── ComponenteBComponent  (Default)
├── EnderecosComponent    (OnPush)
│   └── DetalhesComponent (OnPush)
└── ComponenteCComponent  (OnPush)

Após o clique:

AppComponent (root)       (Executa o CD)
├── ComponenteAComponent  (Executa o CD)
├── ComponenteBComponent  (Executa o CD)
├── EnderecosComponent    (Executa o CD)
│   └── DetalhesComponent (------------)
└── ComponenteCComponent  (------------)
Cenário onde o clique ocorre dentro de um componente com a estratégia Default. O clique atualiza um objeto que é passado via @Input para o EnderecosComponent

Para ficar mais claro vou alterar a estratégia do componente EnderecosComponent (exemplo utilizado no tópico anterior) para ChangeDetectionStrategy.OnPush:

// Lembre-se da estrutura do exemplo:
//
// AppComponent (root)
// ├── ComponenteAComponent
// ├── ComponenteBComponent
// └── EnderecosComponent

import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
import { Endereco } from './endereco';

@Component({
  // *********************************************
  // Adicionei a linha abaixo:
  // *********************************************
  changeDetection: ChangeDetectionStrategy.OnPush,
  selector: 'app-enderecos',
  template: `
    <div>{{ texto }}</div>
    <div *ngFor="let endereco of enderecos">
      {{ endereco.logradouro }}
    </div>
  `,
})
export class EnderecosComponent {
  @Input() enderecos: Endereco[] = [];

  _texto: string = 'Olá, sou o EnderecosComponent';
  get texto(): string {
    console.log('EnderecosComponent.texto()');
    return this._texto;
  }
}
EnderecosComponent

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.

adicionarEndereco() {
  this.enderecos.push({ logradouro: 'Logradouro 5' });
}
Trecho do AppComponent

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:

Resultado da execução do código acima no navegador
Resultado da execução do código acima no navegador

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:

adicionarEndereco() {
  this.enderecos.push({ logradouro: 'Logradouro 5' });
  
  // Cria um novo array clonando o anterior
  this.enderecos = [...this.enderecos];
}
Trecho do AppComponent
Resultado da execução do código acima no navegador
Resultado da execução do código acima no navegador

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: