Pull to refresh

AngularJS: Миграция с 1.2 на 1.4, ч.1

Reading time 25 min
Views 21K
О преимуществах перехода с версии 1.2 написано немало статей. Однако, согласно статистике, более 45% сайтов всё ещё используют версию 1.2, только 31% перешёл на более новую 1.3 и всего 5% используют 1.4.

И это когда космические корабли бороздят просторы вселенной версия 1.2.0 вышла в релиз почти два года назад, версия 1.3.0 − год назад, версия 1.4.0 − ещё этой весной, а 1.5.0 уже выходит в бету.



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

В этой статье мы поговорим о том, с чем можно столкнуться при миграции на новые версии, и разберём наиболее проблемные места.

К сожалению, пост вышел слишком большим, поэтому в этой части я сосредоточусь на breaking changes такого перехода, а о фичах и достоинствах поговорим в следующей части. Но отмечу, что главным плюсом перехода служит существенное повышение скорости работы, а так же солидный список исправленных багов (ведь последние исправления для 1.2 были год назад в 1.2.28).

Этот пост я поделил на две части. Одна из них − мой личный опыт перевода двух проектов на новые версии (её я скрыл под спойлером), вторая − список обнаруженных, вычитанных и перевёденных проблем с пояснениями и примерами.

История нашего перехода
Первым относительно большим проектом, где потребовался переход (с 1.2 на 1.3), была E-Learning платформа, где, несмотря на более 12 000 строк кода (преимущественно директив), никаких проблем не возникло.

Второй проект требовал миграции с версии 1.2.16 на 1.4.4 и был существенно больше (около 75 000 строк чистого angular), а специфика продукта (бухгалтерский учёт) подразумевала сложные связи, множество форм и неизбежные проблемы, но предыдущий опыт воодушевлял.

Первым результатом миграции ожидалось получить неработающее приложение с кучей ошибок в консоли, однако приложение завелось, и никаких проблем не возникло. Это было крайне странно, поэтому я начал своё путешествие по официальному гайду, где, однако, не воспроизвелась ни одна из описанных проблем. Решив, что дело сделано, я отдал задачу на откуп QA и автоматическим тестам.

И уже на следующий день получил 9 багов, затем ещё 9, и ещё. Коварство всех найденных ошибок было не в том, что они ломают работу приложения, но в том, что незаметно изменяют его поведение.

Здесь другом и товарищем в поисках причин стал Change Log и обсуждения внутри найденных там коммитов.

Первым делом отвалились асинхронные проверки на стороне сервера, которые работали не со статусами, а с ответами в виде текстового примитива типа «OK». О таком ни один break change не оповещал, но зато был соответствующий баг-фикс.

Совет: Проверьте свою работу с XHR запросами. Если сервер вам присылает не объект, а какой-либо примитив с заголовкам «application/json», у вас будут проблемы.

Следующими посыпались спиннеры, вложенные в попапы, перестав корректно определять ширину родителя. Проблема заключалась в двух вещах. Во-первых, спиннеры лежали в ng-show, а значит инициализировались раньше, чем контейнер родителя мог быть показан. Во-вторых, показывался/скрывался спиннер, следя за атрибутом через $observe. По какой-то причине в старой версии атрибут менялся позже, чем родитель становился :visible, а в новой наоборот.

Совет:
  1. Не храните динамические директивы, которым важен момент инициализации в ng-show/ng-hide, используйте для этого ng-if. Этот совет актуален и для версии 1.2
  2. Не используйте $observe для слежения за атрибутами, если требуется следить за внешними изменениями иных данных (DOM или $scope). Используйте для этого $watch(function () {}, function () {}). Тем более, что оба варианта добавляют свой вотчер в грязную проверку, разница лишь в условиях вызова коллбэка.


Так же проблема коснулась условий в выражениях внутри $eval: стали пропадать некоторые блоки. Но проблема оказалась очередным баг-фиксом и уже освещалась на хабре.

Совет: Не используйте недокументированные особенности фреймворка, исследуйте код на использование при проверке следующих значений в $scope: 'f', '0', 'false', 'no', 'n', '[]'.

Главная из возникших проблем − это валидация. Начиная с версии 1.3, в ангуляре появились новые методы проверки форм, а с ними и новые подводные камни. Но есть проблемы, которые затронут вас, даже если вы не планируете использовать новую валидацию. Это директивы maxlength/minlength и новая логика работы $setValidity, которые грозят сделать вашу форму постоянно невалидной (так с нами и произошло).

Обнаружилось, что мы держали в ngModelCtrl.$error специальное свойство showError (для удобного показа ошибок по одной), что привело к постоянной инвалидации формы, поскольку в новой версии одно из условий валидности − это пустой хеш ngModelCtrl.$error.

Совет: Не кладите в свойства, начинающиеся с $ ничего, что не указано в официальном API.

Другая проблема возникла с инпутами, использующими маску (для телефона, номеров договоров и т.п.) в тандеме с директивами ng-maxlength/ng-minlength.

