Примечание: исходный код, использованный в этом посте, основан на Angular 2 RC.4. Конечно, исключение встречается и в предыдущих версиях. Тем не менее в примерах используются новые функции, которые могут быть недоступны в предыдущих (бета) версиях.

Во время работы с Angular 2 я пару раз сталкивался с этим исключением. Столкнуться с этим исключением - неприятно. С другой стороны, это говорит нам, что что-то не так с общим рабочим процессом в приложении. Есть какая-то «несогласованность данных».

Алгоритм обнаружения изменений сообщает об этом, выбрасывая исключение «… было изменено после проверки. Предыдущее значение:… Текущее значение:… ».

В больших приложениях, состоящих из множества компонентов, это может быть непросто исправить. Одно из возможных решений - изменить стратегию обнаружения конкретного компонента, но - в основном - это всего лишь способ решения реальной проблемы. Следующий пример демонстрирует это исключение, как выяснить причину и, наконец, как ее исправить.

Рабочий процесс приложения

В этом приложении (https://github.com/jepetko/angular2-fruits-app) у нас есть бюджет и мы хотим купить фрукты. Давайте добавим яблок и груш. Добавляйте столько, сколько израсходовано, пока не будет израсходован бюджет (‹0). Теперь значение разницы выделено красным, потому что это общая цена ›бюджет.

Теперь вернитесь в поле ввода бюджета, удалите значение и нажмите клавишу Tab. Когда вы посмотрите на консоль, вы увидите, что возникло упомянутое исключение:
Ошибка: выражение изменилось после проверки. Предыдущее значение: «false». Текущее значение: «true» в ExpressionChangedAfterItHasBeenCheckedException.BaseException [как конструктор]

Давайте проанализируем поведение компонента: есть форма (созданная FormBuilder), содержащая поле бюджета и по одному полю на каждый фрукт. Ничего особенного здесь не происходит ... Поля ввода привязаны к модели и повторно проверяются при каждом изменении значения. Валидатор CustomValidators.doesBudgetCoverTheFruits имеет полный доступ к модели формы и форме в целом. Также есть своего рода сообщение проверки, сообщающее пользователю, что бюджет необходимо увеличить.

Как понять, что происходит

Теперь вопрос в том, какое выражение на самом деле вызывает исключение. Мы можем предположить, что причиной является фрагмент кода, который выполняется каждый раз, когда значение бюджета изменяется. В компонентах Angular 2 каждая строка кода выполняется, когда механизм обнаружения изменений пытается повторно отрисовать шаблон. К счастью, это приложение небольшое, поэтому мы можем идентифицировать каждое выражение:

  • сообщение проверки отображается, когда выражение _hasBudgetCoverageError () истинно
  • {{model.pricePerFruit}} показывает цену за фрукт.
  • сводка состоит из выражений {{model.budget}}, {{model.getTotalPrice ()}} и {{model.getDiff ()}}

Исключим из шаблона отдельные выражения (Ceteris paribus). Сначала мы удаляем блок ‹div class =” error ”…› ‹/div› и повторяем ввод данных пользователем, как описано выше.

Когда мы смотрим на журнал консоли, мы видим, что исключение больше не возникает. Что это значит? Вы угадали:

пользовательский ввод был изменен * ПОСЛЕ * выражениями в зависимости от пользовательского ввода были обработаны.

В поле бюджета есть обработчик события размытия, который устанавливает значение бюджета по умолчанию равным 100. Означает ли это, что нам не разрешено использовать обработчик события размытия? Нет. Мы просто вызываем ожидаемое изменение данных в методе _hasBudgetCoverageError ().

Теперь мы создаем матрицу, в которой проверяем согласованность поведения приложения с точки зрения обнаружения изменений. Обратите внимание, что выражения перечислены в том же порядке, в котором они размещены в шаблоне, потому что это играет большую роль в механизме обнаружения изменений в Angular 2.

#id  _hasBudgetCoverageError()  budget  getTotalPrice()  getDiff()
------------------------------------------------------------------
1    false                      100      50                50
2    true                       100     200              -100
3    false                      (empty)  50               -50
4    false                      (empty)   0                 0

Мы знаем, что сообщение об ошибке должно появиться (_hasBudgetCoverageError ()), когда разница (getDiff ()) между бюджетом и getTotalPrice () отрицательная. .

В случае № 1 сообщение не отображается, потому что getDiff () равно +50.

В случае № 2 сообщение отображается, потому что getDiff () равно -100.

В случае № 3 сообщение не появляется, даже если getDiff () возвращает -50? Очевидно, что это причина исключения!

Как это исправить

Теперь мы знаем, что реализация метода doesBudgetCoverTheFruits подвержена ошибкам. Когда бюджет пуст, parseInt возвращает NaN. NaN не меньше, чем model.getTotalPrice (), что означает, что возвращается undefined (= форма действительна). В _hasBudgetCoverageError () undefined оценивается как false:

static doesBudgetCoverTheFruits(getModelFn: () => Model): {[key: string]: boolean} {
  return (group: ControlGroup) => {
    let model = getModelFn();
    let budget: number = parseInt(model.budget);
    if (budget < model.getTotalPrice()) {
      return {doesBudgetCoverTheFruits: true};
    }
  };
}

На самом деле мы не хотим проводить какие-либо сравнения значений, если бюджет равен NaN. Мы хотим сказать «Эй, значение бюджета недействительно и поэтому не распространяется на фрукты в вашей корзине для покупок». Итак, давайте изменим его:

static doesBudgetCoverTheFruits(getModelFn: () => Model): {[key: string]: boolean} {
  return (group: ControlGroup) => {
    let err = {doesBudgetCoverTheFruits: true};
    let budget: number = parseInt(model.budget);
    if (isNaN(budget)) {
      return err;
    }
    let model = getModelFn();
    return (budget < model.getTotalPrice()) ? err : undefined;
  };
}

После этой модификации матрица поведения была изменена следующим образом, и исключение также было исправлено.

#id  _hasBudgetCoverageError()  budget  getTotalPrice()  getDiff()
------------------------------------------------------------------
1    false                      100      50                50
2    true                       100     200              -100
3    true (<---)                (empty)  50               -50
4    false                      (empty)   0                 0

Написание модульных тестов

Исключение исчезло, но мне интересно, можно ли этого избежать в будущей работе. Могут ли юнит-тесты быть подходящим инструментом для этого? Я настроил модульный тест и, что ж, это возможно.

Посмотрите на спецификацию:

(Источник: Angular 2 Fruits App)

Давайте разберем спецификацию на отдельные шаги:

  • Во-первых, нам нужно включить автоматическое обнаружение. Автоматическое определение - это то, что на самом деле происходит в браузере. Его не нужно запускать вызовом detectChanges ().
  • во-вторых, нам нужно исправить выполнение конкретных задач обнаружения изменений в ngZone. По умолчанию задача tick (…) выполняется без защиты, что означает, что мы не можем поймать исключение. Вместо использования run (() = ›…) мы должны использовать runGuarded (() =›…).
  • в-третьих, мы указываем тестовый пример. Мы подписываемся на обработчик onError зоны, исчерпываем бюджет (из-за добавления 100 яблок), очищаем ввод бюджета и оставляем его.
  • наконец, ожидание проверяет, был ли вызван errHandler.

Теперь вопрос в том, есть ли смысл писать тесты для такого сценария. На самом деле это не так. Вместо того, чтобы тестировать это конкретное исключение, мы могли бы выполнить своего рода тест на обезьянах.

Обезьяньи тесты для пользовательского ввода

Мы могли бы провести несколько «исследовательских» тестов, предоставив несколько значений, вводимых пользователем во время автоматического обнаружения:

(Источник: Angular 2 Fruits App)

Ключевая идея - повторить ввод пользователя с разными значениями. Спецификация завершится ошибкой, если одно из предоставленных входных значений вызовет несогласованность данных с точки зрения обнаружения изменений.