Inicio / Artículos

Angular más rápido haciendo menos: diez técnicas además de OnPush y trackBy

Reduce el uso innecesario de la red, las tareas reactivas, DOM y CPU en Angular moderno mediante @defer, hidratación incremental, resource(), señales computadas, fases de renderizado y Web Workers.

Editor: REACTAPP.TOP16 min de lectura3569 palabras

Fuentes: How to Make Angular Extremely Fast: 10 Performance Tricks Most Developers Miss

Rara vez es el tiempo de ejecución de Angular la causa de que una aplicación Angular parezca lenta. Con mucha más frecuencia, la aplicación descarga, inicializa, vuelve a calcular y renderiza elementos que nadie solicitó. Las soluciones habituales, como OnPush, rutas perezosas, trackBy, compilación AOT, builds para producción y menos llamadas HTTP, siguen siendo útiles, pero las versiones recientes de Angular ofrecen un control mucho más detallado: cuándo se descarga el código, cuándo los componentes se vuelven interactivos, cuándo se cancelan las solicitudes, qué estado invalida la información, cuándo se ejecutan las tareas en el DOM y en qué hilo tienen lugar los cálculos intensivos. Esta guía explica diez técnicas basadas en ese control, cómo combinarlas y en qué orden aplicarlas una vez que haya medido dónde se gasta realmente el tiempo.

La idea principal es sencilla: en lugar de hacer que Angular sea más rápido en tareas innecesarias, organizar todo de modo que esas tareas nunca ocurran.

Dónde pasa su tiempo una aplicación Angular

Antes de abordar las APIs individuales, es útil imaginar los tres recursos que consume cada aplicación Angular y qué suele consumirlos.

Angular Application
                           │
          ┌────────────────┼────────────────┐
          │                │                │
       Network           CPU              DOM
          │                │                │
     JS downloads      Change detection   Layout
     API requests      Computation        Paint
     Dependencies      Parsing            Reflow
          │                │                │
          └────────────────┼────────────────┘
                           │
                    User Experience

Estos costos interactúan entre sí. Ajustar la detección de cambios sirve de poco si la primera carga de página descarga 5 MB de JavaScript, y un paquete pequeño no te ayuda si cada interacción desencadena un cálculo costoso. La secuencia a seguir es: descargar menos, inicializar menos, calcular menos, renderizar menos y bloquear menos el hilo principal. Las diez técnicas a continuación abordan una o más de estas etapas.

1. Hacer que @defer genere fragmentos realmente separados

El bloque @defer es una de las formas más sencillas de reducir la cantidad de JavaScript necesario al iniciar la aplicación. Imagine un panel de control con gráficos, análisis, informes, mapas, reproductores de video y widgets administrativos. Pocos de estos elementos deben estar presentes en el momento en que se abre la página, pero un template estándar los carga a todos de inmediato:

<app-heavy-chart />
<app-map />
<app-report />

Al envolver un componente pesado en un bloque diferido, se le indica a Angular que obtenga su código solo cuando se active un disparador, en este caso cuando el marcador de posición se desplaza hacia la vista del usuario:

@defer (on viewport) {
  <app-heavy-chart />
}

Mantenga los componentes diferidos fuera de las importaciones grupales

Existe un detalle importante: @defer solo puede separar código que aún no sea accesible a través de una importación inmediata. Si el componente anfitrión importa al componente diferido a través de un archivo grupal, este archivo arrastra todos los demás elementos exportados y sus dependencias al mismo grafo. Por lo tanto, en lugar de importar desde un archivo index:

import { HeavyComponent } from './index';

Importar el componente desde su propio archivo:

import { HeavyComponent } from './heavy.component';

El objetivo es mantener el grafo de dependencias detrás de cada bloque diferido lo más aislado posible. La primera estructura que se debe evitar está etiquetada como:

BAD

Y se ve así, con un único conducto que conecta cada widget al panel de control:

Dashboard
   │
   └── index.ts
        ├── Chart
        ├── Map
        ├── Reports
        ├── Video
        └── Analytics

La estructura preferida está etiquetada como:

GOOD

Y permite que el gráfico complejo tenga una importación directa y, por lo tanto, su propio bloque de código:

Dashboard
   │
   └── HeavyChart
          │
          └── heavy-chart.chunk.js

