В статье речь пойдет о сборке БЭМ-проектов с помощью бандлера Webpack. Я покажу один из примеров конфигурации, не нагружая читателей лишними сущностями.


Материал подойдет тем, кто только начинает знакомство с БЭМ. Сначала коснемся теоретических аспектов методологии, а в разделе «Практика» я покажу, как их можно применить.


Немного теории


Если вы впервые слышите о БЭМ и хотите познакомиться с ним самостоятельно, держите документацию.


БЭМ — методология, которая применяется для организации проектов любого масштаба. Её разработали Яндекс и сперва использовали только в работе своих сервисов, но позже опубликовали в общем доступе.


БЭМ расшифровывается как “Блок, Элемент, Модификатор”.


Блок — сущность с автономной архитектурой, которая может повторно использоваться. Блок может содержать собственные элементы.


Элемент — составная часть блока. Элемент может использоваться только внутри родительского блока.


Модификатор — сущность, которая изменяет отображение, состояние или поведение блока.


Эти компоненты лежат в основе методологии. Они обеспечивают красоту и удобное разделение кода. Более подробно об их устройстве написано в документации.


Документация по БЭМ написана обстоятельно. Однако есть одно “но”: высокий порог вхождения в материал. Если с основами верстки можно разобраться, прочитав одну страницу документации, то вопрос сборки проекта обстоит сложнее.


Почему речь зашла о сборке проекта? При работе над масштабным проектом каждый сталкивается с проблемой организации кода. Хранить весь код большого проекта в одном файле неудобно. Разбивать код на несколько файлов, затем собирать его вручную — тоже не лучший выход. Для решения этой проблемы используются сборщики, или бандлеры, которые автоматизируют преобразование исходного кода проекта в код, готовый к отправке в продакшн.


Напомню: далее подразумевается, что у читателей есть базовые навыки работы с Webpack. Если прежде вы с ним не работали, рекомендую для начала познакомиться с этим инструментом.


В документации БЭМ даются рекомендации по сборке проектов. В качестве примеров предлагаются только два варианта: сборка при помощи ENB и Gulp.


ENBутилита, разработанная специально для сборки БЭМ-проектов. Она способна работать с сущностями БЭМа из коробки. Но взгляните на код. Он с первого взгляда может демотивировать неподготовленного разработчика:


make.js
const techs = {
        // essential
        fileProvider: require('enb/techs/file-provider'),
        fileMerge: require('enb/techs/file-merge'),

        // optimization
        borschik: require('enb-borschik/techs/borschik'),

        // css
        postcss: require('enb-postcss/techs/enb-postcss'),
        postcssPlugins: [
            require('postcss-import')(),
            require('postcss-each'),
            require('postcss-for'),
            require('postcss-simple-vars')(),
            require('postcss-calc')(),
            require('postcss-nested'),
            require('rebem-css'),
            require('postcss-url')({ url: 'rebase' }),
            require('autoprefixer')(),
            require('postcss-reporter')()
        ],

        // js
        browserJs: require('enb-js/techs/browser-js'),

        // bemtree
        // bemtree: require('enb-bemxjst/techs/bemtree'),

        // bemhtml
        bemhtml: require('enb-bemxjst/techs/bemhtml'),
        bemjsonToHtml: require('enb-bemxjst/techs/bemjson-to-html')
    },
    enbBemTechs = require('enb-bem-techs'),
    levels = [
        { path: 'node_modules/bem-core/common.blocks', check: false },
        { path: 'node_modules/bem-core/desktop.blocks', check: false },
        { path: 'node_modules/bem-components/common.blocks', check: false },
        { path: 'node_modules/bem-components/desktop.blocks', check: false },
        { path: 'node_modules/bem-components/design/common.blocks', check: false },
        { path: 'node_modules/bem-components/design/desktop.blocks', check: false },
        'common.blocks',
        'desktop.blocks'
    ];

