Optimiza la detección de cambios de Angular

Implementa una detección de cambios más rápida para mejorar la experiencia del usuario.

Angular ejecuta su mecanismo de detección de cambios periódicamente para que los cambios en el modelo de datos se reflejen en la vista de una app. La detección de cambios se puede activar de forma manual o a través de un evento asíncrono (por ejemplo, una interacción del usuario o una finalización de XHR).

La detección de cambios es una herramienta poderosa, pero si se ejecuta con mucha frecuencia, puede activar muchos procesamientos y bloquear el subproceso principal del navegador.

En esta publicación, aprenderás a controlar y optimizar el mecanismo de detección de cambios omitiendo partes de tu aplicación y ejecutando la detección de cambios solo cuando sea necesario.

Dentro de la detección de cambios de Angular

Para comprender cómo funciona la detección de cambios de Angular, veamos una app de ejemplo.

Puedes encontrar el código de la app en este repositorio de GitHub.

La aplicación enumera a los empleados de dos departamentos de una empresa (ventas e I+D) y tiene dos componentes:

  • AppComponent, que es el componente raíz de la app
  • Dos instancias de EmployeeListComponent, una para ventas y otra para I+D.

Aplicación de ejemplo

Puedes ver las dos instancias de EmployeeListComponent en la plantilla de AppComponent:

<app-employee-list
  [data]="salesList"
  department="Sales"
  (add)="add(salesList, $event)"
  (remove)="remove(salesList, $event)"
></app-employee-list>

<app-employee-list
  [data]="rndList"
  department="R&D"
  (add)="add(rndList, $event)"
  (remove)="remove(rndList, $event)"
></app-employee-list>

Para cada empleado, hay un nombre y un valor numérico. La app pasa el valor numérico del empleado a un cálculo empresarial y visualiza el resultado en la pantalla.

Ahora, observa EmployeeListComponent:

const fibonacci = (num: number): number => {
  if (num === 1 || num === 2) {
    return 1;
  }
  return fibonacci(num - 1) + fibonacci(num - 2);
};

@Component(...)
export class EmployeeListComponent {
  @Input() data: EmployeeData[];
  @Input() department: string;
  @Output() remove = new EventEmitter<EmployeeData>();
  @Output() add = new EventEmitter<string>();

  label: string;

  handleKey(event: any) {
    if (event.keyCode === 13) {
      this.add.emit(this.label);
      this.label = '';
    }
  }

  calculate(num: number) {
    return fibonacci(num);
  }
}

EmployeeListComponent acepta una lista de empleados y un nombre de departamento como entradas. Cuando el usuario intenta quitar o agregar un empleado, el componente activa un resultado correspondiente. El componente también define el método calculate, que implementa el cálculo del negocio.

Esta es la plantilla de EmployeeListComponent:

<h1 title="Department">{{ department }}</h1>
<mat-form-field>
  <input placeholder="Enter name here" matInput type="text" [(ngModel)]="label" (keydown)="handleKey($event)">
</mat-form-field>
<mat-list>
  <mat-list-item *ngFor="let item of data">
    <h3 matLine title="Name">
      {{ item.label }}
    </h3>
    <md-chip title="Score" class="mat-chip mat-primary mat-chip-selected" color="primary" selected="true">
      {{ calculate(item.num) }}
    </md-chip>
  </mat-list-item>
</mat-list>

Este código itera en todos los empleados de la lista y, para cada uno, renderiza un elemento de lista. También incluye una directiva ngModel para la vinculación de datos bidireccional entre la entrada y la propiedad label declarada en EmployeeListComponent.

Con las dos instancias de EmployeeListComponent, la app forma el siguiente árbol de componentes:

Árbol de componentes

AppComponent es el componente raíz de la aplicación. Sus componentes secundarios son las dos instancias de EmployeeListComponent. Cada instancia tiene una lista de elementos (E1, E2, etc.) que representan a los empleados individuales en el departamento.

Cuando el usuario comienza a ingresar el nombre de un empleado nuevo en el cuadro de entrada de un EmployeeListComponent, Angular activa la detección de cambios para todo el árbol de componentes a partir de AppComponent. Esto significa que mientras el usuario escribe la entrada de texto, Angular vuelve a calcular de forma reiterada los valores numéricos asociados con cada empleado para verificar que no hayan cambiado desde la última comprobación.

Para ver qué tan lento puede ser esto, abre la versión no optimizada del proyecto en StackBlitz y prueba ingresar el nombre de un empleado.