Обе эти директивы теперь проверяют ngModelCtrl.$viewValue вместо ngModelCtrl.$modelValue, а значит, максимальная длина для значения «xx-xx» теперь уже не 4, а 5 (с учётом символа "-"). Пришлось бы переписывать сотни правил проверки по всему приложению, поэтому было решено заменить автозаменой все ng-maxlength на кастомный model-maxlength, который снова проверял бы только модель. И это решение было ужасным! Ангуляр зарезервировал себе атрибут-ограничитель maxlength, а это значит, мы больше не могли ограничивать количество вводимых символов. В итоге было решено всё-таки поменять все правила на новые, с учётом символов маски. Однако кастомная директива model-minlength нашла своё применение в директивах, где есть предустановленные символы (например "+7 " для телефона), позволяя проверять только модель без установленных в ngModelCtrl.$viewValue префиксов.

Совет: Если вы используете маску или иным образом манипулируете значением ngModelCtrl.$viewValue, измените проверку с учётом символов маски. Используйте для предустановленных значений в инпутах с проверкой ng-minlength аттрибут placeholder или замените проверку на кастомную. Рабочий код подобной директивы есть в break changes списке.

Вторая волна проблем пошла после перехода нашей валидации на новую (прощайте, $formatters и $parsers). Мы столкнулись с тем, что ряд форм снова стал постоянно невалиден.

С синхронными валидациями ($validators) проблема обнаружилась быстро. Она заключалась в том, что в форме были скрытые через ng-show поля со своими проверками валидации. Из-за особенностей работы в старой версии ($formatters и $parsers) эти поля не включались в проверку формы, однако в новой версии ($validators) это приводит к тому, что появляется скрытое невалидное поле.

Совет: Если у вас есть формы со скрытыми динамическими полями, то скрывайте эти поля или показывайте их через ng-if, не используйте ng-show/ng-hide.

Ремарка: О том, как работают асинхронные валидаторы
Асинхронные валидаторы хранятся в коллекции ngModelCtrl.$asyncValidators и представляют из себя функцию, возвращающую Promise. Промисы возвращают различные сервисы (например $timeout и $http), а также генерируются специальным сервисом $q. От resolved или rejected возвращаемого промиса зависит валидность поля. Асинхронная валидация запускается только после того, как все синхронные валидаторы становятся валидны. В момент вызова валидатора его валидность ($setValidity) будет равна undefined, а в ngModelCtrl.$pending появится название ожидаемого валидатора. Как только произойдёт resolve промиса, его валидность будет выставлена в null.

Если вызвать функцию валидации несколько раз, то последний промис затрёт предыдущие. Это значит, что если у поля вызвали валидацию два раза, и первый промис был resolved, а второй − rejected, то поле будет невалидно.

Пример из документации по API:
      ngModel.$asyncValidators.uniqueUsername = function(modelValue, viewValue) {
        var value = modelValue || viewValue;

        // Lookup user by username
        return $http.get('/api/users/' + value).
           then(function resolved() {
             //username exists, this means validation fails
             return $q.reject('exists');
           }, function rejected() {
             //username does not exist, therefore this validation passes
             return true;
           });
      };


С асинхронной валидацией оказалось сложнее: форма была невалидна, но не содержала в себе никаких ошибок.

Здесь из-за особенностей работы маски (через $formatters и $parsers) асинхронная валидация вызывалась ещё до завершения синхронных валидаций, при этом вызывалась по нескольку раз за 1 изменённый символ. Это порождало баг множественного создания промисов, что приводило к тому, что последний промис не был resolved или rejected. Соответственно, инпут получал бесконечный pending, а форма была невалидной без каких-либо ошибок.

Пример построения валидатора для подобных случаев
    var pendingPromise;

    ngModelCtrl.$asyncValidators.checkPhoneUnique = function (modelValue) {
        if (pendingPromise) {
            return pendingPromise;
        }

        var deferred = $q.defer();

        if (modelValue) {
            pendingPromise = deferred.promise;

            $http.post('/запрос', {value: modelValue})
                .success(function (response) {
                    if (response.Result === 'Хитрое условие с сервера') {
                        deferred.resolve();
                    } else {
                        deferred.reject();
                    }
                }).error(function () {
                    deferred.reject();
                }).finally(function () {
                    pendingPromise = null;
                });
        } else {
            deferred.resolve();
        }

        return deferred.promise;
    };


Совет: Протестируйте поведение асинхронных валидаторов: убедитесь, что возвращаемый промис всегда будет resolved или rejected.

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

Времени на проблему было потрачено изрядно, проблема вновь была в ng-maxlength, а решение оказалось простое: добавить в $formatters директивы преобразование значения $viewValue в строку.

Но почему проблема вообще возникла? Давайте разберёмся!

Всё дело в работе $compile: внутри описано поведение основных директив. Например, для input и textarea вызывается контрол inputDirective, который берёт attr.type и исходя из него вызывает одну из функций коллекции inputType. Для текстовых типов это textInputType. Он, в свою очередь, прокидывает наш контрол в функцию stringBasedInputType, которая добавляет в $formatters код конвертации нашего значения в строку.

Таким образом, когда ngModel не привязан к какому-то существующему базовому элементу вроде input, а, например, висит на простом диве или кастомной директиве (в нашем случае это директива для ввода суммы), то данные прокидываются в $viewModel «как есть», что и вызывает ошибку у директив-фильтров вроде maxlength, которые для своей работы используют свойство .length, отсутствующее у чисел.

Рабочий пример на Plunker.

