Уменьшаем боль в навигации приложения на Yii2


    Доброго времени суток! Большую часть проектов мы пишем на Yii2, потому что он клёвый и мы его любим.
    Однако, всегда есть что улучшить (благо этого не препятствует архитектура Yii). Хочу поделиться решением, которое упрощает прописывание навигации в приложениях на Yii2.


    Проблема


    Когда мы добавляем в приложение страницу, нам нужно прописать для неё следующие вещи (после создания контроллера и вьюшки):


    • Заголовок страницы ($this->title = ...);
    • Хлебные крошки ($this->params['breadcrumbs'][] = ...);
    • Права для действия в контроллере (\yii\base\ActionFilter в behaviors контроллера);
    • Параметр visible с проверкой доступа во всех меню, где есть ссылка на эту страницу;
    • Добавить правило в \yii\web\UrlManager::rules для красивой ссылки;
    • Добавить страницу в sitemap.xml.


    Не жирновато ли для "ещё одной страницы"? Самое плохое в этом то, что все эти пункты нужно держать в голове и не забывать. А если навигация в проекте начинает меняться, то что-то сломать становится еще проще, чаще всего забываешь про хлебные крошки и они становятся просто не рабочими.


    Решение


    Мы предположили, что любая страница приложения должна входить в общую карту сайта. А значит, если создать такую карту сайта (в виде многоуровнего дерева) с исчерпывающей информацией о странице (см. пункты из раздела "Проблема"), то добавление страницы сведётся к описанию её в карте сайте, всего лишь в одном месте! Мы можем прописать там и заголовки, и права и правила ссылки, а имея карту сайта легко получить хлебные крошки и sitemap.xml.


    Таким образом получился компонент [MegaMenu](), который и представляю хабрасообществу.


    Установка


    Устанавливается компонент через Composer:


    $ composer require ExtPoint/yii2-megamenu

    Далее нам нужно добавить компонент в конфигурацию приложения:


    Как компонент приложения:


    'components' => [
        'megaMenu'=> [
            'class' => '\extpoint\megamenu\MegaMenu',
            'items' => [
                // You sitemap
                [
                    'label' => 'Главная',
                    'url' => ['/site/index'],
                    'urlRule' => '/',
                ],
                ...
            ],
        ],
        ...
    ],

    И подгружать его до запуска приложения (для добавления правил в UrlManager):


    ...
    'bootstrap' => ['log', 'megamenu'],
    ...

    API


    АПИ компонента создавалось максимально приближенным к Yii2, часто повторяя его 1 в 1.


    Формат описания страницы (параметр \extpoint\megamenu\MegaMenu::items)


    Каждый item в большинстве соответствует формату задания навигации для \yii\bootstrap\Nav::items, где каждый item имеет атрибуты label, url, visible, active, encode, items, options, linkOptions. Каждый item задается в виде массива, из которого затем создается экземпляр класса \extpoint\megamenu\MegaMenuItem.
    Ниже перечислим нововведенные параметры, которых нет в \yii\bootstrap\Nav::items:


    • urlRule (строка, массив или экземпляр \yii\rest\UrlRule). Формат соответствует правилу из \yii\web\UrlManager::rules;
    • roles (строка или массив строк). Формат идентичен \yii\filters\AccessRule::roles. Поддерживаются значения "?", "@" и указание роли в виде строки.
    • order (число) Каждый уровень меню сортируется согласно этому параметру. Значение по-умолчанию — 0.

    Методы компонента \extpoint\megamenu\MegaMenu


    • setItems(array $items) Добавляет элементы меню в конец списка;
    • addItems() Добавляет элементы меню;
    • getItems() Возвращает элементы меню;
    • getActiveItem() Возвращает текущий роут, аналогично \Yii::$app->requestedRoute, но с распарсеными параметрами;
    • getMenu(array $item, $custom) Находит вложенный элемент меню (null = корень) и возвращает вложенное меню с дочерними элементами. В параметре custom можно переопределять конфигурацию меню, если задать его как массив. Если задать числом — то это укажет на возвращаемую вложенность меню. Например, \Yii::$app->megaMenu->getMenu(null, 2) вернет двухуровневое меню, даже если само меню имеет большее число вложенности.
    • getTitle($url = null) Находит item для указанного url (по-умолчанию — текущая страница) и возвращает его заголовок
    • getFullTitle($url = null, $separator = ' — ') Аналогично предыдущему, но так же добавляет все родительские названия item'ов
    • getBreadcrumbs($url = null) Возвращает хлебные крошки для виджета \yii\widgets\Breadcrumbs::links
    • getItem($item, &$parents = []) Находит item по url/роуту, в parents добавляет item'ы всех родителей для найденного item'а
    • getItemUrl($item) Находит item и возвращает его url

    Логика поиска item'а


    Логика сравнения двух item реализована в методе \extpoint\megamenu\MegaMenu::isUrlEquals. Сравнение ссылок ведется путем сравнения двух строк.
    Роуты сравниваются немного сложнее: сперва они нормализуются (получение полного роута, с указанием модуля, контроллера и экшена), затем сравниваются только роуты. Если роуты совпали, то сравниваются параметры.
    Если параметр отличается от null, то сравнивается как его ключ, так и значение. Если значение указано как null, это означает, что может быть любое значение, сравнивается только наличие ключей.
    Примеры:


    • isUrlEquals('http://ya.ru', 'http://ya.ru') // true
    • isUrlEquals(['qq/ww/ee'], ['aa/bb/cc']) // false
    • isUrlEquals(['aa/bb/cc', 'foo' => null], ['aa/bb/cc']) // false
    • isUrlEquals(['aa/bb/cc', 'foo' => null], ['aa/bb/cc', 'foo' => null]) // true
    • isUrlEquals(['aa/bb/cc', 'foo' => 'qwe'], ['aa/bb/cc', 'foo' => null]) // true
    • isUrlEquals(['aa/bb/cc', 'foo' => 'qwe'], ['aa/bb/cc', 'foo' => '555']) // false

    Пример


    Пример маленького веб-приложения с установленным MegaMenu можно найти в папке тестов:



    Да ладно, это в реальных проектах не будет работать!



    Однако, будет. MegaMenu уже успешно используется в нескольких крупных проектах. В наших проектах мы всегда разбиваем функционал на модули и MegaMenu этому не сопротивляется.
    Пример такой разбивки и более реальный пример можно увидеть в нашем бойлерплейте. Меню по кусочкам собирается из модулей или контроллеров.


    TODO


    Компонент ещё развивается, вот некоторые фичи, которые стоит ждать в ближайшем будущем:


    • Проверка доступа для контроллера (behaviors, анализирующий карту сайта для проверки доступа);
    • Получение карты сайта для sitemap.xml;
    • UI для кастомизации карты сайта с сохранением изменений в БД.

    End


    Спасибо всем, кто дочитал/пролистал до конца. Любые предложения и пожелания пишите на affka@affka.ru


    Ставьте звезды на гитхабе — ExtPoint/yii2-megamenu


    Всем удачного дня!

    Поделиться публикацией
    Комментарии 25
      +2
      Посмотрел исходники и огорчен… где же PSR CodeStyle?
        0
        В чем именно отличия от PSR? В том, что "{" не переносится на новую строку в классах и методах?=)
          +3
          И в расстановке пробелов. Но лично я не вижу ничего критического в данной ситуации.
        • НЛО прилетело и опубликовало эту надпись здесь
            +2
            +1 Судя по всему, автор уже привёл в соответствие с PSR.

            Да, формально, PSR — всего лишь набор рекомендаций.
            Но, де-факто это стандарт для open-source проектов, ибо альтернатив, достаточно распространённых чтобы быть достойными внимания, в современном PHP, лично я не вижу. Раньше, да у каждого более менее крупного фреймворка был свой стандарт.

            Но php — язык интерпретируемый, с широкой практикой использования вендорных библиотек и переиспользования кода вообще. Мало приятного в чтении исходников или использовании кода от третей стороны, когда в каждой либе свой огород. Всем проще и удобнее если это будет один стиль, один формат.

            Вообще, PSR, на мой взгляд, один из важных и удачных аспектов развития языка.

            PS: Не важно какое соглашение вы примите, важно что оно было принято©… Чистый / Совершенный Код (Мартин / Макконнелл)??
            • НЛО прилетело и опубликовало эту надпись здесь
                +1
                Пользуясь предложенной литературной метафорой, я бы определил PSR не как разновидность авторского стиля, а скорее как общепринятую для языка пунктуацию — как мы переносим слова, ставим запятые, большие буквы в начале предложения и т.п.

                Ожидать и желать чтобы повсеместно был PSR, это что-то сродни Grammar-nazi. Но не вижу в этом ничего плохого.

                Авторский же стиль, это из области дизайна — нагородить абстрактных фабрик, фасадов или других абстракций, обеспечить всё публичными интерфейсами или запилить одним статическим божественным классом. Вот где стоит искать (и проявлять) индивидуальность, а не на уровне пунктуации =)

                У Брукса приводится мысль, что жесткие рамки стиля не мешают, а наоборот помогают настоящему мастеру.
                • НЛО прилетело и опубликовало эту надпись здесь
                    +1
                    Для ошибок на уровне синтаксиса языка, я бы привёл аналогию с грамматикой, орфографией — она однозначна как в естественных, так и в искусственных языках.

                    Пунктуация же, строго говоря, есть формальная, для любого языка, но может быть и авторской, что распространено в литературе.

                    PSR при таком взгляде, это официальная пунктуация, e.g. рекомендованная «госпожой Вербицкой» (a.k.a FIG) сообщества PHP =)

                    PS: это как разговорный PHP и литературный. Вроде бы, да многие делают не по PSR, но в «обществе», так говорить — моветон.
            0
            Боюсь даже представить, как будет выглядеть конфиг с вашим MegaMenu на достаточно крупных проектах.
              0
              Оно прекрасно бьется на кусочки, указывая его в модулях и контроллерах. Пример есть тут — https://github.com/ExtPoint/project-boilerplate
              0
              Что-то у вас с namespace не всё хорошо. В одном месте \app\core\components\MegaMenu, в другом \extpoint\megamenu\MegaMenu. На гитхабе у вас вроде всё с этим в порядке. По сабжу — для меня проблема кажется надуманной. Не вижу никаких проблем и сложностей в решении указанных задач для каждой отдельно взятой страницы.
                0
                Спасибо за замечание, поправил.
                  0
                  Вы, видимо, просто не видели массив rules для UrlManager размером в 2 экрана. Есть некоторые удобства в данном решении, но я не буду его использовать для небольших проектов.
                  +1
                  На мой взгляд, если мы реализуем универсальный компонент, то было полезно опционально включить в компонент дополнительные поля, такие как
                  • метатег description
                  • метатеги Open Graph протокола
                    0
                    Отличное предложение! Добавляю в TODO
                    +1
                    правильно я понимаю: при инициализации компонента он для своих целей инициализирует все 100 (ой, 1000) модулей моего проекта?
                      0
                      1. Это могут быть статичные методы модулей/контроллеров
                      2. Это все можно кешировать
                      Само мегаменю не решает эти задачи, решение возлагается на приложение.
                        +2
                        1. Вы на чей-то чужой вопрос ответили?
                        2. Про существование кэша я в курсе. И есть понимание, что кэш при архитектурных проблемах — костыль, а в данном случае и проблему не решает, т.к. объекты остаются в памяти.

                        Я вижу, что модули, которые по умолчанию lazy и инициализируются только во время непосредственного обращения к их контроллерам, инициализируются вами принудительно, для извлечения информации из — внимание — метода coreMenu, который есть только на ваших проектах.
                        Это огромнейший фейл, ошибка в проектировании расширения и вообще сама по себе непонятная вещь — завязаться на какую-то специфичную для вас функциональность, подпортив производительность всем.

                        Решение: предусмотреть в компоненте интерфейс MenuProvider — для всех, а конкретно вам не завязываться на методы модулей, требующих их инициализации (статика как вы правильно подметили, но я бы в конфиг какой-то выносил — модуль не место для хранения конфигурационных данных).
                          –1
                          1. На Ваш. Я имел ввиду, что если меню «хранится» в статичных методах, то не нужно инициализировать (создавать инстанс) всех модулей
                          В любом случае все решает кеш.

                          Реализацию модульности в boilerplate не навязываю, она не включается в MegaMenu. Это (принудительное создание экземпляра) действительно может повлиять на производительность, пересмотрю свое решение.
                            +1
                            1. «Если меню хранится», но я то писал про конкретный кусок реализации.

                            Это серьезно может повлиять. И опять же не надо придумывать способ хранения меню — предоставьте интферфейс и одну-две реализации. Не надо хардкодить, навязывая. Во многих проектах уже есть меню и не в том месте, где вы его захардкодите.
                      0
                      К примерно чему-то похожему пришел в своих меню и модулях.
                      Осветите пожалуйста NeatComet в одной из следующих статей.
                        0
                        NeatComet — библиотека моего коллеги, у него в todo стоит пункт «написать статью на хабр об этом»… Возможно и я напишу о ней в рамках Jii
                        0
                        Может быть я не понимаю ООП, но по моему использование правильных абстракций само по себе избавляет от ВСЕХ перечисленных Вами проблем без необходимости тащит внешнюю зависимость, да еще такую.
                          0
                          Т.е. вы предлагаете реализовывать подобие мегаменю в рамках приложения, а не отдельной библиотеки?) Как использование «правильных абстракций» избавляет от всех проблем, если архитектура Yii по-умолчанию диктует где что прописывать

                        Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                        Самое читаемое