Para verificar que la demora provenga de la función fibonacci, configura el proyecto de ejemplo y abre la pestaña Rendimiento de las Herramientas para desarrolladores de Chrome.

  1. Presiona "Control + Mayúsculas + J" (o "Comando + Opción + J" en Mac) para abrir DevTools.
  2. Haz clic en la pestaña Rendimiento.

Ahora, haz clic en Grabar (en la esquina superior izquierda del panel Rendimiento) y comienza a escribir en uno de los cuadros de texto de la aplicación. Luego de unos segundos, vuelve a hacer clic en Grabar para detener la grabación. Una vez que Chrome DevTools procese todos los datos de perfil que recopiló, verás algo como lo siguiente:

Generación de perfiles de rendimiento

Si hay muchos empleados en la lista, este proceso puede bloquear el subproceso de IU del navegador y provocar que se pierdan fotogramas, lo que genera una mala experiencia del usuario.

Omisión de subárboles de componentes

Cuando el usuario escribe la entrada de texto para sales EmployeeListComponent, sabes que los datos del departamento de I+D no cambiarán, por lo que no hay motivo para ejecutar la detección de cambios en su componente. Para asegurarte de que la instancia de I+D no active la detección de cambios, establece changeDetectionStrategy de EmployeeListComponent en OnPush:

import { ChangeDetectionStrategy, ... } from '@angular/core';

@Component({
  selector: 'app-employee-list',
  template: `...`,
  changeDetection: ChangeDetectionStrategy.OnPush,
  styleUrls: ['employee-list.component.css']
})
export class EmployeeListComponent {...}

Ahora, cuando el usuario escribe una entrada de texto, la detección de cambios solo se activa para el departamento correspondiente:

Detección de cambios en un subárbol de componentes

Puede encontrar esta optimización aplicada a la aplicación original aquí.

Puedes obtener más información sobre la estrategia de detección de cambios de OnPush en la documentación oficial de Angular.

Para ver el efecto de esta optimización, ingresa un empleado nuevo en la aplicación en StackBlitz.

Usa canalizaciones puras

Aunque la estrategia de detección de cambios para EmployeeListComponent ahora se establece en OnPush, Angular aún vuelve a calcular el valor numérico de todos los empleados de un departamento cuando el usuario escribe la entrada de texto correspondiente.

Para mejorar este comportamiento, puedes aprovechar los canales puros. Tanto las canalizaciones puras como las impuras aceptan entradas y muestran resultados que se pueden usar en una plantilla. La diferencia entre ambos es que una canalización pura volverá a calcular su resultado solo si recibe una entrada diferente a la de su invocación anterior.

Recuerda que la app calcula un valor para mostrar según el valor numérico del empleado y, luego, invoca el método calculate definido en EmployeeListComponent. Si mueves el cálculo a una canalización pura, Angular volverá a calcular la expresión de la canalización solo cuando cambien sus argumentos. El framework determinará si los argumentos de la tubería cambiaron realizando una verificación de referencia. Esto significa que Angular no realizará recálculos a menos que se actualice el valor numérico de un empleado.

A continuación, te mostramos cómo trasladar el cálculo empresarial a una canalización llamada CalculatePipe:

import { Pipe, PipeTransform } from '@angular/core';

const fibonacci = (num: number): number => {
  if (num === 1 || num === 2) {
    return 1;
  }
  return fibonacci(num - 1) + fibonacci(num - 2);
};

@Pipe({
  name: 'calculate'
})
export class CalculatePipe implements PipeTransform {
  transform(val: number) {
    return fibonacci(val);
  }
}

El método transform de la canalización invoca la función fibonacci. Observa que la canalización es pura. Angular considerará que todas las canalizaciones son puras, a menos que especifiques lo contrario.

Por último, actualiza la expresión dentro de la plantilla para EmployeeListComponent:

<mat-chip-list>
  <md-chip>
    {{ item.num | calculate }}
  </md-chip>
</mat-chip-list>

Eso es todo. Ahora, cuando el usuario escriba la entrada de texto asociada con cualquier departamento, la app no volverá a calcular el valor numérico para los empleados individuales.

En la siguiente app, puedes ver lo más fluida que es la escritura.

Para ver el efecto de la última optimización, prueba este ejemplo en StackBlitz.

El código con la optimización pura de canalización de la aplicación original está disponible aquí.

Conclusión

Cuando se producen ralentizaciones del entorno de ejecución en una app de Angular, haz lo siguiente:

  1. Genera un perfil de la aplicación con las Herramientas para desarrolladores de Chrome para ver de dónde provienen las demoras.
  2. Presenta la estrategia de detección de cambios OnPush para podar los subárboles de un componente.
  3. Mueve los cálculos pesados a canales puros para permitir que el framework almacenen en caché los valores calculados.