Совет: Все кастомные директивы, работающие с числами, обязательно должны иметь соответствующий форматтер преобразования числа в строку.

Итог:


В итоге было прогнано около 6 итераций тестов и набралось 54 найденных проблемы, но большинство из них имело схожую природу. Части проблем и вовсе могло не быть, не используй некоторые участки кода для своей работы баги и недокументированные возможности. Всего на миграцию было затрачено 56 коммитов и 3 недели рабочего времени одного человека с учётом рефакторинга и перехода на новую валидацию.


Часть первая: Breaking Changes


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

Работа $parse


.bind, .call и .apply


Больше нельзя вызвать .bind, .call и .apply внутри выражения (например, {{ }}).

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

__proto__


С версии 1.3 устаревшее (deprecated) свойство __proto__ удалено.

Раньше оно могло быть использовано для доступа к глобальным прототипам.

Object


Запрещено использовать Object внутри выражений.

Это связано с возможностью выполнять в выражениях произвольный код.
Например:
''.sub.call.call(
    ({})["constructor"].getOwnPropertyDescriptor(''.sub.__proto__, "constructor").value,
    null,
    "alert('evil')"
)()


Если кому-то необходим Object.key или иной метод, пробрасывайте его через scope.

{define,lookup}{Getter,Setter}


С версии 1.3 запрещены свойства {define,lookup}{Getter,Setter}, позволявшие выполнять произвольный код внутри выражения.

Если вам необходимы данные свойства, оборачивайте их в контроллере и прокидывайте в scope руками.

$parseProvider

[commit]

Удалены устаревшие методы $parseProvider.unwrapPromises и $parseProvider.logPromiseWarnings.

$interpolate


Функции, возвращаемые $interpolate, более не содержат массив .parts. Вместо этого они содержат:

  • .expressions, в котором находятся все выражения в тексте.
  • .separators, в котором находятся разделители между выражениями в тексте. Он всегда на 1 элемент длиннее, чем .expressions, для удобства объединения.

toBoolean


В ангуляре для проверки на истину используется собственная реализация toBoolean(), которая приравнивала к false некоторые нестандартные значения в виде следующих строк:

'f', '0', 'false', 'no', 'n', '[]'

Начиная с версии 1.3, к false приравниваются только те же значения, что и в обычном JS:

false, null, undefined, NaN, 0, ""

Об этом писали на хабре

Например:
  $scope.isEnabled = ‘no’;

<1.3:

  {{ isEnabled ? ‘Не выведет’ : ‘Выведет’ }}

1.3+:

  {{ isEnabled ? ‘Выведет’ : ‘Не выведет’ }}




Helpers


.copy()


Раньше при работе с объектами copy копировал все свойства объекта, включая те, что лежат в прототипе, что приводило к потере цепочки прототипов (кроме Date, RegExp и Array).

Начиная с версии 1.3, он копирует только собственные свойства (что-то вроде перебора с hasOwnProperty), а затем ссылается на прототип оригинала.

Например:
  var Foo = function() {};

  Foo.prototype.bar = 1;

  var foo = new Foo();
  var fooCopy = angular.copy(foo);

  foo.bar = 3;

<1.3:

  console.log(foo instanceof Foo); // => true
  console.log(fooCopy instanceof Foo); // => false

  console.log(foo.bar); // => 3
  console.log(fooCopy.bar); // => 1

1.3+:

  console.log(foo instanceof Foo); // => true
  console.log(fooCopy instanceof Foo); // => true

  console.log(foo.bar); // => 3
  console.log(fooCopy.bar); // => 3

IE8: Необходимы полифиллы Object.create и Object.getPrototypeOf

.forEach()


Раньше, если массив увеличивался в процессе перебора, то цикл перебирал и вновь появившиеся элементы тоже.

Начиная с версии 1.3, он кеширует количество элементов в массиве и проходит только по ним, здесь он стал ближе к нативному Array.forEach.

Например:
  var foo = [1, 2];
  


<1.3:

  angular.forEach(foo, function (value, key) {
      foo.push(null);

      // => Будет бесконечно выводить новые null и повесит браузер
      console.log(value);
  });

1.3+:

  angular.forEach(foo, function(value, key) {
      foo.push(null);

      // => Выведет 1, затем 2 и остановит перебор
      console.log(value);
  });


.toJson()


Соль этого хелпера в первую очередь в том, что он сериализирует не все данные, а только те, которые начинаются не со спецсимвола $.

Начиная с версии 1.3, он не сериализирует только свойства, имена которых начинаются с $$.

Например:
  var foo = {bar: 1, baz: 2, $qux: 3};

<1.3:

  angular.toJson(value); // => {"bar": 1}

1.3+:

angular.toJson(value); // => {"bar": 1, "$bar": 2}




jqLite


Коротко о главном:
  • Больше нельзя устанавливать data нодам текста и комментариев. Это связано с утечкой памяти и геморроем очищения;
  • Вызов element.detach() теперь не вызывает срабатывание ивента $destroy;

Select


Контроллер


SelectController теперь является одной абстракцией для директивы Select и для директивы ngOptions.

Это означает, что теперь ngOptions можно удалить из Select, не боясь, что это может как-то на неё повлиять.

Различные вариации директивы Select имеют свои методы SelectController.writeValue и SelectController.readValue, отвечающие за работу с $viewValue тега <select> и его дочерних <option>.

