Angular Performance: Como renderizar 5.000 inputs sem fritar a aba do cliente

Postado em

A dor real

A tela congela quando o usuário digita rápido numa tabela com 5 000 células. O Zone.js dispara change detection a cada tecla, mouse ou timer… e o Angular acaba percorrendo todo o componente a cada evento. Resultado: o Chrome reclama de “processando” e o cliente já está pedindo pra fechar a aba.

O básico que não resolve tudo

OnPush + trackBy continuam obrigatórios, mas o grande erro é usar two‑way binding em milhares de inputs. Troque por one‑way binding e atualize o modelo manualmente:

import { ChangeDetectionStrategy, Component } from '@angular/core';

@Component({
  selector: 'app-fast-grid',
  changeDetection: ChangeDetectionStrategy.OnPush,
  templateUrl: './fast-grid.component.html'
})
export class FastGridComponent {
  rows = Array.from({ length: 5000 }, (_, i) => ({
    id: i,
    value: ''
  }));

  trackById(_: number, item: any): number {
    return item.id;
  }

  onCellInput(row: any, newVal: string): void {
    row.value = newVal; // aqui temos uma atualização controlada
  }
}
<tr *ngFor="let row of rows; trackBy: trackById">
  <td>
    <input
      [value]="row.value"
      (input)="onCellInput(row, $event.target.value)">
  </td>
</tr>
  • O input recebe o valor apenas uma vez (one‑way).
  • O evento (input) dispara a atualização explícita, evitando que o Angular faça “dirty checking” em todos os campos a cada ciclo.

Mesmo assim, ainda há um nó de DOM para cada linha. Quando o número de linhas cresce, o custo de criar/destruir esses nós começa a pesar.

O Elefante na sala - Virtual Scroll

Antes de partir pro DOM puro, experimente o @angular/cdk/scrolling. Ele renderiza só as linhas que cabem na viewport (geralmente ~30) e recicla os elementos à medida que o usuário rola. É a primeira linha de defesa contra o “milhão de nós”.

import { CdkVirtualScrollViewport } from '@angular/cdk/scrolling';
import { ChangeDetectionStrategy, Component } from '@angular/core';

@Component({
  selector: 'app-virtual-grid',
  changeDetection: ChangeDetectionStrategy.OnPush,
  templateUrl: './virtual-grid.component.html',
  styleUrls: ['./virtual-grid.component.css']
})
export class VirtualGridComponent {
  rows = Array.from({ length: 5000 }, (_, i) => ({
    id: i,
    value: ''
  }));

  trackById(_: number, item: any): number {
    return item.id;
  }

  onCellInput(row: any, newVal: string): void {
    row.value = newVal;
  }
}
<cdk-virtual-scroll-viewport itemSize="40" class="viewport">
  <table>
    <tr *cdkVirtualFor="let row of rows; trackBy: trackById">
      <td>
        <input
          [value]="row.value"
          (input)="onCellInput(row, $event.target.value)">
      </td>
    </tr>
  </table>
</cdk-virtual-scroll-viewport>

Para não atirar no próprio pé

itemSize deve corresponder à altura da linha (inclua padding/border). cdkVirtualFor substitui o *ngFor, ele cuida da reciclagem dos nós. Mesmo com Virtual Scroll, mantenha o onCellInput manual, isso evita o ciclo de detecção automático em cada tecla.

Mas a real é que o Virtual Scroll resolve cerca de 80 % dos problemas. Se você está pulando direto para Canvas antes de tentar virtualizar, está complicando a própria vida a toa.

A cirurgia (Detaching Change Detector)

Quando precisar de atualização em lote (colagem, cálculos pesados), desligue o detector e force a renderização apenas quando for realmente necessário.

import { ChangeDetectorRef, Component } from '@angular/core';

@Component({
  selector: 'app-batch-grid',
  templateUrl: './batch-grid.component.html',
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class BatchGridComponent {
  rows = Array.from({ length: 5000 }, (_, i) => ({ id: i, value: '' }));
  private bulkMode = false;

  constructor(private cd: ChangeDetectorRef) {}

  startBulk(): void {
    this.cd.detach();      // nada será detectado automaticamente
    this.bulkMode = true;
  }

  finishBulk(): void {
    // …processamento pesado…
    this.cd.detectChanges(); // única passagem
    this.cd.reattach();      // volta ao normal
    this.bulkMode = false;
  }

  onCellInput(row: any, val: string): void {
    row.value = val;
    if (!this.bulkMode) {
      this.cd.detectChanges(); // atualização pontual
    }
  }
}
  • Use startBulk() antes de operações massivas (colagem, importação).
  • finishBulk() faz o cálculo e dispara uma única passagem de CD.

Num projeto interno de ERP que peguei com 8 mil linhas, a tela levava uns 1.8s só pra responder a cada colagem do usuário. Só de meter o detach ali, o tempo caiu pra uns 200 ms. Foi bizarro!

Chutando o balde (DOM direto / Canvas)

Se Virtual Scroll + detach ainda não dão conta, o último recurso é abandonar o Angular no trecho crítico e tocar o DOM “na unha”. Pode ser via Renderer2 ou, melhor ainda, usando a API de Canvas para desenhar a grade.

import { AfterViewInit, Component, ElementRef, ViewChild } from '@angular/core';

@Component({
  selector: 'app-canvas-grid',
  template: `<canvas #gridCanvas width="1200" height="800"></canvas>`
})
export class CanvasGridComponent implements AfterViewInit {
  @ViewChild('gridCanvas') canvas!: ElementRef<HTMLCanvasElement>;

  ngAfterViewInit(): void {
    const ctx = this.canvas.nativeElement.getContext('2d')!;
    this.drawInitialGrid(ctx);
  }

  drawInitialGrid(ctx: CanvasRenderingContext2D): void {
    const cellSize = 20;
    for (let r = 0; r < 5000; r++) {
      for (let c = 0; c < 10; c++) {
        ctx.strokeRect(c * cellSize, r * cellSize, cellSize, cellSize);
      }
    }
  }

  // Atualiza só a célula alterada
  updateCell(row: number, col: number, value: string): void {
    const ctx = this.canvas.nativeElement.getContext('2d')!;
    const cellSize = 20;
    ctx.clearRect(col * cellSize, row * cellSize, cellSize, cellSize);
    ctx.fillText(value, col * cellSize + 2, row * cellSize + 15);
  }
}

Quando usar?

Dados tabulares simples (texto, cores) que precisam de scroll fluido. Cenários onde a latência de criação/destruição de nós ultrapassa o ganho de interatividade.

❌ O que NÃO fazer

<tr *ngFor="let row of rows; trackBy: trackById">
  <td>{{ heavyCalc(row) }}</td>   <!-- roda a cada CD -->
</tr>
heavyCalc(row: any): string {
  return expensiveService.compute(row.id); // chamada cara em cada ciclo
}

✅ O que FAZER

<tr *ngFor="let row of rows; trackBy: trackById">
  <td>
    <input
      [value]="row.value"
      (input)="onCellInput(row, $event.target.value)">
  </td>
</tr>
onCellInput(row: any, val: string): void {
  row.value = val;
  this.cd.detectChanges(); // atualização controlada
}

No fim das contas, a regra é clara.. se o framework virou o gargalo, tire o framework da jogada no componente específico. Assuma o controle do DOM e deixe o Angular cuidar só do que importa.

Categoria: Frontend
Gostou do conteúdo?

Se este artigo foi útil para você, considere apoiar meu trabalho!

Me compre um café

Comentários