El ciclo de detección de cambios, es parte de la magia de Angular, pero la falta de control sobre esa magia puede jugarte malas pasadas. Voy a explicarte como optimizar la estrategia de detección de cambios por defecto, gracias al servicio NgZone.

El ciclo de detección de cambios

El ciclo de detección de cambios es una pieza fundamental en Angular. Es el mecanismo que se encarga de tener actualizados los componentes de tu web en todo momento.

Cuando te llegan datos de una API rest y se desencadena la actualización de una vista, eso es detección de cambios.

Cuando haces click en un filtro y se actualiza el contenido de una tabla de datos, eso también es detección de cambios.

Seguro que lo vas pillando…

En el momento en que Angular detecta cambios (eventos, XHR, timers, …), empieza a recorrer el DOM, componente a componente, para comprobar si hay que actualizar algo.

Aunque Angular es muy eficiente… un mal diseño puede penalizar el rendimiento cuando tu web crece.

Anticipándose al desastre, Angular te proporciona 2 estrategias diferentes de detección de cambios.

  • Estrategia default: Es la estrategia utilizada, a menos que digas lo contrario. Cuando Angular detecta un cambio, en el lugar que sea, empieza a recorrer el árbol de componentes desde el principio para comprobar si tiene que actualizar algo.

  • Estrategia OnPush: Los componentes que utilizan esta estrategia se saltan los ciclos de detección de cambios a menos que se trate de un cambio de estado interno del propio componente o de sus inputs.

La segunda estrategia es claramente más eficiente que la primera, pero pierdes la comodidad de que todo se actualice «mágicamente».

En cualquier caso, si no hay nada en tu web que sobrecargue la CPU, es habitual trabajar con una estrategia default.

¿Se puede optimizar la estrategia default?

Imagina el siguiente escenario…

Llevas meses desarrollando una web Angular. Poco a poco ha ido ganando en complejidad. Ya tienes cientos de componentes. Y de repente… el desastre.

Un componente imprescindible, por ejemplo una cuenta atrás (que no debería afectar al resto de componentes), está lanzando varios ciclos de detección de cambios por segundo que afectan a toda la web y penaliza seriamente el rendimiento de la aplicación…

Si eres previsor y has seguido una estrategia de detección de cambios OnPush en toda la app, no te vas a encontrar nunca con este problema.

Pero ¿y si no es el caso?

¿Y si has empezado con una estrategia de detección de cambios default y ahora tendrías que migrar cientos de componentes a la estrategia OnPush para evitar el problema de rendimiento que está ocasionando un único componente?

¿No hay otro tipo de solución para este caso concreto?

SI.

LA HAY.

Esto, más o menos, le ocurrió hace poco a un lector, que contactó conmigo para pedirme consejo.
Me pareció un ejemplo interesante, así que voy a partir de un código similar para aclarar el problema.

Escenario inicial: componente contador

El escenario es el siguiente, tengo una vista con un header y un listado.

  • En el header hay:
    • un componente «cuenta atrás»
    • y un botón para reiniciar la cuenta.
  • El listado tiene 500 elementos.

initial scenario ngzones example

El problema radica en que cada segundo (cada vez que se actualiza la cuenta atrás), se ejecuta el ciclo de detección de cambios de todos los componentes en la vista (incluyendo los 500 items del listado).

Para que veas como puede afectar al rendimiento, he añadido un console.log en el hook ngOnCheck de cada item.

El resultado es el que ves a continuación.

performance issue

A cada segundo, el ciclo de detección de cambios provoca una carga brutal de CPU, ya que todos y cada uno de los items escribirán por consola. Obviamente en la vida real esto no pasaría, pero tus componentes podrían realizar otras operaciones, como por ejemplo evaluar sus bindings.

Puedes ver el código inicial a continuación, para echarle un vistazo.

Aislando los cambios frecuentes con ngZone

Para prevenir esta situación sin refactorizar el resto de componentes para que usen ChangeDetection.onPush, lo que puedes hacer es sacar el contador de la zona de detección de cambios de Angular.

¿Qué es la ngZone?

La Zone APIs es un mecanismo de los navegadores que facilita la detección de tareas asíncronas dentro de un contenedor, notificando los momentos clave de las mismas (inicio/final).

Angular utiliza la Zone APIs para crear su propia zona (ngZone) y detectar cuando se completan tareas asíncronas (setTimeouts, XHR, eventos tipo click, etc).

Ejemplo original (sin ngZone)

El objetivo es aislar los cambios frecuentes, pero para eso, hay que entender bien donde se generan. En este caso, el culpable es el componente Countdown.

El componente Countdown tiene un template muy simple:

<!-- countdown.component.html -->
time: {{sessionDuration}}

Como ves, simplemente escribo la propiedad sessionDuration en la vista.

La propiedad sessionDuration está declarada en la parte TS del componente, y se actualiza en la suscripción al observable startCountdown de mi servicio CountdownService.

