Volver al Blog
Angular Development5 de marzo de 202610 min de lectura

Optimización de rendimiento en Angular: Lo que realmente marca la diferencia

DR

Doug Ribeiro

10 min de lectura

La mayoría de artículos sobre rendimiento en Angular tratan sobre OnPush change detection y trackBy. Esos importan, pero no es por donde empiezo. Cada app Angular que he perfilado ya tenía OnPush en algunos componentes y trackBy en algunos loops, y seguía siendo lenta. Los problemas estaban en otro lado: un bundle inicial sobredimensionado, fugas de suscripciones manteniendo vivos componentes destruidos, y listas largas renderizando cientos de nodos DOM de una vez. OnPush era irrelevante hasta que esos se arreglaran. Los ejemplos de código de abajo usan un dominio de solicitudes de préstamo puramente como ilustración; ninguno viene del codebase de un empleador.

Comienza con el bundle

Ejecuta source-map-explorer en tu chunk principal antes de hacer cualquier otra cosa. El output te dice, en bytes, qué está consumiendo tu carga inicial. En cada auditoría que he ejecutado, los mayores infractores son librerías de terceros que se importaron sin pensar y código de features que terminó en el bundle principal porque alguien importó un componente directamente en lugar de usar loadComponent.

  • npx source-map-explorer dist/your-app/browser/main-*.js da un desglose visual del contenido del bundle (compila primero con --source-map, ya que los builds de producción omiten los source maps por defecto)
  • Cualquier librería de más de 50KB en el bundle principal que no se necesite en la primera pantalla es candidata a mover
  • Librerías de fechas, librerías de gráficos, y renderizadores de PDF son los infractores más comunes
  • Cada ruta de feature que usa loadComponent o loadChildren en lugar de un import directo es una frontera de chunk

Convertir rutas de feature importadas de forma eager a lazy loading es, de manera confiable, el mayor recorte individual al bundle inicial, y el puntaje en móvil lo sigue. Todo lo demás en esta lista es incremental en comparación.

OnPush es lo básico, no una bala de plata

ChangeDetectionStrategy.OnPush le dice a Angular que omita un componente durante el change detection a menos que uno de sus inputs cambie por referencia, un evento se origine desde él, o un async pipe o signal lo marque como dirty. En un árbol de componentes con cientos de nodos, esto reduce dramáticamente el trabajo desperdiciado por ciclo de eventos.

typescript
import { CurrencyPipe } from '@angular/common';

