Parece que recientemente, casi todos los días, hay una pregunta sobre stackoverflow con respecto al error ExpressionChangedAfterItHasBeenCheckedError lanzado por Angular. Por lo general, estas preguntas surgen porque los desarrolladores de Angular no entienden cómo funciona la detección de cambios y por qué se requiere la verificación que produce este error. Muchos desarrolladores incluso lo ven como un error. Pero ciertamente no lo es. Este es un mecanismo de precaución implementado para evitar inconsistencias entre los datos del modelo y la interfaz de usuario para que los datos erróneos o antiguos no se muestren a un usuario en la página.
Este artículo explica las causas subyacentes del error y el mecanismo por el cual se detecta, proporciona algunos patrones comunes que pueden provocar el error y sugiere algunas soluciones posibles. El último capítulo proporciona una explicación de por qué esta verificación es importante.
Parece que cuantos más enlaces a las fuentes pongo en el artículo, menos probable es que la gente lo recomiende 😃. Es por eso que no habrá referencia a las fuentes en este artículo.
Una aplicación Angular en ejecución es un árbol de componentes. Durante la detección de cambios, Angular realiza comprobaciones para cada componente, que consta de las siguientes operaciones realizadas en el orden especificado:
Hay otras operaciones que se realizan durante la detección de cambios y las he presentado todas en Todo lo que necesita saber sobre la detección de cambios en Angular .
Después de cada operación, Angular recuerda qué valores usó para realizar una operación. Se almacenan en la propiedad oldValues de la vista de componentes. Una vez que se han realizado las comprobaciones de todos los componentes, Angular inicia el siguiente ciclo de resumen, pero en lugar de realizar las operaciones enumeradas anteriormente, compara los valores actuales con los que recuerda del ciclo de resumen anterior:
Tenga en cuenta que esta verificación adicional solo se realiza en el modo de desarrollo. Explicaré por qué en la última sección del artículo.
Veamos un ejemplo. Suponga que tiene un componente principal A y un componente secundario B. El componente A tiene un nombre y propiedades de texto. En su plantilla utiliza la expresión que hace referencia a la propiedad del nombre:
template: '<span>{{name}}</span>'
Y también tiene el componente B en su plantilla y pasa la propiedad de texto a este componente a través del enlace de propiedad de entrada:
@Component({ selector: 'a-comp', template: ` <span>{{name}}</span> <b-comp [text]="text"></b-comp> ` }) export class BComponent { name = 'I am A component'; text = 'A message for the child component`;
Entonces, esto es lo que sucede cuando Angular ejecuta la detección de cambios. Comienza comprobando el componente A. La primera operación en la lista es actualizar los enlaces para que evalúe la expresión de texto en el mensaje A para el componente secundario y lo pase al componente B. También almacena este valor en la vista:
view.oldValues[0] = 'A message for the child component';
Luego llama a los ganchos del ciclo de vida mencionados en la lista.
Ahora, realiza la tercera operación y evalúa la expresión {{nombre}} al texto Soy un componente. Actualiza el DOM con este valor y coloca el valor evaluado en los valores antiguos:
view.oldValues[1] = 'I am A component';
Luego, Angular realiza la siguiente operación y ejecuta la misma verificación para el componente secundario B. Una vez que se verifica el componente B, el ciclo de resumen actual finaliza.
Si Angular se está ejecutando en el modo de desarrollo, ejecuta el segundo resumen realizando las operaciones de verificación que enumeré anteriormente. Ahora imagine que, de alguna manera, el texto de la propiedad se actualizó en el componente A al texto actualizado después de que Angular pasó el mensaje de valor A para el componente secundario al componente B y lo almacenó. Entonces ahora ejecuta el resumen de verificación y la primera operación es verificar que el nombre de la propiedad no haya cambiado:
AComponentView.instance.text === view.oldValues[0]; // false 'A message for the child component' === 'updated text'; // false
Sin embargo, lo ha hecho, por lo que Angular arroja el error ExpressionChangedAfterItHasBeenCheckedError.
Lo mismo vale para la tercera operación. Si la propiedad del nombre se actualizó después de que se representó en el DOM y se almacenó, obtendremos el mismo error:
AComponentView.instance.name === view.oldValues[1]; // false 'I am A component' === 'updated name'; // false
Probablemente tenga una pregunta en su mente ahora, ¿cómo es posible que estos valores hayan cambiado? Veamos eso.
El culpable siempre es el componente secundario o una directiva. Hagamos una demostración rápida y sencilla. Usaré el ejemplo más simple posible, pero luego mostraré escenarios del mundo real después de eso. Probablemente sepa que los componentes secundarios y las directivas pueden inyectar sus componentes principales. Así que hagamos que nuestro componente B inyecte el componente principal A y actualice el texto de la propiedad enlazada. Actualizaremos la propiedad en el enlace del ciclo de vida ngOnInit a medida que se active después de que se hayan procesado los enlaces, como se muestra aquí:
export class BComponent { @Input() text; constructor(private parent: AppComponent) {} ngOnInit() { this.parent.text = 'updated text'; } }
Y como era de esperar, obtenemos el error:
Error: ExpressionChangedAfterItHasBeenCheckedError: Expression has changed after it was checked. Previous value: 'A message for the child component'. Current value: 'updated text'.
Ahora, hagamos lo mismo con el nombre de propiedad que se usa en la expresión de plantilla del componente principal A:
ngOnInit() { this.parent.name = 'updated name'; }
Y ahora todo funciona bien. ¿Cómo?
Bueno, si observa atentamente el orden de las operaciones, verá que el enlace del ciclo de vida ngOnInit se activa antes de la operación de actualización de DOM. Por eso no hay error. Necesitamos un enlace que se llame después de las operaciones de actualización de DOM y ngAfterViewInit es un buen candidato:
export class BComponent { @Input() text; constructor(private parent: AppComponent) {} ngAfterViewInit() { this.parent.name = 'updated name'; } }
Y esta vez obtenemos el error esperado:
AppComponent.ngfactory.js:8 ERROR Error: ExpressionChangedAfterItHasBeenCheckedError: Expression has changed after it was checked. Previous value: 'I am A component'. Current value: 'updated name'.
Por supuesto, los ejemplos del mundo real son mucho más intrincados y complejos. La actualización de las propiedades del componente principal o las operaciones que causan la representación DOM generalmente se realizan indirectamente mediante el uso de servicios u observables. Pero la causa raíz es siempre la misma.
Ahora veamos algunos patrones comunes del mundo real que conducen al error.
Este patrón se ilustra con este plunker . La aplicación está diseñada para tener un servicio compartido entre un componente principal y uno secundario. Un componente secundario establece un valor para el servicio que, a su vez, reacciona actualizando la propiedad en el componente principal. Llamo a esta actualización de propiedad principal indirecta porque, a diferencia de nuestro ejemplo anterior, no es evidente de inmediato que un componente secundario actualiza una propiedad de componente principal.
Este patrón se ilustra con este plunker . La aplicación está diseñada para tener un componente secundario que emita un evento y un componente principal que escuche este evento. El evento hace que se actualicen algunas de las propiedades principales. Y estas propiedades se utilizan como enlace de entrada para el componente secundario. Esta también es una actualización indirecta de la propiedad principal.
Este patrón es diferente porque, a diferencia de los anteriores, donde los enlaces de entrada se vieron afectados, este patrón hace que la operación de actualización de DOM arroje el error. Este patrón se ilustra con este plunker . La aplicación está diseñada para tener un componente principal que agrega dinámicamente un componente secundario en ngAfterViewInit. Dado que agregar un componente secundario requiere la modificación de DOM y el enlace de ciclo de vida ngAfterViewInit se activa después de que Angular actualizó DOM, se genera el error.
Si observa la descripción del error, la última declaración dice lo siguiente:
La expresión ha cambiado después de que se verificó. Valor anterior:… ¿Se ha creado en un gancho de detección de cambios ?
A menudo, la solución consiste en utilizar el gancho de detección de cambios adecuado para crear un componente dinámico. Por ejemplo, el último ejemplo de la sección anterior con componentes dinámicos se puede corregir moviendo la creación del componente al gancho ngOnInit. Aunque la documentación indica que ViewChildren está disponible solo después de ngAfterViewInit, rellena los elementos secundarios al crear una vista y, por lo tanto, están disponibles antes.
Si busca en Google, probablemente encontrará las dos soluciones más comunes para este error: actualización de propiedad asíncrona y forzar un ciclo adicional de detección de cambios. Aunque aquí los muestro y explico por qué funcionan, no recomiendo usarlos sino rediseñar su aplicación. Explico por qué en el último capítulo.
Una cosa a tener en cuenta aquí es que tanto la detección de cambios como los resúmenes de verificación se realizan de forma sincrónica. Significa que si actualizamos las propiedades de forma asíncrona, los valores no se actualizarán cuando se esté ejecutando el bucle de verificación y no deberíamos obtener ningún error. Probemos:
export class BComponent { name = 'I am B component'; @Input() text; constructor(private parent: AppComponent) {} ngOnInit() { setTimeout(() => { this.parent.text = 'updated text'; }); } ngAfterViewInit() { setTimeout(() => { this.parent.name = 'updated name'; }); } }
De hecho, no se arroja ningún error. La función setTimeout programa una macrotarea que luego se ejecutará en el siguiente turno de VM. También es posible ejecutar la actualización en el turno actual de la VM, pero después de que el código síncrono actual haya terminado de ejecutarse mediante la devolución de llamada de una promesa:
Promise.resolve(null).then(() => this.parent.name = 'updated name');
En lugar de una macrotarea, Promise.then crea una microtarea. La cola de microtareas se procesa después de que el código síncrono actual haya terminado de ejecutarse, por lo que la actualización de la propiedad se realizará después del paso de verificación.
La otra solución posible es forzar otro ciclo de detección de cambios para el componente principal A entre el primero y la fase de verificación. Y el mejor lugar para hacerlo es dentro del enlace del ciclo de vida ngAfterViewInit, ya que se activa cuando se ha realizado la detección de cambios para todos los componentes secundarios y, por lo tanto, todos tenían la posibilidad de actualizar la propiedad de los componentes principales:
export class AppComponent { name = 'I am A component'; text = 'A message for the child component'; constructor(private cd: ChangeDetectorRef) { } ngAfterViewInit() { this.cd.detectChanges(); }
Bueno, no hay error. Así que parece estar funcionando, pero hay un problema con esta solución. Cuando activamos la detección de cambios para el componente principal A, Angular también ejecutará la detección de cambios para todos los componentes secundarios, por lo que existe la posibilidad de que la propiedad principal se actualice nuevamente.
Angular aplica el llamado flujo de datos unidireccional de arriba a abajo . Ningún componente inferior en la jerarquía puede actualizar las propiedades de un componente principal después de que se hayan procesado los cambios principales . Esto asegura que después del primer ciclo de resumen todo el árbol de componentes sea estable. Un árbol es inestable si hay cambios en las propiedades que deben sincronizarse con los consumidores que dependen de esas propiedades. En nuestro caso, un componente B secundario depende de la propiedad de texto principal. Cada vez que estas propiedades cambian, el árbol de componentes se vuelve inestable hasta que este cambio se entrega al componente B secundario. Lo mismo vale para el DOM. Es un consumidor de algunas propiedades en el componente y las representa en la interfaz de usuario. Si algunas propiedades no están sincronizadas, un usuario verá información incorrecta en la página.
Este proceso de sincronización de datos es lo que sucede durante la detección de cambios, particularmente esas dos operaciones que enumeré al principio. Entonces, ¿qué sucede si actualiza las propiedades principales de las propiedades del componente secundario después de que se haya realizado la operación de sincronización? Correcto, te quedas con el árbol inestable y las consecuencias de tal estado no son posibles de predecir. La mayoría de las veces terminará con una información incorrecta que se muestra en la página al usuario. Y esto será muy difícil de depurar.
Entonces, ¿por qué no ejecutar la detección de cambios hasta que el árbol de componentes se estabilice? La respuesta es simple, porque es posible que nunca se estabilice y funcione para siempre. Si un componente secundario actualiza una propiedad en el componente principal como reacción a este cambio de propiedad, obtendrá un bucle infinito. Por supuesto, como dije antes, es trivial detectar un patrón de este tipo con la actualización directa o la dependencia, pero en la aplicación real, tanto la actualización como la dependencia suelen ser indirectas.
Curiosamente, AngularJS no tenía un flujo de datos unidireccional, por lo que intentó estabilizar el árbol. Pero a menudo resultó en las infames 10 $digest() iteraciones alcanzadas. ¡Abortando! error. Continúe y busque en Google este error y se sorprenderá de la cantidad de preguntas que produjo este error.
La última pregunta que puede tener es ¿por qué ejecutar esto solo durante el modo de desarrollo? Supongo que esto se debe a que un modelo inestable no es un problema tan dramático como un error de tiempo de ejecución producido por el marco. Después de todo, puede estabilizarse en la próxima ejecución de resumen. Sin embargo, es mejor recibir una notificación del posible error al desarrollar una aplicación que depurar una aplicación en ejecución en el lado del cliente.