module.exports = function(config) {
    const isProd = process.env.YENV === 'production';

    config.nodes('*.bundles/*', function(nodeConfig) {
        nodeConfig.addTechs([
            // essential
            [enbBemTechs.levels, { levels: levels }],
            [techs.fileProvider, { target: '?.bemjson.js' }],
            [enbBemTechs.bemjsonToBemdecl],
            [enbBemTechs.deps],
            [enbBemTechs.files],

            // css
            [techs.postcss, {
                target: '?.css',
                oneOfSourceSuffixes: ['post.css', 'css'],
                plugins: techs.postcssPlugins
            }],

            // bemtree
            // [techs.bemtree, { sourceSuffixes: ['bemtree', 'bemtree.js'] }],

            // bemhtml
            [techs.bemhtml, {
                sourceSuffixes: ['bemhtml', 'bemhtml.js'],
                forceBaseTemplates: true,
                engineOptions : { elemJsInstances : true }
            }],

            // html
            [techs.bemjsonToHtml],

            // client bemhtml
            [enbBemTechs.depsByTechToBemdecl, {
                target: '?.bemhtml.bemdecl.js',
                sourceTech: 'js',
                destTech: 'bemhtml'
            }],
            [enbBemTechs.deps, {
                target: '?.bemhtml.deps.js',
                bemdeclFile: '?.bemhtml.bemdecl.js'
            }],
            [enbBemTechs.files, {
                depsFile: '?.bemhtml.deps.js',
                filesTarget: '?.bemhtml.files',
                dirsTarget: '?.bemhtml.dirs'
            }],
            [techs.bemhtml, {
                target: '?.browser.bemhtml.js',
                filesTarget: '?.bemhtml.files',
                sourceSuffixes: ['bemhtml', 'bemhtml.js'],
                engineOptions : { elemJsInstances : true }
            }],

            // js
            [techs.browserJs, { includeYM: true }],
            [techs.fileMerge, {
                target: '?.js',
                sources: ['?.browser.js', '?.browser.bemhtml.js']
            }],

            // borschik
            [techs.borschik, { source: '?.js', target: '?.min.js', minify: isProd }],
            [techs.borschik, { source: '?.css', target: '?.min.css', minify: isProd }]
        ]);

        nodeConfig.addTargets([/* '?.bemtree.js', */ '?.html', '?.min.css', '?.min.js']);
    });
};

Код из публичного репозитория project-stub.


Код конфига ENB явно будет сложным для тех, кто только начинает использовать БЭМ.


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


В документации БЭМ хорошо объясняется процесс сборки в теории, однако практических примеров немного и они не всегда подходят для наглядного понимания процесса. Чтобы решить эту проблему я попробую собрать элементарный БЭМ-проект с помощью Webpack.


Практика


До этого я упоминал, что разделение кода и организация сборки упрощают работу с проектом. В примере ниже мы обеспечим разделение кода с помощью БЭМ и его сборку при помощи Webpack.


Мы хотим получить наипростейший конфиг, логика сборки должна быть линейной и интуитивно понятной. Давайте соберём страницу с одним БЭМ-блоком, у которого будут две технологии: CSS и JS.


Можно написать HTML-код с одним DIV с классом "block" и вручную подключить все его технологии. Используя БЭМ-именование классов и соответствующую файловую структуру, мы не нарушаем принципы методологии.


У меня получилось вот такое дерево проекта:


+-- desktop     # Уровень переопределения "desktop"
¦   L-- block       # Блок "block"
¦       +-- block.css   # CSS-технология блока "block"
¦       L-- block.js    # JS-технология блока "block"
+-- dist            # Каталог, где мы увидим собранную страницу
+-- pages           # Каталог, с исходной вёрсткой страниц и их JS-скриптами
¦   +-- index.html  # Файл, содержащий вёрстку будущей страницы
¦   L-- index.js        # Входная точка для сборки страницы index.html
L-- webpack.config.js   # Конфиг-файл Webpack

В первой строке упоминается уровень переопределения “desktop”. В терминологии БЭМ, уровни переопределения — директории, которые содержат собственные реализации блоков. При сборке проекта в итоговый бандл попадают реализации со всех уровней переопределения в определённом порядке.


Например, у нас есть уровень переопределения "desktop", в котором хранятся реализации блоков для настольных устройств. Если нам понадобится дополнить проект вёрсткой для мобильных устройств, нам будет достаточно создать новый уровень переопределения "mobile" и наполнить его новыми реализациями тех же блоков. Удобство такого подхода в том, что на новом уровне переопределения нам не нужно будет дублировать код, уже существующий в "desktop", так как он подключится автоматически.


