«Я буду убивать себя до тех пор, пока стата не заработает»
— Ведущий разработчик в преддверии очереднего апдейта
Введение
AngularJS продолжает набирать популярность, и появляется все больше статей и уроков, в которых рассказывается, как наиболее эффективно работать с этим фреймворком. К сожалению, в них редко приводят примеры из реальных проектов, и нет описания тонкостей, с которыми приходится сталкиваться в процессе. Эту статью я хочу посвятить именно таким вещам, поэтому если вы еще только собираетесь работать с «ангуляркой», некоторые вещи могут представлять трудность для понимания.
Как это часто бывает, я начал свое знакомство с AngularJS, устроившись на новую работу. Мне дали задание побыстрее изучить фронт-энд. А так как проект игровой, требовалось single-page приложение. Потому, собственно, и «ангуляр». Я приступил к работе.
ng-repeat в быстроменяющемся мире
Меня заинтересовал список игроков на сервере во время матча. Весь код, понятное дело, был упакован в контроллер, но внутри представлял из себя кашу. Комментариев, естественно, не было. Суть сводилась примерно к следующему:
- создать N элементов списка
- добавить в них span'ы, проставить там стили
- каждые 5 секунд проивзодить обновление
Ко всему этому был также прикручен собственный скроллбар, что меня несколько удивило.
Немного порывшись в доке, я решил вместо этой кучи кода воспользоваться ng-repeat. Результат меня порадовал: я получил то же самое, но гораздо понятнее и компактнее. Впрочем, торжество было недолгим: выяснилось, что при наличии на экране этого списка, каждые 5 секунд происходило падение производительности. Похоже, что-то я все-таки сделал не так.
Оказалось, что каждый раз, когда я обновляю массив, фреймворк заново отрисовывает все 100500 элементов. А так как операции с DOM'ом традиционно тяжелые, браузер начинает подвисать. Также выяснилось, что кастомный скроллбар был добавлен в том числе для быстродействия. Т.е. на страницу попадал не весь массив, а только его часть. Положение скролла задавало число, на которое нужно отступить. И на экран выводился нужный промежуток. Кроме того, элементы не перерисовывались полностью, внутри них менялось лишь текстовое значение. Мне стало немного понятнее, что происходит на самом деле.
Не хотелось возвращать все обратно, поэтому я решил добавить пагинацию, благо, здесь это происходит очень просто:
- добавляем в scope переменные currentPage, pageSize и pageCount.
- добавляем в шаблон кнопки с директивой ng-click, которые изменяют currentPage
- добавляем в ng-repeat фильтр limitTo, равный pageSize.
Voila. Результат стал выглядеть немного лучше. Браузер больше не умирал, но небольшие провалы все же оставались. Основную проблему я все-таки не решил.
И тут меня осенило. Из-за того, что при обновлении я переопределял массив со списком игроков, «ангулярка» считала, что получает новые объекты, даже если значения в их ключах были полностью идентичны. Эта глупая ошибка стоила мне не один час копания в доках и яростного трейса. Единственное, что от меня требовалось — это менять значения внутри объектов массива.
В итоге я решил сделать гибридную версию: вернул скролл, но обновление элементов отдал на откуп байндингам, и наконец получил то, что хотел: быстрый и лаконичный контроллер, который впоследствии можно будет легко расширять.
Финальный шаблон выглядел следующим образом:
<ul id="list-players">
<li ng-repeat="player in listPlayers"
ng-class-odd="'odd'"
ng-class="{ 'highlighted': player.highlighted, 'obs': player.observing, 'empty': player.empty }"
ng-click="ObservePlayer(player.nickname)">
<span class="place" ng-bind="player.place">
</span><span class="nickname" ng-bind="player.nickname">
</span><span class="kills" ng-bind="player.kills">
</span><span class="deaths" ng-bind="player.deaths">
</span>
</li>
</ul>
В контроллере тоже все достаточно просто:
for (var i = 0, index, player, $li; i < $scope.listSize; i++) {
index = startIndex + i;
var $player = $scope.listPlayers[i];
$player.empty = !player;
if (!$player.empty) {
$player.highlighted = player.id == focusOnPlayer
$player.observing = player.status == 3;
$player.place = !$player.empty ? index + 1 : '';
$player.nickname = player.nickname || '';
$player.deaths = player.deaths !== undefined ? player.deaths : '';
$player.kills = player.kills !== undefined ? player.kills : '';
}
}
ng-class и ng-style против спрайтов
Закончив этот небольшой, но достаточно утомительный рефакторинг, я наконец-то приступил к главному: добавлению фич. Мое задание состояло в том, чтобы возле ника игрока в списке также отображалась иконка его ранга, а в конце раунда — еще и медали, если он занял первые три места. С медальками все достаточно просто, я лишь добавил в $player значение medal: 0 — нет медали, 1 — золотая и т.д., в шаблон добавился следующий элемент:
<i class="medal-icon-small"
ng-class="{ 'medal-gold': player.medal == 1, 'medal-silver': player.medal == 2, 'medal-bronze': player.medal == 3 }">
</i>
Т.е. я просто добавляю класс в зависимости от полученного значения. Нет класса — и вместо иконки только пустота.
Но что делать, если нужно выбрать не из трех иконок, а из десяти. А если из ста или тысячи? В игре 14 рангов, и создавать на каждый ранг отдельный класс — это как минимум затратно по времени. Вереница из классов будет захламлять как шаблон, так и контроллер, если мы перенесем это в функцию. Впрочем, функцию можно использовать, и тут мне на выручку пришел ng-style:
<i class="rank-icon-small" ng-style="getRankStyle(player.rank)"></i>
В getRankStyle просто передается значение ранга, а он уже возвращает нужный стиль:
$scope.getRankStyle = function(index) {
return { 'background-position': '0px ' + -(index * 16) + 'px' };
}
При желании эту функцию можно сделать еще более универсальной. Вместо магии передавать высоту на которую следует отступить. Или даже ширину спрайта, если требуется сгруппировать иконки в виде квадрата. Если же мы используем один большой спрайт для всей графики на сайте, то можно задавать первоначальный отступ. Применение ограничено только здравым смыслом и фантазией разработчика.
Подобным же образом, к слову, я сделал представление скинов персонажа и визуализацию статуса сервера. Впрочем, первое у меня вышло даже немного сложнее, потому что вместе с background-position я возвращался также и background-image, ведь путь до картинки, из которой вынимались preview спрайтов, должен был приходить с сервера.
Подводные камни
При всех своих достоинствах AngularJS обладает рядом существенных недостатков, и по иронии судьбы, они находятся не внутри самого фреймворка, а внутри голов его разработчиков. С одной стороны, это полное отсутствие желания привести документацию к читаемому виду. С ней можно начать ознакомление только после того, как вы уже уяснили основные принципы работы фреймворка, и хотите получше разобраться в деталях. С другой — слишком большая разница между стабильной и нестабильной версией, и даже больше: какие-то фичи могут попросту не работать в сжатой версии, приведенной на официальном сайте.
Следует также внимательно относится к работе байндингов. В первом примере я совершил достаточно глупую ошибку, обратившись к массиву, а не к его элементам. Это касается всего: вместо большого количества значений в самом scope, лучше группировать их по категориям в отдельных объектах (те же формы, например).
Наверное, стоило бы рассказать о том, как эффективно организовать структуру проекта, но это уже тема для отдельной статьи.
P.S.: кто-то, наверное, уже догадался, что в статье идет речь о проекте Bombermine, в котором, как уже говорил, с недавнего времени работаю.
Автор: 5angel