angular
March 24

Почему возникает ошибка 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 было обработано функцией шаблона, и, следовательно, привязка выдает ошибку.

Это вольный перевод материала