Angular: changeDetector, ngZone y asyncPipe

Introducción

Llevamos ya varios meses utilizando Angular como framework principal para desarrollar aplicaciones cliente. Superada la curva de aprendizaje inicial, los resultados son bastante satisfactorios. Angular nos está permitiendo sacar a producción proyectos web, a un coste reducido, consiguiendo además un rendimiento más que correcto sin hacer demasiadas magias con el framework.

Pero de vez en cuando se nos presentan proyecto diferentes que nos obligan a exprimir la tecnología. En esta ocasión se trata de una aplicación con una alta necesidad de refresco de datos: contamos con una fuente de datos remota (a través de websocket), que nos obliga a actualizar varias variables 25 veces por segundo… y hay que mostrar por pantalla esas variables. Por supuesto, queremos que la CPU ni se entere de todo esto.

Utilizar el mecanismo de detección de cambios y actualización de vistas de angular nos ha llevado a buen puerto, aunque en esta ocasión sí que ha hecho falta algo más que usar el framework de manera estándar.
A continuación vamos a describir el proceso de actualización de las vistas en angular. Se da por hecho que el lector ha utilizado mínimamente el framework para seguir lo que vamos a exponer a continuación:

  • Expondremos qué es una aplicación angular
  • Que significa y cómo detectar un cambio
  • Cómo angular detecta esos cambios
  • Cómo se utiliza zone.js y que es ngZone
  • Cómo funciona la estrategia de detección de cambios en angular
  • Consejos para conseguir sacar el mejor rendimiento del framework

Esquema de una aplicación angular

Una aplicación Angular está compuesta por una estructura jerárquica en forma de árbol que interconecta componentes.
Un componente está compuesto por un modelo de datos (lo llamaremos estado del componente), y una plantilla donde se renderizan esos datos. El estado del componente serán las propiedades de la clase componente, y la vista será el HTML generado por el componente.
Un componente generará (o podrá generar) otros componentes angular, generado el HTML necesario para instanciar esos nuevos componentes.
En principio nada más… en esta ocasión vamos a obviar servicios, inyección de dependencias, pipes, módulos…
El mayor reto planteado será mantener sincronizado el estado del componente con su representación en la vista mediante nodos DOM. En realidad, es el reto de cualquier framework Javascript, ya que es ese proceso de renderizado lo más caro a nivel de proceso.

Detectando cambios

Angular tiene una mecanismo denominado ChangeDetector para detectar inconsistencias (cambios), entre el estado del component y la vista.
Es un algoritmo ultra eficiente, optimizado para la VM de Javascript y generado específicamente para cada componente (en tiempo de transpilación).
Es un mecanismo que se comienza a ejecutar en el nodo padre (root-element), y que se va propagando a cada nodo hijo. Si se detecta un cambio no aplicado en la vista, éste generará una modificación en el árbol DOM.

Pero antes de seguir con cómo se detectan los cambios, vamos a descubrir lo más importante: ¿en qué momento debemos ejecutar el ChangeDetector? ¿Cada cuánto tiempo vamos a buscar cambios en cada uno de los componentes de nuestra aplicación?

Invocando el ChangeDetector

Conseguir un buen rendimiento en nuestra aplicación, va a estar muy relacionado con cuántas veces (y con qué frecuencia), se ejecuta el proceso de detección de cambios.
Para dar respuesta a este problema, primero hay que responder a la simple pregunta: ¿qué puede provocar un cambio en el estado de nuestro componente? En el 99% de los casos la respuesta se encuentra en 3 grandes grupos de causas:

  • Eventos en la interfaz (clicks, mouseover, resizes, etc)
  • Peticiones Ajax
  • Ejecuciones dentro de timers (setTimeout o setInterval)

Se puede concluir con cierta seguridad que deberemos actualizar nuestras vistas (o comprobarlo al menos), después de ejecutarse uno de estos 3 supuestos.

zone.js

El paradigma resuelto con Zone.js, se resume en un contexto de ejecución para operaciones asíncronas. Una zona se encarga de ejecutar una porción de código asíncrono, siendo capaz de conocer cuando terminan todas las operaciones asíncronas.
Básicamente, todas las invocaciones a código asíncrono son parcheadas en el contexto de ejecución, para poder tener control de éstas.

