Изоморфный БЭМ

  • Tutorial
Когда появился node.js, многие web-разработчики стали задумываться о возможности использовать один и тот же код как на клиенте, так и на сервере. Сейчас существует несколько фреймворков, ставящих подход «пишем код один раз, используем везде» во главу угла, время от времени появляются новые. Вот и я не смог пройти мимо, пишу подобный микро-фреймворк — bnsf. Он предназначен для тех, кто предпочитает создавать front-end своих приложений по БЭМ-методологии, пользуясь соответствующим набором технологий и инструментов.

Давайте попробуем начать писать front-end для простого одностраничного web-приложения, используя bnsf. Чтобы не отвлекаться на создание back-end части, будем использовать в качестве back-end'a API vk.com. Наше приложение будет состоять всего из двух страниц, главной — с формой поиска пользователей по идентификатору — и вторичной, на ней будем выводить информацию о выбранном пользователе.

Для начала работы вам потребуется node.js, yeoman и gulp. Рекомендую использовать *nix OS, так как под Windows код не тестировался, хотя, теоретически, работать должен. Я исхожу из предположения, что node.js у вас уже установлен. Если это не так, советую воспользоваться nvm.

Устанавливаем gulp, yeoman и соответствующий генератор:

npm install -g gulp yo generator-bnsf

Создаем наш проект:

yo bnsf vk-test-app 
cd vk-test-app

Можно посмотреть, какие файлы и папки сгенерировались:

ls

Выведет примерно такой набор файлов (порядок может отличаться на разных операционных системах):

README.md	desktop.blocks	gulpfile.js	node_modules
bower.json	desktop.bundles	libs		package.json

Проект уже можно попробовать собрать:

gulp

gulp не только соберет проект, но еще и запустит сервер, начнет следить за изменениями в проекте и при необходимости его пересобирать.

Проверим, что все работает. Пробуем открыть в браузере http://localhost:3000 — мы должны увидеть страницу с текстом page-index и заголовком main page.

Одна страница у нас уже есть, давайте создадим вторую, для вывода записей со стены пользователя. Для этого нам снова понадобится генератор. Поскольку он работает из командной строки, вам понадобится еще одна терминальная сессия, чтобы не прерывать gulp. На этом этапе можно просто согласиться со всем, о чем будет спрашивать yeoman. Он будет предупреждать о конфликтах — это стандартная практика, когда файл не создается новый, а редактируется существующий, так что просто нажимайте «ввод» в ответ на все вопросы yo. Итак, выполним из корня проекта:

yo bnsf:page user

Еще раз напомню, на все вопросы отвечаем согласием — то есть жмем ввод.

gulp должен заметить появление новой страницы и пересобрать проект. Проверяем: запрос на http://localhost:3000/user должен отдать страницу с текстом page-user.

Давайте теперь разместим на главной странице форму поиска, отредактировав файл desktop.blocks/page-index/page-index.bemtree следующим образом:

block('page-index')(
    content()(function () {
        return [
            {
                block: 'search-form',
                content: [
                    {
                        block: 'input',
                        mods: {
                            theme: 'simple'
                        }
                    },
                    {
                        block: 'button',
                        mods: {
                            type: 'submit',
                            theme: 'simple'
                        },
                        content: 'search'
                    }
                ]
            },
            {
                block: 'search-results'
            }
        ];
    })
);

block('page-index').elem('title').content()('main page');

И изменим соответственно зависимости в page-index.deps.js:

({
    mustDeps: ['i-page'],
    shouldDeps: [
        { elem: 'title' },
        'search-form',
        {
            block: 'input',
            mods: { theme: 'simple' }
        },
        {
            block: 'button',
            mods: { theme: 'simple' }
        },
        'search-results'
    ]
})

