Упрощаем работу с BEM-версткой в AngularJS

Внимание! С момента написания статьи прошло много времени и API angular-bem успело существенно измениться. Перед началом использования библиотеки обязательно ознакомьтесь с актуальной документацией.

Вряд ли кому нравится то, как выглядит BEM-верстка, однако свою эффективность она доказала. Подробнее о BEM подходе в верстке можно почитать здесь - http://bem.info/method/definitions/. Её основная идея в том, чтобы каждый блок, элемент блока и их модификаторы выражались отдельным CSS-классами имена которых ни в какой ситуации не могли бы конфликтовать при условии, что у нас имена блоков уникальны. Данный подход не решает всех проблем, но помогает избегать множество side-эффектов и повторно использовать блоки на других проектах.

Мне очень нравится данный подход, однако в своих старых проектах я старался избегать BEM-верстки, ведь без готовых инструментов для работы с блоками/элементами/модификаторами код серьезно разрастался, а тянуть к себе всю инфраструктуру BEM я не хотел, не фанат. Быстро придумать удобное решение проблемы мне не удалось.

Всё изменилось, когда я познакомился с Angular а позже в какой-то момент вернулся к BEM-верстке на одном маленьком проекте. Мне было интересно, способен ли Angular помочь мне решить комплексную проблему чрезмерной сложности работы с BEM-версткой. Мне показалось, что идея обновлять модификаторы через bindings, довольно многообещающая, но в лоб это выглядело бы слишком некрасиво.

<div class=block__element" 
    ng-class="{ 'block__element_mod': model.isMod }">
</div>

А чтобы использовать динамические классы на основании значения модификатора пришлось бы писать еще на порядок длиннее.

Второй идеей было каким-то образом упростить запись блоков и элементов и сделать её более наглядной. Например:

<div block="block-name">
    <div elem="elem-name"></div>
</div>

Ведь по контексту и так понятно, что первый блок вверх по иерархии и является родительским блоком данного элемента. Иной вариант можно расценивать как bad practice в BEM. Разумеется, при данном подходе мы можем задать только один блок или элемент на одной ноде. С этим вполне можно жить, ведь это ограничение улучшает поддерживаемость верстки, а все исключения (если такие вдруг возникнут), можно реализовать стандартными средставами.

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

Реализация

К счастью, в Angular довольно гибкий механизм создания директив. Единственная проблема, нельзя использовать isolated scope иначе это нарушит третий пункт. Но как жить, если нам нужно получить объект с модификаторами. Для этого можно напрямую вызвать метод Angular'а который выполняет эту логику.

scope.$eval(attrs.mod)

Решив эту проблему мне удалось довольно быстро создать модуль angular-bem. Он довольно миниатюрный, при желании вы сможете изучить исходники за несколько минут, но то что он делает выглядит для меня довольно интересным. Возьмем пример из документации:

<body ng-app="app">
  <div block="my-block" 
      mod="{ modName: 'value' }">
    <div elem="my-element" 
        mod="{ modName: 'value', secondModName: true }">
    </div>
  </div>
</body>

Такая верстка выглядит намного приятней, так как не содержит в себе ничего лишнего. Давай-те также посмотрим на итогой результат после $compile:

<div block class="my-block my-block_mod-name_value">
  <div elem class="my-block__my-element 
      my-block__my-element_mod-name_value 
      my-block__my-element_second-mod-name">
  </div>
</div>

Директива mod также умеет принимать строку, в этом случае строка считается названием модификатора, который надо применить. Это может быть очень удобно, если определенный блок/элемент лишь переключается между модификаторами и не может иметь одновременно более одного.

Кастомный синтакс

BEM был придуман как некий универскальный способ организации CSS классов. Практика показала, что не все, что в него закладывалось реально необходимо. Например, изначально BEM был расчитан, что одна нода может содержать одновременно любое кол-во блоков и элементов если требуется. Как я писал выше, такой подход неудобен и также может считаться bad practice. Всё это приводит к тому, что BEM-синтаксис можно считать избыточным для большинства ситуаций. Поэтому я решил добавить возможность кастомизации в свой модуль. Вот пример синтаксиса, который на данный момент мне нравится больше всего:

<div block class="my-block ~mod-name-value">
  <div elem class="my-block--my-element
      ~mod-name-value ~second-mod-name">
  </div>
</div>

Изменить синтаксис можно на шаге конфигурации Angular:

app.config(function(bemConfigProvider) {
  bemConfigProvider.generateClass = function generateClass(blockName, elemName, modName, modValue) {
    var cls = blockName;

    if (elemName != null) {
      cls += '--' + elemName;
    }

    if (modName != null) {
      cls = '~' + modName;
      if ((typeof(modValue) !== 'boolean' && modValue != null)) {
        cls += '-' + modValue;
      }
    }

    return cls;
  };
});

Как видно, уточнение названия блока/элемента для каждого модификатора стало излишним и я его убрал, получив приятную для восприятия верстку даже после компиляции. Такой вариант будет рендериться браузером чуть-чуть медленнее из-за особенностей его алгоритма обработки css-классов, но в общем, не искуственном случае это замедление будет незаметным и возможно даже неизмеримым (в пределах погрешности).

Не претендую на то, что этот подход лучше, пусть каждый решает сам, исходя из своих потребностей. Но надеюсь сам инструмент кому-нибудь еще пригодится. Хотя в эру огромного кол-ва css-фреймворков это не так вероятно :)

P.S. Разумеется последний предложенный мной вариант намного проще реализуется на чистом Angular, чем то, что я показывал в начале. Но задача была сделать поддержку именно для базового синтаксиса, а также улучшить семантику, что, как мне кажется, удалось.