[Из песочницы] AngularJS: Миграция с 1.2 на 1.4, ч.1
О преимуществах перехода с версии 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.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 недели рабочего времени одного человека с учётом рефакторинга и перехода на новую валидацию.
Сразу отмечу, что отныне мы лишаемся поддержки 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 (например, при работе с большими таблицами). Переход на простые числа решает эту проблему.
broadcast и emit
Теперь устанавливают currentScope в null, как только ивент доходит до конца цепочки распространения.
Это связано с трудноотслеживаемым багом при неправильном использовании event.currentScope, когда кто-то пытается обратиться к нему из асинхронной фукнции.
Раньше event.currentScope в таком случае был равен последнему $scope в цепочке, незаметно приводя к неправильной работе приложения.
Теперь в подобном случае при использовании event.currentScope будет ошибка.
Для асинхронного доступа к event.currentScope теперь необходимо использовать event.targetScope.
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.
Вообще это баг-фикс, это позволяет избежать некоторых костылей при работе с ответом, однако в некоторых случаях это может сломать существующий код.
<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');