Перед вами конфиг Webpack:


//  webpack.config.js
//  Подключаем внешние модули
const path = require('path');
const сopy = require('copy-webpack-plugin');

module.exports = {
// Указываем entry и output - входную точку и имя конечного бандла
    entry: path.resolve(__dirname, "pages", "index.js"),
    output: {
        filename: 'index.js',
        path: path.join(__dirname, 'dist')
    },
    module: {
        rules: [
            // Добавляем загрузчики для CSS-технологий
            { test: /\.css$/, loader: 'style-loader!css-loader' }
        ]
    },
    plugins: [
        new сopy([
            // Копируем HTML-файл с версткой в конечную директорию
            { from: path.join(__dirname, 'pages'), test: /\.html$/, to: path.join(__dirname, "dist") }
        ])
    ]
}

Здесь мы указываем файл /pages/index.js как входную точку, добавляем загрузчики для стилей CSS и копируем /pages/index.html в /dist/index.html.


index.html
<html>
    <body>
    <div class="block">Hello, World!</div>
    <script src="index.js"></script>
    </body>
</html>

block.css
.block {
    color: red;
    font-size: 24px;
    text-align: center;
}

block.js
document.getElementsByClassName('block')[0].innerHTML += " [This text is added by block.js!]"

В примере использован один уровень переопределения и один блок. Задача — собрать страницу так, чтобы к ней были подключены технологии (css, js) нашего блока.


Для подключения технологий воспользуемся require():


// index.js
require('../desktop/block/block.js');
require('../desktop/block/block.css');

Запустим Webpack и посмотрим, что получилось. Откроем index.html из папки ./dist:


Скриншот страницы


Стили блока подгрузились, javascript успешно отработал. Теперь к нашему проекту по праву можно добавить заветные буквы "БЭМ".


Прежде всего БЭМ создавался для работы с большими проектами. Давайте представим, что наш дизайнер постарался и на страничке теперь находится не один блок, а сто. Действуя по предыдущему сценарию, мы будем вручную подключать технологии каждого блока, используя require(). То есть в index.js появится как минимум сто дополнительных строк кода.


Лишние строки кода, которых можно было избежать, — это плохо. Неиспользуемый код — ещё хуже. Что если на нашей странице будет всего 10 из имеющихся блоков, или 20, или 53? У разработчика появится дополнительная работа: ему придется фокусировать внимание на том, какие именно блоки используются на странице, а также подключать и отключать их вручную, чтобы избежать лишнего кода в итоговом бандле.


К счастью, эту работу можно поручить Webpack.


Оптимальный алгоритм действий для автоматизации этого процесса:


  1. Выделить из имеющегося HTML-кода классы, соответствующие именованию БЭМ;
  2. На основании классов получить список БЭМ-сущностей, используемых на странице;
  3. Проверить, есть ли на уровнях переопределения директории используемых блоков, элементов и модификаторов;
  4. Подключить технологии этих сущностей в проект, добавив соответствующие выражения require().

Для начала я решил проверить, нет ли готовых загрузчиков для данной задачи. Модуля, который предоставлял бы весь нужный функционал в одном флаконе, я не обнаружил. Но наткнулся на bemdecl-to-fs-loader, который преобразует декларации БЭМ в выражения require(). Он основывается на уровнях переопределения и технологиях, которые имеются в файловой структуре проекта.


Декларация в БЭМ — список БЭМ-сущностей, используемых на странице. Подробнее о них в документации.

Не хватает одного звена — преобразования HTML в массив БЭМ-сущностей. Эту задачу решает модуль html2bemjson.


bemjson — данные, которые отражают структуру будущей страницы. Обычно они используются шаблонизатором bem-xjst для формирования страниц. Синтаксис bemjson схож с синтаксисом деклараций, но декларация содержит только список используемых сущностей, в то время как bemjson также отражает их порядок.

bemjson не является декларацией, поэтому предварительно преобразуем его в формат decl для передачи в bemdecl-to-fs-loader. Для этой задачи используем модуль из SDK: bemjson-to-decl. Так как это обычные NodeJS-модули, а не загрузчики Webpack, придется сделать загрузчик-обертку. После этого мы сможем использовать их для преобразований в Webpack.