Las importaciones mediante conductos son una de las razones ocultas más comunes por las que un bloque @defer no logra reducir el tamaño del paquete inicial. La forma más sencilla de confirmar el éxito es verificar la salida de compilación en busca de un nuevo bloque lazy después de agregar el bloque.

La ventaja se multiplica: un paquete inicial más pequeño implica menos datos para descargar, analizar, compilar y ejecutar, y todo ese ahorro ocurre antes de que Angular muestre cualquier contenido.

2. Hidratar solo lo que los usuarios necesitan primero

Con el renderizado del lado servidor, aparece un segundo cuello de botella después de que llegue el HTML. El servidor puede generar el marcado rápidamente, pero el navegador debe hacer que toda la página sea interactiva, lo que implica descargar y ejecutar el código de cada componente que contiene. Considere un diseño típico de panel de control:

Dashboard
│
├── Header
├── Navigation
├── KPI Cards
├── Huge Chart
├── Map
├── Analytics Table
├── Video Player
└── Recommendations

No todas estas secciones necesitan responder inmediatamente a las entradas. La hidratación completa tradicional trata la página como una sola unidad:

SSR HTML
   ↓
Hydrate EVERYTHING
   ↓
Interactive

La hidratación incremental permite dividirla en etapas, de modo que la página esté visible de inmediato y las secciones se vuelvan interactivas progresivamente:

SSR HTML
   ↓
Show page immediately
   ↓
Hydrate only what is needed
   ↓
Hydrate other sections later

Se expresa con los mismos bloques @defer, ampliados con desencadenantes de hidratación. En el ejemplo siguiente, el servidor sigue generando el HTML del gráfico; el cliente lo hidrata cuando el navegador está inactivo, y en una renderización puramente del lado del cliente, el bloque se carga al interactuar con él:

@defer (on interaction; hydrate on idle) {
  <app-heavy-chart />
}

La ventaja arquitectónica es que la generación de HTML y su conversión en interactivo deja de ser un paso único, todo-o-nada. Es necesario habilitar la hidratación incremental en su configuración de hidratación; consulte la guía de SSR para su versión de Angular para conocer la opción del proveedor exacta.

3. Cancelar solicitudes obsoletas con resource()

La búsqueda mientras se escribe es el caso clásico de uso inútil de ancho de banda. A medida que el usuario escribe, la entrada pasa por cada valor intermedio:

a
an
ang
angu
angul
angula
angular

Una implementación ingenua podría enviar una solicitud por cada uno de ellos:

Request 1 → "a"
Request 2 → "an"
Request 3 → "ang"
Request 4 → "angu"
Request 5 → "angul"
Request 6 → "angular"

Solo importa la última respuesta, por lo que las otras cinco son inútiles. Angular moderno ofrece resource() y httpResource() para una carga asíncrona reactiva. Se comienza con una señal que contiene la consulta:

const query = signal('');

Luego se declara un recurso cuya función params lee dicha señal y cuyo loader realiza la solicitud. En el ejemplo a continuación, loader se ha ejecutado en la misma línea que el paréntesis de cierre de params; ambos son propiedades separadas del objeto de opciones:

const results = resource({
  params: () => ({
    q: query()
  }),  loader: ({ params, abortSignal }) =>
    fetch(
      `/api/search?q=${params.q}`,
      { signal: abortSignal }
    ).then(res => res.json())
});

Cada vez que los parámetros cambian mientras una carga aún está en curso, Angular cancela la anterior. Esto solo funciona porque el loader transfiere la señal proporcionada a fetch, que es el elemento al que hay que prestar atención:

{ signal: abortSignal }

Si no se incluye, la solicitud anterior sigue ejecutándose hasta completarse. En el código real vale la pena hacer dos mejoras: envolver la consulta en encodeURIComponent antes de integrarla en la URL, y recordar que la cancelación no es un mecanismo de debounce. Cada pulsación sigue iniciando una solicitud que luego se aborta, por lo que combine el recurso con una señal de consulta debounced si también desea evitar enviarlas. La solicitud más rápida es aquella que se detiene en cuanto deja de ser relevante, y este patrón resulta útil para búsquedas, autocompletado, filtrado, paneles en tiempo real, IDs que cambian rápidamente y cadenas de llamadas dependientes.

