О преимуществах перехода с версии 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).
Этот пост я поделил на две части. Одна из них − мой личный опыт перевода двух проектов на новые версии (её я скрыл под спойлером), вторая − список обнаруженных, вычитанных и перевёденных проблем с пояснениями и примерами.
Сразу отмечу, что отныне мы лишаемся поддержки IE8. Впрочем, его можно вернуть, подключив необходимые полифиллы.
Больше нельзя вызвать .bind, .call и .apply внутри выражения (например, {{ }}).
Это позволяет быть уверенными в том, что поведение существующих функций невозможно изменить.
С версии 1.3 устаревшее (deprecated) свойство __proto__ удалено.
Раньше оно могло быть использовано для доступа к глобальным прототипам.
Запрещено использовать Object внутри выражений.
Это связано с возможностью выполнять в выражениях произвольный код.
Если кому-то необходим Object.key или иной метод, пробрасывайте его через scope.
С версии 1.3 запрещены свойства {define,lookup}{Getter,Setter}, позволявшие выполнять произвольный код внутри выражения.
Если вам необходимы данные свойства, оборачивайте их в контроллере и прокидывайте в scope руками.
Удалены устаревшие методы $parseProvider.unwrapPromises и $parseProvider.logPromiseWarnings.
Функции, возвращаемые $interpolate, более не содержат массив .parts. Вместо этого они содержат:
В ангуляре для проверки на истину используется собственная реализация toBoolean(), которая приравнивала к false некоторые нестандартные значения в виде следующих строк:
'f', '0', 'false', 'no', 'n', '[]'
Начиная с версии 1.3, к false приравниваются только те же значения, что и в обычном JS:
false, null, undefined, NaN, 0, ""
Об этом писали на хабре
Раньше при работе с объектами copy копировал все свойства объекта, включая те, что лежат в прототипе, что приводило к потере цепочки прототипов (кроме Date, RegExp и Array).
Начиная с версии 1.3, он копирует только собственные свойства (что-то вроде перебора с hasOwnProperty), а затем ссылается на прототип оригинала.
Раньше, если массив увеличивался в процессе перебора, то цикл перебирал и вновь появившиеся элементы тоже.
Начиная с версии 1.3, он кеширует количество элементов в массиве и проходит только по ним, здесь он стал ближе к нативному Array.forEach.
Соль этого хелпера в первую очередь в том, что он сериализирует не все данные, а только те, которые начинаются не со спецсимвола $.
Начиная с версии 1.3, он не сериализирует только свойства, имена которых начинаются с $$.
Коротко о главном:
SelectController теперь является одной абстракцией для директивы Select и для директивы ngOptions.
Это означает, что теперь ngOptions можно удалить из Select, не боясь, что это может как-то на неё повлиять.
Различные вариации директивы Select имеют свои методы SelectController.writeValue и SelectController.readValue, отвечающие за работу с $viewValue тега <select> и его дочерних <option>.
Ранее в ngOptions для суррогатного ключа использовался индекс или ключ item в переданной коллекции.
Начиная с версии 1.4, для этого используется вызов hashKey для item в коллекции.
Соответственно, если читать value напрямую из DOM, то могут возникнуть проблемы.
Начиная с версии 1.4, директива select начинает сравнивать значение option и ngModel, используя строгое сравнение.
Это означает, что значение 1 не эквивалентно «1» так же, как не эквивалентно значению false или true.
Если вы положите в модель значение 1, то получите unknown option.
Чтобы этого избежать, необходимо класть в модель строку, например scope.model = «1».
Если же в модели необходимо именно число, предлагается воспользоваться конвертированием через formatters и parsers.
Как и в случае с ngRepeat, сортировка в алфавитном порядке теперь не работает, а соответствует в последовательности вызову Object.keys(obj).
Ранее ngRepeat, перебирая объект, сортировал его в алфавитном порядке по ключам. Начиная с версии 1.4, он возвращает его в порядке, зависящем от браузера, как если бы вы перебирали его for key in obj.
Это связано с тем, что браузеры обычно возвращают ключи объектов в том порядке, в котором они были объявлены, за исключением того случая, когда ключи были удалены или переустановлены.
Для перебора объекта предлагается использовать кастомные фильтры, преобразующие объект в массив.
В версии 1.3 был введён bindToController. Начиная с версии 1.4, в него можно передавать объект для указания изолированного scope.
В связи с этим теперь возвращаемый из конструктора контроллера объект перезаписывает scope.
Вьюхи, использовавшие controllerAs синтаксис, больше не получают ссылку на саму функцию, но на объект, который она возвращает.
Если в директиве используется bindToController, то все предыдущие биндинги переустанавливаются в новый контроллер, все установленные вотчеры удаляются (unwatch).
Раньше на выражение с & всегда создавалась функция, даже если атрибут вместе с выражением отсутствовал (в таком случае создавалась функция, которая возвращает undefined).
Начиная с 1.4, поведение & приблизилось к @. Теперь если выражение отсутствует, то отсутствует и соответствующий метод в $scope. При обращении к нему вы получите undefined вместо функции, которая вернёт undefined.
Начиная с 1.3, оно становится deprecated и должно быть удалено в следующем мажорном релизе.
Объясняется это тем, что возникают некие проблемы с мерджем атрибутов.
Начиная с версии 1.3, мы наконец получили удобный способ удаления обсервера атрибутов: функция-деструктор возвращается при вызове attr.observe (как делает watch). Раньше он возвращал ссылку на функцию обсервера.
Теперь чтобы иметь ссылку на функцию обсервера, надо её предварительно где-то сохранить.
Больше нельзя получить свойство изолированного scope посредством атрибута элемента, где определена изолированная директива.
Поведение $setViewValue() немного изменилось, теперь оно не прокидывает изменения в $modelValue сразу же, как раньше.
Теперь модель обновляется в зависимости от двух настроек ngModelOptions, а в частности:
По умолчанию updateOn равен default, а debounce равен 0, поэтому $modelValue выполняется, как и раньше, мгновенно.
Однако стоит учитывать описанные выше особенности при работе со старым кодом.
Если вы хотите любой ценой обновить $modelValue мгновенно, игнорируя updateOn и debounce, то используйте $commitViewValue().
$commitViewValue() не принимает аргументов. Ранее у него был недокументированный аргумент revalidate, использовавшийся
в приватном апи как хак для насильного запуска ревалидации и сопутствующих процессов, даже если $$lastCommittedViewValue
не обновился, но в последних версиях это убрали.
Был переименован в $rollbackViewValue().
Вызов позволяет «откатить» $viewValue до состояния $$lastCommittedViewValue, отменить все находящиеся в процессе выполнения debounce и перерисовать вьюху (к примеру, input).
С версии 1.3 ангуляр нормально поддерживает HTML5 инпуты, связанные с числами.
В ng-model таких инпутов должен находиться строго объект Date
В старых браузерах, не поддерживающих эти инпуты, пользователь будет видеть текстовый. В таких случаях ему придётся вводить корректный ISO формат для необходимой даты.
Ранее можно было хранить в $error произвольные свойства, управляя валидностью контрола вручную через $setValidity.
Начиная с версии 1.3, конечная валидация зависит от того, пуст ли хеш $error. Прокинув в ngModelCtrl.$error какое-либо свойство вручную и вовремя его оттуда не убрав, вы получите перманентно невалидный контрол, независимо от значения этого свойства.
$setValidity позволяет выставлять валидность тех или иных свойств контрола, принимая два аргумента: name и result.
Ранее result всегда приводился к true или false, независимо от того, что туда передали.
Начиная с версии 1.3, $setValidity начинает различать false, undefined и null, передаваемые в result. Стоит теперь самим позаботиться о том, чтобы в result попало именно булево значение.
Значения undefined и null используются, например, внутри для асинхронных валидаторов. Так, если не все синхронные валидаторы валидны, то значения асинхронных будут установлены в null. Если же синхронные валидаторы готовы и началась асинхронная валидация, то до тех пор пока идёт ожидание (pending), значение валидатора будет установлено в undefined.
Ранее можно было прокидывать undefined в цепочке $parsers если, например, ты хочешь её оборвать.
Начиная с версии 1.3, парсеры более не обрабатывают undefined и делают контрол невалидным, выставляя в $error значение {parse: true}.
Это сделано для предотвращения запуска парсеров в случаях, когда $viewValue (ещё не установлен)
Начиная с 1.4.5, директива ngPattern осуществляет валидацию на основе $viewValue (ранее − на основе $modelValue), до того как сработает цепочка $parsers.
Это связано с проблемой, когда input[date] и input[number] не валидируются из-за того, что парсеры преобразили $viewValue в Date и Number соответственно.
Если вы используете вместе с этой директивой модификаторы $viewValue и вам необходимо проверять именно $modelValue, как и раньше, то стоит использовать кастомную директиву.
Начиная с 1.3, директивы ngMinlength и ngMaxlength осуществляют валидацию на основе $viewValue (ранее − на основе $modelValue).
Это может приводить к неправильной валидации при использовании данных директив вместе с директивами, изменяющими $viewValue, например, маски для ввода телефона.
Для избежания проблем есть два пути решения:
Рекомендую для большинства случаев использовать первый вариант как наименее проблемный.
Второй вариант может быть полезен для minLength. В случаях, когда есть необязательный инпут с маской, где заранее введено n символов (например, инпут телефона с установленной "+7"), это происходит из-за того, что minLength не валидирует поле лишь до тех пор, пока оно пустое.
Проблема виртуального ngModel:
Если вы используете ngMinlength/ngMaxlength на элементе, не предназначенном для прямого ввода данных (например, на корне директивы, которая содержит в себе несколько инпутов, работающих с корневым ngModel), и используете числовые данные, то получите неправильную валидацию данных (всегда будет ошибка).
Если конкретнее, то в $viewValue будет храниться всегда число, которое валидатор не может проверить, т.к. не может получить его .length.
Теперь целоисчеслительный.
Ранее из-за опасений, что чисел может не хватить для подсчёта scope’s, решили использовать для обозначения $id строки (а по факту это массив вида [‘0’, ‘0’, ‘0’]), однако опасения на этот счёт не оправдались.
Взамен мы получили некоторую лишнюю нагрузку (добавляет несколько миллисекунд) при создании большого количества scope’s (например, при работе с большими таблицами). Переход на простые числа решает эту проблему.
Теперь устанавливают currentScope в null, как только ивент доходит до конца цепочки распространения.
Это связано с трудноотслеживаемым багом при неправильном использовании event.currentScope, когда кто-то пытается обратиться к нему из асинхронной фукнции.
Раньше event.currentScope в таком случае был равен последнему $scope в цепочке, незаметно приводя к неправильной работе приложения.
Теперь в подобном случае при использовании event.currentScope будет ошибка.
Для асинхронного доступа к event.currentScope теперь необходимо использовать event.targetScope.
Начиная с версии 1.3, ответы с Content-Type:application/json, содержащие примитивы, начинают парситься как JSON.
Вообще это баг-фикс, это позволяет избежать некоторых костылей при работе с ответом, однако в некоторых случаях это может сломать существующий код.
Начиная с версии 1.4, функция transformRequest больше не поддерживается и не изменяет заголовки запроса. Вместо этого стоит использовать в параметрах запроса свойство headers и соответствующие нужному заголовку функции геттеры.
В функцию первым аргументом прикидывается объект config, что позволяет определять и устанавливать заголовки динамически.
Коллекция responseInterceptors в $httpProvider уже имела статус deprecated и имела два разных API (один из которых не совсем очевиден), что приводило к различным конфузам.
Начиная с версии 1.3, данная коллекция [удалена], как и её функциональность.
Вместо этого доступен новый, прозрачный API для регистрации перехватчиков.
Теперь ангуляр ловит ошибки в «success» ивентах, пустой ответ (отсутствующие данные в коллбеке) в JSONP не приводит к ошибке (ранее генерировал ошибку и выставлял статус -2).
IE8: Теперь не поддерживатся ивент onreadystatechanged.
Если вызвать toJson() на инстансе $resource, то он будет содержать свойства $promise и $resolved, которые раньше вырезались при сериализации, как и все свойства, начинающиеся с одинарного $.
Согласно вышеописанному изменению toJson(), свойства, начинающиеся с $, больше не сериализуются. Теперь сериализуются только те свойства, которые начинаются с $$.
Исходя из этого, можно ожидать, что сериализованный $resource будет содержать эти свойства, однако это не так. Конкретно эти два свойства он вырезает при сериализации сам.
Все остальные свойства, в том числе добавленные пользователем, сериализуются и будут содержаться в итоговом json.
Раньше было возможно вызвать .config() до того как сработает .provider()
Начиная с версии 1.3, такое поведение невозможно, .config() всегда будет вызываться только после того, как сработали все .provider() модуля.
Ранее у всех методов $animate последним аргументом служил коллбек done, который выполнялся при завершении анимации.
Начиная с версии 1.3 туда передаётся набор стилей options, который применяется к элементу.
Вместо done все функции теперь возвращают promise, resolve которого означает завершение анимации.
У данных методов есть четыре аргумента (element, parent, after, options).
Ранее, если аргумент after не был указан, то новый элемент добавлялся после указанного element, а если был указан, то после after.
Проблема в том, что с подобным API невозможно добавить новый элемент в начало контейнера parent.
Начиная с версии 1.3, если аргумент after не указан, то этот элемент добавляется в начало контейнера parent.
Соответственно, теперь необходимо всегда указывать, после какого именно элемента вы хотите вставить новый.
Ранее все фильтры имели недокументированную особенность: внутренний контекст их контрола this ссылался на $scope, в котором этот фильтр был вызван.
Теперь работает только с массивами.
С версией 1.4 попытка вызвать filter на объекте приведёт к ошибке. Раньше он просто «втихую» возвращал пустой массив.
Для перебора объекта предлагается использовать кастомные фильтры, преобразующие объект в массив.
Ранее, если в limitTo передавался неправильный лимит (например, undefined), то он возвращал пустой массив или строку.
Начиная с версии 1.4, при неправильном лимите он будет отдавать оригинальные входные данные, т.е. массив или строку без применения фильтра.
Начиная с версии 1.4, браузерные куки больше не будут копироваться в объект сервиса $cookies, работа с данными будет реализована не через сеттеры/геттеры, а через более прозрачный API для работы со значениями:
Это связано с багами синхронизации данных, когда в объекте находятся уже не актуальные данные. Что означает, что больше нельзя использовать вотчеры, отслеживая изменения Cookies через объект.
Подобные манипуляции были необходимы в прошлом, например, для общения между вкладками браузера, но в наши дни есть более удобные инструменты, например localStorage.
Начиная с версии 1.4, сервис $cookieStore получил статус deprecated, вся полезная логика была перенесена в сервис $cookies, обращение к $cookieStore в данный момент возвращает инстанс сервиса $cookies.
Проблем с переходом не возникнет, если только в проекте не используется чрезмерного количествакостылей нетривиальных решений на основе багов и недокументированных возможностей фреймворка.
Совсем безболезненным он окажется для тех, кто не пользовался средствами ангуляра для анимации и валидации приложения.
В следующей статье я расскажу о всех преимуществах новых версий и о том, как повысить с их помощью производительность.
Если вы встречались с какими-то другими интересными проблемами при переходе, прошу поделиться ими в комментариях.
И это когда
Как правило, большие проекты от перехода сдерживают непрозрачность этого процесса и скудность материалов на данную тематику.
В официальном гайде можно найти лишь маленький кусочек всех возможных проблем, а блоги, как правило, лишь пересказывают его.
В этой статье мы поговорим о том, с чем можно столкнуться при миграции на новые версии, и разберём наиболее проблемные места.
К сожалению, пост вышел слишком большим, поэтому в этой части я сосредоточусь на 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, а в новой наоборот.
Совет:
Так же проблема коснулась условий в выражениях внутри $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.
С асинхронной валидацией оказалось сложнее: форма была невалидна, но не содержала в себе никаких ошибок.
Здесь из-за особенностей работы маски (через $formatters и $parsers) асинхронная валидация вызывалась ещё до завершения синхронных валидаций, при этом вызывалась по нескольку раз за 1 изменённый символ. Это порождало баг множественного создания промисов, что приводило к тому, что последний промис не был resolved или rejected. Соответственно, инпут получал бесконечный pending, а форма была невалидной без каких-либо ошибок.
Совет: Протестируйте поведение асинхронных валидаторов: убедитесь, что возвращаемый промис всегда будет 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 недели рабочего времени одного человека с учётом рефакторинга и перехода на новую валидацию.
Второй проект требовал миграции с версии 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.
Ремарка: О том, как работают асинхронные валидаторы
Асинхронные валидаторы хранятся в коллекции ngModelCtrl.$asyncValidators и представляют из себя функцию, возвращающую Promise. Промисы возвращают различные сервисы (например $timeout и $http), а также генерируются специальным сервисом $q. От resolved или rejected возвращаемого промиса зависит валидность поля. Асинхронная валидация запускается только после того, как все синхронные валидаторы становятся валидны. В момент вызова валидатора его валидность ($setValidity) будет равна undefined, а в ngModelCtrl.$pending появится название ожидаемого валидатора. Как только произойдёт resolve промиса, его валидность будет выставлена в null.
Если вызвать функцию валидации несколько раз, то последний промис затрёт предыдущие. Это значит, что если у поля вызвали валидацию два раза, и первый промис был resolved, а второй − rejected, то поле будет невалидно.
Пример из документации по API:
Если вызвать функцию валидации несколько раз, то последний промис затрёт предыдущие. Это значит, что если у поля вызвали валидацию два раза, и первый промис был 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, ""
Об этом писали на хабре
Например:
<1.3:
1.3+:
$scope.isEnabled = ‘no’;
<1.3:
{{ isEnabled ? ‘Не выведет’ : ‘Выведет’ }}
1.3+:
{{ isEnabled ? ‘Выведет’ : ‘Не выведет’ }}
Helpers
.copy()
Раньше при работе с объектами copy копировал все свойства объекта, включая те, что лежат в прототипе, что приводило к потере цепочки прототипов (кроме Date, RegExp и Array).
Начиная с версии 1.3, он копирует только собственные свойства (что-то вроде перебора с hasOwnProperty), а затем ссылается на прототип оригинала.
Например:
<1.3:
1.3+:
IE8: Необходимы полифиллы Object.create и Object.getPrototypeOf
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.
Например:
<1.3:
1.3+:
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, он не сериализирует только свойства, имена которых начинаются с $$.
Например:
<1.3:
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, то могут возникнуть проблемы.
Например:
<1.4:
1.4+:
<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 посредством атрибута элемента, где определена изолированная директива.
Например:
Дана следующая директива:
<1.3:
1.3+:
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:
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, например, маски для ввода телефона.
Для избежания проблем есть два пути решения:
- Изменить количество максимальных символов в соответствии с $viewValue (например, маски вида “xx-xx”, если в модели находятся только “хххх”, стоит учитывать как maxlength=«5», а не 4, как было раньше)
- Использовать свои, кастомные директивы, которые проверяют $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.
Когда ngModel не привязан к какому-то существующему базовому элементу вроде input, а, например, висит на простом диве или кастомной директиве, то данные прокидываются в $viewModel «как есть», без дополнительного преобразования в строку, что и вызывает ошибку у директив-фильтров вроде ngMaxlength.
Исходя из этого, все кастомные директивы, работающие с числами, обязательно должны иметь соответствующий форматтер преобразования числа в строку.
Рабочий пример на Plunker.
Scopes and Digests
$id
Теперь целоисчеслительный.
Ранее из-за опасений, что чисел может не хватить для подсчёта scope’s, решили использовать для обозначения $id строки (а по факту это массив вида [‘0’, ‘0’, ‘0’]), однако опасения на этот счёт не оправдались.
Взамен мы получили некоторую лишнюю нагрузку (добавляет несколько миллисекунд) при создании большого количества scope’s (например, при работе с большими таблицами). Переход на простые числа решает эту проблему.
Например:
<1.3: [Пример на Plunker]
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:
Где мы инициировали customEvent в GrandChildCtrl
<1.3: [Пример на Plunker]
1.3+: [Пример на Plunker]
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:
1.3+:
<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:
1.4+:
<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]
1.3+: [Пример на Plunker]
< 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 это невозможно.
Сейчас они заменены на jQuery ивенты для возможности получения доступа к объекту event.
Это привело к тому, что теперь трудно проверить, зарегистрирован ли коллбек вообще.
Эту проверку можно осуществить через метод $.data(«events»), однако в текущей реализации с jqLite это невозможно.
IE8: Теперь не поддерживатся ивент onreadystatechanged.
$resource
Если вызвать toJson() на инстансе $resource, то он будет содержать свойства $promise и $resolved, которые раньше вырезались при сериализации, как и все свойства, начинающиеся с одинарного $.
Согласно вышеописанному изменению toJson(), свойства, начинающиеся с $, больше не сериализуются. Теперь сериализуются только те свойства, которые начинаются с $$.
Исходя из этого, можно ожидать, что сериализованный $resource будет содержать эти свойства, однако это не так. Конкретно эти два свойства он вырезает при сериализации сам.
Все остальные свойства, в том числе добавленные пользователем, сериализуются и будут содержаться в итоговом json.
$inject
Модули: .config() и .provider()
Раньше было возможно вызвать .config() до того как сработает .provider()
Начиная с версии 1.3, такое поведение невозможно, .config() всегда будет вызываться только после того, как сработали все .provider() модуля.
Например:
<1.3: [Пример на Plunker]
Выведет Provider 1, Config, Provider 2
1.3+: [Пример на Plunker]
Выведет Provider 1, Provider 2, Config
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:
1.3+:
<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 предлагал использовать следующий вариант:
Начиная с версии 1.3, $scope более недоступен в фильтре в качестве контекста (this) и равен undefined.
Для проброски $scope внутрь фильра необходимо передать его в качестве аргумента, но **делать этого ни в коем случае не стоит**, ссылка на $scope внутри фильтра замедляет некоторые браузеры до 10%, а изменение $scope внутри фильтра приведёт к множественным повторным перезапускам цикла $digest и может стать следствием ошибки
Error: 10 $digest() iterations reached. Aborting!.
Помните два негласных правила работы с фильтрами:
Если необходимо кешировать результат работы всех фильтров в ng-repeat:
Используйте прямое присваивание item in (filterResults = (items | filter:query)) или же, что предпочтительнее, специальный синтаксис алиасов as: item in items | filter:query as filterResults).
К примеру 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.
Заключение:
Проблем с переходом не возникнет, если только в проекте не используется чрезмерного количества
Совсем безболезненным он окажется для тех, кто не пользовался средствами ангуляра для анимации и валидации приложения.
В следующей статье я расскажу о всех преимуществах новых версий и о том, как повысить с их помощью производительность.
Если вы встречались с какими-то другими интересными проблемами при переходе, прошу поделиться ими в комментариях.