División de código en servicios pesados de Angular bajo demanda con injectAsync
Por qué un servicio inyectado estáticamente arrastra sus bibliotecas al paquete principal de Angular, y cómo injectAsync carga de forma perezosa los servicios según la intención del usuario, incluyendo la carga condicional.
Fuentes: Stop Loading Massive Services: Mastering injectAsync in Angular 22
El cargado diferido a nivel de ruta y los bloques @defer evitan que las páginas y componentes poco utilizados formen parte del paquete inicial de una aplicación Angular, pero los servicios son otro asunto. Una sola llamada a inject() para incluir un servicio pesado en un componente siempre visible lleva a que ese servicio, junto con todas las bibliotecas que importe, se incluyan en el bloque principal. Este artículo explica por qué ocurre esto, por qué las soluciones anteriores eran complicadas y cómo la API injectAsync, introducida en Angular 22, permite cargar el código de un servicio solo cuando el usuario realmente lo necesita.
Considere los detalles de la API que se presentan a continuación como una información actualizada en un momento determinado: en el momento de redactar este texto, injectAsync es nuevo, por lo que debe verificar su firma exacta, su estado de estabilidad y las reglas de uso en la documentación actual de Angular antes de adoptarlo.
Un problema familiar con los paquetes
Imagínese un panel de control empresarial con un rendimiento adecuado durante la ejecución, pero con una carga inicial deficiente, donde el volumen del código JavaScript inicial ha aumentado hasta los 2,4 MB. Al ejecutar el proceso mediante source-map-explorer se observa que el principal culpable no es en absoluto un componente de interfaz de usuario, sino PdfExportService, que depende de librerías intensivas de renderizado del lado del cliente para generar informes financieros detallados de varias páginas.
Lo curioso es que solo alrededor del 5% de los usuarios pulsa “Exportar a PDF”. Sin embargo, como este servicio se integra en el componente del encabezado del panel de control, cada usuario descarga aproximadamente 600 KB de código de exportación en su primera visita. El costo lo pagan los 95% que nunca utilizan esta función.
Angular ya ofrece @defer y el dividir código por rutas para componentes y páginas. Lo que hasta ahora no ofrecía era una forma sencilla de cargar de forma perezosa un servicio manteniéndolo dentro del sistema de inyección de dependencias (DI).
Por qué un inject() estático termina en el paquete principal
Para entender qué cambia con injectAsync, es útil saber cómo herramientas de empaquetado como esbuild y Vite interactúan con el DI de Angular. Considere un componente de encabezado que inyecta el servicio de informes de la manera habitual:
import { Component, inject } from '@angular/core';
import { HeavyReportService } from './heavy-report.service';
@Component({ ... })
export class DashboardHeaderComponent {
// The Static Analysis Trap
private reportService = inject(HeavyReportService);
exportData() {
this.reportService.generate();
}
}
Lo importante es la instrucción import de nivel superior. El bundler analiza las importaciones de forma estática, detecta que DashboardHeaderComponent depende de HeavyReportService, y coloca ese servicio, junto con cada biblioteca de terceros que se importe, en el mismo bloque que el componente. Si el componente forma parte de la vista inicial, lo mismo ocurre con la biblioteca PDF. El propio llamado a inject() no es el problema; sí lo es la importación estática que lo hace posible.
La solución anterior y sus limitaciones
Los desarrolladores experimentados han evitado esto durante mucho tiempo utilizando await import(...) de forma dinámica dentro del manejador de clics, de modo que el bundler emita el servicio como un bloque separado.
La dificultad radica en que una clase importada dinámicamente es simplemente una clase. Si la construyes tú mismo, queda fuera del inyector de Angular, por lo que no se proporcionan dependencias como HttpClient para obtener un template, ni un servicio de autenticación que agregue un token. Para obtener una instancia correctamente configurada, era necesario inyectar primero un Injector y resolver la clase cargada a través de él, lo cual funciona pero dispersa la lógica de inyección de dependencias por todo el código del componente y es fácil cometer errores. (Para repasar cómo funciona la resolución en sí, consulta cómo Angular inyección de dependencias resuelve servicios con inject.)
Qué cambia con injectAsync
injectAsync cierra la brecha entre las importaciones dinámicas de ECMAScript y el árbol de inyector de Angular. Usted describe la dependencia mediante una función de carga, y de hecho le indica al compilador: mantén esto fuera del chunk principal, y consíguelo e instántialízalo solo cuando se solicite.
La inyección de dependencias ya no tiene que ser síncrona. La carga del código de un servicio puede esperar hasta el momento en que el usuario muestre intención de usarlo.
Paso 1: escriba el servicio complejo como de costumbre
No es necesario cambiar nada en el propio servicio. Todavía puede utilizar inject() y cualquier otra herramienta del ecosistema de Angular. Aquí se inyecta HttpClient e importa pdfMake, la gran dependencia que desea excluir de la carga inicial.
// heavy-report.service.ts (This will become its own separate JS chunk)
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import * as pdfMake from 'pdfmake/build/pdfmake'; // Massive dependency
@Injectable({ providedIn: 'root' })
export class HeavyReportService {
private http = inject(HttpClient);
async generate(data: any) {
// Complex, heavy PDF generation logic
}
}
Dado que nada en el código cargado de forma anticipada importa este archivo de manera estática, el bundler puede emitirlo, junto con pdfMake, como un chunk separado.
Paso 2: resolver el servicio en el límite de interacción
En lugar de inyectar el servicio como un campo de clase, el componente lo solicita al hacer clic en el botón de exportación. Una señal, isExporting, controla el estado deshabilitado y la etiqueta del botón mientras se descarga el chunk y se ejecuta el proceso.
// The Modern Code-Split Architecture
import { Component, injectAsync, signal } from '@angular/core';
@Component({
selector: 'app-dashboard-header',
template: `
<button (click)="onExport()" [disabled]="isExporting()">
{{ isExporting() ? 'Loading Exporter...' : 'Export PDF' }}
</button>
`
})
export class DashboardHeaderComponent {
isExporting = signal(false);
async onExport() {
this.isExporting.set(true);
try {
// 1. The Magic: The browser downloads the chunk over the network right now.
// Angular instantiates it and wires up all its DI dependencies (like HttpClient).
const reportService = await injectAsync(
() => import('./heavy-report.service').then(m => m.HeavyReportService)
);
// 2. Execute the heavy logic
await reportService.generate({ id: 123 });
} catch (error) {
console.error('Failed to load or execute the report service', error);
} finally {
this.isExporting.set(false);
}
}
}
Cuando se ejecuta el manejador, el navegador obtiene el fragmento del servicio, Angular crea la instancia y proporciona sus dependencias como HttpClient, y el componente llama a generate(). Aquí son importantes los bloques try/catch/finally: un fallo de red al cargar el fragmento ahora constituye un posible error en tiempo de ejecución, y el bloque finally garantiza que el botón se vuelva a habilitar en cualquier caso.
Un detalle que hay que verificar según la documentación: al igual que inject(), las funciones de inyección normalmente deben llamarse dentro de un contexto de inyección, como en un constructor o inicializador de campo, y no dentro de un manejador de eventos arbitrario. Si su versión de Angular exige lo mismo para injectAsync, llámela en un inicializador de campo para obtener un cargador e invoque ese cargador desde el manejador de clic en su lugar.
Después de una compilación para producción, el resultado contiene un pequeño archivo main.js y un chunk separado heavy-report.service-[hash].js. Los usuarios que nunca hacen clic en exportar nunca descargan la biblioteca PDF.
Carga condicional: enviar solo el código que puede ejecutar un usuario
Dividir una biblioteca grande en partes es el uso más obvio, pero el mismo mecanismo permite la inyección condicional de dependencias. Supongamos que las cuentas gratuitas utilizan un StandardDataProcessor mientras que las cuentas corporativas premium reciben un AIProDataProcessor.
El enfoque tradicional incluye ambas implementaciones y utiliza un proveedor de fábrica para elegir una en tiempo de ejecución, por lo que todos los usuarios descargan ambas. Con injectAsync, la rama determina qué chunk se debe cargar:
async processData(payload: any) {
this.isProcessing.set(true);
let processor;
if (this.currentUser.tier === 'PRO') {
// Only Pro users pay the network cost for the AI chunk
processor = await injectAsync(
() => import('./ai-pro-processor.service').then(m => m.AiProProcessorService)
);
} else {
// Free users download the lightweight chunk
processor = await injectAsync(
() => import('./standard-processor.service').then(m => m.StandardProcessorService)
);
}
await processor.execute(payload);
this.isProcessing.set(false);
}
Los usuarios profesionales pagan el costo de red del procesador de IA, mientras que todos los demás utilizan uno más ligero. Se trata de polimorfismo en tiempo de ejecución, donde la elección también determina qué se descarga, lo que separa el tamaño de los datos que carga un usuario del tamaño total de la base de código. Tal como está escrito, isProcessing permanece en true si se está cargando o si execute() lanza un error; es necesario envolver el cuerpo del código en try/finally, como en el ejemplo de exportación. Otorgar a ambos procesadores una interfaz compartida también permite que processor tenga un tipo definido en lugar de ser implícitamente any.
Cuándo no usarlo
El carga diferida sacrifica el tamaño inicial del paquete en favor de un retraso en el momento de uso. Para un servicio que la mayoría de los usuarios necesita en cada visita, posponer su carga solo traslada la espera a un momento peor. Sí resulta útil para funcionalidades pesadas, poco utilizadas o específicas de ciertos niveles. También se puede atenuar el retraso activando la carga al pasar el cursor o al centrar la atención en el botón, antes de hacer clic.
Puntos clave
- Realice una auditoría primero: utilice source-map-explorer o una herramienta similar para identificar los servicios pesados que se usan solo con poca frecuencia o de forma condicional.
- Elimine las inyecciones estáticas de esos servicios de los componentes cargados de forma inmediata, ya que es la importación estática la que los incluye en el paquete principal.
- Cargúelos en el momento en que el usuario tenga la intención de usarlos mediante
injectAsyncy unimport()dinámico, manteniendo así el soporte completo de inyección de dependencias.