4. Mantenga las señales pequeñas y enfocadas

Las señales son eficientes, pero aún así se pueden utilizar de manera que se desperdicie esa eficiencia. Un patrón común es que una sola señal almacene el estado de toda una página:

const pageState = signal({
  user: {},
  filters: {},
  notifications: [],
  products: [],
  statistics: {},
  settings: {}
});

Si solo cambian los filtros, se notifica a todos los consumidores de pageState, ya que el valor del señal se reemplazó por completo. Dividir las partes independientes del estado en señales separadas reduce el alcance de los efectos. La primera se convierte en:

const user = signal(...);

y el resto sigue el mismo patrón (aquí se muestran juntas en una sola línea; en el código real cada una está en su propia línea):

const filters = signal(...);const notifications = signal(...);const products = signal(...);const statistics = signal(...);const settings = signal(...);

Ahora cada consumidor depende únicamente del estado que realmente lee. Una hoja de cálculo es un buen modelo mental. Cuando cambia una sola celda:

A1 changes

no se desea recalcular toda la hoja, solo la cadena que depende de ella:

A1
 ↓
Dependent cells
 ↓
Only affected UI

Una señal de objeto grande invalida a los consumidores que solo se interesan por una parte de él; las señales pequeñas permiten un ámbito de invalidación más restringido. En lugar de preguntarse si todo el estado cabe en una sola señal, hay que determinar qué debe reaccionar realmente cuando cambia un valor específico. Si realmente necesita un objeto compuesto, derívelo con computed() a partir de las señales pequeñas en lugar de almacenarlo como fuente de verdad.

5. Sacar los cálculos costosos de las plantillas

Llamar a una función desde una plantilla es una de las formas más sencillas de generar trabajo repetitivo:

<div>
  {{ calculateTotal(items()) }}
</div>

Parece inofensivo, pero Angular vuelve a evaluar esa expresión cada vez que se verifica la vista, por lo que una costosa función calculateTotal() se ejecuta una y otra vez incluso cuando items no ha cambiado. La solución es utilizar una señal computed(). Declare el estado de origen:

const items = signal<Item[]>([]);

Obtenga el total a partir de él:

const total = computed(() =>
  items().reduce(
    (sum, item) => sum + item.price,
    0
  )
);

Y lea la señal derivada en el template:

<div>
  {{ total() }}
</div>

computed() memoriza su resultado y lo vuelve a calcular solo cuando cambia una señal que haya leído, en lugar de en cada evaluación del template. Esto lo convierte en una opción ideal para tareas de filtrado, ordenamiento, agregación, estadísticas, gráficos de panel de control y cualquier otro estado de interfaz derivado. Una lista de productos filtrados sigue la misma estructura, partiendo de una señal con el término de búsqueda:

const searchTerm = signal('');

Y una lista derivada que depende tanto de los productos como del término:

const filteredProducts = computed(() =>
  products().filter(product =>
    product.name.includes(searchTerm())
  )
);

Esto reemplaza las llamadas a templates como las siguientes, trasladando el trabajo al grafo reactivo:

{{ filterProducts(products(), searchTerm()) }}

Un detalle: includes es sensible a mayúsculas y minúsculas, por lo que una búsqueda real normalizaría primero ambas cadenas. Considere computed() como un caché para el estado derivado que se invalida correctamente.

6. Cargar de forma perezosa servicios pesados, no solo componentes

El carga perezosa se suele discutir en dos niveles:

Lazy route
Lazy component

No obstante, los servicios pueden tener sus propios árboles de dependencias grandes. Un servicio de vista previa en Markdown es un buen ejemplo:

MarkdownService
   │
   ├── marked
   ├── highlight.js
   ├── syntax definitions
   └── other dependencies

Si el servicio se utiliza solo cuando alguien hace clic en el botón “Vista previa de Markdown”, no hay razón para incluir el analizador, el resaltador de sintaxis y sus gramáticas en el paquete inicial. La técnica consiste en resolver el servicio mediante una importación dinámica en el momento en que se necesita. Conceptualmente, se ve así, utilizando una ayuda injectAsync():

const markdownService = injectAsync(
  () =>
    import('../markdown.service')
      .then(m => m.MarkdownService)
);

Luego, el componente espera al servicio dentro del manejador que lo necesita:

