Все, що потрібно знати про помилку ‘ExpressionChangedAfterItHasBeenCheckedError’

Все, що потрібно знати про помилку 'ExpressionChangedAfterItHasBeenCheckedError'

Схоже, що останнім часом майже кожен день виникає питання на stackoverflow щодо ExpressionChangedAfterItHasBeenCheckedError помилки, що видає Angular. Зазвичай ці питання виникають тому, що розробники Angular не розуміють, як працює виявлення змін і чому необхідна перевірка, яка дає цю помилку. Багато розробників навіть розглядають це як помилку. Але це, звичайно, не так. Це запобіжний механізм, який запобігає невідповідності даних моделей та інтерфейсу користувача, щоб помилкові або старі дані не відображалися користувачеві на сторінці.

Ця стаття пояснює основні причини помилки та механізм її виявлення, надає кілька загальних шаблонів, які можуть призвести до помилки, і пропонує кілька можливих виправлень. Останній розділ пояснює, чому ця перевірка важлива.

Здається, що чим більше посилань на джерела, які я посилаю в цій статті, тим рідше люди рекомендують її. Тому в цій статті не буде посилання на джерела.

Відповідні операції виявлення змін

Запуск програми Angular – дерево компонентів. Під час виявлення змін Angular виконує перевірку для кожного компонента, який складається з наступних операцій, що виконуються в заданому порядку:

  1. оновлювати прив’язані властивості для всіх дочірніх компонентів / директив
  2. виклик ngOnInit, OnChanges а також ngDoCheck гаки життєвого циклу для всіх дочірніх компонентів / директив
  3. оновлення DOM для поточного компонента
  4. виявлення змін для дочірнього компонента
  5. називати ngAfterViewInit гачок життєвого циклу для всіх дочірніх компонентів / директив

Після кожної операції Angular запам’ятовує, які значення він використовував для виконання операції. Вони зберігаються у oldValues властивості виду компонента. Після того, як перевірки були виконані для всіх компонентів, Angular розпочинає наступний цикл збірки, але замість виконання перерахованих вище операцій він порівнює поточні значення з тими, які він запам’ятав з попереднього дайджест-циклу:

  1. Переконайтеся, що значення, передані до дочірніх компонентів, такі, як значення, які будуть використовуватися для оновлення властивостей цих компонентів зараз
  2. Переконайтеся, що значення, що використовуються для оновлення елементів DOM, такі, як значення, які будуть використовуватися для оновлення цих елементів зараз
  3. виконувати ті ж перевірки для всіх дочірніх компонентів

Зверніть увагу, що ця додаткова перевірка виконується лише в режимі розробки. Я поясню, чому в останньому розділі статті.

Подивимося приклад. Припустимо, що у вас є батьківський компонент A і дочірній компонент B. A Компонент має name і text властивість. У його шаблоні використовується вираз, що посилається на name властивість:

template: '<span> {{name}} </span>'

І він також має B компонент у своєму шаблоні і передає text властивість цьому компоненту через вхідну властивість:

@Component({
    selector: 'a-comp',
    template: `
        <span>{{name}}</span>
        <b-comp [text]="text"></b-comp>
    `
})
export class AComponent {
    name = 'I am A component';
    text = 'A message for the child component`;

Так ось що відбувається, коли Angular запускає виявлення змін. Починається перевірка A компонента. Перша операція в списку полягає в оновленні прив’язки, тому вона оцінює text вираз A message for the child component і передає її на B компонент. Він також зберігає це значення в перегляді:

view.oldValues[0] = 'A message for the child component';

Потім він називає гаки життєвого циклу, згадані в списку.

Тепер він виконує третю операцію і порівнює вираз {{name}} та текст I am A component. Він оновлює DOM за допомогою цього значення та додає оцінене значення до oldValues:

view.oldValues[1] = 'I am A component';

Потім Angular виконує наступну операцію і виконує ту ж перевірку на дочірній B компонент. Після перевірки B компонента поточний збірник завершується.

Якщо Angular виконується в режимі розробки, він запускає другий дайджест, виконуючи операції перевірки, перераховані вище. Тепер уявіть собі, що як-то властивість text було оновлено в A компоненті до updated text після Angular передає значення A message for the child component в B компонент і зберігатє його. Таким чином, тепер він запускає перевірку дайджесту і перша операція полягає в тому, щоб перевірити, що властивість text не змінюється:

AComponentView.instance.text === view.oldValues[0]; // false
'A message for the child component' === 'updated text'; // false

І все ж зміна є і тому Angular викидає помилку ExpressionChangedAfterItHasBeenCheckedError.

Те ж саме для третьої операції. Якщо name властивість було оновлено після рендеринга в DOM і збережено, ми отримаємо таку ж помилку:

AComponentView.instance.name === view.oldValues[1]; // false
'I am A component' === 'updated name'; // false

У вас, напевно, виникає запитання, як можна змінити ці значення. Подивимося це.

Причини зміни значеннь

Винуватцем завжди є дочірній компонент або директива. Давайте зробимо швидку просту демонстрацію. Я буду використовувати найпростіший приклад, як тільки можливо, але після цього я покажу реальні сценарії. Ви, напевно, знаєте, що дочірні компоненти та директиви можуть вводити свої батьківські компоненти. Отже, давайте зробимо наш B компонент ін’єкційним батьківським A компонентом і оновлюємо пов’язану властивість text. Ми оновлюватимемо властивість у ngOnInit гачку життєвого циклу, оскільки вона спрацьовує після обробки прив’язок, яка показана тут:

export class BComponent {
    @Input() text;

