Capítulo 25 - Parte 4: Zoneless Angular con provideExperimentalZonelessChangeDetection¶
Parte 4 de 4 · Capítulo 25 · PARTE XII - Optimización y Rendimiento
Durante décadas, Zone.js fue la pieza invisible que mantenía la magia de Angular: cualquier cosa asíncrona disparaba automáticamente la detección de cambios. El precio es el monkey-patching de decenas de APIs del navegador, un bundle ligeramente más grande y una capa de indirección que complica el debugging de stacks de error. Angular 18 introduce una alternativa experimental que elimina Zone.js del todo: provideExperimentalZonelessChangeDetection. Es experimental, pero estable en producción para equipos que adoptan Signals consistentemente.
Configurar la aplicación Zoneless¶
El primer paso es quitar Zone.js del bundle. En angular.json, buscamos la sección polyfills del proyecto y eliminamos la entrada de zone.js:
Luego, en main.ts, registramos el proveedor Zoneless:
import { bootstrapApplication } from '@angular/platform-browser';
import { provideExperimentalZonelessChangeDetection } from '@angular/core';
import { AppComponent } from './app/app.component';
bootstrapApplication(AppComponent, {
providers: [
provideExperimentalZonelessChangeDetection()
// El resto de providers: provideRouter, provideHttpClient, etc.
]
}).catch(err => console.error(err));
Estos dos cambios son todo lo que Angular necesita para arrancar sin Zone.js. Lo que cambia profundamente es cómo los componentes notifican que su estado cambió.
Qué deja de funcionar automáticamente¶
Sin Zone.js, ninguna API asíncrona del navegador notifica a Angular que algo cambió. Un setTimeout que modifica una propiedad del componente no produce ninguna actualización de vista, porque nadie intercepta su finalización.
import { Component, ChangeDetectionStrategy } from '@angular/core';
@Component({
selector: 'app-contador',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush, // obligatorio en Zoneless
template: `<p>{{ contador }}</p><button (click)="iniciar()">Iniciar</button>`
})
export class ContadorComponent {
contador = 0;
iniciar(): void {
// Sin Zone.js: este setTimeout NO actualiza la vista
setTimeout(() => {
this.contador++; // la propiedad cambia, pero Angular no lo sabe
}, 1000);
// El clic del botón SÍ disparará CD porque Angular gestiona
// los event listeners del template internamente, incluso sin Zone.js
}
}
Las APIs que dejan de disparar CD automáticamente incluyen: setTimeout, setInterval, Promise.then, fetch (directamente), addEventListener fuera del template de Angular y cualquier callback de librería nativa.
Migrar código async a Signals¶
La respuesta de Angular al mundo Zoneless es Signals. Un Signal notifica al sistema de reactividad de Angular directamente, sin necesitar que Zone.js observe ninguna API del navegador.
import {
Component, ChangeDetectionStrategy, signal, computed, effect
} from '@angular/core';
@Component({
selector: 'app-contador-zoneless',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<p>Contador: {{ contador() }}</p>
<p>Doble: {{ doble() }}</p>
<button (click)="iniciar()">Iniciar</button>
`
})
export class ContadorZonelessComponent {
// Signal en lugar de propiedad plana - Angular rastreará cambios
contador = signal(0);
// computed se recalcula cuando contador() cambia
doble = computed(() => this.contador() * 2);
iniciar(): void {
setTimeout(() => {
// signal.set() notifica a Angular directamente
// No necesita Zone.js para disparar la actualización de vista
this.contador.set(this.contador() + 1);
}, 1000);
}
}
signal.set(), signal.update() y signal.mutate() (este último deprecado en Angular 18+) notifican al scheduler interno de Angular. En modo Zoneless, Angular comprueba si hay Signals marcados como "sucios" en cada ciclo de animación del navegador -de forma similar a como React lo hace con su Fiber scheduler.
El async pipe sigue funcionando¶
Una buena noticia para migraciones graduales: el async pipe es completamente compatible con Zoneless. Internamente llama a markForCheck() cuando el Observable emite, lo cual es suficiente para que Angular actualice el componente incluso sin Zone.js.
import { Component, ChangeDetectionStrategy, inject } from '@angular/core';
import { AsyncPipe } from '@angular/common';
import { Observable } from 'rxjs';
import { ProductosService } from '../services/productos.service';
interface Producto {
readonly id: number;
readonly nombre: string;
}
@Component({
selector: 'app-lista-zoneless',
standalone: true,
imports: [AsyncPipe],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
@for (producto of productos$ | async; track producto.id) {
<p>{{ producto.nombre }}</p>
}
`
})
export class ListaZonelessComponent {
private productosService = inject(ProductosService);
// El async pipe llama markForCheck() en cada emisión → funciona en Zoneless
productos$: Observable<Producto[]> = this.productosService.obtenerTodos();
}
Diagrama: comparación Zone.js vs Zoneless¶
flowchart LR
subgraph ConZone["Con Zone.js"]
A1[setTimeout finaliza] --> B1[Zone.js intercepta]
B1 --> C1[NgZone notifica a Angular]
C1 --> D1[Ciclo de CD\nen todo el árbol]
end
subgraph SinZone["Zoneless - con Signals"]
A2[signal.set llama] --> B2[Scheduler interno\nde Angular]
B2 --> C2[Marca componente\ncon Signal como sucio]
C2 --> D2[CD selectivo\nsolo componentes afectados]
end
style ConZone fill:#fef3c7
style SinZone fill:#d1fae5
Checklist de compatibilidad Zoneless¶
Antes de migrar una aplicación existente a Zoneless, vale recorrer esta lista:
setTimeout/setIntervalque modifican estado del componente: migrar asignal.set()dentro del callback, o usarrxjs timer()conasync pipe.Promise.thenque actualiza propiedades: envolver en Signal o convertir a Observable confrom().- Integraciones con librerías de terceros que llaman callbacks: añadir
ChangeDetectorRef.markForCheck()odetectChanges()en el callback (→ ver Parte 3). - Formularios reactivos:
FormControl.valueChangeses un Observable →async pipeo Signal derivado contoSignal(). HttpClient: siempre Observable →async pipeotoSignal(). Funciona en Zoneless sin cambios si se usa elasync pipe.- Componentes de terceros (librerías UI): verificar que no dependan de Zone.js para actualizar su estado interno.
- Pruebas unitarias:
ComponentFixture.detectChanges()sigue funcionando en Zoneless;fakeAsync/ticktambién.
Estado actual y el futuro sin Zone¶
En Angular 18 y 19, provideExperimentalZonelessChangeDetection estaba marcado como experimental. Angular 20 lo promovió a developer preview y Angular 21 lo graduó a estable, renombrándolo a provideZonelessChangeDetection() y eliminando el prefijo Experimental.
El futuro de Angular es un scheduler propio basado en Signals, más predecible y más rápido que el modelo basado en Zone.js, comparable en filosofía a cómo funciona Solid.js o el modelo de reactividad fino de Vue 3. Zone.js no desaparecerá de inmediato -el soporte continuará por compatibilidad- pero los equipos que adopten Signals hoy están alineados con la dirección del framework.
Puntos clave¶
- Zoneless requiere dos cambios: quitar
zone.jsdepolyfillsenangular.jsony agregarprovideExperimentalZonelessChangeDetection()enbootstrapApplication - Sin Zone.js,
setTimeoutyfetchno disparan CD automáticamente; los Signals sí lo hacen signal.set()ysignal.update()notifican al scheduler de Angular directamente, sin intermediarios- El
async pipees compatible con Zoneless porque internamente llamamarkForCheck() - En Angular 18-19 es experimental pero funcional; la API estable se espera en Angular 20
¿Qué sigue?¶
En el Capítulo 26 pasamos de optimización de Change Detection a optimización de rendimiento visual: Virtual Scrolling para listas largas, imágenes optimizadas con NgOptimizedImage y análisis del bundle para identificar el peso real de nuestra aplicación.