value для ngOptions


Ранее в ngOptions для суррогатного ключа использовался индекс или ключ item в переданной коллекции.

Начиная с версии 1.4, для этого используется вызов hashKey для item в коллекции.

Соответственно, если читать value напрямую из DOM, то могут возникнуть проблемы.

Например:
<select ng-model="model" ng-option="i in items"></select>

<1.4:

  <option value="1">a</option>
  <option value="2">b</option>
  <option value="3">c</option>
  <option value="4">d</option>

1.4+:

  <option value="string:a">a</option>
  <option value="string:b">b</option>
  <option value="string:c">c</option>
  <option value="string:d">d</option>
  


Сравнение ngModel с option value


Начиная с версии 1.4, директива select начинает сравнивать значение option и ngModel, используя строгое сравнение.

Это означает, что значение 1 не эквивалентно «1» так же, как не эквивалентно значению false или true.
Если вы положите в модель значение 1, то получите unknown option.

Чтобы этого избежать, необходимо класть в модель строку, например scope.model = «1».

Если же в модели необходимо именно число, предлагается воспользоваться конвертированием через formatters и parsers.

Пример:
ngModelCtrl.$parsers.push(function(value) {
    return parseInt(value, 10); // Конвертация в число
});

ngModelCtrl.$formatters.push(function(value) {
    return value.toString(); // Конвертация в строку
});


Сортировка


Как и в случае с ngRepeat, сортировка в алфавитном порядке теперь не работает, а соответствует в последовательности вызову Object.keys(obj).


ngRepeat


Сортировка

[commit] [issue] [holy war]

Ранее ngRepeat, перебирая объект, сортировал его в алфавитном порядке по ключам. Начиная с версии 1.4, он возвращает его в порядке, зависящем от браузера, как если бы вы перебирали его for key in obj.

Это связано с тем, что браузеры обычно возвращают ключи объектов в том порядке, в котором они были объявлены, за исключением того случая, когда ключи были удалены или переустановлены.

Для перебора объекта предлагается использовать кастомные фильтры, преобразующие объект в массив.


$compile


controllerAs, bindToController


В версии 1.3 был введён bindToController. Начиная с версии 1.4, в него можно передавать объект для указания изолированного scope.

В связи с этим теперь возвращаемый из конструктора контроллера объект перезаписывает scope.

Вьюхи, использовавшие controllerAs синтаксис, больше не получают ссылку на саму функцию, но на объект, который она возвращает.

Если в директиве используется bindToController, то все предыдущие биндинги переустанавливаются в новый контроллер, все установленные вотчеры удаляются (unwatch).

Выражение ‘&’ в изолированном scope


Раньше на выражение с & всегда создавалась функция, даже если атрибут вместе с выражением отсутствовал (в таком случае создавалась функция, которая возвращает undefined).

Начиная с 1.4, поведение & приблизилось к @. Теперь если выражение отсутствует, то отсутствует и соответствующий метод в $scope. При обращении к нему вы получите undefined вместо функции, которая вернёт undefined.

Свойство директивы replace


Начиная с 1.3, оно становится deprecated и должно быть удалено в следующем мажорном релизе.

Объясняется это тем, что возникают некие проблемы с мерджем атрибутов.

Подробнее:
Если объединить

  <div ng-class="{hasHeader: true}"></div>
  

С

  <div ng-class="{active: true}"></div>
  

То получим

  <div ng-class="{active: true}{hasHeader: true}"></div>
  

С соответствующей ошибкой о том, что выражение не валидно.

А ещё недостаточным уровнем инкапсуляции таких директив и вообще.

Холивар на эту тему доступен здесь.

$observer


Начиная с версии 1.3, мы наконец получили удобный способ удаления обсервера атрибутов: функция-деструктор возвращается при вызове attr.observe (как делает watch). Раньше он возвращал ссылку на функцию обсервера.

Теперь чтобы иметь ссылку на функцию обсервера, надо её предварительно где-то сохранить.

Например:
<1.3:

directive('directiveName', function() {
    return {
        link: function(scope, elm, attr) {
            var observer = attr.$observe('someAttr', function(value) {
                console.log(value);
            });
        }
    };
});

Как теперь:

directive('directiveName', function() {
    return {
        link: function(scope, elm, attr) {
            var observer = function(value) {
                console.log(value);
            };

            var destructor = attr.$observe('someAttr', observer);

            destructor(); // Перестанет следить
        }
    };
});


Доступ к isolated scope извне


Больше нельзя получить свойство изолированного scope посредством атрибута элемента, где определена изолированная директива.

Например:
Дана следующая директива:

  app.controller('testController', function($scope) {
      $scope.controllerScope = true;
  });

  app.directive('testDirective', function() {
      return {
          template:'<span ng-if="directiveScope">world!</span>',
          scope: {directiveScope: '='},
          controller: function($scope) {},
          replace: true,
          restrict: 'E'
      }
  });

<1.3:

  Hello <test-directive directive-scope="controllerScope"></test-directive> // Hello
  

1.3+:

  Hello <test-directive directive-scope="controllerScope"></test-directive> // Hello world!
  




ngModelController


$setViewValue()


Поведение $setViewValue() немного изменилось, теперь оно не прокидывает изменения в $modelValue сразу же, как раньше.

