Магия AngularJS: никогда не вешайте binding на примитивы
Если вы используете AngularJS, скорее всего вы неоднократно сталкивались с правилом «Не вешайте binding на примитивы». В этом посте я подробно разберу пример, в котором использование примитивов создает проблемы: создание списка элементов , в котором каждый из элементов привязан к строке.
Наш пример
Скажем, вы работаете над приложением с книгами, и у каждой книги есть список тегов. Наивным способом предоставления пользователю возможности редактировать теги будет:
<div ng-controller="bookCtrl">
<div ng-repeat="tag in book.tags">
<input type="text" ng-model="tag">
</div>
</div>
(Вы, вероятно, захотите добавить еще одно поле для добавления новых тегов и кнопки для удаления существующих тегов, но мы проигнорируем это для простоты примера.)
Демо нашего примера доступно здесь. Попробуйте отредактировать одно из полей ввода. Казалось бы, все нормально. Но это не так. Если вы приглядитесь повнимательнее, то увидите, что внесенные изменения не синхронизируются с массивом book.tags.
Это происходит потому, что ng-repeat создает child scope для каждого тега и в реальности scopes могут выглядеть так:
bookCtrl scope = { tags: [ 'foo', 'bar', 'baz' ] }
ng-repeat child scopes: { tag: 'foo' }, { tag: 'bar' }, { tag: 'baz' }
В этих child scopes ng-repeat не создает двусторонний binding для значения тега. Это означает, что при изменении первого поля ng-model просто меняет первый child scope на { tag: 'something' }, и это никак не отражается в объекте book.
Теперь вы увидели, как примитивы могут сыграть с вами дурную шутку. Если бы мы для каждого тега вместо строк использовали объекты, то все бы работало, так как тег в child scopes был бы тем же самым instance, что и в book.tags, и изменения его значения (например, tag.name) просто бы работало, даже без 2-way binding.
Но, предположим, что здесь мы не хотим использовать объекты. Как поступить в таком случае?
Неудачная попытка
– Я знаю! – могли бы вы подумать. – Я свяжу ng-repeat напрямую со списком тегов вышестоящего уровня! Давайте попробуем:
<div ng-controller="bookCtrl">
<div ng-repeat="tag in book.tags">
<input type="text" ng-model="book.tags[$index]">
</div>
</div>
Таким образом, связав ng-model непосредственно с нужным элементов в списке тегов и не ссылаясь на child-scope, мы заставили наш код работать. Ну, почти. Теперь значения внутри списка будут меняться при вводе текста. Но теперь кое-что еще не так. Можете посмотреть сами. Сделайте это, я подожду.
Как вы можете видеть, когда вы печатаете символ, поле ввода теряет фокус. WTF?
В этом нужно винить ng-repeat. Ради эффективности ng-repeat отслеживает все значения в списке и перерисовывает конкретные элементы, подвергшиеся изменению.
Но примитивы (числа и строки, например) являются immutable в JavaScript. Поэтому, если их нужно изменить, предыдущий instance выбрасывается и используется новый. Таким образом, любое изменения примитива заставляет ng-repeat перерисовать его. В нашем случае это означает, что когда мы избавляемся от старого и добавляем новый, по дороге мы теряем фокус.
Решение
Нам нужно найти способ заставить ng-repeat идентифицировать элементы в списке без зависимости от их примитивного значения. Хорошим вариантом было бы использовать индекс примитива в списке. Но как научить ng-repeat отслеживать элементы в списке?
На наше счастье в Angular 1.2 появился оператор track by:
<div ng-controller="bookCtrl">
<div ng-repeat="tag in book.tags track by $index">
<input type="text" ng-model="book.tags[$index]">
</div>
</div>
Это решает нашу проблему, так как ng-repeat теперь использует индекс примитивов в списке вместе самих примитивов. Это означает, что ng-repeat больше не перерисовывает значения тега каждый раз, когда вы меняете его значение, так как его индекс остается тем же самым. Можете убедиться в этом сами.
На самом деле track by гораздо более полезен для повышения производительности приложений, но и о вышеописанном обходном решении тоже полезно знать. И, на мой взгляд, это поможет немного лучше понять магию Angular.
Автор: c017