    constructor(private parent: AppComponent) {}

    ngOnInit() {
        this.parent.text = 'updated text';
    }
}

І, як очікується, ми отримаємо помилку:

Error: ExpressionChangedAfterItHasBeenCheckedError: Expression has changed after it was checked. Previous value: 'A message for the child component'. Current value: 'updated text'.

Тепер давайте зробимо те ж саме для властивості, name яка використовується у виразі шаблону батьківського A компонента:

ngOnInit() {
    this.parent.name = 'updated name';
}

А тепер все працює нормально. Як?

Якщо ви уважно подивитеся на порядок операцій, ви побачите, що ngOnInit гачок життєвого циклу спрацьовує перед операцією оновлення DOM. Тому немає помилок. Нам потрібен гачок, який викликається після операцій оновлення DOM, і ngAfterViewInit це хороший кандидат:

export class BComponent {
    @Input() text;

    constructor(private parent: AppComponent) {}

    ngAfterViewInit() {
        this.parent.name = 'updated name';
    }
}

І на цей раз ми отримуємо очікувану помилку:

AppComponent.ngfactory.js:8 ERROR Error: ExpressionChangedAfterItHasBeenCheckedError: Expression has changed after it was checked. Previous value: 'I am A component'. Current value: 'updated name'.

Звичайно, реальні приклади набагато складніші та складніші. Оновлення властивостей батьківського компонента або операції, що викликають візуалізацію DOM, зазвичай робляться непрямим шляхом за допомогою служб або спостережувчів. Але корінна причина завжди однакова.

Тепер давайте подивимось деякі реальні загальні шаблони, які призводять до помилки.

Спільний сервіс

Цей зразок ілюструється цим модулем. Програма призначена для надання спільної служби між батьківським і дочірнім компонентом. Дочірній компонент встановлює значення для служби, яка в свою чергу реагує шляхом оновлення властивості батьківського компонента. Я називаю це оновлення властивості батьків непрямим, тому що на відміну від нашого прикладу вище, відразу не видно, що дочірній компонент оновлює властивість батьківського компонента.

Трансляція синхронних подій

Цей зразок ілюструється цим модулем. Програма призначена для того, щоб дочірній компонент, що випромінює подію, і батьківський компонент, який слухав цю подію. Ця подія викликає оновлення деяких батьківських властивостей. І ці властивості використовуються як вхідні прив’язки для дочірнього компонента. Це також непряме оновлення батьківськич властивостей.

Динамічна реалізація компонентів

Цей шаблон відрізняється тим, що на відміну від попередніх, на які впливали вхідні прив’язки, цей шаблон викликає операцію оновлення DOM, щоб викинути помилку. Цей зразок ілюструється цим модулем. Програма призначена для батьківського компонента, який динамічно додає дочірній компонент у ngAfterViewInit. Оскільки додавання дочірнього компонента вимагає модифікації DOM, а ngAfterViewInit гачок життєвого циклу ініціюється після оновлення DOM, оновлено.

Можливі виправлення

Якщо ви подивитеся на опис помилки, в останньому реченні сказано наступне:

Expression has changed after it was checked. Previous value:… Has it been created in a change detection hook?

Часто виправлення полягає у використанні гачка для виявлення відповідних змін для створення динамічної складової. Наприклад, останній приклад у попередньому розділі з динамічними компонентами можна виправити, перемістивши створення компонента на& ngOnInit гачок. Хоча в документації зазначається, що ViewChild доступна тільки після ngAfterViewInit, вона заповнює дітей при створенні перегляду і тому вони доступні раніше.

Якщо ви використаєте Google, можливо, знайдете два найпоширенішіх виправлення для цієї помилки – асинхронне оновлення властивостей і примусово додатковий цикл виявлення змін. Хоча я показую їх тут і пояснюю, чому вони працюють, я не рекомендую їх використовувати, а переробити свою програму. Я поясню чому в останній главі.

Асинхронне оновлення

Єдине, що слід зазначити тут, це те, що виявлення змін і перевірки дайджесту виконуються синхронно. Це означає, що якщо ми будемо оновлювати властивості асинхронно, значення не будуть оновлюватися, коли цикл перевірки запущений, і ми не повинні отримувати помилки.

Протестуємо:

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';
        });
    }
}

