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 de forma periódica 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 manualmente o a través de un evento asíncrono (por ejemplo, la interacción del usuario o la finalización de XHR).

La detección de cambios es una herramienta poderosa, pero si se ejecuta con mucha frecuencia, puede activar muchos cálculos 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.

Detección de cambios en 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 para 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 empresarial.

Esta es la plantilla para 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 del departamento.

Cuando el usuario comienza a ingresar el nombre de un nuevo empleado en el cuadro de entrada de una EmployeeListComponent, Angular activa la detección de cambios en 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, abre la versión no optimizada del proyecto en StackBlitz y, luego, intenta ingresar el nombre de un empleado.

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

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

para detener la grabación. Una vez que las Herramientas para desarrolladores de Chrome procesen todos los datos de generación de perfiles que recopilaron, verás algo como esto:

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 la pérdida de fotogramas, lo que genera una mala experiencia del usuario.

Cómo omitir los subárboles de componentes

Cuando el usuario escribe la entrada de texto para el EmployeeListComponent de ventas, sabes que los datos del departamento de I+D no están cambiando, 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

Puedes encontrar esta optimización aplicada en 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 nuevo empleado en la aplicación de StackBlitz.

Uso de canalizaciones puras

Si bien la estrategia de detección de cambios para EmployeeListComponent ahora está configurada como OnPush, Angular 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 las canalizaciones puras. Tanto las canalizaciones puras como las impuras aceptan entradas y muestran resultados que se pueden usar en una plantilla. La diferencia entre ambas 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 que se mostrará en función del valor numérico del empleado invocando 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 barra vertical solo cuando cambien sus argumentos. El framework determinará si los argumentos de la canalización cambiaron mediante una verificación de referencia. Esto significa que Angular no volverá a hacer ningún cálculo, a menos que se actualice el valor numérico de un empleado.

A continuación, se muestra cómo mover 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á todas las canalizaciones 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>

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

En la siguiente app, puedes ver cuánto más fluida es la escritura.

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

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

Conclusión

Cuando te enfrentes a demoras en el tiempo de ejecución en una app de Angular:

  1. Genera un perfil de la aplicación con las Herramientas para desarrolladores de Chrome para ver de dónde provienen las demoras.
  2. Se introdujo la estrategia de detección de cambios OnPush para reducir los subárboles de un componente.
  3. Traslada procesamientos pesados a canalizaciones puras para permitir que el framework realice el almacenamiento en caché de los valores calculados.