Теперь модель обновляется в зависимости от двух настроек ngModelOptions, а в частности:

  • updateOn: Модель не обновится, пока не вызовется один из указанных в данном тригере ивентов
  • debounce: Модель не обновится, пока не пройдёт время, указанное в одном из debounce

По умолчанию updateOn равен default, а debounce равен 0, поэтому $modelValue выполняется, как и раньше, мгновенно.
Однако стоит учитывать описанные выше особенности при работе со старым кодом.

$commitViewValue



Если вы хотите любой ценой обновить $modelValue мгновенно, игнорируя updateOn и debounce, то используйте $commitViewValue().

$commitViewValue() не принимает аргументов. Ранее у него был недокументированный аргумент revalidate, использовавшийся
в приватном апи как хак для насильного запуска ревалидации и сопутствующих процессов, даже если $$lastCommittedViewValue
не обновился, но в последних версиях это убрали.

$cancelUpdate()


Был переименован в $rollbackViewValue().

Вызов позволяет «откатить» $viewValue до состояния $$lastCommittedViewValue, отменить все находящиеся в процессе выполнения debounce и перерисовать вьюху (к примеру, input).

Например:
<1.3:
$scope.resetWithCancel = function (e) {
    $scope.myForm.myInput.$cancelUpdate();
    $scope.myValue = '';
};


1.3+:
$scope.resetWithCancel = function (e) {
    $scope.myForm.myInput.$rollbackViewValue();
    $scope.myValue = '';
};


инпуты: date, time, datetime-local, month, week


С версии 1.3 ангуляр нормально поддерживает HTML5 инпуты, связанные с числами.

В ng-model таких инпутов должен находиться строго объект Date

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


Валидация


Коллекция $error

[commit]

Ранее можно было хранить в $error произвольные свойства, управляя валидностью контрола вручную через $setValidity.

Начиная с версии 1.3, конечная валидация зависит от того, пуст ли хеш $error. Прокинув в ngModelCtrl.$error какое-либо свойство вручную и вовремя его оттуда не убрав, вы получите перманентно невалидный контрол, независимо от значения этого свойства.

result в $setValidity

[commit]

$setValidity позволяет выставлять валидность тех или иных свойств контрола, принимая два аргумента: name и result.

Ранее result всегда приводился к true или false, независимо от того, что туда передали.

Начиная с версии 1.3, $setValidity начинает различать false, undefined и null, передаваемые в result. Стоит теперь самим позаботиться о том, чтобы в result попало именно булево значение.

Значения undefined и null используются, например, внутри для асинхронных валидаторов. Так, если не все синхронные валидаторы валидны, то значения асинхронных будут установлены в null. Если же синхронные валидаторы готовы и началась асинхронная валидация, то до тех пор пока идёт ожидание (pending), значение валидатора будет установлено в undefined.

$parsers и undefined.

[commit]

Ранее можно было прокидывать undefined в цепочке $parsers если, например, ты хочешь её оборвать.

Начиная с версии 1.3, парсеры более не обрабатывают undefined и делают контрол невалидным, выставляя в $error значение {parse: true}.

Это сделано для предотвращения запуска парсеров в случаях, когда $viewValue (ещё не установлен)

ngPattern

[commit]

Начиная с 1.4.5, директива ngPattern осуществляет валидацию на основе $viewValue (ранее − на основе $modelValue), до того как сработает цепочка $parsers.

Это связано с проблемой, когда input[date] и input[number] не валидируются из-за того, что парсеры преобразили $viewValue в Date и Number соответственно.

Если вы используете вместе с этой директивой модификаторы $viewValue и вам необходимо проверять именно $modelValue, как и раньше, то стоит использовать кастомную директиву.

Например:
  .directive('patternModelOverwrite', function patternModelOverwriteDirective() {
      return {
          restrict: 'A',
          require: '?ngModel',
          priority: 1,
          compile: function() {
              var regexp, patternExp;

              return {
                  pre: function(scope, elm, attr, ctrl) {
                      if (!ctrl) return;

                      attr.$observe('pattern', function(regex) {
                          /**
                           * The built-in directive will call our overwritten validator
                           * (see below). We just need to update the regex.
                           * The preLink fn guaranetees our observer is called first.
                           */
                          if (isString(regex) && regex.length > 0) {
                              regex = new RegExp('^' + regex + '$');
                          }

                          if (regex && !regex.test) {
                              //The built-in validator will throw at this point
                              return;
                          }

                          regexp = regex || undefined;
                      });

                  },
                  post: function(scope, elm, attr, ctrl) {
                      if (!ctrl) return;

                      regexp, patternExp = attr.ngPattern || attr.pattern;

                      //The postLink fn guarantees we overwrite the built-in pattern validator
                      ctrl.$validators.pattern = function(value) {
                          return ctrl.$isEmpty(value) ||
                                  isUndefined(regexp) ||
                                  regexp.test(value);
                      };
                  }
              };
          }
      };
  });
  


ngMinlength/ngMaxlength


Начиная с 1.3, директивы ngMinlength и ngMaxlength осуществляют валидацию на основе $viewValue (ранее − на основе $modelValue).

Это может приводить к неправильной валидации при использовании данных директив вместе с директивами, изменяющими $viewValue, например, маски для ввода телефона.