async preview(content: string) {
  const service = await this.markdownService();

y lo utiliza para renderizar el contenido:

  this.html = service.render(content);
}

Trate injectAsync() como un patrón y no como una API central garantizada: verifique si su versión de Angular o alguna biblioteca que utilice la proporciona. Se puede lograr el mismo efecto con un simple await import('../markdown.service') dentro del manejador, seguido de la resolución de la clase a través del inyector. De cualquier manera, el servicio y sus dependencias pesadas terminan en su propio chunk. La regla general es cargar las dependencias costosas donde sean útiles, y no solo en los límites de las rutas.

7. Trate las lecturas y escrituras en el DOM como una API costosa

A veces, el cuello de botella no es JavaScript en absoluto, sino el motor de layout del navegador. Esto ocurre cuando el código mezcla mediciones y mutaciones:

read DOM
write DOM
read DOM
write DOM
read DOM
write DOM

Cada lectura después de una escritura puede obligar al navegador a recalcular el diseño de forma síncrona, un patrón conocido como layout thrashing. Angular proporciona ganchos sensibles a la renderización para esto. El primero es:

afterNextRender()

Y el segundo, que admite fases explícitas, es:

afterRenderEffect()

Con afterRenderEffect(), se colocan las mediciones en la fase earlyRead:

afterRenderEffect({
  earlyRead: () => {
    return element.getBoundingClientRect();
  },

Y las mutaciones en la fase write, que recibe el valor devuelto por la lectura (este fragmento corresponde al resto de la misma llamada en TypeScript, no a CSS):

  write: height => {
    element.style.height = `${height}px`;
  }
});

Angular ejecuta las fases de lectura de todos los componentes antes que sus fases de escritura, por lo que el navegador ve el trabajo agrupado de esta manera:

READ
 ↓
READ
 ↓
READ
 ↓
WRITE
 ↓
WRITE
 ↓
WRITE

En lugar de alternar constantemente:

READ → WRITE → READ → WRITE

Esta coordinación basada en fases es especialmente importante para gráficos, mapas, animaciones, manejo de cambio de tamaño y desplazamiento, bibliotecas de interfaz de usuario de terceros y paneles complejos, donde muchos componentes modifican el DOM después de cada renderizado.

8. Detección de cambios por lotes con unificación de eventos y ejecuciones

En aplicaciones basadas en zonas, varios desencadenantes pueden activarse uno tras otro para lo que lógicamente es una sola acción del usuario:

click
 ↓
event bubbles
 ↓
parent handler
 ↓
ngZone.run()
 ↓
another update

Sin unificación, cada paso puede programar su propia pasada de detección de cambios. Angular puede combinarlas cuando se activan ambas opciones en provideZoneChangeDetection:

bootstrapApplication(AppComponent, {
  providers: [
    provideZoneChangeDetection({
      eventCoalescing: true,
      runCoalescing: true
    })
  ]
});

En lugar de una pasada por cada evento:

Event A → Change Detection
Event B → Change Detection
Event C → Change Detection

el trabajo superpuesto se reduce a una sola pasada:

Event A ─┐
Event B ─┼──→ One Change Detection
Event C ─┘

Esto es de gran utilidad en aplicaciones con muchos manejadores de eventos, flujos WebSocket activos, llamadas frecuentes a ngZone.run() o paneles de control altamente interactivos. Tenga en cuenta que estas opciones se aplican a la detección de cambios basada en Zone.js; las versiones recientes de Angular prefieren la detección de cambios sin zonas, donde la programación ya está controlada por señales y notificaciones explícitas, así que verifique qué modo utiliza su aplicación antes de recurrir a ellas.

9. Encuentre las dependencias que no pretendía incluir

Algunos de los mayores problemas de rendimiento no son problemas de Angular, sino problemas de importación. Puede que necesite una sola función de una biblioteca, pero su estructura de importación puede incluir mucho más:

Application
   │
   └── Library
        │
        ├── Feature A
        ├── Feature B
        ├── Feature C
        ├── Feature D
        └── Feature E

cuando en realidad lo único que utilizó fue:

Feature A

Por eso el análisis de paquetes debe formar parte de toda investigación de rendimiento. Genere estadísticas de la compilación para producción:

ng build --configuration=production --stats-json