function miMetodo() {
  setStyles();
  setTimeout(notificarCabecera, 3000);
  metodoSecreto()
}

zone.miMetodo();

En este ejemplo, zone.run terminará de ejecutarse en 3.000 milisegundos, cuando concluye la ejecución de setTimeout.

Una zona tiene varios hooks disponibles:

  • onZoneCreated  (al crear un nuevo fork)
  • beforeTask  (antes de ejecutar una nueva tarea con zone.run)
  • afterTask (después de ejecutar la tarea con zone.run)
  • onError (Se ejecuta con cualquier error lanzado desde zone.run)
const myZoneSpec = {
  beforeTask: () => {
    console.log('Antes del run');
  },
  afterTask: () => {
    console.log('Después del run');
  }
};

const myZone = zone.fork(myZoneSpec);
myZone.run(miMetodo);

Al hacer fork en una zona, obtendremos otra zona con todos esos hooks definidos en su padre, heredados en nuestro nueva zona. Cuando ejecutemos «miMetodo» dentro de myZone, podremos ver por consola dos mensajes; «Antes del run» se ejecutará antes de correr nuestro método, y pasados los 3 ms definidos en el setTimeout, se ejecutará el callback definido en afterTask.

NgZone

NgZone es la implementación utilizada en angular para la ejecución de tareas asíncronas. Es un fork de zone.js con ciertas funcionalidades extra orientadas a gestionar la ejecución de nuestros componentes y servicios dentro (o fuera) de la zona de angular.
Será también el encargado de notificar al ChangeDetector de que debe ejecutarse para buscar posibles cambios en el estado de los componentes, y actualizar el DOM si fuera necesario. NgZone se utiliza como un servicio inyectable en nuestros componentes o servicios que nos expone los siguientes métodos para facilitarnos la gestión del mecanismo:

  • runOutsideAngular: Ejecuta el código fuera de la zona de angular; al concluir, no ejecuta ChangeDetector
  • run: ejecuta cierto código dentro de la zona
  • runGuard: igual que run, pero los errores se inyectan en onError en lugar de ser re-ejecutados

Hay que tener en cuenta que cualquier código ejecutado dentro de la zona, y que sea susceptible a cambios (actualización Ajax, evento de ratón por ejemplo), va a forzar la ejecución del detector de cambios en todo el árbol de la aplicación. Y hay que tener en cuenta que esa detección de cambios, aunque optimizada, puede tener un coste de proceso costoso, sobre todo si se ejecuta varias veces por segundo. En determinadas ocasiones existe un elevado número de ejecuciones asíncronas, que nos llevarían a una ejecución del detector de cambios con una frecuencia más elevada de lo deseado.

Por ejemplo, capturar el evento mousemove en un determinado componente, invocará el ChangeDetector en todo el árbol de la aplicación con cada pixel por el que pase el puntero. Es una buena práctica capturar ese evento fuera de la zona de angular (runOutsideAngular), cosa que debe utilizarse con cautela, ya que puede dar lugar a inconsistencias entre el estado de los componentes y el DOM.

Estrategias de detección de cambios

Con cada actualización en NgZone, angular deberá ejecutar el detector de cambios en cada uno de los componentes. Para que un nodo DOM se actualice, es imprescindible que se ejecute el detector de cambios en «ese» nodo; y para que esto se lleve a cabo, se debe ejecutar en todos su antepasados hasta el document-root.

En principio no hay manera de asociar determinadas zonas a determinados componentes: una ejecución de cualquier método asíncrono en la zona, desencadenará la ejecución de ChangeDetector en todo el árbol de nuestra aplicación. Angular ha implementado 2 estrategias para detectar posibles cambios en el estado del componente: Default y OnPush. Cada una de estas dos estrategias determina cómo y sobre todo cuándo se ejecuta el ChangeDetector en qué componentes del árbol de componentes que es nuestra aplicación

ChangeDetectionStrategy.Default