Получаем такой код загрузчика:


let html2bemjson = require("html2bemjson");
let bemjson2decl = require("bemjson-to-decl");

module.exports = function( content ){
    if (content == null && content == "") callback("html2bemdecl requires a valid HTML.");

    let callback = this.async();
    let bemjson = html2bemjson.convert( content );
    let decl = bemjson2decl.convert( bemjson );

    console.log(decl);  // Проверим корректность формирования декларации
    callback(null, decl);

}

Чтобы упростить установку загрузчика и сэкономить время в дальнейшем, я загрузил модуль на NPM.


Давайте установим загрузчик в наш проект и внесём изменения в конфигурацию Webpack:


const webpack = require('webpack');
const path = require('path');
const сopy = require('copy-webpack-plugin');

module.exports = {
    entry: path.resolve(__dirname, "pages", "index.js"),
    output: {
        filename: 'index.js',
        path: path.join(__dirname, 'dist')
    },
    module: {
        rules: [
            { 
                test: /\.html$/,                
                use: [
                {
                    // Передаем результат в bemdecl-to-fs-loader
                    loader: 'bemdecl-to-fs-loader',
                    // Указываем уровни переопределения и расширения технологий
                    options: { levels: ['desktop'], extensions: ['css', 'js'] }
                },
                // Для начала передаем файл в html2bemdecl-loader
                { loader: 'html2bemdecl-loader' }
                ] },
            { test: /\.css$/, loader: 'style-loader!css-loader' }
        ]
    },
    plugins: [
        new сopy([
            { from: path.resolve(__dirname, 'pages'), test: /\.html$/, to: path.resolve(__dirname, "dist") }
        ])
    ]
}

Параметр levels загрузчика bemdecl-to-fs-loader указывает, какие уровни переопределения использовать и в каком порядке. В extensions даны расширения файлов-технологий, которые используются в нашем проекте.


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


Давайте заменим содержимое index.js строкой:


require('./index.html');

Теперь запустим Webpack. При сборке выводится строка:


[ BemEntityName { block: 'block' } ]

Это значит, что формирование декларации прошло успешно. Смотрим непосредственно вывод Webpack:


    Entrypoint main = index.js
    [0] ./pages/index.js 24 bytes {0} [built]
    [1] ./pages/index.html 74 bytes {0} [built]
    [2] ./desktop/block/block.css 1.07 KiB {0} [built]
    [3] ./node_modules/css-loader/dist/cjs.js!./desktop/block/block.css 217 bytes {0} [built]
    [7] ./desktop/block/block.js 93 bytes {0} [built]
        + 3 hidden modules

Скриншот страницы


Мы получили результат, идентичный предыдущему, с той разницей, что все технологии блока подключились автоматически. Сейчас нам достаточно добавить в HTML БЭМ-именованный класс, подключить этот HTML с помощью require() и создать соответствующий каталог с технологиями для подключения.


Итак, у нас есть файловая структура, которая соответствует методологии БЭМ, а также механизм автоматического подключения технологий блоков.


Абстрагируясь от механизмов и сущностей методологии, мы создали предельно простой, но эффективный конфиг Webpack. Надеюсь, данный пример поможет всем, кто начинает своё знакомство с БЭМ, лучше понять базовые принципы сборки БЭМ-проектов.


Полезные ссылки


Комментарии (2)


  1. Spunreal
    10.04.2019 16:14

    Есть же лоадер от команды БЭМ — github.com/bem/webpack-bem-loader
    Выглядит симпатичней:

    import Block from 'b:block';
    import Block from 'b:block m:modName';
    import Block from 'b:block m:modName=modVal1|modVal2';
    import BlockElem from 'b:block e:elem';
    import BlockElem from 'b:block e:elem m:modName';
    import BlockElem from 'b:block e:elem m:modName=modVal1|modVal2';
    


    А что будет в вашем случае, если добавить динамики? Всё так же в html?


    1. Ortophius Автор
      11.04.2019 15:18

      Да, конкретно для webpack уже существуют готовые загрузчики, но цель статьи — показать алгоритм сборки изнутри на элементарном примере. Это может помочь и тем, кто собирается использовать более экзотические сборщики.

      Касательно динамики — тут нужно отталкиваться от специфики конкретного проекта.