Luego, examine la salida para ver qué archivos y bibliotecas dominan el payload. Herramientas como source-map-explorer funcionan con mapas de origen, así que activelas en la compilación que analice; el formato del archivo de estadísticas depende del generador que utilice su proyecto, por lo que verifique qué analizador lo soporta. La pregunta importante no es si la aplicación parece lenta, sino exactamente qué se está enviando al navegador. Una vez que esto sea visible, la mayoría de las optimizaciones se vuelven obvias. Para evitar regresiones posteriormente, considere aplicar presupuestos de rendimiento en CI.

10. Mueva los cálculos intensivos fuera del hilo principal

A veces el framework está bien y el problema radica en el algoritmo. Supongamos que un panel de control recibe un conjunto de datos de este tamaño:

100,000 records

Y ejecuta estadísticas, procesamiento de imágenes, cálculos geométricos, transformaciones complejas, análisis o agregación avanzada sobre él en el navegador. En el hilo principal, ese trabajo compite con todo lo demás:

Main Thread
│
├── Angular
├── Rendering
├── User input
├── Animations
└── 100K-record calculation

El resultado es una mala experiencia de uso: entrada congelada, animaciones entrecortadas y renderizado retrasado. Un Web Worker traslada los cálculos a un hilo separado y solo envía el resultado:

Main Thread                  Worker
     │                          │
     │──── large dataset ─────>│
     │                          │
     │                       Calculate
     │                       Transform
     │                       Analyze
     │                          │
     │<──── result ─────────────│
     │
Render UI

Angular CLI puede crear una estructura básica para un worker y configurarlo adecuadamente:

ng generate web-worker app

Y el componente lo crea con una URL relativa al módulo para que el bundler pueda encontrarlo y compilarlo:

const worker =
  new Worker(
    new URL('./app.worker', import.meta.url)
  );

Un worker no hace que el trabajo propio de Angular sea más económico. Lo que sí hace es evitar que la lógica de aplicación costosa bloquee el renderizado y las interacciones del usuario. Utilice workers solo cuando el cálculo sea realmente intensivo, ya que los datos transferidos entre hilos se copian mediante clonación estructurada, y esa transferencia tiene su propio costo. Para una perspectiva neutral respecto al framework sobre esta decisión, consulte cuándo mover el trabajo de la CPU a un Web Worker.

Combinar las técnicas en una única interfaz de control

Estos no son diez trucos aislados; se refuerzan mutuamente. Una interfaz de control de análisis no optimizada realiza todo de antemano:

Browser
  │
  ├── Download huge JS bundle
  ├── Initialize everything
  ├── Hydrate everything
  ├── Start every API request
  ├── Recalculate large state
  ├── Execute expensive template functions
  ├── Perform DOM reads/writes
  └── Calculate large datasets on main thread

Con las técnicas aplicadas, cada capa solo realiza lo que exige el momento actual:

Browser
  │
  ├── Smaller initial bundle
  │      └── @defer
  │
  ├── Partial interactivity
  │      └── Incremental hydration
  │
  ├── Only latest API request
  │      └── resource/httpResource
  │
  ├── Narrow reactive updates
  │      └── Fine-grained signals
  │
  ├── Cached derived values
  │      └── computed()
  │
  ├── Heavy services loaded later
  │      └── injectAsync()
  │
  ├── Coordinated DOM work
  │      └── afterRenderEffect()
  │
  ├── Fewer redundant CD cycles
  │      └── Event coalescing
  │
  ├── Smaller dependencies
  │      └── Bundle analysis
  │
  └── Heavy computation elsewhere
         └── Web Worker

La aplicación no funciona más rápido, sino que realiza mucho menos trabajo.

Una fórmula compacta de rendimiento

Una forma de resumir este enfoque es como una suma de reducciones:

Performance
    =
Less JavaScript
+
Less Network Work
+
Less Reactive Work
+
Less DOM Work
+
Less Main-Thread Work

En términos aún más breves: un Angular rápido significa realizar menos trabajo, hacerlo más tarde y solo cuando sea necesario.

Un orden práctico de optimización

No aplique todo al mismo tiempo. Mida primero y luego proceda por las etapas aproximadamente en este orden, ya que los pasos iniciales suelen tener el mayor impacto en el inicio de la aplicación.