Для избежания проблем есть два пути решения:

  1. Изменить количество максимальных символов в соответствии с $viewValue (например, маски вида “xx-xx”, если в модели находятся только “хххх”, стоит учитывать как maxlength=«5», а не 4, как было раньше)
  2. Использовать свои, кастомные директивы, которые проверяют $modelValue. Однако здесь могут возникнуть проблемы с maxlength, ведь, согласно спецификации, он ограничивает количество введённых символов, так что придётся реализовать своё ограничение.


Рекомендую для большинства случаев использовать первый вариант как наименее проблемный.
Второй вариант может быть полезен для minLength. В случаях, когда есть необязательный инпут с маской, где заранее введено n символов (например, инпут телефона с установленной "+7"), это происходит из-за того, что minLength не валидирует поле лишь до тех пор, пока оно пустое.

Пример кастомного maxlength
    (function (angular) {
        'use strict';

        angular
            .module('mainModule')
            .directive('maxModelLength', maxlengthDirective);

        function maxlengthDirective () {
            return {
                restrict: 'A',
                require: '?ngModel',
                link: function (scope, elm, attr, ctrl) {
                    if (!ctrl) {
                        return;
                    }

                    var maxlength = -1;
                    attr.$observe('maxModelLength', function (value) {
                        var intVal = parseInt(value);
                        maxlength = isNaN(intVal) ? -1 : intVal;
                        ctrl.$validate();
                    });

                    ctrl.$validators.maxlength = function (modelValue, viewValue) {
                        return (maxlength < 0) || ctrl.$isEmpty(modelValue) || (String(modelValue).length <= maxlength);
                    };

                    /*
                     * Спасибо ангуляру, он забрал себе под валидатор аттрибут-ограничитель maxlength
                     * Поэтому придётся ограничивать длинну поля ручками, если мы хотим проверять модель
                     * */
                    elm.bind('keydown keypress', function (event) {
                        var stringModel = String(ctrl.$modelValue);

                        if (maxlength > 0 && !ctrl.$isEmpty(ctrl.$modelValue) && stringModel.length >= maxlength) {
                            if ([8, 37, 38, 39, 40, 46].indexOf(event.keyCode) === -1) {
                                event.preventDefault();
                            }
                        }
                    });
                }
            };
        }
    })(angular);
  


Пример кастомного minlength
    (function (angular) {
        'use strict';

        angular
            .module('mainModule')
            .directive('minModelLength', minlengthDirective);

        function minlengthDirective () {
            return {
                restrict: 'A',
                require: '?ngModel',
                link: function (scope, elm, attr, ctrl) {
                    if (!ctrl) {
                        return;
                    }

                    var minlength = 0;
                    attr.$observe('minModelLength', function (value) {
                        minlength = parseInt(value) || 0;
                        ctrl.$validate();
                    });

                    ctrl.$validators.minlength = function (modelValue, viewValue) {
                        return ctrl.$isEmpty(modelValue) || String(modelValue).length >= minlength;
                    };
                }
            };
        }
    })(angular);
  


Проблема виртуального ngModel:

Если вы используете ngMinlength/ngMaxlength на элементе, не предназначенном для прямого ввода данных (например, на корне директивы, которая содержит в себе несколько инпутов, работающих с корневым ngModel), и используете числовые данные, то получите неправильную валидацию данных (всегда будет ошибка).

Если конкретнее, то в $viewValue будет храниться всегда число, которое валидатор не может проверить, т.к. не может получить его .length.

Почему так происходит?
В $compile прописаны основные директивы. Например, для input и textarea вызывается контрол inputDirective, который берёт attr.type и исходя из него вызывает одну из функций коллекции inputType. Для текстовых типов это, соответственно, textInputType, он, в свою очередь, прокидывает наш контрол в функцию stringBasedInputType, которая добавляет в $formatters код конвертации нашего значения в строку.

Когда ngModel не привязан к какому-то существующему базовому элементу вроде input, а, например, висит на простом диве или кастомной директиве, то данные прокидываются в $viewModel «как есть», без дополнительного преобразования в строку, что и вызывает ошибку у директив-фильтров вроде ngMaxlength.

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

Рабочий пример на Plunker.



Scopes and Digests


$id


Теперь целоисчеслительный.

Ранее из-за опасений, что чисел может не хватить для подсчёта scope’s, решили использовать для обозначения $id строки (а по факту это массив вида [‘0’, ‘0’, ‘0’]), однако опасения на этот счёт не оправдались.

Взамен мы получили некоторую лишнюю нагрузку (добавляет несколько миллисекунд) при создании большого количества scope’s (например, при работе с большими таблицами). Переход на простые числа решает эту проблему.

Например:
<1.3: [Пример на Plunker]

console.log($rootScope.$id); // => 001

1.3+: [Пример на Plunker]

console.log($rootScope.$id); // => 1


broadcast и emit


Теперь устанавливают currentScope в null, как только ивент доходит до конца цепочки распространения.

Это связано с трудноотслеживаемым багом при неправильном использовании event.currentScope, когда кто-то пытается обратиться к нему из асинхронной фукнции.
Раньше event.currentScope в таком случае был равен последнему $scope в цепочке, незаметно приводя к неправильной работе приложения.
Теперь в подобном случае при использовании event.currentScope будет ошибка.

