Cuando se optimiza el rendimiento de las aplicaciones Angular, muchos desarrolladores asocian directamente la estrategia de detección de cambios OnPush de Angular. Pero, si no sabes exactamente cómo funciona OnPush bajo el capó, te enseñará rápidamente lo que estás haciendo mal por las malas. En este artículo, vamos a profundizar en cómo la estrategia OnPush afecta al mecanismo de detección de cambios de Angular y qué trampas debemos evitar a toda costa.
Todos los ejemplos y referencias de código se refieren a Angular 14.2.2. Además, ten en cuenta que todas las explicaciones solo se aplican a las versiones de Angular basadas en Ivy.
En el trabajo diario de un programador por supuesto, que el tema de la optimización del rendimiento está siempre en la agenda. Muchos desarrolladores del entorno Angular asocian la "optimización del rendimiento" directamente con OnPush.
En consecuencia, OnPush se suele utilizar en muchos proyectos desde el principio. Pero OnPush no garantiza automáticamente que se dispare el rendimiento de tu aplicación. Si no sabes lo que hace bajo el capó, puede convertirse rápidamente en lo contrario. Puede dar lugar a problemas inesperados, que sólo se manifiestan mucho más tarde en el proyecto y no son fáciles de detectar.
Para ayudarte a evitar estos escollos desde el principio, primero nos sumergiremos en el código de Angular para ver qué hace OnPush con el mecanismo de detección de cambios y luego nos adentraremos en algunos ejemplos.
🫣 Antes de profundizar en los aspectos internos de OnPush, debes asegurarte de que tienes una comprensión general de lo que es la detección de cambios, por qué la necesitamos y cómo la maneja Angular.
Componente = Vista
Como todos sabemos, nuestros componentes en Angular forman una estructura de árbol jerárquica. Sin embargo, la detección de cambios no se ejecuta en el componente sino en una capa de abstracción de bajo nivel llamada Vista o, más precisamente, LView
(el término "vista" se utiliza indistintamente con la palabra "componente" en lo que sigue). Una vista está directamente asociada a una instancia de un componente y contiene información de estado adicional, los LViewFlags
. Estas banderas son significativas para decidir si un ciclo de detección de cambios para la vista y todos sus hijos será omitido o no. La propiedad más importante aquí es CheckAlways
.
Nota: Cómo habrás notado, las banderas representan bits específicos de un número. Este mecanismo funciona bien cuando se almacenan múltiples banderas en un solo valor.
Cuando no está presente la bandera CheckAlways
, Angular omitirá las comprobaciones de detección de cambios de la vista y de todos sus hijos. Y aquí es donde entra en juego la ChangeDetectionStrategy
.
Las dos estrategias de detección de cambios de Angular
El valor predeterminado de CheckAlways corresponde a ChangeDetectionStrategy.Default
de Angular. Aquí, Angular comienza en la vista superior y aplica recursivamente el proceso de comprobación y actualización para todas las vistas hijas, el árbol hacia abajo.
Por lo tanto, ChangedetectionStrategy.OnPush
debe desactivar la bandera CheckAlways
, ¿verdad? No es tan sencillo. Según los comentarios del código de Angular, ocurre lo siguiente:
Utiliza la estrategia CheckOnce
, lo que significa que la detección automática de cambios se desactiva hasta que se reactiva estableciendo la estrategia como Default
(CheckAlways
).
La detección de cambios puede seguir siendo invocada explícitamente. Esta estrategia se aplica a todas las directivas hijas y no puede ser anulada. (Código Angular aquí)
Podemos confirmar esa cita mirando dentro de la clase ComponentFactory
de Angular (código de Angular aquí). Con OnPush, la vista se marca como Dirty para comprobarla una vez; en caso contrario, CheckAlways
es el valor por defecto.
const rootFlags = this.componentDef.onPush
? LViewFlags.Dirty | LViewFlags.IsRoot
: LViewFlags.CheckAlways | LViewFlags.IsRoot;
Sin embargo, si el @Input
del componente cambia, Angular programará las comprobaciones de nuevo. La función setInput
del ComponentRef
se encarga de esto. Una función markDirtyIfOnPush
es llamada y establece la bandera Dirty en la vista, por lo que se comprueba.
export function markDirtyIfOnPush(lView: LView, viewIndex: number): void {
ngDevMode && assertLView(lView);
const childComponentLView = getComponentLViewByIndex(viewIndex, lView);
if (!(childComponentLView[FLAGS] & LViewFlags.CheckAlways)) {
childComponentLView[FLAGS] |= LViewFlags.Dirty;
}
}
Llegados a este punto, deberíamos ver los tres escenarios en los que un componente OnPush desencadena una detección de cambios:
Cuando un @Input
cambia
Cuando una @Salida
se dispara
Cuando se llama a la función markForCheck
Entonces, ¿qué podemos sacar de esto? Vimos que OnPush no es una herramienta de rendimiento per se; es sólo una estrategia diferente para la detección de cambios de Angular que puede ayudar a reducir los ciclos de detección de cambios innecesarios, lo que puede resultar en un mejor rendimiento. Especialmente en aplicaciones grandes con muchos componentes, pero no es un potenciador de rendimiento garantizado para todas las aplicaciones.
Después de toda esta árida teoría, es hora de algunos ejemplos prácticos.
Hazlo siempre a la "manera de Angular"
Durante mi trabajo diario, me tropecé con un repositorio git, que implementa un componente gráfico de selección de datos. Es un excelente ejemplo de cómo OnPush puede mostrarte tus errores. Se creo una aplicación Angular con el mencionado selector de datos en el siguiente ejemplo (Se tomó el código del repositorio y se adaptó un poco para ajustarlo a la versión actual de Angular; por favor, siéntete libre de jugar con él).
Usando ChangeDetectionStrategy.Default
(que es la predeterminada), todo funciona sin problemas. Pero el componente parece roto cuando lo establecemos en OnPush (como en el ejemplo de abajo). Normalmente, la rueda de datos debería girar y bloquearse cuando se suelta. ¿Pero cuál es la razón de esto?
Cuando miramos el código de los componentes, estamos aún más confundidos. Hay varios manejadores de eventos que reaccionan a los eventos del ratón. Cuando interactuamos con la rueda y miramos la consola, vemos que los eventos están registrados y haciendo su trabajo.
Como resultado de lo que hemos aprendido, debería activarse un ciclo de detección de cambios. Pero, ¿por qué no fue así? 😥
El error está básicamente en el lugar donde se produce el registro del EventHandler
. Cuando miramos el código, podemos ver que los manejadores se registran directamente en el elemento con addEventListener
. Como resultado, Angular no sabe que los eventos se levantan en este componente en particular. Por lo tanto, no puede establecer la bandera Dirty en la vista y omitirá el componente (y sus hijos) de la detección de cambios.
addEventsForElement(el): void {
const _ = this.touchOrMouse.isTouchable;
const eventHandlerList = [
{ name: _ ? 'touchstart' : 'mousedown', handler: this.handleStart },
{ name: _ ? 'touchmove' : 'mousemove', handler: this.handleMove },
{ name: _ ? 'touchend' : 'mouseup', handler: this.handleEnd },
{
name: _ ? 'touchcancel' : 'mouseleave',
handler: this.handleCancel,
},
];
eventHandlerList.forEach((item, index) => {
el.removeEventListener(item.name, item.handler, false);
el.addEventListener(item.name, item.handler.bind(this), false);
});
}
Pero, ¿cómo podemos resolver el problema? En este caso, es bastante simple. Necesitamos registrar los eventos a la "manera de Angular". En este ejemplo, debemos hacerlo a través de la vinculación de plantillas. De este modo, el framework puede mapear el evento al componente y marcarlo para que sea comprobado, aunque utilizando OnPush.
Este pequeño ejemplo nos muestra lo importante que es resolver nuestros problemas con las herramientas que nos proporciona Angular y cómo OnPush nos ha mostrado nuestros errores que posiblemente no habríamos descubierto con la estrategia por defecto.
Los objetos mutables y OnPush no se gustan
Veamos otro ejemplo. Un componente de presentación con detección de cambios OnPush recibe un objeto persona como parámetro de entrada. Después de la puesta en marcha, el objeto se muestra correctamente; hasta aquí, todo bien. Entonces cambiamos un valor en el formulario. ¿Pero qué vemos? No ocurre nada. El componente hijo no muestra el cambio correctamente. Pero cuando comprobamos la salida en la consola, todo debería funcionar correctamente.
La sencilla razón es que los componentes basados en OnPush comparan sus parámetros de entrada mediante la comparación de objetos ( Object.is()
, código de Angular aquí). En el caso anterior, el objeto en sí no cambió; en cambio, el código mutó sólo un miembro del objeto.
mutatePerson() {
this.person.name = this.form.value.name;
this.person.lastName = this.form.value.lastName;
this.person.age = this.form.value.age;
console.log('Person mutated values: ', this.person);
}
El @Input
no registra ningún cambio y no establece la bandera Dirty para el siguiente ciclo. Exactamente estas constelaciones de problemas pueden conducir a menudo a sesiones de depuración involuntariamente extensas.
Para evitarlos desde el principio, deberías usar un estado inmutable u objetos inmutables. Hacer cambios en objetos inmutables significa que no modificas ninguna propiedad sino que siempre creas un nuevo objeto con los valores cambiados. También es obligatorio tratar tus objetos de esta manera cuando se utiliza cualquier patrón de tienda o biblioteca relacionada con la tienda como ngrx.
Por último, pero no menos importante, ¿cómo arreglamos nuestro ejemplo? Como ya hemos dicho, tenemos que sustituir el objeto persona por uno nuevo. Y todo funciona como se esperaba.
mutatePerson() {
this.person = this.form.value;
console.log('Person mutaded values: ', this.person);
}
Conclusión
Por último, vamos a resumir brevemente lo que hemos aprendido. Hemos visto que OnPush no es una herramienta de rendimiento per se. Simplemente cambia la estrategia de Angular para manejar los ciclos de detección de cambios.
Al utilizar OnPush, tenemos que asumir la responsabilidad de saber cuándo se actualiza realmente la vista y posiblemente tengamos que actualizarla manualmente.
Como resultado, nos recompensa con la reducción de los ciclos de detección de cambios y, por lo tanto, puede aumentar el rendimiento de nuestras aplicaciones. Pero hay que tener en cuenta que OnPush también nos muestra sin piedad nuestros errores, lo que puede provocar algunos dolores de cabeza.
Pero después de leer este artículo, estos dolores de cabeza ya deberían ser historia.