Сейчас форма уже выводится (можно проверить, снова зайдя на http://localhost:3000), только тэг не form, а div. Чтобы это исправить, создадим соответствующий файл шаблона, desktop.blocks/search-form/search-form.bemhtml:

block('search-form').tag()('form');

Сейчас может показаться избыточным создавать отдельную директорию с файлом, хранящим всего одну строчку кода. Но в реальном проекте встретить такое практически невозможно: обязательно появляется или файл со стилями, или с JavaScript, или сам шаблон блока более сложен. Зачастую — все вышеперечисленное сразу.

Отлично, у нас есть форма, но она пока не умеет ничего искать. Пусть «искать» с точки зрения формы — это перенаправлять на текущую же страницу с параметром запроса. Чтобы форма начала это делать, понадобится следующий JS в файле desktop.blocks/search-form/search-form.browser.js:

/**@module search-form*/
modules.define('search-form', ['i-bem__dom', 'app-navigation'], function (provide, BEMDOM, navigation) {
    "use strict";

    /**
     * @class SearchForm
     * @extends BEM.DOM
     * @exports
     */
    provide(BEMDOM.decl(this.name, /**@lends SearchForm#*/{

        onSetMod: {
            js: {
                /**
                 * @constructs
                 * @this SearchForm
                 */
                inited: function () {
                    this._input = this.findBlockInside('input');
                }
            }
        },

        /**
         * @param {Event} e
         * @private
         */
        _onSubmit: function (e) {
            e.preventDefault();
            var query = this._input.getVal(),
                params = query ? {query: query} : null;
            navigation.navigate('page-index', params);
        }

    }, /**@lends SearchForm*/{
    	/**
    	 * @static
    	 */
        live: function () {
            var init = { modName: 'js', modVal: 'inited' };
            this
                .liveInitOnBlockInsideEvent(init, 'button')
                .liveInitOnBlockInsideEvent(init, 'input')
                .liveBindTo('submit', function (e) {
                    this._onSubmit(e)
                });
        }
    }));
});

Придется также немного усложнить шаблон, добавив в него информацию, что у блока есть логика, файл desktop.blocks/search-form/search-form.bemhtml:

block('search-form')(
    tag()('form'),
    js()(true)
);

Итак, теперь у нас есть форма, способная менять get-параметр у страницы. В этом можно убедиться, введя, скажем, «1» в текстовый инпут и нажав ввод. Пришло время получать какие-то данные по этому параметру. Я не хочу использовать API, требующее аутентификации, поэтому воспользуюсь методом, доступным кому угодно по url api.vk.com/method/users.get. Пусть форма принимает идентификатор пользователя, а выводиться будет ссылка на его страницу (на страницу user, которую мы создали выше) и на страницы еще 4-х пользователей с идентификаторами, полученными простым инкрементом. В качестве текста ссылок будем использовать имена пользователей.

Первое, что нам нужно сделать — добавить маршрут в файл с конфигурацией маршрутов API. Это файл desktop.bundles/index/index.api.routing.yml, и вот каким должно получиться его содержимое:

- host: api.vk.com
  routes:
    - id: users
      path: /method/users.get

Второе — Создадим файл desktop.blocks/search-results/search-results.bemtree. Основная мысль такова: кому данные надо отображать, тот за ними и ходит. В нашем случае данные нужны блоку search-results, ему за данными и идти:

block('search-results').content()(function () {
    if (!this.route.parameters.query) {
        return '';
    }
    var id = parseInt(this.route.parameters.query, 10);
    return id ? this.get('users', { // отправляем запрос на маршрут сервера API с идентификатором user
        user_ids: [id, id + 1, id + 2, id + 3, id + 4]
    }, function (data) { // в этой функции обрабатываем результаты запроса
        return data.body.response.map(function (dataItem) {
            return {
                block: 'search-results',
                elem: 'item',
                content: {
                    block: 'link',
                    url: path('page-user', { id: dataItem.uid }), // генерируем url по идентификатору маршрута приложения page-user
                    content: dataItem.first_name + ' ' + dataItem.last_name
                }
            };
        });
    }) : 'Something goes wrong';
});

В этом шаблоне данных мы смотрим, пришел ли нам id, если пришел — запрашиваем данные по маршруту API с идентификатором user и параметром user_ids, используя метод get. Если id не число — отдаем строку 'Something goes wrong'. Поскольку выводить нужно будет список, а мы любим семантику, создадим desktop.blocks/search-results/search-results.bemhtml:

block('search-results')
    .tag()('ul')
    .elem('item').tag()('li');

Кроме того, нам понадобится файл для декларации зависимостей блока, desktop.blocks/search-results/search-results.deps.js:

({
    shouldDeps: ['link']
})

Теперь страница уже умеет искать пользователей и выводить результаты. Попробуйте, только не забудьте обновить страницу. Если введете «1» — в выдаче результатов должны найти Павла Дурова. Только вот беда — перерисовывается каждый раз вся страница целиком. Это легко исправить, научив ее обновлять только необходимое. Дополним page-index.bemtree, чтобы он выглядел следующим образом:

block('page-index')(
    content()(function () {
        return [
            {
                block: 'search-form',
                content: [
                    {
                        block: 'input',
                        mods: {
                            theme: 'simple'
                        }
                    },
                    {
                        block: 'button',
                        mods: {
                            type: 'submit',
                            theme: 'simple'
                        },
                        content: 'search'
                    }
                ]
            },
            {
                block: 'search-results'
            }
        ];
    }),
    js()({
        update: 'search-results' // мы добавили конфигурацию для клиентского JavaScript: имя блока, который следует обновлять
    })
);

block('page-index').elem('title').content()('main page');

Теперь, открыв инспектор в браузере, можно убедиться, что при новых запросах к API обновляется только блок search-results.

Ну что же, пришла пора заняться второй страницей, не зря ведь мы ее создавали.
Начнем с desktop.blocks/page-user/page-user.bemtree:

block('page-user').content()(function () {
    return [
        {
            block: 'menu',
            content: {
                block: 'link',
                url: path('page-index'),
                content: 'main page'
            }
        },
        {
            block: 'user-card'
        }
    ];
});

block('page-user').elem('title').content()('user');

Мы добавили фейковый блок меню — просто как обертку для ссылки на главную страницу, саму ссылку и блок user-card, который будет выводить информацию о пользователе.
Не забываем обновить зависимости в desktop.blocks/page-user/page-user.deps.js:

({
    mustDeps: ['i-page'],
    shouldDeps: ['link', 'user-card']
})

Я не добавил в зависимости блок menu, потому что не собираюсь его реализовывать.

Чтобы вывести карточку пользователя, создадим файл desktop.blocks/user-card/user-card.bemtree:

block('user-card').content()(function () {
    return this.get('users', {
        user_ids: this.route.parameters.id
    }, function (data) {
        return data.body.response.map(function (dataItem) {
            var output = [];
            for (var key in dataItem) {
                if (dataItem.hasOwnProperty(key)) {
                    output.push({
                        elem: 'row',
                        content: [
                            {
                                elem: 'key',
                                content: key
                            },
                            {
                                elem: 'value',
                                content: JSON.stringify(dataItem[key])
                            }
                        ]
                    });
                }
            }
            return output;
        });
    });
});

В таком виде уже будет работать. Можно попробовать кликнуть на ссылку в результатах поиска, только не забудьте перед этим обновить страницу, чтобы подтянуть новый код. Но давайте сделаем карточку пользователя таблицей, определив desktop.blocks/user-card/user-card.bemhtml:

block('user-card')(
    tag()('table'),
    elem('row').tag()('tr'),
    elem('key').tag()('td'),
    elem('value').tag()('td')
);

Вот, так гораздо лучше.

Думаю, на этом пора закончить, хотя можно было бы еще добавить как минимум валидацию пользовательского ввода, более аккуратные url, показ процесса загрузки, возврат к последнему поиску… Оставлю это на домашнее задание тем, кто заинтересовался. Ну или на следующую статью, если заинтересовавшиеся попросят о таковой.

Полезные ссылки:
bnsf — фреймворк, о котором речь в статье. На самом деле просто библиотека блоков в терминологии БЭМ.
bem-core — библиотека блоков, от которой зависит bnsf
bem-components — библиотека блоков, которая используется в проекте, созданном выше
bem.info — сайт про bem с документацией, в частности, там можно почитать про:
bemtree — технологию для построения входных данных для шаблонизатора по данным от API и
bemhtml — декларативный шаблонизатор
Статья в тему by Nickolas Zackas. Есть перевод.
Поделиться публикацией
Реклама помогает поддерживать и развивать наши сервисы

Подробнее
Реклама
Комментарии 31
  • +1
    Привет. Есть шансы на поддержку BH наравне с BEMHTML?
    • 0
      Я думал над этим, но если оставлять bemtree — то есть ли смысл в bh?
      А если не оставлять — то что использовать взамен?
  • 0
    • +2
      Конечно, видел, пробовал.
      Многое не понравилось.
      Например, там плохо реализована маршрутизация. Если про то, что там есть для роутинга, вообще можно употреблять такие слова как «маршрутизация» и «реализована».
      • +1
        Такое надо сразу же развидеть.
        Там много моментов под вопросов в коде, работающим с АПИ, очень маленькая скорость шаблонизации в BEM.JSON, bem-bl вместо bem-core и еще куча всего.
        Имеет лишь смысл форкнуть проект, переписать на bh, поработать с i-api-request медленным и неоптимальным и возможно потом писать что-то в продакшн на получившемся велосипеде.
        • +1
          Я бы сказал, что форкать смысла нет. Проще заново написать — что я и сделал, собственно.
          • 0
            Если позволяет время и команда, то да, bem-node даже не стоит переписывать.
          • 0
            Сначала хотел возмутиться, а потом таки да поход в АПИ все равно я переписывал под себя и то что нет новых библиотек тоже напрягает.
          • +1
            Специально залогинился, чтобы описать одну вещь, которая действительно мешает нам в bem-node.
            1. Есть например базовое описание страницы, состоящее из трех блоков. Один из этих блоков раскрывается еще в три. Ну и один из этих трех раскрывается еще в два.
            2. Допустим для описания структуры у нас будет blocks-desktop, а данные будут забираться через ctx.defer() в blocks-data (#евпочя)

            И вот тут если для каждого из блоков мне нужны данные, по логике bem-node их логично получать для каждого блока внутри blocks-data. По факту же это означает последовательное выполнение запросов, в то время как зачастую они не связаны друг с другом и эффективнее было бы выполнять их параллельно.

            Это разумеется легко обходится с помощью i-state, но это 1) невообразимый костыль и 2) придется данные, полученные главным блоком прокидывать во внутренние блоки через ctx.content().

            Пока что это у нас одна из главных причин отказаться от bem-node.
            • 0
              Это разумеется легко обходится с помощью i-state

              А можно поподробнее?
              • 0
                i-state — это аналог req в express, туда можно положить данные, которые относятся к конкретному запросу. То есть в главном блоке, описывающем страницу сайта (например главную), можно сделать все запросы через ctx.defer(promise) и положить все данные в i-state, а уже во внутренних блоках брать данные из i-state. Выглядит очень странно и идет вразрез с логикой bem-node, но работает.
              • 0
                В случае bnsf подобную проблему можно решить, например, таким способом:

                // в этот блок вложен элемент data, в котором есть свой запрос
                block('user-card').content()(function () {
                    // рендерим элемент data
                    var dataBlock = applyCtx({
                        elem: 'data'
                    });
                    // шлем запрос
                    return this.get('users', {
                        user_ids: this.route.parameters.id
                    }, function (data) {
                         // что-то делаем с отрендеренным элементом и ответом от сервера
                        return [dataBlock, {elem: 'bla', content: data.body}];
                    });
                });
                
                // описание элемента data, который посылает запрос
                block('user-card').elem('data').content()(function () {
                    return this.get('users', {
                        user_ids: 1
                    }, function (data) {
                        return data.body;
                    });
                });
                

                Запросы объединятся. Выбор, когда рендерить блоки — до отправки запроса или после — остается за разработчиком.
                • 0
                  Вам правда нравится xjst?
                  • 0
                    Ну, скобочек многовато, конечно, но свою задачу вполне решает.

                    Когда я начинал, то хотел сразу делать и под bh, но не нашел аналога bemtree. Сейчас появился, судя по тому проекту, на который чуть выше дает ссылку tadatuta, можно добавить поддержку. Это не должно быть очень сложно.
                    • 0
                      Я скорее о том, что любой веб-разработчик не из Яндекса вряд ли выберет bemtree хотя бы по той причине, что непонятно, что он делает. Сюда можно добавить и новый синтаксис несмотря даже на js-вариант XJST — он очень непривычный.
                      • 0
                        Может быть, хотя я — вполне себе любой разработчик не из Яндекса.

                        Кстати, я тут подумал, что мне все-таки будет не очень просто сделать нормальную интеграцию bem-priv+bh, потому что первым я вообще не пользовался, а на втором сделал только один небольшой проект. А там ведь наверняка свои «лучшие практики» уже и сформировавшися подходы.
            • +2
              Написал node-приложение, позволяющее создавать бем-сущности исходя из указанной папки (например, если натравить на папку block/__elem, то создастся файл с именем block__elem с нужным расширением и шаблоном), а также автоматическое создание структурыэлементов и модификаторов по deps- файлам. Легко интегрируется в webstorm. Стоит ли выложить в opensource?
              • 0
                Конечно. Хуже от этого никому не станет
                • 0
                  > и шаблоном
                  какой шаблонизатор?
                  • 0
                    С шаблоном файла. Например, если в параметрах вы указали 'css js', то в файлы создадутся по шаблонам, которые идут в поставке либо настроены вами. По-умолчанию, для css файла используется шпблон с содержимым .{{block}}{{elem}}{{modname}}{{modval}} { }, и следуя примеру выше, в нем станет .block__elem {}. Разделители сущностей и пути к шаблонам настраиваемы.
                  • +1
                    Если вы считаете, что сделали что-то полезное и готовы потратить время и силы — выкладывайте, почему нет?
                    • 0
                      Выложил. Буду рад помощи переводу на англ. github.com/f0rmat1k/bemy
                      • 0
                        Только что понял, что это очень похоже на bem create. Вот дока. Можете пояснить в двух словах, в чем разница?
                        • 0
                          Отличия примерно такие:
                          — bem tools, насколько я помню, требует наличие level.js, без него отказывается даже просто блок создать. Bemy работает проще (кому-то плюс, кому-то минус) и ориентируется на path, который вы в него отправили.
                          — bemy использует шаблоны для файлов и во время создания заполняет их бем-именами.
                          — bemy умеет создавать структуру элементов, модификаторов текущего блока по deps-файлам (bem tools вроде бы так не умеет, он создает файлы страниц по депсам, как заявлено. Не проверял). То есть вы заполняете deps тем, что вам надо, 1 хоткей и структура (по-умолчанию с css-файлами) готова.
                          — планирую допилить глубокий rename бем-сущности.
                          • +2
                            Наконец то доделал задачу переименования (в т. ч. внутри файлов). И добавил еще различных плюшек:
                            — автооткрытие файлов с указателем на нужной строке (указатель конфигурируется в шаблонах)
                            — шоткат bemy для простого вызова тулзы
                            — более гибкая работа с файловой структурой (например, можно вызывать беми относительно файлов)
                            — поддержка кастомных разделителей БЕМ сущностей (теперь не только '_' и '__')
                            — тесты и другое.
                            • +1
                              Ммм, развиваетесь, очень интересно!
                              Напишите на ru.bem.info/forum пост — там намного больше народу увидит.
                      • 0
                        Можно задать обратный вопрос: а почему код изначально не на GitHub'е? )
                        • 0
                          На гитхабе, да не на том. На днях выложу.
                        • +1
                          github.com/f0rmat1k/bemy

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