И это когда
Как правило, большие проекты от перехода сдерживают непрозрачность этого процесса и скудность материалов на данную тематику.
В официальном гайде можно найти лишь маленький кусочек всех возможных проблем, а блоги, как правило, лишь пересказывают его.
В этой статье мы поговорим о том, с чем можно столкнуться при миграции на новые версии, и разберём наиболее проблемные места.
К сожалению, пост вышел слишком большим, поэтому в этой части я сосредоточусь на breaking changes такого перехода, а о фичах и достоинствах поговорим в следующей части. Но отмечу, что главным плюсом перехода служит существенное повышение скорости работы, а так же солидный список исправленных багов (ведь последние исправления для 1.2 были год назад в 1.2.28).
Этот пост я поделил на две части. Одна из них ? мой личный опыт перевода двух проектов на новые версии (её я скрыл под спойлером), вторая ? список обнаруженных, вычитанных и перевёденных проблем с пояснениями и примерами.
Второй проект требовал миграции с версии 1.2.16 на 1.4.4 и был существенно больше (около 75 000 строк чистого angular), а специфика продукта (бухгалтерский учёт) подразумевала сложные связи, множество форм и неизбежные проблемы, но предыдущий опыт воодушевлял.
Первым результатом миграции ожидалось получить неработающее приложение с кучей ошибок в консоли, однако приложение завелось, и никаких проблем не возникло. Это было крайне странно, поэтому я начал своё путешествие по официальному гайду, где, однако, не воспроизвелась ни одна из описанных проблем. Решив, что дело сделано, я отдал задачу на откуп QA и автоматическим тестам.
И уже на следующий день получил 9 багов, затем ещё 9, и ещё. Коварство всех найденных ошибок было не в том, что они ломают работу приложения, но в том, что незаметно изменяют его поведение.
Здесь другом и товарищем в поисках причин стал Change Log и обсуждения внутри найденных там коммитов.
Первым делом отвалились асинхронные проверки на стороне сервера, которые работали не со статусами, а с ответами в виде текстового примитива типа «OK». О таком ни один break change не оповещал, но зато был соответствующий баг-фикс.
Совет: Проверьте свою работу с XHR запросами. Если сервер вам присылает не объект, а какой-либо примитив с заголовкам «application/json», у вас будут проблемы.
Следующими посыпались спиннеры, вложенные в попапы, перестав корректно определять ширину родителя. Проблема заключалась в двух вещах. Во-первых, спиннеры лежали в ng-show, а значит инициализировались раньше, чем контейнер родителя мог быть показан. Во-вторых, показывался/скрывался спиннер, следя за атрибутом через $observe. По какой-то причине в старой версии атрибут менялся позже, чем родитель становился :visible, а в новой наоборот.
Совет:
- Не храните динамические директивы, которым важен момент инициализации в ng-show/ng-hide, используйте для этого ng-if. Этот совет актуален и для версии 1.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.
Если вызвать функцию валидации несколько раз, то последний промис затрёт предыдущие. Это значит, что если у поля вызвали валидацию два раза, и первый промис был 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). Раньше он возвращал ссылку на функцию обсервера.
Теперь чтобы иметь ссылку на функцию обсервера, надо её предварительно где-то сохранить.
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).
$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, например, маски для ввода телефона.
Для избежания проблем есть два пути решения:
- Изменить количество максимальных символов в соответствии с $viewValue (например, маски вида “xx-xx”, если в модели находятся только “хххх”, стоит учитывать как maxlength=«5», а не 4, как было раньше)
- Использовать свои, кастомные директивы, которые проверяют $modelValue. Однако здесь могут возникнуть проблемы с maxlength, ведь, согласно спецификации, он ограничивает количество введённых символов, так что придётся реализовать своё ограничение.
Рекомендую для большинства случаев использовать первый вариант как наименее проблемный.
Второй вариант может быть полезен для minLength. В случаях, когда есть необязательный инпут с маской, где заранее введено n символов (например, инпут телефона с установленной "+7"), это происходит из-за того, что minLength не валидирует поле лишь до тех пор, пока оно пустое.
(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);
(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.
Когда ngModel не привязан к какому-то существующему базовому элементу вроде input, а, например, висит на простом диве или кастомной директиве, то данные прокидываются в $viewModel «как есть», без дополнительного преобразования в строку, что и вызывает ошибку у директив-фильтров вроде ngMaxlength.
Исходя из этого, все кастомные директивы, работающие с числами, обязательно должны иметь соответствующий форматтер преобразования числа в строку.
Рабочий пример на Plunker.
Scopes and Digests
$id
Теперь целоисчеслительный.
Ранее из-за опасений, что чисел может не хватить для подсчёта scope’s, решили использовать для обозначения $id строки (а по факту это массив вида [‘0’, ‘0’, ‘0’]), однако опасения на этот счёт не оправдались.
Взамен мы получили некоторую лишнюю нагрузку (добавляет несколько миллисекунд) при создании большого количества scope’s (например, при работе с большими таблицами). Переход на простые числа решает эту проблему.
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.
001 ($rootScope)
L 002 ($scope of ParentCtrl)
L 003 ($scope of ChildCtrl)
L 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.
Вообще это баг-фикс, это позволяет избежать некоторых костылей при работе с ответом, однако в некоторых случаях это может сломать существующий код.
<1.3:
response === 'OK' // => false
response === '"OK"' // => true
1.3+:
response === 'OK' // => true
response === '"OK"' // => false
$http transformRequest
Начиная с версии 1.4, функция transformRequest больше не поддерживается и не изменяет заголовки запроса. Вместо этого стоит использовать в параметрах запроса свойство headers и соответствующие нужному заголовку функции геттеры.
В функцию первым аргументом прикидывается объект config, что позволяет определять и устанавливать заголовки динамически.
<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 для регистрации перехватчиков.
< 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).
Сейчас они заменены на 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.
Соответственно, теперь необходимо всегда указывать, после какого именно элемента вы хотите вставить новый.
<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, в котором этот фильтр был вызван.
К примеру 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.
Заключение:
Проблем с переходом не возникнет, если только в проекте не используется чрезмерного количества
Совсем безболезненным он окажется для тех, кто не пользовался средствами ангуляра для анимации и валидации приложения.
В следующей статье я расскажу о всех преимуществах новых версий и о том, как повысить с их помощью производительность.
Если вы встречались с какими-то другими интересными проблемами при переходе, прошу поделиться ими в комментариях.
Комментарии (7)
erlyvideo
06.10.2015 10:28+1Если честно, то мы сейчас в процессе миграции с Angular 1.2 на React.
React конечно топорненький в некоторых моментах, но я знаю, что я так и не смог асилить некоторые моменты Angular 1.2, потому что настолько всё сложно наворочено, что разобраться чрезвычайно сложно.StGeass
06.10.2015 13:05+3Для нашего проекта iSnifer тоже привел чудесный пример работы одного из модулей на ReactJS. Но одно дело сделать это, а другое — убедить руководство в целесообразности перехода всего проекта на новый инструмент.
Это почти невыполнимо — доказать, что польза от перехода для бизнеса будет эквивалентна затраченному времени команды.
Боюсь, что такое же отношение постигнет не только ReactJS, но и AngularJS 2.0.
А это значит, что все, кто внедрил в компании большие проекты на AngularJS 1.x, крепко увязли в нём, и им остаётся лишь довольствоваться минорными обновлениями первой версии.
Lisio
Возможно, люди просто ждут Angular 2.0, чтобы два раза не вставать
Alexufo
Скорее нормальной документации для 2.0, поскольку там без бутылки не разобраться.
MilkyWay
как на счет «работает — не трогай»?
StGeass
Позиция «зачем тратить время и два раза всё переписывать, когда можно подождать и сделать это один раз» более чем понятна, но я как раз хотел показать в статье, что переписывать ничего как правило не придётся, а затраты по времени минимальны даже для больших проектов.
Тут ещё вопрос в том, надо поддерживать и развивать продукт или нет. Если надо, то лучше всё это время делать с отсутствием старых багов и более вменяемыми инструментами, чем ждать волшебной пилюли.
Всё-таки 2.0 выйдет не скоро и тем более не скоро все решатся перенести на это свои проекты, ведь с учётом различий между этими версиями переписать придётся практически всё.
Судя по моим наблюдениям, всему все кому не понравился Англуяр 1.х, но есть время и возможность всё переделать с нуля — предпочитают перейти на тот же ReactJS, Vue или Ember, чем ожидать 2.0. Посмотрим, изменится ли тендеция после релиза второй версии.