Aquí tienes la parte interesante del código:

// countdown.component.ts

///...some imports...

@Component({
  selector: 'app-countdown',
  templateUrl: './countdown.html',
})
export class CountDownComponent implements OnInit{
  public sessionDuration:string = "00:00";

  constructor(private countdownService:CountdownService){}

  ngOnInit(){//start counter on component init
    this.countdownService.startCountdown(10)
    .subscribe(value =>{
      //and update the value displayed on the template
      this.sessionDuration = value;
    });
  }

  //...more stuff...
}

En cuanto al servicio CountdownService, lo único que debes saber es que su método startCountdown(x) devuelve un observable que emite un valor por segundo hasta acabar la cuenta atrás de x segundos.

En cualquier caso, recuerda que el problema es que cada vez que se actualiza sessionDuration, Angular lanza la detección de cambios a nivel global.

Conociendo a runOutsideAngular

Angular te ofrece el servicio NgZone para decidir qué código se ejecuta dentro de la Zona de Angular y qué código no.

Si das un vistazo a su API verás que es autoexplicativa, pero en todo caso, me gustaría destacar este método: runOutsideAngular().

¿Para que servirá?

¡Bingo!

Te permite trabajar fuera de la Zona de Angular.

Voy a refactorizar el código anterior para que veas cómo se usaría:

//...inside countdown.component.ts...

  //import NgZone in the constructor
  constructor(private  countdownService:CountdownService, private  ngZone:NgZone){}

  ngOnInit(){
    this.ngZone.runOutsideAngular(()=>{
      //execute subscription outside Angular Zone
      this.countdownService.startCountdown(10)
      .subscribe(value =>{
        this.sessionDuration = value;
      });
    });
  }

Si observaras la salida por consola, verías que ya no se están printando los 500 console logs por segundo.

¡Genial!

Solo que este código tiene un problema…

¿Te acuerdas del template del componente Countdown?

<!-- countdown.component.html -->
time: {{sessionDuration}}

Pues eso que ves ahí entre llaves, es una interpolación de Angular 🙁

Como sessionDuration se está actualizando fuera de la ngZone, Angular no se entera de que el valor de sessionDuration ha cambiado, así que tampoco actualizará la vista.

Entonces…

¿qué puedo hacer?

Ejecuta fuera de ngZone, actualiza a mano

En el momento en que quieres mantenerte al margen de ngZone, pierdes la actualización mágica de las vistas vía interpolación y/o bindings.

Si insistes en actualizar la vista, tendrás que hacerlo a mano, pero no sufras, es fácil.

Para actualizar el DOM a mano, necesitarás acceso a éste, así que lo primero es actualizar el template countdown.component.html para que quede así:

<!-- countdown.component.html -->
time: <span  #sessionDuration>00:00</span>

Ya no haces interpolación. Ahora estás definiendo una template reference variable para acceder a ese elemento <span> donde escribes la cuenta atrás.

Ahora solo te falta acceder a ese elemento desde el lado TS (con el decorador @ViewChild), y actualizar el DOM con el servicio Renderer2. Está chupado.

Así queda countdown.component.ts:

// countdown.component.ts

///...some imports...

@Component({
  selector: 'app-countdown',
  templateUrl: './countdown.html',
})
export class CountDownComponent implements OnInit{
  //get access to #sessionDuration element
  @ViewChild('sessionDuration') durationElement:ElementRef;

  //inject Renderer2 service
  constructor(private  renderer:Renderer2, private  countdownService:CountdownService, private  ngZone:NgZone){}

  ngOnInit(){
    this.ngZone.runOutsideAngular(()=>{
      this.countdownService.startCountdown(10).subscribe(value  =>{
        //use Renderer2 service to update DOM manually
        this.renderer.setProperty(this.durationElement.nativeElement, 'innerHTML', value);
      });
    });   
  }

  //...more stuff...
}

Ahora sí, la vista actualizará correctamente la cuenta atrás y en cambio, Angular no estará lanzando la detección de cambios constantemente.

Los resultados, a nivel de performance, son indudables:

angular performant countdown

Mientras antes cada cambio del contador me generaba una carga de CPU durante 650ms por el código que ejecutaba cada uno de los 500 items, ahora puedes ver que no son ni 0.9ms, ya que no les está afectando.

A continuación puedes ver el código completo del ejemplo final.

Reflexiones personales

Espero que este ejemplo te haya servido de ayuda para entender como funciona la Angular Zone, y cuándo conviene usar el servicio ngZone.

Como ves, conocer bien el framework con el que trabajas puedes marcar la diferencia entre unos resultados excelentes y unos pésimos, y te puede ahorrar muchos dolores de cabeza al enfrentarte a la «magia por defecto» de tu herramienta de trabajo.

¿Te ha gustado este artículo? No te cortes, déjame un comentario y ayúdame a compartirlo 😉