Дійсно, помилка не викидається. У setTimeout розкладі функції а макрозавдання потім буде виконано в наступному VM черзі.

Також можливо виконати оновлення в поточному повороті віртуальної машини, але після того, як поточний синхронний код закінчився, використовуючи then зворотний виклик promise:

Promise.resolve(null).then(() => this.parent.name = 'updated name');

Замість макрозавдання Promise.then створюється мікрзавдання. Черга мікрозавданнь обробляється після завершення поточного синхронного коду, тому оновлення властивості відбудеться після кроку перевірки. Щоб дізнатися більше про мікро- та макро-завданнях у Angular, ви можете прочитати I reverse-engineered Zones (zone.js) and here is what I’ve found.

Якщо ви використовуєте, EventEmitter ви можете передати true опцію асинхронно:

new EventEmitter (true);

Примусове виявлення змін

Іншим можливим рішенням є запровадження іншого циклу виявлення змін для батьківського A компонента між першим і етапом верифікації. І найкраще це зробити всередині ngAfterViewInit гака життєвого циклу, оскільки він спрацьовує, коли виявлено зміни для всіх дочірніх компонентів, і тому всі вони мали можливість оновити властивість батьківських компонентів:

export class AppComponent {
    name = 'I am A component';
    text = 'A message for the child component';

    constructor(private cd: ChangeDetectorRef) {
    }

    ngAfterViewInit() {
        this.cd.detectChanges();
    }

Помилки немає. Так що, здається, працює, але є проблема з цим рішенням. Коли ми запускаємо виявлення змін для батьківського A компонента, Angular також запускатиме виявлення змін для всіх дочірніх компонентів, і тому є можливість оновлення властивості батьків.

Чому нам потрібний цикл верифікації

Angular накладає так званий ;unidirectional data flow from top to bottom. Жоден компонент нижчий в ієрархії не дозволяється оновлювати властивості батьківського компонента після обробки батьківських змін. Це гарантує, що після першого циклу переривання все дерево компонентів буде стабільним. Дерево нестійке, якщо є зміни властивостей, які потрібно синхронізувати з споживачами, які залежать від цих властивостей. У нашому випадку дочірній B компонент залежить від батьківської text властивості. Всякий раз, коли ці властивості змінюють дерево компонентів, стає нестабільним, поки ця зміна не буде передано до дитини B компоненту. Те ж саме стосується і DOM. Вона є споживачем деяких властивостей компонента і передає їх на інтерфейс користувача. Якщо деякі властивості не синхронізовані, користувач побачить неправильну інформацію на сторінці.

Цей процес синхронізації даних відбувається під час виявлення змін – особливо тих двох операцій, які я перерахував на початку. Так що ж відбувається, якщо ви оновлюєте батьківські властивості з властивостей дочірнього компонента після виконання операції синхронізації? Так, ви залишитися з нестійким деревом і наслідки такого стану неможливо передбачити. У більшості випадків користувач може отримати неправильну інформацію, показану на сторінці. І це буде дуже важко налагоджувати.

Так чому б не запустити виявлення змін, поки дерево компонентів не стабілізується? Відповідь проста – тому що вона ніколи не може стабілізуватися і працюватиме назавжди. Якщо дочірній компонент оновлює властивість батьківського компонента як реакцію на цю зміну властивості, ви отримаєте нескінченний цикл. Звичайно, як я вже говорив раніше, тривіально виявити таку картину з прямим оновленням або залежністю, але в реальному застосуванні як оновлення, так і залежність зазвичай непрямі.

Цікаво, що AngularJS не мав односпрямованого потоку даних, тому він намагався стабілізувати дерево. Але це часто призводило до сумнозвісної 10 $digest() iterations reached. Aborting! помилки. Виконайте цю помилку, і ви будете здивовані кількістю питань, які виникли у цій помилці.

Останнє питання, яке ви можете мати, чому це запускається тільки в режимі розробки? Я думаю, це тому, що нестабільна модель не є такою драматичною проблемою, як помилка під час виконання, яку створює фреймворк. Адже вона може стабілізуватися в наступному запуску. Однак краще отримувати повідомлення про можливу помилку при розробці програми, ніж налагоджувати запущену програму на стороні клієнта.

Переклад статті “Everything you need to know about the `ExpressionChangedAfterItHasBeenCheckedError` error