Для асинхронного доступа к event.currentScope теперь необходимо использовать event.targetScope.

Например:
У нас есть следующее дерево scope:

      001 ($rootScope)
       └ 002 ($scope of ParentCtrl)
          └ 003 ($scope of ChildCtrl)
             └ 004 ($scope of GrandChildCtrl)
  

Где мы инициировали customEvent в GrandChildCtrl

<1.3: [Пример на Plunker]

  .controller('ParentCtrl', function($scope, $timeout) {
      $scope.$on('customEvent', function(event) {
          console.log(event.currentScope); // $id это 002

          $timeout(function() {
              console.log(event.targetScope) // => $id это 004
              console.log(event.currentScope) // => $id это 001
          });
      })
  })
  .controller('ChildCtrl', function($scope, $timeout) {
      $scope.$on('customEvent', function(event) {
          console.log(event.currentScope); // $id это 003

          $timeout(function() {
              console.log(event.targetScope) // => $id это 004
              console.log(event.currentScope) // => $id это 001
          });
      })
  })
  

1.3+: [Пример на Plunker]

  .controller('ParentCtrl', function($scope, $timeout) {
      $scope.$on('customEvent', function(event) {
          console.log(event.currentScope); // $id это 2

          $timeout(function() {
              console.log(event.targetScope) // => $id это 4
              console.log(event.currentScope) // => null
          });
      })
  })
  .controller('ChildCtrl', function($scope, $timeout) {
      $scope.$on('customEvent', function(event) {
          console.log(event.currentScope); // $id это 3

          $timeout(function() {
              console.log(event.targetScope) // => $id это 4
              console.log(event.currentScope) // => null
          });
      })
  })
  




http и resource


JSON примитивы

[commit]

Начиная с версии 1.3, ответы с Content-Type:application/json, содержащие примитивы, начинают парситься как JSON.

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

Например:
С сервера приходит строка «OK», сообщающая, что запрос закончился удачей.

<1.3:

  response === 'OK' // => false
  response === '"OK"' // => true
  

1.3+:

  response === 'OK' // => true
  response === '"OK"' // => false
  


$http transformRequest


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

В функцию первым аргументом прикидывается объект config, что позволяет определять и устанавливать заголовки динамически.

Например:
Нам необходимо добавить заголовок ‘X-MY_HEADER’

<1.4:

  function requestTransform(data, headers) {
      headers = angular.extend(headers(), {
          'X-MY_HEADER': 'test'
      });

      return angular.toJson(data);
  }
  

1.4+:

  $http.get(url, {
      headers: {
          'X-MY_HEADER': function(config) {
              return 'test';
          }
      }
  })
  


$http interceptor


Коллекция responseInterceptors в $httpProvider уже имела статус deprecated и имела два разных API (один из которых не совсем очевиден), что приводило к различным конфузам.

Начиная с версии 1.3, данная коллекция [удалена], как и её функциональность.

Вместо этого доступен новый, прозрачный API для регистрации перехватчиков.

Например:
Зарегистрируем myHttpInterceptor как новый сервис и добавим для него перехватчик.

< 1.3: [Пример на Plunker]

  $provide.factory('myHttpInterceptor', function($q, dependency1, dependency2) {
      return function(promise) {
          return promise.then(function(response) {
              // обработка success
              return response;
          }, function(response) {
              // обработка error
              if (canRecover(response)) {
                  return responseOrNewPromise
              }
              return $q.reject(response);
          });
      }
  });

  $httpProvider.responseInterceptors.push('myHttpInterceptor');
  

1.3+: [Пример на Plunker]

  $provide.factory('myHttpInterceptor', function($q) {
      return {
          response: function(response) {
              // обработка success
              return response;
          },
          responseError: function(response) {
              // обработка error
              if (canRecover(response)) {
                  return responseOrNewPromise
              }
              return $q.reject(response);
          }
      };
  });

  $httpProvider.interceptors.push('myHttpInterceptor');
  


$httpBackend и JSONP


Теперь ангуляр ловит ошибки в «success» ивентах, пустой ответ (отсутствующие данные в коллбеке) в JSONP не приводит к ошибке (ранее генерировал ошибку и выставлял статус -2).

Интересно знать (или не очень) что:
До этого нововведения коллбеки onload и onerror прикреплялись напрямую к JSONP script тегу.
Сейчас они заменены на jQuery ивенты для возможности получения доступа к объекту event.

Это привело к тому, что теперь трудно проверить, зарегистрирован ли коллбек вообще.

Эту проверку можно осуществить через метод $.data(«events»), однако в текущей реализации с jqLite это невозможно.

IE8: Теперь не поддерживатся ивент onreadystatechanged.

$resource


Если вызвать toJson() на инстансе $resource, то он будет содержать свойства $promise и $resolved, которые раньше вырезались при сериализации, как и все свойства, начинающиеся с одинарного $.

Согласно вышеописанному изменению toJson(), свойства, начинающиеся с $, больше не сериализуются. Теперь сериализуются только те свойства, которые начинаются с $$.

Исходя из этого, можно ожидать, что сериализованный $resource будет содержать эти свойства, однако это не так. Конкретно эти два свойства он вырезает при сериализации сам.

Все остальные свойства, в том числе добавленные пользователем, сериализуются и будут содержаться в итоговом json.


