Существует вполне обоснованное мнение, что найденный в Интернете чужой код намного лучше собственноручно написанного, т.к. его уже оттестировали тысячи ленивых разработчиков. Собственно поэтому, когда передо мной возникла задача, описанная в названии статьи, я решил не изобретать велосипед, а найти готовое решение. К моему удивлению, ни на англоязычных, ни на русскоязычных ресурсах ничего подходящего под мои запросы на основе ангуляра я не нашел. Поэтому было принято решение написать код самостоятельно и поделиться им с общественностью.
Возможности меню, реализованные в данной статье:
Исходный код директивы можно посмотреть тут.
Писать все с нуля, естественно, я не стал, поэтому ниже привожу список позаимствованных материалов:
Первым неприятным сюрпризом для меня стала воспроизводимость проекта. Мир web-разработки не стоит на месте, каждый день выходят новые версии вышеперечисленных продуктов и моё меню, лениво написанное в завалявшемся пару месяцев назад проекте, напрочь отказывалось работать в проекте, собранном недавно. Ниже приведен список проблем, с которыми я столкнулся.
С организационными моментами закончили, переходим к написанию самой директивы.
Для удобства, вынесем html-шаблон директивы в отдельный файл.
По сути, это модификация стандартного меню из документации бутстрапа с небольшими нюансами:
Далее вкратце рассмотрим css файл:
Ничего особенного, код для меню «одолжил» тут, анимацию лого — тут.
Вот так не спеша, мы добрались до самой интересной части, ради которой и задумывалась эта статья — код директивы меню на ангуляре.
Файл navbar.module.js
Начнем с культуры программирования. Сам ангуляр устроен так, что не позволит Вам сильно накосячить, но считается хорошим тоном использовать строгий режим 'use strict' и оборачивать код модулей в анонимную функцию.
Вы спросите, почему такое большое количество функционала вынесено в отдельный файл? Все очень просто. Одним из плюсов ангуляра является его модульность, что позволяет легко переносить куски функционала из одного проекта в другой. В данном случае мы объявляем отдельный модуль 'navbar', на который в дальнейшем можно навесить директивы, контроллеры, фабрики и прочие радости.
При этом при переносе функционала в другой проект достаточно будет лишь подключить в зависимости сам модуль 'navbar'. Все остальные зависимости, навешанные на него, не требуют объявления и подтянутся автоматически.
Отдельно отмечу, что вторым аргументом при объявлении модуля идет массив зависимостей, которые требуются для его работы. В данном случае это 'ui-router'. Если зависимостей нет, то необходимо указать пустой массив, иначе экспортировать модуль в другое приложение будет невозможно.
Довольно часто требуется проводить предстартовые настройки приложения, которые выполняются до запуска директив, контроллеров и сервисов. Такие операции осуществляются в секции config (выполняется один раз при инициализации приложения) и в секции run (выполняется каждый раз при переходе на состояние, в котором она описана). Очень удобно держать код этих настроек в вышеописанном файле.
Файл navbar.directive.js:
Сразу отмечу, что я не горжусь кодом, описанным в директиве. Он не представляет большого интереса, т.к. тут всего лишь описан функционал открытия/закрытия меню для разных разрешений экрана и присвоение нужных классов в зависимости от вида пункта. Более или менее полезную информацию несут две рекурсивные функции: проверка клика пользователем вне меню (строка 181) и проверка того, является ли пункт меню активным (строка 70).
Отмечу, что сделано правильно с моей точки зрения:
Что сделано неправильно:
Файл navbar.provider.js
Итак, наша директива реализована и работает, но откуда брать список пунктов меню? Можно описать массив пунктов в самой директиве, но это неудобно при последующем добавлении/удалении состояний приложения. Каждый раз придется лезть в массив пунктов директивы, искать в нем нужное место и добавлять новый. А при удалении состояния вообще можно забыть про наличие пункта в меню, что приведет к ошибкам при попытке пользователя посетить страницу.
Выход из ситуации очевиден — необходимо регистрировать каждый пункт меню непосредственно возле описания конкретного состояния. Тут есть небольшой нюанс. Порядок инициализации ангуляровского приложения следующий:
Исходя из очереди, нам подходит секция config, для которой доступен только provider. К провайдеру можно достучаться из любой части приложения просто подключив его имя в зависимости. На этапе конфига провайдер доступен по своему имени с добавкой «Provider», т.е., например, если имя нашего провайдера navbarList — то в секции конфиг он будет доступен под именем navbarListProvider.
Код нашего провайдера представлен ниже:
$get — служебная функция, которая, в нашем случае, возвращает метод добавления пункта в меню add и сам список меню list, который хранится в замыкании.
Функция add принимает на вход объект со следующими полями:
Принцип работы функции add прост. Сперва идет валидация принимаемого на вход объекта, затем осуществляется поиск места для вставки текущего пункта. Если совпадений с пунктами не найдено — вызывается рекурсивная функция makeOriginalPart(), которая возвращает новосозданную часть меню; если совпадение найдено — вызывается changeExistPart(), которая рекурсивно идет на следующий уровень вложенности до тех пор, пока есть совпадения в названии пунктов из массива place.
После каждого добавления пункта выполняется сортировка меню по полю priority.
При написании кода провайдера специально не использовались конструкции else if. Вместо этого в конце условия добавлялся return. Я считаю, данный шаг оправданным, т.к. он повышает читаемость кода. Вообще код провайдера неоднократно оптимизировался. Кому интересно, ниже прикрепляю первую версию.
Файл navbar.permission.js
Скрипт фильтрации меню в зависимости от уровня доступа, определенного модулем angular-permission. Код вынесен в отдельную фабрику для повышения читаемости и модульности (не всем нужен данный функционал).
Фабрика состоит из двух методов:
Файл navbar.decorator.js
По сути, все задуманное мной реализовано, посмотрим, как это работает. Ниже пример кода объявления состояния «персидская кошка» с регистрацией данного пункта подменю в цепочке подуровней «живые существа» => «млекопитающие» => «кошки». Пункт доступен всем пользователям, кроме «anonymous» и «banned».
Вроде бы все работает, но, согласитесь — некрасиво? Почти вся информация, необходимая для объявления пункта меню дублируется при объявлении состояния. Чтобы объединить все воедино воспользуемся функцией декоратором, которую любезно нам предоставили разработчики модуля UI-router. Фактически, декоратор создает обертку вокруг существующей функции и позволяет менять ее функционал. Ниже представлен код декорирования нашего метода «.state», который позволяет обрабатывать поле menu из передаваемого в state объекта:
Теперь объявление нашего состояния с регистрацией в меню выглядит так:
Согласитесь — более элегантно.
И напоследок небольшой лайфхак: создавайте в своих проектах под каждое состояние не только отдельную папку, но и отдельный ангуляровский модуль, и подключайте его в список зависимостей. Это существенно сократит Ваше время при удалении/переносе состояний из проекта. Достаточно будет удалить модуль из списка зависимостей и папку с состоянием.
Спасибо за внимание, всем удачи.
Возможности меню, реализованные в данной статье:
- Вся начинка меню спрятана под капотом директивы. При верстке html страницы указывается лишь DOM-элемент с директивой, что повышает читабельность кода.
- У меню есть возможность создавать пункты с бесконечным уровнем вложенностей.
- Подсветка активной страницы в меню осуществляется не только на первом уровне, но и на любом уровне вложенности.
- Возможность зарегистрировать пункт меню на этапе конфигурации приложения.
- Возможность отображения/сокрытия конкретных пунктов меню в зависимости от прав доступа текущего пользователя.
Исходный код директивы можно посмотреть тут.
Писать все с нуля, естественно, я не стал, поэтому ниже привожу список позаимствованных материалов:
Просмотр списка
P.S.: пункты 5-8 необязательны, но существенно упрощают жизнь современного front-end разработчика.
- AngularJS — супергероический фреймворк от гугла, реализующий MVVM шаблон проектирования архитектуры приложения.
- UI-router — ангуляровский модуль, без которого немыслимо проектирование приложений, основанных на состояниях.
- Angular-permission — ангуляровский модуль (работает только в паре с ui-router), упрощающий контроль доступа и авторизацию на стороне клиента.
- Bootstrap 3 — CSS-фреймворк, ускоряющий верстку адаптивных страниц.
- Yeoman-генератор — консольная утилита для автоматического построения структуры проекта.
- Bower — менеджер пакетов, упрощающий установку и обновление зависимостей проекта.
- Gulp — потоковый сборщик проектов на JS.
- NodeJS — среда разработки серверной части.
P.S.: пункты 5-8 необязательны, но существенно упрощают жизнь современного front-end разработчика.
Первым неприятным сюрпризом для меня стала воспроизводимость проекта. Мир web-разработки не стоит на месте, каждый день выходят новые версии вышеперечисленных продуктов и моё меню, лениво написанное в завалявшемся пару месяцев назад проекте, напрочь отказывалось работать в проекте, собранном недавно. Ниже приведен список проблем, с которыми я столкнулся.
Просмотр проблем
- Последняя версия UI-router выпадает с ошибкой, если в объекте params есть поля со значениями, которые приравниваются к логическому отрицанию (false, 0, undefined, null или пустая строка). Решения проблемы я не нашел, поэтому откатился до последней работоспособной версии «0.2.13».
- Генератор Yeoman предлагает довольно удобную структуру будущего приложения. В корневом каталоге, помимо служебных, создается каталог src с самим проектом. В нем находится основная html страница и три каталога:
app — каталог с состояниями приложения (рекомендуется под каждое состояние выделять свою папку).
assets — папка со статическим контентом.
components — папка для элементов приложения, которые могут использоваться многократно (в нашем случае это директивы, сервисы, фабрики, провайдеры и т.д.).
В соответствии с такой структурой Yeoman-овский генератор настраивает gulp на мониторинг изменений и подключение файлов к запущенному приложению (все делается автоматически, не нужно подключать зависимости к html-странице вручную).
В последней версии генератора папка components была перемещена в каталог app и, соответственно, были изменены настройки gulp. Чтобы наш проект видел папку components и не выдавал в консоли разработчика ошибку об отсутствии модуля navbar, правим следующие файлы в папке gulp:
- скрипт inject.js
в массив injectScripts добавляем элемент
options.src + '/components/**/*.js'
в массив injectStyles добавляем элемент
options.src + '/components/**/*.css'
- скрипт watch.js — добавляем следующие правила:
gulp.watch(options.src + '/components/**/*.css', function(event) { if(isOnlyChange(event)) { browserSync.reload(event.path); } else { gulp.start('inject'); } }); gulp.watch(options.src + '/components/**/*.js', function(event) { if(isOnlyChange(event)) { gulp.start('scripts'); } else { gulp.start('inject'); } }); gulp.watch(options.src + '/components/**/*.html', function(event) { browserSync.reload(event.path); });
- скрипт inject.js
- Так как директива написана на бутстрапе, то, естественно, она требует его компонентов, в частности, библиотеку jQuery. При создании проекта, Yeoman будет спрашивать про необходимость подключения jquery, bootstrap и как с ним работать (ангуляровские директивы ui-bootstrap или AngularStrap, официальное применение bootstrap с jQuery либо чистый CSS). Тут есть небольшой подвох. При установке, еще до выбора вышеперечисленных опций, будет предложено добавить в проект jQuery. Обязательно нужно выбрать эту опцию, иначе останемся без важных зависимостей и все сломается.
P.S.: на самом деле исправить данный момент не сложно. Всего лишь нужно подшаманить код самой директивы и можно вообще обойтись без jQuery, но, как говорится, «работает — не трогай (с)».
- Если возникнет желание побаловаться в проекте гугловским angular-material, который Yeoman предлагает включить в проект, нужно знать, что в таком случае подключится старая версия библиотеки, для которой документация с официального сайта не подходит. Поэтому правильным вариантом будет подключение библиотеки с помощью bower с опцией --save.
С организационными моментами закончили, переходим к написанию самой директивы.
Для удобства, вынесем html-шаблон директивы в отдельный файл.
Показать шаблон
<div class="container" ng-mouseleave="closeMenu($event)">
<div class="navbar-header">
<button type="button" class="navbar-toggle" ng-click="collapseMenu($event)">
<span class="sr-only">Toggle navigation</span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
</button>
<a class="link-kukuri" href="#" ui-sref="{{::sref}}" data-letters="{{::name}}">{{::name}}</a>
</div>
<div id="navbar" class="collapse navbar-collapse" aria-expanded="false" ng-class="navCollapsed">
<ul class="nav navbar-nav navbar-right">
<li ng-repeat="items in navbar" class="{{::menuClass(items.name, 'firstLevel')}} list-status">
<a href="#" ng-if="!items.name.pop" ui-sref="{{items.state}}" ng-mouseenter="closeOnMoveMenu()">{{items.name}}</a>
<a href="#" ng-if="items.name.pop" class="dropdown-toggle dropdown-toggle-firstLevel" dropdown-toggle aria-expanded="false" ng-click="expandMenu($event)" ng-mouseenter="expandMenu($event)" ng-mouseleave="closeSubMenu($event)">
{{::items.name[0]}}<b class="caret"></b>
</a>
<ul ng-if="items.name.pop" class="dropdown-menu" ng-include="'submenu.template'"></ul>
</li>
</ul>
</div>
</div>
<script type="text/ng-template" id="submenu.template">
<li ng-repeat="items in items.name" ng-if="$index !== 0" class="{{::menuClass(items.name)}} sub-menu">
<a href="#" class="sub-link" ng-if="!items.name.pop" ui-sref="{{::items.state}}" ng-mouseenter="closeOnMoveSubMenu($event)"> {{::items.name}}</a>
<a href="#" ng-if="items.name.pop" class="dropdown-toggle" data-toggle="dropdown" ng-click="expandSubMenu($event)" ng-mouseenter="expandSubMenu($event)">
{{::items.name[0]}}
</a>
<ul ng-if="items.name.pop" class="dropdown-menu" ng-include="'submenu.template'">
</ul>
</li>
</script>
По сути, это модификация стандартного меню из документации бутстрапа с небольшими нюансами:
Показать особенности
- Список пунктов меню генерируется с помощью директивы ng-repeat, которая клонирует заготовленный html-шаблон, подставляя в него данные из массива пунктов меню, который определяется в текущем скоупе директивы. Отмечу, что в шаблоне используется так называемое одноразовое присваивание (one time binding), синтаксис которого — две точки возле переменной (например {{::name}} ). Дело в том, что на каждую переменную ангуляр создает отдельного слушателя (watcher), который проверяет ее изменение при каждом дайджесте (проверка на изменение всех переменных в текущем скоупе до тех пор, пока их значения меняются, по окончанию происходит отрисовка DOM с новыми значениями). Так как пункты нашего меню — величины постоянные, то имеет смысл отрисовать их один раз, сократив при этом число слушателей и повысив производительность.
- Вложенные подпункты собираются рекурсивно при помощи ng-include. Рекурсивная часть шаблона хранится в теге script c атрибутом type=«text/ng-template». Браузер не знает такой тип скрипта и не обрабатывает эту часть DOM, однако директива ng-include вставляет лишь содержимое скрипта в нужном месте, что позволяет браузеру нормально обрабатывать DOM элемент.
Сама вложенность контролируется директивой ng-if, которая проверяет, является ли текущий элемент массивом пунктов или строкой с названием пункта. Проверка осуществляется при помощи так называемой «утиной типизации», если перед нами массив, то он имеет методы массива (push, pop и т.д.), обращение к которым без () вернет нам функцию, которая приравнивается к логическому true. Если перед нами строка, то такое обращение к методу массива вернет undefined.
- Существует внегласное правило работы с директивами ангуляра, которое гласит: «директива не должна изменять элементы DOM-дерева вне своего элемента». Для работы раскрывающихся пунктов меню требуются слушатели, которые будут отслеживать события клика, наведения и покидания курсором элемента. Можно было бы использовать обычный поиск элементов по селекторам элементов DOM дерева и навешать на них слушателей. Но в большом проекте существует вероятность, что кто-то другой будет использовать идентичные названия селекторов. Последствия такого события непредсказуемы :) Для подобных случаев предусмотрены директивы ng-click, ng-mouseenter и ng-mouseleave, которые были навешаны на соответствующие элементы.
Далее вкратце рассмотрим css файл:
Показать CSS
@import url(http://fonts.googleapis.com/css?family=Gloria+Hallelujah);
.navbar-brand {
font-family: «Gloria Hallelujah», Verdana, Tahoma;
font-size: 23px;
}
.sub-menu {
background-color: #333;
}
.sub-menu>a {
color: #9d9d9d !important;
padding-left: 10px !important;
}
.dropdown-menu {
padding: 0px;
margin-left: -1px;
margin-right: -1px;
min-width: 90px !important;
}
.dropdown-submenu {
position:relative;
}
.dropdown-submenu>.dropdown-menu {
top:0;
right:100%;
margin-top:6px;
margin-left:-1px;
-webkit-border-radius:0 6px 6px 6px;
-moz-border-radius:0 6px 6px 6px;
border-radius:0 6px 6px 6px;
}
.dropdown-submenu:hover>a:after {
border-left-color:#ffffff;
}
.dropdown-submenu.pull-left {
float:none;
}
.dropdown-submenu.pull-left>.dropdown-menu {
left:-100%;
margin-left:10px;
-webkit-border-radius:6px 0 6px 6px;
-moz-border-radius:6px 0 6px 6px;
border-radius:6px 0 6px 6px;
}
.dropdown-submenu>a:before {
display:block;
content:" ";
float:left;
width: 0;
height: 0;
border-style: solid;
border-color: transparent #cccccc transparent transparent;
margin-top: 7px;
margin-left: -5px;
margin-right: 10px;
}
.dropdown-submenu-big>a:before {
border-width: 4.5px 7.8px 4.5px 0;
}
.dropdown-submenu-small>a:before {
margin-right: 7px;
border-left: 5px solid transparent;
border-right: 5px solid transparent;
border-top: 5px solid #cccccc;
}
.dropdown-menu:hover,
.dropdown-toggle:focus,
li>[aria-expanded=«true»],
.navbar-brand:hover,
.sub-menu>a:hover,
.list-status:hover,
.nav .open > a {
color: #fff !important;
background-color: #004444 !important;
}
.menu-active,
.menu-active>a {
font-weight: bold !important;
text-decoration: underline;
}
.navbar-cheat {
width: 100%;
height: 45px;
}
.sub-link:before {
display:block;
content:" ";
float:left;
width: 12px;
height: 5px;
}
/* Kukuri */
.link-kukuri {
font-family: «Gloria Hallelujah»;
outline: none;
text-decoration: none !important;
position: relative;
font-size: 23px;
line-height: 2;
color: #c5c2b8;
display: inline-block;
}
.link-kukuri:hover {
color: #c5c2b8;
}
.link-kukuri:hover::after {
-webkit-transform: translate3d(100%,0,0);
transform: translate3d(100%,0,0);
}
.link-kukuri::before {
content: attr(data-letters);
position: absolute;
z-index: 2;
overflow: hidden;
color: #424242;
white-space: nowrap;
width: 0%;
-webkit-transition: width 0.4s 0.0s;
transition: width 0.4s 0.0s;
}
.link-kukuri:hover::before {
width: 100%;
}
.link-kukuri:focus {
color: #9e9ba4;
}
.navbar-brand {
font-family: «Gloria Hallelujah», Verdana, Tahoma;
font-size: 23px;
}
.sub-menu {
background-color: #333;
}
.sub-menu>a {
color: #9d9d9d !important;
padding-left: 10px !important;
}
.dropdown-menu {
padding: 0px;
margin-left: -1px;
margin-right: -1px;
min-width: 90px !important;
}
.dropdown-submenu {
position:relative;
}
.dropdown-submenu>.dropdown-menu {
top:0;
right:100%;
margin-top:6px;
margin-left:-1px;
-webkit-border-radius:0 6px 6px 6px;
-moz-border-radius:0 6px 6px 6px;
border-radius:0 6px 6px 6px;
}
.dropdown-submenu:hover>a:after {
border-left-color:#ffffff;
}
.dropdown-submenu.pull-left {
float:none;
}
.dropdown-submenu.pull-left>.dropdown-menu {
left:-100%;
margin-left:10px;
-webkit-border-radius:6px 0 6px 6px;
-moz-border-radius:6px 0 6px 6px;
border-radius:6px 0 6px 6px;
}
.dropdown-submenu>a:before {
display:block;
content:" ";
float:left;
width: 0;
height: 0;
border-style: solid;
border-color: transparent #cccccc transparent transparent;
margin-top: 7px;
margin-left: -5px;
margin-right: 10px;
}
.dropdown-submenu-big>a:before {
border-width: 4.5px 7.8px 4.5px 0;
}
.dropdown-submenu-small>a:before {
margin-right: 7px;
border-left: 5px solid transparent;
border-right: 5px solid transparent;
border-top: 5px solid #cccccc;
}
.dropdown-menu:hover,
.dropdown-toggle:focus,
li>[aria-expanded=«true»],
.navbar-brand:hover,
.sub-menu>a:hover,
.list-status:hover,
.nav .open > a {
color: #fff !important;
background-color: #004444 !important;
}
.menu-active,
.menu-active>a {
font-weight: bold !important;
text-decoration: underline;
}
.navbar-cheat {
width: 100%;
height: 45px;
}
.sub-link:before {
display:block;
content:" ";
float:left;
width: 12px;
height: 5px;
}
/* Kukuri */
.link-kukuri {
font-family: «Gloria Hallelujah»;
outline: none;
text-decoration: none !important;
position: relative;
font-size: 23px;
line-height: 2;
color: #c5c2b8;
display: inline-block;
}
.link-kukuri:hover {
color: #c5c2b8;
}
.link-kukuri:hover::after {
-webkit-transform: translate3d(100%,0,0);
transform: translate3d(100%,0,0);
}
.link-kukuri::before {
content: attr(data-letters);
position: absolute;
z-index: 2;
overflow: hidden;
color: #424242;
white-space: nowrap;
width: 0%;
-webkit-transition: width 0.4s 0.0s;
transition: width 0.4s 0.0s;
}
.link-kukuri:hover::before {
width: 100%;
}
.link-kukuri:focus {
color: #9e9ba4;
}
Ничего особенного, код для меню «одолжил» тут, анимацию лого — тут.
Вот так не спеша, мы добрались до самой интересной части, ради которой и задумывалась эта статья — код директивы меню на ангуляре.
Файл navbar.module.js
'use strict';
(function () {
angular.module('navbar', ['ui.router']);
})();
Начнем с культуры программирования. Сам ангуляр устроен так, что не позволит Вам сильно накосячить, но считается хорошим тоном использовать строгий режим 'use strict' и оборачивать код модулей в анонимную функцию.
Вы спросите, почему такое большое количество функционала вынесено в отдельный файл? Все очень просто. Одним из плюсов ангуляра является его модульность, что позволяет легко переносить куски функционала из одного проекта в другой. В данном случае мы объявляем отдельный модуль 'navbar', на который в дальнейшем можно навесить директивы, контроллеры, фабрики и прочие радости.
При этом при переносе функционала в другой проект достаточно будет лишь подключить в зависимости сам модуль 'navbar'. Все остальные зависимости, навешанные на него, не требуют объявления и подтянутся автоматически.
Отдельно отмечу, что вторым аргументом при объявлении модуля идет массив зависимостей, которые требуются для его работы. В данном случае это 'ui-router'. Если зависимостей нет, то необходимо указать пустой массив, иначе экспортировать модуль в другое приложение будет невозможно.
Довольно часто требуется проводить предстартовые настройки приложения, которые выполняются до запуска директив, контроллеров и сервисов. Такие операции осуществляются в секции config (выполняется один раз при инициализации приложения) и в секции run (выполняется каждый раз при переходе на состояние, в котором она описана). Очень удобно держать код этих настроек в вышеописанном файле.
Файл navbar.directive.js:
Показать navbar.directive.js
'use strict';
(function () {
angular.module('navbar')
.directive('navbar', function ($document, $state, navbarList, navPermission) {
return {
restrict: 'A',
scope: {
name: '@',
sref: '@'
},
templateUrl: '/components/navbar.directive/navbar.template.html',
link: function (scope, elem) {
var openedMenu = null,
openedSubMenu = null,
username = navPermission.getUser($state.params);
// присваиваем нашему DOM элементу необходимые классы и атрибуты для работы bootstrap
elem.addClass('navbar navbar-inverse navbar-fixed-top');
elem.attr('role', 'navigation');
// редактируем список пунктов меню в соотвествии с доступом и передаем его в scope директивы
if(username) {
navPermission.acceptPermission(navbarList.list, username);
}
scope.navbar = navbarList.list;
// открытие/сокрытие меню на телефонах или при узком экране браузера
scope.collapseMenu = function ($event) {
var navbar = elem.find('#navbar'),
expanded = navbar.hasClass('in');
navbar.attr('aria-expanded', !expanded);
scope.navCollapsed = (expanded) ? '' : 'in';
closeAllMenu();
stopBubleAndPropagation($event);
};
// присвоение класса активного пункта меню соответствующей страницы и класса подменю, если пункт содержит подпункты
scope.menuClass = function (item, level) {
var status = false,
activePage = getActivePage($state.current.name),
currentPage = (item.pop) ? item[0] : item,
classList = (level === 'firstLevel') ? 'dropdown dropdown-firstLevel ' :
'menu-item dropdown dropdown-submenu ',
activeClass = (currentPage === activePage || isActive(item, activePage, status) ) ?
'menu-active' : '';
if(item.pop) {
return classList + activeClass;
} else {
return activeClass;
}
};
// получение имени активного пункта меню в соответствии с открытой страницей (состоянием)
function getActivePage(state, currentList) {
var name;
if(!currentList) {
currentList = scope.navbar;
}
for(var i = (currentList[0].name) ? 0 : 1; i < currentList.length; i++) {
if(currentList[i].state === state) {
return currentList[i].name;
} else if(currentList[i].name.pop) {
name = getActivePage(state, currentList[i].name);
}
}
return name;
}
// проверка, является ли пункт меню активным
function isActive (item, activePage, status) {
if(item.pop) {
for(var i = 1; i < item.length; i++) {
if(item[i].name.pop) {
status = isActive(item[i].name, activePage, status);
} else if(item[i].name === activePage) {
return true;
}
}
} else if(item === activePage) {
return true;
}
return status;
}
// раскрытие сокрытие подпунктов меню по кликку или наведению мыши (страшная функция, т.к. учтены варианты разного разрешения экрана)
scope.expandMenu = function ($event) {
var clickedElem = $($event.currentTarget),
parentClicked = $($event.currentTarget.parentElement),
expanded = clickedElem.attr('aria-expanded'),
isOpened = parentClicked.hasClass('open'),
attrExpanded = (expanded === 'false'),
allOpenedMenu = parentClicked.parent().find('.open'),
smallWindow = window.innerWidth < 768,
eventMouseEnter = $event.type === 'mouseenter',
subMenuAll = elem.find('.dropdown-submenu');
if(!smallWindow || !eventMouseEnter) {
allOpenedMenu.removeClass('open');
clickedElem.attr('aria-expanded', attrExpanded);
if(isOpened && !eventMouseEnter) {
parentClicked.removeClass('open');
} else {
parentClicked.addClass('open');
openedMenu = clickedElem; //**
}
}
subMenuAll.removeClass('dropdown-submenu-small dropdown-submenu-big');
if(smallWindow) {
subMenuAll.addClass('dropdown-submenu-small');
} else {
subMenuAll.addClass('dropdown-submenu-big');
}
stopBubleAndPropagation($event);
};
// закрытие подменю при наведении на соседний пункт в основном меню
scope.closeOnMoveMenu = function () {
var smallWindow = window.innerWidth < 768;
if(openedMenu && !smallWindow) {
var clickedLink = openedMenu,
clickedElement = clickedLink.parent();
clickedElement.removeClass('open');
clickedLink.attr('aria-expanded', false);
openedMenu = null;
}
};
// раскрытие сокрытие подпунктов подменю (аналогично функции с 92 строки)
scope.expandSubMenu = function ($event) {
var elemClicked = $($event.currentTarget.parentElement),
smallWindow = window.innerWidth < 768,
eMouseEnter = $event.type === 'mouseenter',
sameElement = elemClicked.hasClass('open');
if(!smallWindow || !eMouseEnter) { // потом подумать как упростить
if(!sameElement && !eMouseEnter || !eMouseEnter || !sameElement) {
elemClicked.parent().find('.open').removeClass('open');
}
if(!sameElement) {
elemClicked.addClass('open');
openedSubMenu = elemClicked;
}
}
stopBubleAndPropagation($event);
};
// закрытие подменю при наведении на соседний подпункт в подменю (звучит то как:))
scope.closeOnMoveSubMenu = function ($event) {
var smallWindow = window.innerWidth < 768;
if(openedSubMenu && !smallWindow) {
var clickedElement = openedSubMenu,
savedList = clickedElement.parent(),
currentList = $($event.target).parent().parent();
if(savedList[0] === currentList[0]) {
clickedElement.removeClass('open');
openedSubMenu = null;
}
}
};
scope.closeMenu = closeMenu;
// удаляем всех слушателей с документа при его уничтожении
var $body = $document.find('html');
elem.bind('$destroy', function() {
$body.unbind(); //не хватает проверки на удаленный элемент
});
// при клике вне меню - закрываем все открытые позиции
$body.bind('click', closeMenu);
function closeMenu ($event) {
var elemClicked = $event.relatedTarget || $event.target;
if(isClickOutNavbar(elemClicked)) {
closeAllMenu();
}
}
// рекурсивно поднимаемся по родителям элемента, чтобы узнать, был клик по меню или нет
function isClickOutNavbar(elem) {
if($(elem).hasClass('dropdown-firstLevel')) {
return false;
}
if(elem.parentElement !== null) {
return isClickOutNavbar(elem.parentElement);
} else {
return true;
}
}
// закрываем все открытые пункты и подпункты меню
function closeAllMenu() {
elem.find('.open').removeClass('open');
elem.find('[aria-expanded=true]').attr('aria-expanded', false);
}
// служебная функция предотвращения действий браузера поумолчанию и всплывающих событий
function stopBubleAndPropagation($event) {
$event.stopPropagation();
$event.preventDefault();
}
}
};
});
})();
Сразу отмечу, что я не горжусь кодом, описанным в директиве. Он не представляет большого интереса, т.к. тут всего лишь описан функционал открытия/закрытия меню для разных разрешений экрана и присвоение нужных классов в зависимости от вида пункта. Более или менее полезную информацию несут две рекурсивные функции: проверка клика пользователем вне меню (строка 181) и проверка того, является ли пункт меню активным (строка 70).
Отмечу, что сделано правильно с моей точки зрения:
- Директива имеет изолированный скоуп, в который пробрасываются параметры name и sref через атрибуты элемента. Т.е. в большом проекте меньше шанс нарваться на неприятности.
- Сложные конструкции (нахождение элемента, проверка атрибута) вынесены в переменные. Название переменных и функций говорит об их назначении.
Хорошим тоном считается присвоение имени в виде верблюжьей нотации. Также, если в коде идет объявление нескольких переменных подряд, нет смысла постоянно писать var, можно просто перечислить переменные через запятую, а еще лучше указать каждую из них с новой строки. Это повышает читаемость кода.
Что сделано неправильно:
- Код слишком сложный, некоторые функции можно разбить на более простые. Основное правило: мысленно произносим, что делает функция и если во фразе проскакивает буква «И», значит, функцию нужно делить на более простую.
- Слишком тривиальные комментарии. Хороший код должен говорить сам за себя, что он делает. Комментариев требуют либо сложные в понимании моменты, либо те участки кода, в которых Вы выбрали более сложное решение вместо простого, т.к. что то в простом Вас не устроило.
В данном случае комментарии написаны, чтобы читателю было проще вникнуть в суть вопроса.
Файл navbar.provider.js
Итак, наша директива реализована и работает, но откуда брать список пунктов меню? Можно описать массив пунктов в самой директиве, но это неудобно при последующем добавлении/удалении состояний приложения. Каждый раз придется лезть в массив пунктов директивы, искать в нем нужное место и добавлять новый. А при удалении состояния вообще можно забыть про наличие пункта в меню, что приведет к ошибкам при попытке пользователя посетить страницу.
Выход из ситуации очевиден — необходимо регистрировать каждый пункт меню непосредственно возле описания конкретного состояния. Тут есть небольшой нюанс. Порядок инициализации ангуляровского приложения следующий:
- подключение зарегистрированных модулей ангуляра (module),
- регистрация провайдеров (provider),
- обработка секции config (выполняется один раз при инициализации приложения),
- регистрация factory, service, value, constant,
- обработка секции run (выполняется каждый раз при смене состояния),
- регистрация контроллеров и директив.
Исходя из очереди, нам подходит секция config, для которой доступен только provider. К провайдеру можно достучаться из любой части приложения просто подключив его имя в зависимости. На этапе конфига провайдер доступен по своему имени с добавкой «Provider», т.е., например, если имя нашего провайдера navbarList — то в секции конфиг он будет доступен под именем navbarListProvider.
Код нашего провайдера представлен ниже:
Показать navbar.provider.js
'use strict';
(function () {
angular.module('navbar')
.provider('navbarList', function () {
var list = [];
// основная функция добавления пункта в меню
this.add = function (obj) {
// проверка на правильно заданные параметры расположения пункта
if(obj.location) {
if(obj.location.place.length !== obj.location.priority.length ||
!obj.location.place.pop || !obj.location.priority.pop) {
console.log('Warning! Bad location params for menu "' + obj.name + '". Skip item');
return;
}
}
// добавление пункта на первый уровень меню при отстутствии местоположения
if(!obj.location) {
var name = obj.name;
for(var i = 0; i < list.length; i++) { // рассказать про тернарный оператор и утиную типизацию
var currentName = (list[i].name.pop) ? list[i].name[0] : list[i].name;
if(currentName === name) {
console.log('Warning! Duplicate menu "' + name + '". Skip item');
return;
}
}
list.push(obj);
list.sort(sortByPriority);
return;
}
// поиск пункта, в который нужно добавить подпункт согласно местоположению
var place = obj.location.place.shift(),
priority = obj.location.priority.shift();
for(i = 0; i < list.length; i++) { // описать в статье, что i блочная не в JS
var currentSubName = (list[i].name.pop) ? list[i].name[0] : null;
if(place === currentSubName) {
list[i].name = changeExistPart(obj, list[i].name);
if(priority !== list[i].priority) {
console.log('Warning! Priority of menu "' + list[i].name + '" has been changed from "' +
list[i].priority + '" to "' + priority + '"');
list[i].priority = priority;
list.sort(sortByPriority);
}
return;
}
currentName = list[i].name;
if(place === currentName) {
console.log('Warning! Duplicate submenu "' + place + '". Skip item');
return;
}
}
// ни одно вышеописанное условие не совпало, добавляем новый пункт со всеми вложениями
list.push( {
name: [place, makeOriginalPart(obj)],
priority: priority
} );
list.sort(sortByPriority);
};
// рекурсивный поиск места в подпунктах меню для вставки нового пункта
function changeExistPart(obj, list) {
var place = obj.location.place.shift(),
priority = obj.location.priority.shift(), // возможно необходимо сделать двойной приоритет
searchName = (place) ? place : obj.name;
for(var i = 1; i < list.length; i++) {
var currentName = (list[i].name.pop) ? list[i].name[0] : list[i].name;
if(searchName === currentName) {
if(!list[i].name.pop || (!place && list[i].name.pop) ) {
console.log('Warning! Duplicate menu "' + searchName + '". Skip item');
return list;
} else {
list[i].name = changeExistPart(obj, list[i].name);
if(priority !== list[i].priority) {
console.log('Warning! Priority of menu "' + list[i].name +
'" has been changed from "' + list[i].priority + '" to "' + priority + '"');
list[i].priority = priority;
list.sort(sortByPriority);
}
return list;
}
}
}
if(!place) {
delete obj.location;
list.push(obj);
} else {
list.push({
name: [place, makeOriginalPart(obj)],
priority: priority
});
}
list.sort(sortByPriority);
return list;
}
// рекурсивное создание новой, оригинальной части пункта меню с подпунктами
function makeOriginalPart (obj) {
var place = obj.location.place.shift(),
priority = obj.location.priority.shift();
if(place) {
var menu = {
priority: priority,
name: [place, makeOriginalPart(obj)]
};
} else {
delete obj.location;
menu = obj;
}
return menu;
}
// функция сортировки пунктов меню по приоритету
function sortByPriority(a, b) {
return a.priority - b.priority;
}
// служебная функция для работы провайдера angularJS
this.$get = function () {
return {
list: list,
add: this.add
};
};
});
})();
$get — служебная функция, которая, в нашем случае, возвращает метод добавления пункта в меню add и сам список меню list, который хранится в замыкании.
Функция add принимает на вход объект со следующими полями:
- priority — численное значение приоритета, по которому сортируется список,
- permission — необязательный объект, содержащий одно из двух полей:
- except — массив запрещенных ролей пользователя,
- only — массив разрешенных ролей пользователя,
- location — необязательный объект, содержащий два поля:
- place — массив имен, по которым строится вложенное меню,
- priority — массив такой же длины, содержащий численное значение приоритета каждого пункта вложенности соответственно,
- name — строковое имя текущего пункта.
Принцип работы функции add прост. Сперва идет валидация принимаемого на вход объекта, затем осуществляется поиск места для вставки текущего пункта. Если совпадений с пунктами не найдено — вызывается рекурсивная функция makeOriginalPart(), которая возвращает новосозданную часть меню; если совпадение найдено — вызывается changeExistPart(), которая рекурсивно идет на следующий уровень вложенности до тех пор, пока есть совпадения в названии пунктов из массива place.
После каждого добавления пункта выполняется сортировка меню по полю priority.
При написании кода провайдера специально не использовались конструкции else if. Вместо этого в конце условия добавлялся return. Я считаю, данный шаг оправданным, т.к. он повышает читаемость кода. Вообще код провайдера неоднократно оптимизировался. Кому интересно, ниже прикрепляю первую версию.
Смотреть первую версию провайдера
Внимание! Код не для слабонервных.
'use strict';
(function () {
angular.module('navbar')
.provider('navbarList', function () {
var list = [];
this.add = addMenu;
function addMenu(obj, nestedMenu, currentList) {
if(currentList) {
list = currentList;
} else if(list.length < 1) {
list.push(makeOriginalPart(obj));
return;
}
if(!obj.location || !obj.location.place) { // переделать проверку. Глобально проверять длину place==priority
isDuplicate(obj.name, list);
list.push(obj);
list.sort(sortByPriority);
return;
} else if(obj.location.place.length > 0){
var searchName = obj.location.place.shift(),
priority = (obj.location.priority) ? obj.location.priority.shift() : null;
for(var i = (nestedMenu) ? 1 : 0; i < list.length; i++) {
var currentName = (list[i].name.pop) ? list[i].name[0] :list[i].name;
if(currentName === searchName) {
if(list[i].name.pop) { // можно переписать по аналогии с пермишн
if(!nestedMenu) {
nestedMenu = [list];
}
var sublistName = list[i].name.shift();
list[i].name.sort(sortByPriority);
list[i].name.unshift(sublistName);
list[i].name.priority = priority; // свойство присвоено массиву
nestedMenu.push(list[i].name);
addMenu(obj, nestedMenu, list[i].name);
return;
} else {
console.log('Warning! Duplicate menu', currentName);
}
}
}
if(nestedMenu) {
var last = nestedMenu.length - 1;
nestedMenu[last].push({
name: [searchName, makeOriginalPart(obj, null, nestedMenu[last]) ],
priority: priority
});
}
} else {
last = nestedMenu.length - 1;
nestedMenu[last].push(makeOriginalPart(obj, null, nestedMenu[last]));
}
if(nestedMenu) { // changeExistPart возвращает ундефайнед при правильной архитектуре
nestedMenu[nestedMenu.length - 1].sort(sortByPriority);
list = changeExistPart(nestedMenu);
} else {
if(priority) { // переделать проверку. Глобально проверять длину place==priority
obj.location.priority.unshift(priority);
}
obj.location.place.unshift(searchName);
list.push(makeOriginalPart(obj, null, list));
list.sort(sortByPriority);
}
}
function changeExistPart(nestedMenu) {
if(nestedMenu.length > 1) {
var subList = nestedMenu.pop(),
priority = subList.priority,
searchName = subList[0],
last = nestedMenu.length - 1;
for(var i = 1; i < nestedMenu[last].length; i++) {
var currentName = (nestedMenu[last][i].name.pop) ? nestedMenu[last][i].name[0] : '';
if(searchName === currentName){
nestedMenu[last][i].name = subList;
nestedMenu[last][i].priority = priority;
return changeExistPart(nestedMenu);
}
}
return changeExistPart(nestedMenu); // ошибка в архитектуре. Эта строка должна быть не нужна
} else {
return nestedMenu[0];
}
}
function makeOriginalPart(obj, menu, currentList){
if(!menu) {
isDuplicate(obj.name, currentList);
menu = {
name: obj.name,
priority: obj.priority,
state: obj.state,
permissions: obj.permissions
};
}
if(obj.location.place.length > 0) {
var currentLocation = obj.location.place.pop(),
priority = (obj.location.priority) ? obj.location.priority.pop() : null,
currentMenu = {
priority: priority,
name: [currentLocation, menu]
};
return makeOriginalPart(obj, currentMenu);
} else {
return menu;
}
}
function isDuplicate(name, list) {
if(!list || list.length < 1) {
return;
}
for(var i = (list[0].name) ? 0 : 1; i < list.length; i++) {
var currentName = (list[i].name.pop) ? list[i].name[0] : list[i].name;
if(currentName === name) {
console.log('Warning! Duplicate menu', currentName);
}
}
}
function sortByPriority(a, b) {
return a.priority - b.priority;
}
this.$get = function () {
return {
list: list,
add: this.add
};
};
});
})();
Файл navbar.permission.js
Смотреть navbar.permission.js
'use strict';
(function () {
angular.module('navbar')
.factory('navPermission', function (Permission, $q) {
// перебираем все роли и возвращаем подходящую в виде промиса
function getUser(params) {
var users = Permission.roleValidations,
names = Object.keys(users),
promisesArr = [];
for(var i = 0; i < names.length; i++) {
var current = names[i],
validUser = $q.when( users[current](params) );
promisesArr.push(validUser);
}
return $q.all(promisesArr).then(function (users) {
for(var i = 0; i < users.length; i++) {
if(users[i]) {
return names[i];
}
}
return null;
});
}
// если пришел промис, ждем его разрешения и меняем меню, если пользователь - сразу меняем меню
function acceptPermission (list, username) {
if(!username.then) {
return changeList(list, username);
} else {
return username.then(function (username) {
return changeList(list, username);
});
}
}
// рекурсивно пробегаемся по массиву меню и удаляем пункты, которые запрещены для текущей роли
function changeList(list, username) {
for(var i = (list[0].name) ? 0 : 1; i < list.length; i++) {
if(list[i].permissions) {
if(list[i].permissions.except) {
var except = list[i].permissions.except;
for(var j = 0; j < except.length; j++) {
if(except[j] === username) {
list.splice(i--, 1);
}
}
} else if(list[i].permissions.only) {
var only = list[i].permissions.only,
accessDenided = true;
for(j = 0; j < only.length; j++) {
if(only[j] === username) {
accessDenided = false;
}
}
if(accessDenided) {
list.splice(i--, 1);
}
}
} else if(list[i].name.pop) {
list[i].name = changeList( list[i].name, username);
if(list[i].name.length === 1 ) {
list.splice(i--, 1);
}
}
}
return list;
}
// возвращаем созданные методы фабрики
return {
getUser: getUser,
acceptPermission: acceptPermission
};
});
})();
Скрипт фильтрации меню в зависимости от уровня доступа, определенного модулем angular-permission. Код вынесен в отдельную фабрику для повышения читаемости и модульности (не всем нужен данный функционал).
Фабрика состоит из двух методов:
- acceptPermission — рекурсивно проходимся по массиву пунктов меню и удаляем запрещенные.
- getUser — метод определения текущей роли пользователя. Очевидно, что в реальном проекте роль пользователя может определяться не только локально, но и на сервере. Поэтому роль пользователя определяется асинхронно с применением промисов.
Файл navbar.decorator.js
По сути, все задуманное мной реализовано, посмотрим, как это работает. Ниже пример кода объявления состояния «персидская кошка» с регистрацией данного пункта подменю в цепочке подуровней «живые существа» => «млекопитающие» => «кошки». Пункт доступен всем пользователям, кроме «anonymous» и «banned».
.config(function ($stateProvider, navbarListProvider) {
// объявляем текущее состояние
$stateProvider
.state('persianCat', {
url: '/персидская кошка',
templateUrl: 'app/cats/persianCat.html',
controller: 'persianCatCtrl',
permissions: {
except: ['anonymous', 'banned'],
redirectTo: 'login'
}
});
// добавляем пункт в меню
navbarListProvider.add({
state: 'persianCat',
name: 'персидская кошка',
permissions: {
except: ['anonymous', 'banned']
},
priority: 20,
location: {
place: ['живые существа', 'млекопитающие', 'кошки'],
priority: [10, 10, 10]
}
});
});
Вроде бы все работает, но, согласитесь — некрасиво? Почти вся информация, необходимая для объявления пункта меню дублируется при объявлении состояния. Чтобы объединить все воедино воспользуемся функцией декоратором, которую любезно нам предоставили разработчики модуля UI-router. Фактически, декоратор создает обертку вокруг существующей функции и позволяет менять ее функционал. Ниже представлен код декорирования нашего метода «.state», который позволяет обрабатывать поле menu из передаваемого в state объекта:
Смотреть navbar.decorator.js
'use strict';
(function() {
angular.module('navbar')
.config(function ($stateProvider, navbarListProvider) {
// добавляем в метод state функционал регистрации пунктов меню
$stateProvider.decorator('state', function (obj) {
var menu = obj.menu,
permissions = (obj.data) ? obj.data.permissions : null;
// если в коде не указана регистрация текущего стейта в меню - ничего не делаем
if(!menu) {
return;
}
menu.state = obj.name;
// регистрируем права доступа пункта при их наличии
if(permissions) {
menu.permissions = {};
if(permissions.except) {
menu.permissions.except = permissions.except;
} else if(permissions.only) {
menu.permissions.only = permissions.only;
} else {
delete menu.permissions;
}
}
// регистрируем пункт меню по скомпонованному объекту menu
navbarListProvider.add(menu);
});
});
})();
Теперь объявление нашего состояния с регистрацией в меню выглядит так:
.config(function ($stateProvider) {
$stateProvider
.state('persianCat', {
url: '/персидская кошка',
templateUrl: 'app/cats/persianCat.html',
controller: 'persianCatCtrl',
permissions: {
except: ['anonymous', 'banned'],
redirectTo: 'login'
},
menu: {
name: 'персидская кошка',
priority: 20,
location: {
place: ['живые существа', 'млекопитающие', 'кошки'],
priority: [10, 10, 10]
}
}
});
});
Согласитесь — более элегантно.
И напоследок небольшой лайфхак: создавайте в своих проектах под каждое состояние не только отдельную папку, но и отдельный ангуляровский модуль, и подключайте его в список зависимостей. Это существенно сократит Ваше время при удалении/переносе состояний из проекта. Достаточно будет удалить модуль из списка зависимостей и папку с состоянием.
Спасибо за внимание, всем удачи.