Es la estrategia por defecto, que hace que todo funcione correctamente sin necesidad de preocuparnos de invocar digest o applys variados; eso si, se realiza una especie de estrategia de fuerza bruta.
Por cada cambio ejecutado y detectado en la zona, Angular realiza comparaciones en todas las variables referenciadas en el template, tanto por referencia como por valor (incluso en objetos muy profundos), en todos los componentes de la aplicación.
Hay que tener en cuenta que esas comparaciones por valor, son muy costosas en cuanto a consumo de CPU, ya que deberá ir comparando cualquier objeto que esté asociado a la vista. Y es un cálculo que se ejecutará con cualquier cambio detectado, tenga o no tenga que ver con nuestro componente.
No obstante, por norma general, en el 95% de las aplicaciones web, es una estrategia válida y que consigue un rendimiento más que aceptable.

ChangeDetectionStrategy.OnPush

Esta estrategia de actualización, como veremos a continuación en mucho más barata en términos de consumo de CPU. Es una estrategia que se hereda de padres a hijos, por lo que podrá estar definida en todo el árbol de la aplicación, o bien en ciertas ramas.
Con cada cambio registrado en la actualización de zona, la única comprobación que se realizará por componente, será de los parámetros @Input de dicho componente.
Únicamente se invocará el ChangeDetector de ese componente, si se detecta cambios en la referencia de los parámetros @Input del componente. La comprobación por referencia es mucho más óptima y rápida, pero puede dar lugar a ciertas frustraciones si no controlamos bien qué estamos haciendo.
Es cuando una referencia se actualiza, cuando se volverá a renderizar la vista… ¿Qué hacemos cuando necesitamos que la vista se refresque, aunque no cambie la referencia @Input de nuestro componente?

ChangeDetectorRef

Angular nos ofrece un servicio denominado ChangeDetectorRef, que es una referencia al ChangeDetector inyectable en nuestro componente.

import { ChangeDetectorRef, Component } from '@angular/core';

export class MyComponent {
constructor(
  private cdRef: ChangeDetectorRef
) {}

Este servicio nos facilita poder gestionar el detector de cambios a voluntad, lo cual resulta muy útil cuando estamos utilizando la estrategia de actualización OnPush, o bien cuando ejecutamos código fuera de la zona.
El servicio expone los siguientes métodos públicos que exponemos a continuación:

ChangeDetectorRef.markForCheck()

Al invocar a este método, nos aseguramos que el detector de cambios del componente y de todos sus antepasados (hasta document-root), se ejecutará en la próxima ejecución de la zona.
Una vez realizada una única detección de cambios en el componente, se volverá a la estrategia OnPush (no tiene sentido invocar este método con otra estrategia de detección).

RemoteObservable.subscribe(config => {
  this.config = config;
  this.cdRef.markForCheck();
});

Un caso de uso típico sería invocar este método cada vez que actualicemos valores representados en la plantilla; por ejemplo en la suscripción a un observable (Aunque es recomendable ver la última parte del artículo, AsyncPipe).

ChangeDetectorRef.detach()

Al invocar a este método, estamos sacando el componente (y todos sus hijos), de la futura detección de cambios de la aplicación.

Las futuras sincronizaciones entre los estados del componente y las plantillas, deberá realizarse manualmente.

ChangeDetectorRef.reattach()

Al invocar a este método, volvemos a incluir el componente (y todos sus hijos), en las futuras detecciones de cambios de la aplicación.

ChangeDetectorRef.detectChanges()

Se ejecutará manualmente el detector de cambios en el componente, y en todos sus hijos.

Muy usado en componentes en los que se ha invocado «detach«, consiguiendo así una actualización de la vista.

Ideas generales para un rendimiento exigente

Una vez definidos todos los elementos utilizados por angular para mantener sincronizadas las vistas y el estado de sus componentes, vamos a exponer las ideas generales para conseguir un rendimiento óptimo en aplicaciones exigentes.
Entendemos una aplicación de rendimiento exigente aquella que ejecuta un elevado número de operaciones de cambio por segundo (eventos/ XHR/websocket), y que debe actualizar la vista en base a estas ejecuciones.
Si optamos por quedarnos con lo sencillo y funcional y utilizamos la estrategia ChangeDetectionStrategy.Default, es recomendable seguir ciertos consejos:

  • Ejecutar fuera de la zona cualquier evento de alta frecuencia; de manera que esas ejecuciones quedan desacoplados de la ejecución de los detectores de cambios en nuestro árbol de componentes.
  • Evitar en la medida de lo posible «getters» costosos directamente enchufados a la vista (utilizar memoizes por ejemplo).
  • Desacoplar (detach) los detectores de cambios, y ejecutarlos únicamente bajo demanda.

Utilizar la estrategia ChangeDetectionStrategy.OnPush nos va a suponer un mayor rendimiento en nuestra aplicación. Por la sencilla razón de que el algoritmo de detección de cambios se va a ejecutar muchas menos veces. A cambio, esta estrategia nos va a obligar a disponer de un diseño mucho más cuidado y específico en nuestros componentes. Estos serían ciertos consejos a tener en cuenta optando por estrategía OnPush:

  • Crear (y anidar) componentes con la mayor profundidad posible. Esto implica que nuestro componentes serán más simples y reutilizables.
  • Inyectar propiedades en estos componentes mediante @Input, utilizando estrategias de inmutabilidad:
    • Utilizar immutable.js
    • La implementación de ngrx, nos trae un estado inmutable (redux style) a angular
    • O siempre nos quedará Object.assing() / {…object}
  • (ab)usar de los Observables; siempre nos avisan cuando llega un nuevo dato:
    • En esos casos podríamos invocar markForCheck en nuestro componente con cada emisión en nuestra suscripción.
    • O mejor aún, podemos no suscribirnos al observable, y delegar la suscripción directamente en el template mediante AsyncPipe.

AsyncPipe

Desde las versiones 4.* de angular(Abril 2017), tenemos disponible el pipe AsyncPipe de manera nativa en nuestro templates.
Este pipe nos permite delegar la suscripción de un observable directamente a la vista. Igualmente, el componente es el encargado de desuscribirse del observable,  evitando potenciales memory leaks por dejar suscripciones olvidadas.
Se evitan asignaciones «feas» en el código fuente del componentes.
El uso de observables y todos los operadores disponibles, nos permite hacer llegar el dato desde cualquier fuente de datos, hasta el template de manera ciertamente elegante, además de óptima, ya que la vista se actualizará únicamente cuando el nuevo dato esté disponible.
Únicamente debemos preocuparnos de exponer el observable en el componente:

ngOnInit() {
  this.config = this.service.getRemoteObservable()
}

Y utilizar el pipe directamente en la vista:

<div *ngIf="(config | async) as myConfig">
  <h1 [style.color]="myConfig.color">{{ myConfig.title }}</h1>
</div>

 

Este pipe compatible 100% con ChangeDetectionStrategy.OnPush, ya que internamente invoca el método checkForUpdate() con la llegada del nuevo dato.

Conclusión

Esto ha sido un breve resumen de cómo funciona internamente el proceso de actualización de vistas en angular. Este contenido es un resumen de unas presentación que se ha utilizado en un curso de desarrollo para un equipo de frontend.

Si tu equipo necesita un curso de formación de angular, quizá podamos echaros una mano. Contáctanos y hablamos.

Para saber maś y mejor, recomiendo encarecidamente echar un ojo a los siguientes artículos:
https://blog.thoughtram.io/angular/2016/02/01/zones-in-angular-2.html
https://blog.thoughtram.io/angular/2016/01/22/understanding-zones.html
https://blog.angularindepth.com/everything-you-need-to-know-about-change-detection-in-angular-8006c51d206f
https://blog.thoughtram.io/angular/2016/02/22/angular-2-change-detection-explained.html



¿Te gusta este post? Es solo un ejemplo de cómo podemos ayudar a tu empresa...
Sobre Javier Infante Porro

Linux, HTTP, JS, TS, PHP... Programo (entre otras cosas) en Irontec desde hace más de 12 ó 13 años... @jabiinfante

1 Comentario

¿Por qué no comentas tú también?


  • Muy buen artículo! Gracias

    mmfilesi Hace 6 años Responde


Queremos tu opinión :)