$inject


Модули: .config() и .provider()


Раньше было возможно вызвать .config() до того как сработает .provider()

Начиная с версии 1.3, такое поведение невозможно, .config() всегда будет вызываться только после того, как сработали все .provider() модуля.

Например:
  app
      .provider('$rootProvider1', function() {
          console.log('Provider 1');
          this.$get = function() {};
      })
      .config(function() {
          console.log('Config');
      })
      .provider('$rootProvider2', function() {
          console.log('Provider 2');
          this.$get = function() {};
      });
  

<1.3: [Пример на Plunker]

Выведет Provider 1, Config, Provider 2

1.3+: [Пример на Plunker]

Выведет Provider 1, Provider 2, Config



ngAnimate


Все методы


Ранее у всех методов $animate последним аргументом служил коллбек done, который выполнялся при завершении анимации.
Начиная с версии 1.3 туда передаётся набор стилей options, который применяется к элементу.

Вместо done все функции теперь возвращают promise, resolve которого означает завершение анимации.

animate.enter() и animate.move()


У данных методов есть четыре аргумента (element, parent, after, options).

Ранее, если аргумент after не был указан, то новый элемент добавлялся после указанного element, а если был указан, то после after.

Проблема в том, что с подобным API невозможно добавить новый элемент в начало контейнера parent.

Начиная с версии 1.3, если аргумент after не указан, то этот элемент добавляется в начало контейнера parent.

Соответственно, теперь необходимо всегда указывать, после какого именно элемента вы хотите вставить новый.

Например:
Мы хотим воспользоваться анимацией $animate.enter

<1.3:

  // Вставит новый элемент после `element`
  `$animate.enter(element, parent);`
  

1.3+:

  // Вставит новый элемент в начало `parent`
  `$animate.enter(element, parent);`

  // Вставит новый элемент после `element`
  `$animate.enter(element, parent, angular.element(parent[0].lastChild));`
  




Фильтры


Внутренний контекст

[commit]

Ранее все фильтры имели недокументированную особенность: внутренний контекст их контрола this ссылался на $scope, в котором этот фильтр был вызван.

Почему так нельзя делать, и как делать правильно:
Это давало полёт для извращённой фантазии, например, можно было неочевидно изменять $scope внутри фильтра. В частности, это использовалось для кеширования результатов работы всех фильтров внутри таких директив как ng-repeat.

К примеру Andy Joslin’s предлагал использовать следующий вариант:

  yourModule.filter("as", function($parse) {
      return function(value, path) {
          return $parse(path).assign(this, value);
      };
  });
  

Начиная с версии 1.3, $scope более недоступен в фильтре в качестве контекста (this) и равен undefined.

Для проброски $scope внутрь фильра необходимо передать его в качестве аргумента, но **делать этого ни в коем случае не стоит**, ссылка на $scope внутри фильтра замедляет некоторые браузеры до 10%, а изменение $scope внутри фильтра приведёт к множественным повторным перезапускам цикла $digest и может стать следствием ошибки
Error: 10 $digest() iterations reached. Aborting!.

Помните два негласных правила работы с фильтрами:

  • Передаваемые в них данные немутабельны: Фильтры возвращают значения, а не изменяют их внутри себя.
  • Фильтры не работают напрямую со $scope и тем более не изменяют его внутри себя.

Если необходимо кешировать результат работы всех фильтров в ng-repeat:

Используйте прямое присваивание item in (filterResults = (items | filter:query)) или же, что предпочтительнее, специальный синтаксис алиасов as: item in items | filter:query as filterResults).

filter


Теперь работает только с массивами.

С версией 1.4 попытка вызвать filter на объекте приведёт к ошибке. Раньше он просто «втихую» возвращал пустой массив.

Для перебора объекта предлагается использовать кастомные фильтры, преобразующие объект в массив.

limitTo


Ранее, если в limitTo передавался неправильный лимит (например, undefined), то он возвращал пустой массив или строку.

Начиная с версии 1.4, при неправильном лимите он будет отдавать оригинальные входные данные, т.е. массив или строку без применения фильтра.


ngCookies


$cookies


Начиная с версии 1.4, браузерные куки больше не будут копироваться в объект сервиса $cookies, работа с данными будет реализована не через сеттеры/геттеры, а через более прозрачный API для работы со значениями:

  • get
  • put
  • getObject
  • putObject
  • getAll
  • remove

Это связано с багами синхронизации данных, когда в объекте находятся уже не актуальные данные. Что означает, что больше нельзя использовать вотчеры, отслеживая изменения Cookies через объект.

Подобные манипуляции были необходимы в прошлом, например, для общения между вкладками браузера, но в наши дни есть более удобные инструменты, например localStorage.

$cookieStore


Начиная с версии 1.4, сервис $cookieStore получил статус deprecated, вся полезная логика была перенесена в сервис $cookies, обращение к $cookieStore в данный момент возвращает инстанс сервиса $cookies.


Заключение:


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

В следующей статье я расскажу о всех преимуществах новых версий и о том, как повысить с их помощью производительность.

Если вы встречались с какими-то другими интересными проблемами при переходе, прошу поделиться ими в комментариях.
Tags:
Hubs:
+21
Comments 7
Comments Comments 7

Articles