Primero, reduzca el JavaScript inicial:

@defer
injectAsync()
bundle analysis

Segundo, reduzca el trabajo de hidratación inicial:

Incremental Hydration

Tercero, elimine el trabajo de red innecesario:

resource()
httpResource()

Cuarto, limite la invalidación reactiva:

Fine-grained signals
computed()

Quinto, reduzca la sobrecarga de renderizado:

afterRenderEffect()
afterNextRender()

Sexto, elimine la detección de cambios redundante:

eventCoalescing
runCoalescing

Séptimo, elimine los cuellos de botella en la CPU:

Web Workers

Mida antes de optimizar

La regla más importante es evitar la optimización a ciegas. Distribuir esto por todas partes no constituye una estrategia válida:

OnPush

Tampoco lo es implementar cualquiera de estas medidas sin saber cuál es el cuello de botella:

memoization
workers
RxJS operators
defer blocks

Realice un análisis con Chrome DevTools, Lighthouse, Angular DevTools, análisis del paquete de producción y el panel de rendimiento del navegador. Luego haga la pregunta básica:

What is actually slow?

Y reduzca la respuesta a una categoría específica:

Network?
JavaScript?
Change detection?
Rendering?
Layout?
CPU?

Solo una vez que sepa cuál de estos factores domina, debe elegir la técnica correspondiente. Después de cada cambio, mida nuevamente para confirmar que la mejora es real.

De una detección de cambios más rápida a menos trabajo

Los consejos antiguos sobre el rendimiento de Angular giraban principalmente en torno a una pregunta:

"How can I make change detection faster?"

Angular moderno fomenta una pregunta más útil:

"Why am I running this work at all?"

Esa segunda pregunta se aplica a todas las capas. ¿Por qué descargar este componente ahora? ¿Por qué cargar esta sección ahora? ¿Por qué esperar a que termine una solicitud HTTP cuando el usuario ya ha cambiado la consulta? ¿Por qué invalidar todo un objeto de estado por un solo campo? ¿Por qué volver a calcular un valor cuyas entradas no han cambiado? ¿Por qué modificar el DOM antes de que termine el renderizado? ¿Por qué realizar cálculos intensivos en el hilo de la interfaz gráfica? Cada operación innecesaria que encuentres representa una oportunidad.

Puntos clave

Las diez técnicas abordan diferentes capas y cada una elimina un tipo específico de desperdicio:

  • @defer con importaciones directas reduce el JavaScript inicial.
  • La hidratación incremental disminuye el trabajo de carga inicial.
  • resource() con una señal de interrupción elimina el trabajo en red obsoleto.
  • Las señales de precisión fina limitan la invalidación reactiva.
  • computed() evita derivaciones repetidas.
  • Los servicios resueltos de forma perezosa evitan la carga anticipada de dependencias pesadas.
  • Las fases de afterRenderEffect() impiden problemas de rendimiento en el layout.
  • La unión de eventos y ejecuciones elimina la detección redundante de cambios en aplicaciones basadas en zonas.
  • El análisis de paquetes revela dependencias accidentales.
  • Los Web Workers transfieren las tareas que consumen mucho CPU del hilo principal.
  • La aplicación Angular más rápida no es aquella que ejecuta JavaScript con mayor velocidad; es la que ejecuta menos JavaScript innecesario. Toda la estrategia se resume en una idea:

    MAKE ANGULAR FAST
                         │
              ┌──────────┴──────────┐
              │                     │
           DO LESS              DO LATER
              │                     │
        ┌─────┼─────┐         ┌─────┼─────┐
        │     │     │         │     │     │
      Less   Less  Less      Defer Lazy  Hydrate
      JS    CPU   DOM       code   data  later
        │     │     │         │     │     │
        └─────┴─────┴─────────┴─────┴─────┘
                         │
                         ▼
                  FASTER EXPERIENCE
    

    Mide, identifica el trabajo desperdiciado, úsalo y mide de nuevo.

    Lecturas relacionadas

  • Angular CLI más allá de ng serve: Generadores, destinos, caché y estadísticas de construcción — Conozca las opciones de Angular CLI que eliminan el trabajo rutinario: flags de generadores, ayuda integrada, comandos por proyecto, destinos de ng run, limpieza del caché y estadísticas de construcción.