@Component({
  // Attribute selector, not an element selector. <app-application-row> inside a <tbody> is
  // invalid HTML, and the browser hoists it out of the table before Angular gets a say.
  selector: 'tr[appApplicationRow]',
  standalone: true,
  imports: [CurrencyPipe],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <td>{{ application.applicantName }}</td>
    <td>{{ application.requestedAmount | currency:'CAD' }}</td>
    <td [class]="'status-' + application.status">{{ application.status }}</td>
  `
})
export class ApplicationRowComponent {
  @Input({ required: true }) application!: LoanApplication;
}

La trampa: OnPush requiere patrones de input inmutables. Si mutas un array y pasas la misma referencia, OnPush no re-renderizará. Esto afecta a developers que hacen array.push() y esperan que la vista se actualice. Los signals no eliminan esta regla. Un signal solo notifica cuando recibe un valor nuevo, comparado con Object.is por defecto, así que update(list => [...list, item]) re-renderiza y un push() sobre el mismo array no.

Las fugas de suscripciones matan el rendimiento silenciosamente

Las suscripciones no gestionadas son el problema de rendimiento que nunca aparece en Lighthouse. Un componente se suscribe a un interval o un WebSocket y nunca se desuscribe. El componente se destruye por navegación. La suscripción sigue corriendo. Su callback sigue haciendo trabajo, mantiene el componente destruido en memoria, y en una app con Zone.js cada emisión sigue disparando una pasada de change detection en toda la aplicación. Multiplica eso por un usuario que navega la app durante treinta minutos y tienes docenas de suscripciones activas en componentes destruidos.

En cualquier codebase que corra Angular 16 o posterior, takeUntilDestroyed entra en cada feature que entrego. Después de perseguir fugas de este tipo en más de un codebase, es lo primero que busco cuando reviso una suscripción.

typescript
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { DestroyRef, inject } from '@angular/core';

@Component({ standalone: true })
export class ApplicationStatusComponent implements OnInit {
  private statusService = inject(ApplicationStatusService);
  private destroyRef = inject(DestroyRef);

  @Input({ required: true }) applicationId!: string;
  status = signal<ApplicationStatus | null>(null);

  ngOnInit(): void {
    // Polling every 10s for status updates
    this.statusService.pollStatus(this.applicationId).pipe(
      takeUntilDestroyed(this.destroyRef)
    ).subscribe(s => this.status.set(s));
  }
}

Virtual Scrolling para listas grandes

Una tabla de 500 filas no son 500 nodos DOM. Son 500 filas más cada celda dentro de ellas, así que tres columnas ya te llevan más allá de 2000 elementos. Cada uno carga estilos, event listeners, y overhead de change detection. CDK virtual scrolling renderiza solo lo que cabe en el viewport, que es el único enfoque sensato para cualquier lista que pueda crecer más allá de 100 elementos.

typescript
import { ScrollingModule } from '@angular/cdk/scrolling';

@Component({
  standalone: true,
  imports: [ScrollingModule, ApplicationRowComponent],
  // The viewport has to be the scrolling container, so the table lives inside it. Set
  // table-layout: fixed and a row height that matches itemSize, or the recycling maths drifts.
  template: `
    <cdk-virtual-scroll-viewport itemSize="64" style="height: 70vh">
      <table class="application-table">
        <tbody>
          <tr appApplicationRow
            *cdkVirtualFor="let app of applications(); trackBy: trackById"
            [application]="app"
          ></tr>
        </tbody>
      </table>
    </cdk-virtual-scroll-viewport>
  `
})
export class ApplicationListComponent {
  applications = input.required<LoanApplication[]>();
  trackById = (_: number, app: LoanApplication) => app.id;
}

Pure Pipes sobre métodos en templates

Una llamada a método en un template se ejecuta en cada ciclo de change detection. Un pure pipe se ejecuta solo cuando sus inputs cambian. Este es un flag de code review que levanto cada vez que lo veo.

typescript
// In the template. Bad: runs on every CD cycle
// <td>{{ formatAmount(application.requestedAmount) }}</td>

// Good: runs only when requestedAmount changes
// <td>{{ application.requestedAmount | loanAmount }}</td>

@Pipe({ name: 'loanAmount', standalone: true, pure: true })
export class LoanAmountPipe implements PipeTransform {
  transform(value: number): string {
    return new Intl.NumberFormat('en-CA', {
      style: 'currency',
      currency: 'CAD',
      maximumFractionDigits: 0
    }).format(value);
  }
}

El orden que realmente funciona

  • 1. Audita el bundle. Elimina todo lo grande que no se necesite en el primer render.
  • 2. Aplica lazy loading a todas las rutas de feature. Este es el cambio de mayor impacto por hora de trabajo.
  • 3. Encuentra fugas de suscripciones. Agrega takeUntilDestroyed en todo el codebase como estándar.
  • 4. Agrega OnPush a todos los componentes. Arregla las violaciones de inmutabilidad de inputs conforme surjan.
  • 5. Agrega virtual scrolling a cualquier lista que pueda crecer más allá de 100 elementos.
  • 6. Reemplaza llamadas a métodos en templates con pure pipes.
Reducción del tamaño del bundlesource-map-explorer, tree shaking, auditoría de imports 1 Lazy loadingcode splitting a nivel de ruta, estrategias de preloading 2 Fugas de suscripcionestakeUntilDestroyed, async pipe, profiler de memoria 3 OnPush + Signalseliminar ciclos innecesarios de change detection 4 Virtual scrollCDK VirtualScrollViewport para listas grandes 5 Pipes purostransformaciones memoizadas, evitar métodos en el template 6

El código más rápido es el que no se ejecuta. Cada optimización en esta lista funciona difiriendo o eliminando trabajo que Angular estaba haciendo innecesariamente.

Nada de esto requiere una reestructuración arquitectónica. Todo puede aplicarse incrementalmente, feature por feature, sprint por sprint. El análisis de bundle solo te dice por dónde empezar. Después de eso es solo ejecución.

Referencias

AngularPerformanceTypeScriptFrontend