Почему возникает ошибка ExpressionChangedAfterItHasBeenCheckedError в Angular
Часто на StackOverflow обсуждают ошибку Angular ExpressionChangedAfterItHasBeenCheckedError
из-за непонимания её причины или способа устранить. Если коротко, то эта проверка необходима для поддержания однонаправленного потока данных и синхронизации UI с состоянием приложения после проверки изменений. Если Angular обнаруживает изменения после этой проверки, то возникает ошибка. Это механизм защиты, а не недостаток фреймворка, обеспечивающий предсказуемость и стабильность работы приложения.
Однонаправленный поток данных означает, что после обработки текущего компонента (bindings
), нельзя обновлять свойства компонента, которые задействованы в привязках. Angular использует метод checkNoChanges
, который запускается после обычного цикла обнаружения изменений. Если в этот момент Angular обнаруживает, что выражение генерирует новое значение (dirty checking
), он выдает ошибку ExpressionChangedAfterItHasBeenCheckedError
.
bindingUpdated
проверяет наличие различий и выдает ошибку. Вот упрощенная реализация этой функции:
// https://github.com/angular/angular/blob/663d477cb04da4960a9a6552c556abe73417b95b/packages/core/src/render3/bindings.ts#L46 export function bindingUpdated(lView, bindingIndex, value) { const oldValue = lView[bindingIndex]; // no update is needed if (Object.is(oldValue, value)) { return false; } else { // if we're in development mode and the values are not equal, // throw the error and return without updating the binding if (ngDevMode && isInCheckNoChangesMode()) { const oldValueToCompare = oldValue !== NO_CHANGE ? oldValue : undefined; if (!devModeEqual(oldValueToCompare, value)) { const details = getExpressionChangedErrorDetails(...); throwErrorIfNoChangesMode(oldValue === NO_CHANGE, details.oldValue) } return false; } lView[bindingIndex] = value; return true; } }
Мы можем провести быстрый поиск, чтобы определить все инструкции Ivy
, которые используют bindingUpdated
и, следовательно, могут вызвать ошибку.
Интересно, что существует тест, который проверяет логику, приводящую к ошибке. Вот как он проверяет, правильно ли код, который обновляет свойство [id]
, вызывает ошибку:
class MyApp { unstableStringExpression: string = 'initial'; ngAfterViewChecked() { this.unstableStringExpression = 'changed'; } } it('should include field name in case of property binding', () => { const message = `Previous value for 'id': 'initial'. Current value: 'changed'`; expect(() => initWithTemplate('<div [id]="unstableStringExpression"></div>')) .toThrowError(new RegExp(message)); });
Тест использует ngAfterViewChecked
для обновления свойства, поскольку этот хук срабатывает после обработки связываний (биндингов). Цикл проверки checkNoChanges
вызовет ошибку ExpressionChangedAfterItHasBeenCheckedError
, потому что изменение unstableStringExpression
производилось после ngAfterViewChecked
. Из этого следует, что ошибка будет получена каждый раз когда значение полученное в detectChanges
и detectNoChanges
будет различным.
Этот набор тестов в основном определяет все возможные случаи, когда связывание вызовет ошибку:
initWithTemplate('<div [id]="unstableStringExpression"></div>') initWithTemplate('<div id="Expressions: {{ a }}') initWithTemplate('<div [attr.id]="unstableStringExpression"></div>') initWithTemplate('<div [style.color]="unstableColorExpression"></div>') initWithTemplate('<div [class.someClass]="unstableBooleanExpression"></div>') initWithTemplate('<div i18n>Expression: {{ unstableStringExpression }}</div>') initWithTemplate('<div i18n-title title="Expression: {{ unstableStringExpression }}"></div>') initWithHostBindings({'[id]': 'unstableStringExpression'}) initWithHostBindings({'[style.color]': 'unstableColorExpression'}) initWithHostBindings({'[class.someClass]': 'unstableBooleanExpression'})
Чтобы исправить ошибку, нужно знать, откуда берется разница в значениях. Далее посмотрим на очень простую иллюстрацию того, как работает механизм верификации.
Механизм проверки значений
Чтобы увидеть, как обнаруживается и выбрасывается ошибка, реализуем компонент, который отображает случайное число, полученное с помощью Math.random()
:
@Component({ selector: 'p-cmp', template: ` <h3> <button (click)="noop()">Generate number</button> </h3> <div [textContent]="number"></div> ` }) export class P { noop() {} get number() { return Math.random(); } }
Значение [textContent]
, получает разные значения во время обычного и проверочного циклов обнаружения изменений. Из-за этого факта при запуске этого кода мы можем ожидать ошибки:
Math.random()
каждый раз возвращает разные результаты при вызове. Поэтому значение, которое получает textContent
во время detectChanges
, никогда не будет совпадать со значением, которое она получает для выполнения checkNoChanges
.
Ошибка, которую мы получили, выглядит следующим образом:
ERROR Error: NG0100: ExpressionChangedAfterItHasBeenCheckedError: Expression has changed after it was checked. Previous value for ‘textContent’: ‘0.9449286101652989’. Current value: ‘0.1686897170657191’. Find more at https://angular.io/errors/NG0100
Ошибка сообщает, что значения, полученные функцией number()
для textContent
, были разными во время detectChanges
и checkNoChanges
. Сама ошибка возвращается функцией bindingUpdated
, о которой говорили выше:
Мы можем даже увидеть это с помощью инструментов разработчика Chrome:
Этот конкретный случай фактически соответствует спецификации из вышеупомянутого набора модульных тестов:
initWithTemplate('<div [id]="unstableStringExpression"></div>')
Конечно, реальные примеры намного более сложные и запутанные. Они обычно включают в себя иерархию компонентов и механизм взаимодействия между родительскими и дочерними компонентами. Это взаимодействие часто является косвенным и происходит через общие службы, события или наблюдаемые объекты. Для быстрого обнаружения причины требуется хорошее понимание и много практики.
Чтобы проиллюстрировать механизм ошибки, включающей в себя иерархию, давайте рассмотрим очень простое приложение с родительским и дочерним компонентом.
Пример
Создадим простой пример из двух компонентов. Родительский компонент объявляет свойство text
, которое используется в шаблоне. Дочерний компонент внедряет родительский компонент через DI в конструктор и обновляет его свойство text в хуке ngAfterViewChecked
.
@Component({ selector: 'q-cmp', template: ` <h3>Q1 text: {{text}}</h3> <q1-cmp></q1-cmp> ` }) export class Q { text = 'initial'; } @Component({ selector: 'q1-cmp', template: `` }) export class Q1 { constructor(private q: Q) {} ngAfterViewChecked() { this.q.text = 'updated'; } }
Ожидаемо будет получена ошибка:
На этот раз наш соответствует этому этому тесту:
initWithTemplate('<div id="Expressions: {{ a }}')
Так же это видно из кода ошибки в консоли браузера:
Причем, даже несмотря на использование хука OnInit()
может быть получена такая же ошибка:
@Component({ selector: 'q-cmp', template: ` <q2-cmp></q2-cmp> <h3>Q2 text: {{text}}</h3> ` }) export class Q { text = 'initial'; } @Component({ selector: 'q2-cmp', template: `` }) export class Q2 { constructor(private q: Q) {} ngOnInit() { this.q.text = 'updated'; } }
Ошибка не появится, если поменять местами элементы в шаблоне:
<h3>Q2 text: {{text}}</h3> <q2-cmp></q2-cmp> ====> <q2-cmp></q2-cmp> <h3>Q2 text: {{text}}</h>
Для объяснения стоит заглянуть "под капот" Angular. В случае, когда привязка создается до объявления дочернего компонента debugger будет выглядеть так:
Когда <q2-cmp>
идет перед обновлением {{text}}
, срабатывает ngOnInit
из функции шаблона Q_Template
, и это происходит до того, как Angular обновляет DOM. Вот почему ошибки нет. Однако, если <q2-cmp>
идет после обновления {{text}}
, ngOnInit
выполняется через executeInitAndCheckHooks
после того, как свойство text
было обработано функцией шаблона, и, следовательно, привязка выдает ошибку.
Это вольный перевод материала