Перед вами история интегрирования БЭМ-методологии в React-вселенную. Материал, который вы прочитаете, построен на опыте разработчиков Яндекса, развивающих самый масштабный и нагруженный сервис в России — Яндекс.Поиск. Мы никогда раньше не рассказывали настолько подробно и глубоко о том, почему делали так, а не иначе, что нами двигало и чего мы действительно хотели. Внешнему человеку доставались сухие релизы и обзоры на конференциях. Лишь в кулуарах можно было услышать нечто подобное. Я как соавтор негодовал из-за скудности информации снаружи каждый раз, когда рассказывал о новых версиях библиотек. Но в этот раз мы поделимся всеми подробностями.



Все слышали о методологии БЭМ. CSS-селекторы с подчёркиваниями. Компонентный подход, о котором говорят, имея в виду способ написания CSS-селекторов. Но про CSS в статье не будет ни слова. Только JS, только хардкор!


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


Пролог


БЭМ и правда зарождался как спасение от сильной связности и вложенности в CSS. Но деление простыней style.css на файлы для каждого блока, элемента или модификатора неизбежно привело к схожему структурированию JavaScript-кода.


В 2011 году Open Source обзавёлся первыми коммитами фреймворка i-bem.js, который работал в связке с шаблонизатором bem-xjst. Обе технологии выросли из XSLT и служили популярной тогда идее разделять бизнес-логику и представление компонента. Во внешнем мире это были прекрасные времена Handlebars и Underscore.


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


В силу специфики поисковых сервисов Яндекса пользовательские интерфейсы строятся по данным. Страница результатов поиска уникальна для каждого запроса.



Поисковый запрос по ссылке



Поисковый запрос по ссылке



Поисковый запрос по ссылке


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


src/components
+-- ComponentName
¦   +-- _modName
¦   ¦   +-- ComponentName_modName.tsx — простой модификатор
¦   ¦   L-- ComponentName_modName_modVal.tsx — модификатор со значением
¦   +-- ElementName
¦   ¦   L-- ComponentName-ElementName.tsx — элемент блока ComponentName
¦   +-- ComponentName.i18n — файлы переводов
¦   ¦   +-- ru.ts — словарь для русского языка
¦   ¦   +-- en.ts — словарь для английского языка
¦   ¦   L-- index.ts — словарь используемых языков
¦   +-- ComponentName.test — файлы тестов
¦   ¦   +-- ComponentName.page-object.js — Page Object
¦   ¦   +-- ComponentName.hermione.js — функциональный тест
¦   ¦   L-- ComponentName.test.tsx — unit-тест
¦   +-- ComponentName.tsx — визуальное представление блока
¦   +-- ComponentName.scss — визуальные стили
¦   +-- ComponentName.examples.tsx — примеры компонента для Storybook
¦   L-- README.md — описание компонента

Современная структура директории компонента


Как и в некоторых других компаниях, в Яндексе разработчики интерфейсов отвечают за фронтенд, состоящий из клиентской части в браузере и серверной части на Node.js. Серверная часть обрабатывает данные «большого» поиска и накладывает на них шаблоны. Первичная обработка данных преобразует JSON в BEMJSON — структуру данных для шаблонизатора bem-xjst. Шаблонизатор обходит каждый узел дерева и накладывает на него шаблон. Так как первичное преобразование происходит на сервере и благодаря делению на мелкие сущности узлы соответствуют файлам, при шаблонизации мы пушим в браузер код, который будет использован только на текущей странице.


Ниже соответствие BEMJSON ноды файлам на файловой системе.


module.exports = {
    block: 'Select',
    elem: 'Item',
    elemMods: {
        type: 'navigation'
    }
};

src/components
+-- Select
¦   +-- Item
¦   ¦   _type
¦   ¦   +-- Select-Item_type_navigation.js
¦   ¦   L-- Select-Item_type_navigation.css

За изоляцию компонентов JavaScript-кода в браузере отвечала модульная система YModules. Она позволяет синхронно и асинхронно доставлять модули в браузер. Пример работы компонентов с YModules и i-bem.js можно найти здесь. Сегодня для большинства разработчиков подобное делает модульная система webpack и невышедший стандарт динамических импортов.


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


Новая надежда


В 2013 году в Open Source феерично пришёл React. На самом же деле Facebook начал использовать его ещё в 2011 году. Джеймс Лонг (James Long) в своих записях с конференции JS Conf US говорит:


The last two sessions were a surprise. The first one was given by two Facebook developers and they announced Facebook React. I didn’t take many notes because I was kind of in shock of how bad of an idea I think it is. Essentially, they created a language called JSX which lets you embed XML in JavaScript to create live reactive user interfaces. XML. In JavaScript. 

React изменил подход к проектированию веб-приложений. Он стал настолько популярен, что сегодня не найти разработчика, который бы не слышал о React. Но важно иное: приложения стали другими, в нашу жизнь пришли SPA.


Принято считать, что у разработчиков Яндекса особенное чувство прекрасного в отношении технологий. Порой странное, с чем спорить сложно, но безосновательное — никогда. Когда React набирал звезды на GitHub, многие, кто были знакомы с веб-технологиями Яндекса, настаивали: Facebook победил, бросайте свои поделки и бегите всё переписывать на React, пока не поздно. Тут важно понимать две вещи. 


Во-первых, не было войны. Компании не соревнуются в создании лучшего фреймворка на Земле. Если компания начнёт тратить меньше времени (читай — денег) на инфраструктурные задачи при той же продуктивности, все от этого только выиграют. Нет смысла писать фреймворки, чтобы писать фреймворки. Лучшие разработчики создают инструменты, которые решают задачи компании лучшим способом. Компании, сервисы, цели — всё это разное. Отсюда многообразие инструментов.


Во-вторых, мы искали способ применять React так, как бы именно нам этого хотелось. Со всеми возможностями, что давали наши технологии, описанные выше.


Бытует мнение, что код с использованием React по умолчанию быстрый. Если так считаете и вы, то глубоко заблуждаетесь. Единственное, что делает React, — в большинстве случаев помогает оптимально взаимодействовать с DOM.


Вплоть до версии 16 React обладал фатальным недостатком. Он был в 10 раз медленнее bem-xjst на сервере. Мы не могли позволить себе такое расточительство. Время ответа для Яндекса — одна из ключевых метрик. Представьте, на запрос с рецептом глинтвейна вы получите ответ в 10 раз медленнее обычного. Вас не устроят оправдания, даже если вы хоть что-то смыслите в веб-разработке. Что уж говорить об объяснении вроде «зато разработчикам стало удобнее общаться с DOM». Прибавьте сюда соотношение цены внедрения и профита — и вы сами примете единственно верное решение.


К счастью ли к горю, разработчики — странные люди. Если что-то не получается, то это совсем не повод всё бросать…


Шиворот-навыворот


Мы были уверены, что можем победить медлительность React. У нас уже есть быстрый шаблонизатор. Всего-то надо на сервере генерировать HTML, используя bem-xjst, а на клиенте «заставить» React принять эту разметку за свою. Идея была настолько проста, что ничто не предвещало провала.


В версиях до 15 включительно React валидировал достоверность разметки через хеш-сумму — алгоритм, который любую оптимизацию превращает в тыкву. Чтобы убедить React в валидности разметки, требовалось проставить каждой ноде id и вычислить хеш-сумму всех нод. Также это означало поддержку двойного набора шаблонов: React для клиента и bem-xjst для сервера. Простые тесты на скорость с простановкой id дали понять, что продолжать нет смысла.


Шаблонизатор bem-xjst — очень недооценённый инструмент. Посмотрите доклад главного мейнтейнера Славы Олиянчука и сами во всём убедитесь. bem-xjst построен на базе архитектуры, которая позволяет использовать один синтаксис шаблонов для разных преобразований исходного дерева. Очень похоже на React, не так ли? Эта особенность сегодня позволяет существовать таким инструментам, как react-sketchapp.


Из коробки bem-xjst содержит два вида преобразований: в HTML и в JSON. Любой достаточно усидчивый разработчик может написать свой движок преобразований шаблонов во что угодно. Мы научили bem-xjst преобразовывать дерево с данными в последовательность вызовов HyperScript-функций. Что означало полную совместимость и с React, и с другими реализациями Virtual DOM алгоритма, например с Preact.



Подробный рассказ о подходе с генерацией вызовов HyperScript-функций


Поскольку шаблоны React предполагают совместное существование вёрстки и бизнес-логики, нам пришлось принести логику из i-bem.js в свои шаблоны, для этого не предназначенные. Для них это было противоестественно. Они и собирались иначе. Кстати!


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


block('select').elem('menu')(
    def()(function() {
        const React = require('react');
        const Menu = require('../components/menu/menu');
        const MenuItem = require('../components/menu-item/menu-item');
        const _select = this.ctx._select;
        const selectComponent = _select._select;

        return React.createElement.apply(React, [
            Menu,
            {
                mix: { block : this.block, elem : this.elem },
                ref: menu => selectComponent._menu = menu,
                size: _select.mods.size,
                disabled: _select.mods.disabled,
                mode: _select.mods.mode,
                content: _select.options,
                checkedItems: _select.bindings.checkedItems,
                style: _select.bindings.popupMenuWidth,
                onKeyDown: _select.bindings.onKeyDown,
                theme: _select.mods.theme,
            }].concat(_select.options.map(option => React.createElement(
                MenuItem,
                {
                    onClick: _select.bindings.onOptionCheck,
                    theme: _select.mods.theme,
                    val: option.value,
                }, option.content)
            ))
        );
    })
);

Конечно, у нас была своя сборка. Как известно, самая быстрая операция — это конкатенация строк. На ней построен движок bem-xjst, на ней же строилась сборка. Файлы блоков, элементов и модификаторов лежали по папочкам, и сборке надо было только склеить файлы в правильной последовательности. При таком подходе можно параллельно склеивать JS, CSS и шаблоны, равно как и сами сущности. То есть если у вас в проекте четыре компонента, на ноутбуке четыре ядра, а сборка одной технологии компонента занимает одну секунду, то сборка проекта займёт две секунды. Тут должно становиться понятнее как нам удаётся пушить в браузер только необходимый код.


Всё это для нас делал ENB. Итоговое дерево для шаблонизации мы получали только в рантайме, а так как зависимость между компонентами должна была возникнуть немного раньше, чтобы собирать бандлы, то эту функцию на себя взяла мало кому известная технология deps.js. Она позволяла строить граф зависимостей между компонентами, после чего сборщик мог склеить код в нужной последовательности, обходя граф. 


Работу в этом направлении прекратил выход React версии 16. Скорости выполнения шаблонов на сервере сравнялись. На мощностях продакшна разница становилась незаметной.


Node: v8.4.0
Children: 5K


renderer mean time ops/sec
preact v8.2.6 66.235ms 15
bem-xjst v8.8.4 71.326ms 14
react v16.1.0 73.966ms 14

По ссылкам ниже можно восстановить историю подхода:



Пробовали ли мы что-то ещё?




Мотивация


В середине рассказа будет нелишним поговорить о том, что нами двигало. Стоило сделать это в начале, но — кто старое помянет, тому глаз в подарок. Зачем нам всё это? Что такого может принести БЭМ, чего не может React? Вопросы, которые задаёт чуть ли не каждый.


Декомпозиция


Функциональность компонентов из года в год усложняется, и количество вариаций увеличивается. Это выражается конструкциями if или switch, в итоге неизбежно растёт кодовая база, как следствие — увеличивается вес компонента и использующего такой компонент проекта. Основная часть логики React-компонента заключена в методе render(). Чтобы изменить функциональность компонента, необходимо переписать большую часть метода, что неизбежно ведёт к экспоненциальному росту количества узкоспециализированных компонентов. 


Все знают библиотеки material-uifabric-ui и react-bootstrap. В целом у всех известных библиотек с компонентами один и тот же недостаток. Представьте, что у вас несколько проектов и все используют одну библиотеку. Вы берёте одни и те же компоненты, но в разных вариациях: тут селекты с чекбоксами, там без, тут синие кнопки с иконкой, там красные без. Вес CSS и JS, который вам приносит библиотека, во всех проектах будет одинаков. Но почему? Вариации компонентов заэмбежены внутрь самого компонента и поставляются вместе с ним, хотите вы этого или нет. Для нас это неприемлемо.


В Яндексе тоже есть своя библиотека с компонентами — Лего. Применяется в ~200 сервисах. Хотим ли мы, чтобы использование Лего в Поиске стоило столько же для Яндекс.Здоровья? Ответ вы знаете.


Кроссплатформенная разработка


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


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


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


Хотим ли мы, чтобы наши родители/друзья/коллеги/дети пользовались десктопными версиями на мобильных с более низкой скоростью интернета и более низкой производительностью? Ответ вы знаете.


Эксперименты


Если вы разрабатываете проекты для большой аудитории, надо быть уверенными в каждом изменении. A/B-эксперименты — один из способов получить такую уверенность.


Способы организации кода для экспериментов:


  • форк проекта и создание инстансов сервиса в продакшне;
  • точечные условия внутри кодовой базы.

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


Точечные условия работают гибче, но усложняют кодовую базу: условия эксперимента могут затрагивать разные части проекта. Большое количество условий ухудшает производительность за счёт увеличения объёма кода для браузера. Надо убирать условия, делать код основным или полностью удалять неудачный эксперимент.


В Поиске ~100 экспериментов онлайн в различных комбинациях на различной аудитории. Вы могли это видеть сами. Вспомните, может, вы замечали функциональность, а спустя неделю она магически исчезала. Хотим ли мы проверять продуктовые теории ценой поддержания сотен веток активной кодовой базы в 500 000 строк, которую изменяют ~60 разработчиков ежедневно? Ответ вы знаете.


Глобальные изменения 


Например, можно создать компонент CustomButton, унаследованный от Button из библиотеки. Но унаследованный CustomButton не применится ко всем компонентам из библиотеки, содержащим Button. В библиотеке может быть компонент Search, построенный из Input и Button. В этом случае внутри компонента Search не появится унаследованный CustomButton. Хотим ли мы вручную обходить всю кодовую базу, где используется Button?



Долгая дорога в композицию


Мы решили изменить стратегию. В предыдущем подходе брали за основу технологии Яндекса и пытались заставить React работать на этой основе. Новая тактика предполагала обратное. Так появился проект bem-react-core.


Стоп! Почему вообще React?

Мы увидели в нём возможность избавиться от явного первоначального рендеринга в HTML и от ручной поддержки состояния JS-компонента уже потом в рантайме — по сути, стало возможным слить в одну технологию BEMHMTL-шаблоны и JS-компоненты.


v1.0.0


Вначале мы планировали перенести все лучшие практики и свойства bem-xjst в библиотеку поверх React. Первое, что бросается в глаза, — это сигнатура, или, если вам удобнее, синтаксис описания компонентов.


Что вы наделали, есть же JSX!


Первая версия была построена на базе inherit — библиотеки, которая помогает реализовывать классы и наследование. Как некоторые из вас помнят, в те самые времена апплай прототайпов в JavaScript не было классов, не было super. В общем, их и сейчас нет, точнее, это не те классы, которые в первую очередь приходят на ум. inherit делала всё, что сейчас умеют классы в стандарте ES2015, и то, что принято считать чёрной магией: множественное наследование и слияние прототипов вместо перестроения цепочки, что положительно влияет на производительность. Вы не ошибётесь, если подумаете, что это похоже по смыслу на inherits в Node.js, но работают они по-разному.


Ниже пример синтаксиса шаблонов bem-react-core@v1.0.0.


App-Header.js


import { decl } from 'bem-react-core';

export default decl({
  block: 'App',
  elem: 'Header',
  attrs: {
    role: 'heading'
  },
  content() {
    return 'я заголовок';
  }
});

App-Header@desktop.js


import { decl } from 'bem-react-core';

export default decl({
  block: 'App',
  elem: 'Header',
  tag: 'h1',
  attrs() {
    return {
      ...this.__base(...arguments),
      'aria-level': 1
    },
  },
  content() {
    return `А ${this.__base(...arguments)} на десктопах превращаюсь в h1`;
  }
});

App-Header@touch.js


import { decl } from 'bem-react-core';

export default decl({
  block: 'App',
  elem: 'Header',
  tag: 'h2',
  content() {
    return `А ${this.__base(...arguments)} на тачах`;
  }
});

index.js


import ReactDomServer from 'react-dom/server';
import AppHeader from 'b:App e:Header';

ReactDomServer.renderToStaticMarkup(<AppHeader />);

output@desktop.html


<h1 class="App-Header" role="heading" aria-level="1">A я заголовок на десктопах превращаюсь в h1</h2>

output@touch.html


<h2 class="App-Header" role="heading">я заголовок на тачах</h2>

Устройство шаблонов более сложных компонентов можно посмотреть здесь.


Поскольку класс есть объект, а работать с объектами в JavaScript удобнее всего, синтаксис вышел соответствующим. Позже синтаксис перекочевал в своего вдохновителя bem-xjst.


Библиотека представляла собой глобальное хранилище из объектныx деклараций — результатов выполнения функции decl, частей сущностей: блока, элемента или модификатора. БЭМ предоставляет механизм уникального именования и поэтому подходит для создания ключей в хранилище. Итоговый React-компонент склеивался по месту своего использования. Хитрость в том, что decl отрабатывал при импорте модуля. Это позволяло указывать, какие части компонента нужны в каждом конкретном месте, с помощью простого списка импортов. Но вспомните: компоненты сложные, частей много, список импортов длинный, разработчики ленивые.


Магия импортов


Как вы могли заметить, в примерах кода есть строки import AppHeader from 'b:App e:Header'.


Вы сломали стандарт! Так нельзя! Это просто не будет работать!


Во-первых, стандарт импортов не оперирует терминами в духе «в строке импорта должен быть путь до реально существующего модуля». Во-вторых, это синтаксический сахар, который преобразовывался с помощью Babel. В-третьих, странные конструкции из знаков препинания в импортах для webpack import txt from 'raw-loader!./file.txt'; почему-то никого не смущали.
Итак, наш блок представлен в двух платформах: desktoptouch.


import Hello from 'b:Hello';

// Запись будет трансформирована в следующее:

var Hello = [?
    require('path/to/desktop/Hello/Hello.js'),?
    require('path/to/touch/Hello/Hello.js')?
][0].applyDecls();

Здесь в коде произойдёт последовательный импорт всех определений компонента Hello, а затем вызов функции applyDecls, которая склеит все декларации блока из глобального хранилища через inherit и создаст новый, уникальный для конкретного места в проекте React-компонент.


Плагин для Babel, выполняющий такое преобразование, можно найти здесь. А лоадер для webpack, который искал на файловой системе определения компонентов, вот здесь.


В итоге, что было хорошо:


  • краткий, декларативный синтаксис шаблонов, позволяющий доопределять разные части компонента в любом месте проекта;
  • нет цепочек прототипов в наследовании;
  • уникальный React-компонент для каждого места использования.

А это было плохо:


  • нет поддержки TypeScript/Flow;
  • непривычный для большинства React-разработчиков синтаксис;
  • из-за динамической природы импортов невозможно поставлять код в транспилированном виде;
  • обязательна специальная настройка сборки на проекте.

v2.0.0


Мы учли опыт использования bem-react-core@v1.0.0 в проектах, отзывы и здравый смысл и попробовали снова.


import { Elem } from 'bem-react-core';
import { Button } from '../Button';

export class AppHeader extends Elem {
    block = 'App';
    elem = 'Header';

    tag() {
        return 'h2';
    }

    content() {
        return (
            <Button>Я кнопка</Button>
        );
    }
}

В качестве синтаксиса описания блоков, элементов и модификаторов выбрали классы. Классы отличаются декларативной записью, встроенной поддержкой наследования, они просто великолепно работают с TypeScript/Flow. Внимательный читатель заметил, что мы отказались от inherit и «своих» импортов, что означало более удобную отладку, но и более длинную цепочку прототипов со всеми вытекающими последствиями для производительности.


Основными задачами были:
— отказаться от дополнительных надстроек в виде лоадеров для webpack и плагинов для Babel;
— максимально приблизиться к привычному всем языку;
— обзавестись нативной поддержкой всех инструментов для отладки, написания и тестирования кода.


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


import * as React from 'react';
import * as ReactDOM from 'react-dom';
import { Block, Elem, withMods } from 'bem-react-core';

interface IButtonProps {
    children: string;
}

interface IModsProps extends IButtonProps {
    type: 'link' | 'button';
}

// Создание элемента Text
class Text extends Elem {
    block = 'Button';
    elem = 'Text';

    tag() {
        return 'span';
    }
}

// Создание блока Button
class Button<T extends IModsProps> extends Block<T> {
    block = 'Button';

    tag() {
        return 'button';
    }

    mods() {
        return {
            type: this.props.type
        };
    }

    content() {
        return (
            <Text>{this.props.children}</Text>
        );
    }
}
// Расширение функциональности блока Button, при наличии свойства type со значением link

class ButtonLink extends Button<IModsProps> {
    static mod = ({ type }: any) => type === 'link';

    tag() {
        return 'a';
    }

    mods() {
        return {
            type: this.props.type
        };
    }

    attrs() {
        return {
            href: 'www.yandex.ru'
        };
    }
}

// Объединение классов Button и ButtonLink
const ButtonView = withMods(Button, ButtonLink);

ReactDOM.render(
    <React.Fragment>
        <ButtonView type='button'>Click me</ButtonView>
        <ButtonView type='link'>Click me</ButtonView>
    </React.Fragment>,
    document.getElementById('root')
);

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


withMods принимал аргументами базовый класс блока и классы, расширяющие базовый (модификаторы), модификаторы обладали предикатом на входящие пропсы. При отрисовке компонентов, как только срабатывает предикат модификатора, withMods перестраивает цепочку прототипов относительно всех активных модификаторов так, чтобы каждый следующий был наследником предыдущего. Так происходит при каждом изменении пропсов. На первой отрисовке не случается никаких проблем, но, как только начинают включаться модификаторы, базовый блок (его прототип) получает функциональность модификатора. В результате все инстансы на странице будут обладать всей функциональностью модификаторов независимо от входящих пропсов. Кейс может повториться и на второй перерисовке, и на третьей, и на четвёртой — в зависимости от того, когда сработают предикаты модификаторов.


Решения, которые не помогли:


  • Заворачивать модификаторы в функцию. Так, чтобы на каждый вызов модификатора возвращался новый класс. Частично решает проблему, но несовместимо с TS. Так как из функции модификатора начинает возвращаться класс, который экстендит базовый из внешнего скопа. При компиляции для ES5 TS перестраивает вызовы super не через прототип, а через внешнюю ссылку на базовый класс из константы. Да, TS транспилирует код не по стандартам, а как ему нравится.
  • Компилировать в два прохода. TS для ES6 и Babel для ES5. Помогает только на уровне собираемого проекта, поставляемый код в npm-пакетах так обработан не будет. Кроме того, это сильно замедлит сборку и завяжет всех на использование Babel.

Дополнительные трудности:


  • Невозможно расширить базовые блоки на уровне проекта, который их использует. Например, использовать блоки библиотек на сервисах. Кейс: атрибуты счетчиков на DOM-нодах. Расширить можно только через HOC, а модификаторы применяются только к классу. Всякое использование withMods не закрывало доступ к методам базового класса.
  • Все сущности (блок, элемент, модификатор) для правильной генерации классов обязаны быть классами. Тогда как большинство сущностей обладают слабой функциональностью и могут быть выражены через SFC.
  • Жирные CSS-модификаторы. Любой CSS-модификатор обязан иметь JS-представление в виде расширения базового класса. Это не проблема само по себе, но было подозрение, что такой подход не снижал нам количество кода в браузере.

Мы были вынуждены прервать разработку v2.


Манифест


Естественно, это нас не остановило. Мы написали манифест. Следуя ему, можно было решить проблемы, которые мы встретили в версиях 1 и 2. Ниже я перескажу часть из этого манифеста.


Основная мысль — работаем через полную композицию. Работу с CSS-классами и модификаторы выражаем через HOC, а переопределение кода по платформам и экспериментам — через dependency injection.


Необходимое и достаточное от БЭМ в React:



Модификатор более не сможет влиять на внутреннее устройство компонента. Дополнительная функциональность должна быть выражена через управляющие компоненты сверху. А сами компоненты могут быть выражены как React.ComponentType по необходимости без базовых БЭМ-компонентов. Подключение модификаторов не отличается от подключения любых других HOC и работает через любой compose и в любом порядке.


Модификатор определяет истинность предиката и добавит дополнительный класс через пропсы к базовому классу.


Переопределение компонентов и их составляющих выражается через dependency injection, которое реализуется на базе React.ContextAPI и множественных реестров компонентов. Каждый компонент волен регистрировать свои зависимости в реестре и позволять переопределять их сверху, что напрямую не заложено в работу стандартного контекста, но реализуется иным способом вычисления нового значения контекста. По умолчанию зависимости можно переопределять по контексту вниз, что есть стандартный механизм работы контекста. В итоге DI — это HOC, который провайдит реестры в контекст. Реестры могут работать в режимах проваливания и всплытия зависимостей. Это позволяет переопределять что угодно, где угодно, на любом уровне вложенности простым дописыванием компонентов в реестр.


То, что у нас получилось, уже можно увидеть в продакшене на странице результатов Поиска. Мы уместили всё, что нам было необходимо от БЭМ, в библиотеку из 4 пакетов, общим весом в 1.5Kb.


На этом историческая часть заканчивается. Спасибо тем, кто дочитал до конца. В следующей статье я расскажу, как мы работаем с React в Яндекс.Поиске сегодня.

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


  1. ArsenAbakarov
    05.02.2019 11:01

    Все это конечно очень классно, но вот недавно у меня назревал на работе новый проект, и я очень хотел использовать готовое из мира БЭМ, посмотрел кучу докладов про bem-react-core, уже хотел внедрять, а тут бах… У меня legacy на Django, с шаблонизацией были проблемы, нужен был SSR, преодолею, подумал я… А тут бах… bem-react-core v2 перестал рекомендоваться к использованию, были какие то архитектурные проблемы, ну я взял и запилил свой велосипед, расширил Django шаблонизатор, прикрутил Nunjucks на клиент, реализовал там недостающее, и у меня изоморфный рендеринг, написал простую компонентную систему на js
    Да, рантайм слабоват, не все есть из БЭМ, но текущего ф-нала для обычного интернет магазина хватает с головой


    1. verybigman Автор
      05.02.2019 11:36
      +1

      К сожалению, мы не предусмотрели проблемы с v2, надеюсь что новая версия решает все ваши трудности. Подробный гайд по использованию нового API мы опубликуем чуть позже. Было бы интересно глянуть на ваше решение для Django.


      1. ArsenAbakarov
        05.02.2019 12:42

        Мое решение находится внутри компании, но вкратце как то так:

        Код
        шаблоны:
        {% load bem %}
        
        {# isomorphic #}
        {% with bem_name='product-card' product=content.product %}
            <div class="{{ bem_name }} {{ mods.type.html_class }} {{ mixes }}" data-key="{{ key }}" data-js='[{"name": "{{ bem_name }}"}]'>
                <div class="{% bem_class 'e:about' %}">
                    <div class="{% bem_class 'e:catalog' %}">{{ content.catalog.name }}</div>
                    <div class="{% bem_class 'e:art' %}">Артикул: {{ product.id }}</div>
                    {% render_bem 'b:product-title' mixes 'e:title' content title=product.title href=product.href%}
                    {% render_bem 'b:product-description' mixes 'e:description' content description=product.short_description %}
                </div>
        
                    {% slot as link_slot %}
                        {# выбор корректной иконки #}
                        {% if product.is_new or product.offer_label_src %}
                            {% render_bem 'b:offer-label m:novelty={{product.is_new}}' mixes 'e:offer-label' content src=product.offer_label_src %}
                        {% endif %}
                        {% render_bem 'b:img' content src=product.img_href alt=product.short_description width='170' height='170' %}
                    {% endslot %}
                    {% render_bem 'b:link' mixes 'e:img' content slot=link_slot href=product.href %}
              
                <div class="{% bem_class 'e:usage-and-price' %}">
                    {% render_bem 'b:product-price' mixes 'e:price' content benefit=product.sale old=product.old_price current=product.price %}
                    {% render_bem 'b:product-usage m:size=m' mixes 'e:usage' %}
                </div>
                {% render_bem 'b:product-available' mixes 'e:available' content available_text=content.product.available %}
            </div>
        {% endwith %}
        {# endisomorphic #}
        


        В js
        import "b:icon";
        import {ControlToggle} from "b:control-toggle";
        import {Block, Elem} from "b:block-system";
        
        
        const ANIMATION_DURATION = 400;
        
        
        class ControlExpand extends Block {
          initState() {
            this.state.define('expanded', this.mods.expanded, true);
          }
        
          beforeElemsInited() {
            let toggleElem = this.elems.findElemByName('control-toggle');
            toggleElem.mergeWithElem(this.elems.findElemByName('control'));
          }
        
          beforeElemCallbacksFired() {
            let domNodeMaxHeight = this.domNode.css('max-height');
            let initialHeight = (domNodeMaxHeight === 'none') ? '0' : domNodeMaxHeight;
            this.elems.filter('content').delegate('setInitialHeight', initialHeight);
            this.elems.filter('gradient').delegate('setInitialHeight', initialHeight);
            this.elems.filter('content').delegate('collapse');
            this.elems.filter('content').delegate('display');
          }
        
          onInit() {
            this.domNode.css('max-height', 'none');
          }
        
          bindEvents() {
            this.onElemChange({
              'control-toggle': {
                'toggled': event => {
                  this.state.expanded = event.eventData;
        
                  let defaultControl = this.elems.findElemByKey('control', 'default-control');
                  if (defaultControl) {
                    defaultControl.setExpandState(this.state.expanded);
                  }
        
                  this.elems.filter('show-text').delegate('setExpandState', this.state.expanded);
                  this.elems.filter('gradient').delegate('setExpandState', this.state.expanded);
                }
              }
            });
        
            this.onModChange({
              'expanded:true': () => {
                this.elems.filter('content').delegate('expand');
                this.emit({type: 'expanded'});
              },
              'expanded:false': () => {
                this.elems.filter('content').delegate('collapse');
                setTimeout(() => {
                  this.emit({type: 'collapsed'});
                }, ANIMATION_DURATION)
              }
            })
          }
        
          expand() {
            this.elems.filter('control-toggle').delegate('toggleOn');
          }
        
          hide() {
            this.elems.filter('control-toggle').delegate('toggleOff');
          }
        }
        
        
        class ControlExpandControl extends Elem {
          /*
            Встроенный контрол в компонент, так же можно прокинуть и любой другой,
            но обработка будет не на этой стороне, по сутки просто чтобы повернуть стрелочку
          * */
          initState() {
            this.state.define('expanded', this.mods.expanded, true);
          }
        
          setExpandState(newState) {
            this.state.expanded = newState;
          }
        }
        
        
        class ControlExpandContent extends Elem {
          beforeInit() {
            this.initialHeight = null;
          }
        
          display() {
            this.domNode.css('visibility', 'visible')
          }
        
          setInitialHeight(height) {
            this.initialHeight = height;
          }
        
          expand() {
            this.domNode.css('max-height', this.domNode[0].scrollHeight);
          }
        
          collapse() {
            this.domNode.css('max-height', this.initialHeight);
          }
        }
        
        
        class ControlExpandShowText extends Elem {
          /*
            Встроенный текст контрола, тут меняется текстовочка
          * */
          setExpandState(newState) {
            let newText = (newState) ? 'Скрыть' : 'Показать';
            this.domNode.text(newText);
          }
        }
        
        
        class ControlExpandGradient extends Elem {
          initState() {
            this.state.define('expanded', this.mods.expanded, true);
          }
        
          setInitialHeight(height) {
            this.domNode.css('height', height);
          }
        
          setExpandState(newState) {
            if (newState) {
              this.state.expanded = newState;
            }
            else {
              setTimeout(() => {
                this.state.expanded = newState;
              }, ANIMATION_DURATION)
            }
          }
        }
        
        
        Block.register(ControlExpand);
        Elem.register(ControlExpandControl);
        Elem.register(ControlExpandContent);
        Elem.register(ControlExpandShowText);
        Elem.register(ControlExpandGradient);
        


        1. verybigman Автор
          05.02.2019 13:58

          Возможно, в вашем случае, это лучше чем библиотека на JS, если нет особой динамики. Но я очень рекомендую присмотреться к новой версии.


          1. ArsenAbakarov
            05.02.2019 14:05

            Да, конечно гляну, мне из мира БЭМ все интересно


  1. mrTyler
    05.02.2019 11:01

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

    Сейчас существует множество решений, позволяющих рендерить вашу страницу быстро, без большой вложенности и проблем с поиском селекторов, исключив, очевидно, банальные ошибки, вроде > *.

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


    1. Aingis
      05.02.2019 11:11
      +1

      Думаю, можно взять и БЭМ на вооружение, но большого смысла нет. Старый добрый CSS Modules покрывает большинство нужд. И даже здесь непонятно, чем эти велосипеды лучше банального JSX+CSS Modules.


      1. JustDont
        05.02.2019 12:23

        В случаях масштаба яндекса я в принципе могу в теории понять, почему CSS Modules им может быть неудобен: CSS Modules «из коробки» не умеет в перекрытие стилей сверху (из HOC или вообще с уровня приложения), а любые решения на перекрытия стилей «врукопашную», как делает тот же react-css-themr (и как можно сделать самому, там идея и код абсолютно тривиальны) — будут заведомо более медленными голого цсс.


        1. Aingis
          05.02.2019 13:30

          Варианты есть. Можно тупо пробрасывать классы через пропсы, но это несколько громоздкое и ненадёжное решение. Можно использовать темы через контекст. Можно просто создавать разные компоненты с разными темами.

          А вообще нарушать инкапсуляцию — это уже не про БЭМ. Если речь, например, о расположении элементов, то компоненты должны быть отдельно «вещью в себе», а позиционирование и отступы задаваться отдельно.


          1. verybigman Автор
            05.02.2019 14:00

            Можно тупо пробрасывать классы через пропсы, но это несколько громоздкое и ненадёжное решение. Можно использовать темы через контекст. Можно просто создавать разные компоненты с разными темами.


            Всё это просто не будет работать, если вы намерены как и мы шарить бандлы компонентов между сервисами. Мы не можем себе позволить 200 раз загружать одно и тоже на разных сервисах. Почему бы не использовать механизмы браузера для ускорения своих сервисов, вместо того чтобы слепо использовать какие-либо решения.


            1. Aingis
              05.02.2019 14:32

              Странно. Везде работает же…


    1. verybigman Автор
      05.02.2019 11:41
      +1

      У нас есть противоположный опыт и будем рады им поделится. Не очень понятно какие ресурсы на какую поддержку ты имеешь в виду. Суть методологии как раз в том, что её не надо поддерживать – её надо использовать. Для поддержки есть мы. Обращайтесь напрямую к нам через issue на Github или пишите в Telegram-канал.


  1. vladbarcelo
    05.02.2019 11:05

    Интересно, но дока очень (ОЧЕНЬ!) краткая, и нет примеров использования, хотя бы в виде демо-проекта.


    1. verybigman Автор
      05.02.2019 11:42
      +2

      Мы обещаем исправится! Мы постараемся сделать её более полезной.


  1. Fengol
    05.02.2019 11:32
    -1

    Бедные Яндексовцы! Вам хоть коому-то реально с этим хочется работать? 2019 год, жизнь прекрасна, разработка фронта просто волшебная. Нафиг вы сделали такую чушь? Сложные проекты у Вас? А какое отношение сложные\кривые инструменты имеют отношение к сложности проектов? БЭМ, оченьпросто и удобно, сам им пользуюсь, так как до того как о нем узнал писал почти также. css модули тоже классная вещь. Но вот это что?? У Яндекса денег на архитектора нет? Или вы берете только пятерочников с мозгом роботов которые просто не понимают что такое драйв, что такое процесс разработки от которого прёт или ну ваще тащит? От которого просто стоит. Вам хоть слово стояк говорит о чем-то кроме сантехники?

    И Вам не стыдно, что вы такие, вроде бы единственные в России, крутые чуваки, а у Вас все постоянно отстой? Ваши темы, Ваши уроки на youtube, Ваши митапы, они просто на сотню пунктов стремнее чем у остальных. Где все эти бренды и прочие hr фиговины на которые вы тонну денег сливаете? У Вас только дизайнеры заслуживают похвалы, если они конечно Ваши…


  1. 96467840
    05.02.2019 12:00

    к середине статьи у меня возник только один вопрос: а зачем яндексу серверный рендеринг????


    1. tadatuta
      05.02.2019 12:16

      Конец немного предсказуем. Где-то с середины статьи было такое чувство, что в ней будет раскрыт и этот вопрос. Так и вышло.

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


  1. JustDont
    05.02.2019 12:20

    Вплоть до версии 16 React обладал фатальным недостатком. Он был в 10 раз медленнее bem-xjst на сервере.

    Очень напомнило классику:
    — Армяне лучше, чем евреи!
    — Чем лучше?
    — Чем евреи!!!


  1. amarao
    05.02.2019 12:33

    А можно меньше js'а для представления результатов? Мой опыт говорит, что если отключать js на информационных сайтах (т.е. не сайтах-приложениях), то батарейка садится много медленее, страницы открываются быстрее и трафика качается меньше.


    1. verybigman Автор
      05.02.2019 14:08

      Мы постоянно работаем над эффективностью потребления ресурсов устойства. В Яндекс.Браузере например, есть индикатор использования батареи. В данном же случае с Поиском мы загружаем JS-код лениво, когда пользователь уже начал потреблять контент. А «толстый» JS присутствует только там, где есть интерактивные элементы. Как правило, код, который необходим для их работы загружается за каким-либо событием, и практически никогда в лоб.


      1. amarao
        05.02.2019 14:34

        13 кук, 8 скриптов, 3 XHR — и всё это чтобы выдать результат поиска?

        Это не считая запросов на tns-counter (у меня он заблокирован, что там происходит не знаю).

        Что-то в интернетах сильно сломано. Почему нельзя просто взять и показать результат?


        1. Lexicon
          05.02.2019 21:42

          Показать результат

          image


  1. vintage
    05.02.2019 18:29
    -1

    Я так и не понял из статьи зачем бэму Реакт. Вы смотрели как устроен $mol? Там и блочно-элементная метафора есть (только рекурсивная), и переопределения для разных платформ на уровне сборки, и бандлинг только нужного, и кастомизация виджетов под проект, и сами виджеты миниатюрные, и декларативная композиция, и реактивность, и построение онлайн редактора компонента по его коду, и использование любого приложения, как компонента, и ленивый рендеринг, и куча других плюшек. И результирующее приложение получается меньше, чем один только голый Реакт.


    1. Frozik
      05.02.2019 21:54
      +1

      Да, только стоимость разработки выше. Несколько лет показали, проект интересный, но не более.


      1. vintage
        05.02.2019 22:48

        Интересно, на чём основаны ваши выводы о стоимости разработки?
        И что конкретно устарело в readme?


        1. Frozik
          05.02.2019 23:11

          В $mol ничего, в bem-react-core пример, в котором бага, описанная в этом тикете — https://github.com/bem/bem-react/issues/380. А про стоимость внедрения $mol — не использует его никто.


          1. vintage
            06.02.2019 08:30
            -1

            А, так о том и речь, что движение бэма в сторону $mol снизил бы стоимость разработки. А с Реактом она только ещё больше повысится.


    1. vintage
      06.02.2019 21:20
      -1

      Так смотрели или нет? Вроде бы не сложный вопрос.


  1. Frozik
    05.02.2019 21:57

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


  1. kashey
    06.02.2019 08:02

    Много историй, много примеров того как что-то сделать неправильно, жирное многоточие в конце, без внятной ссылки на github.com/bem/bem-react и каких либо примеров ну теперь точно правильного подхода.

    Обожаю БЕМ с точки зрения CSS, что его js релизации всегда вызывали если не тошноту, то удивление.


    1. kashey
      06.02.2019 08:20
      +1

      Разберем код, который реализует уровни переопределения, в принципе основную задачу

      const cnApp = cn('App');
      const cnPage = cn('Page');
      const cnHeader = cn('Header');
      const cnFooter = cn('Footer');
      // ^ никто не гарантирует наличия этих ключей, и сигнатуры компонентов
      
      export const App: React.SFC = () => (
          <RegistryConsumer>
              {registries => {
                  // Get registry with components
                  const registry = registries[cnApp()];
                  // ^ а почему бы именно "нужный" регист и не возвращать?
                  
                  // Get the needed version of the component based on registry
                  const Header = registry.get(cnHeader());
                  const Footer = registry.get(cnFooter());
                  // ^ TypeScript как плакал, так и плачет.
      
                  return(
                      <div className={ cnPage() }>
                          <Header /> 
                          <Content />
                          <Footer />
                      </div>
                  );
              }}
          </RegistryConsumer>
      );
      

      Какие другие варианты?
      — использование (babel|nodejs requre hooks|webpack loader|webpack resolve) для ресолва «импортов» в нужную тенхлогию.
      — повесить хук на `React.createElement` который будет в начале смотреть входящий `type` в `registry`, и если он там есть — заменять на реальное значение что уйдет в реакт (так работает React-Hot-Loader)
      — Использовать Proxy|геттер на registry для доступа к нужным элементам. Или просто «конечную» версию регистра, от куда можно просто по ключу прочитать что надо.
      export const App: React.SFC = () => (
          <BlockConsumer>
              {({Header, Footer }) => ....
          </BlockConsumer>
      );
      
      Осталось за кажром - как работает code splitting/bundle picking. Иметь 100500 имортов в App@mobile.tsx, и далее по дереву. Хотя (я очень надеюсь) эти файла генерятся автоматически на основе структуры директорий (скрыто завесой тайны)


      1. verybigman Автор
        06.02.2019 14:29

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


        1. kashey
          06.02.2019 14:48

          Как проработавший в Яндексе 6 лет скажу просто — Яндекс совершенно не умеет опенсорс. Ни технически, ни (особенно) с точки документации или сообщества.

          Как результат слишком много технологий «заперто» в Яндексе, и особо во внешний мир не уходит. Скажу по честноку — это экономически не выгодно.
          Мне через месяц про БЕМ коллегам расказывать. Расказать то раскажу — а вот ссылки на репы будет давать немного стыдно.


          1. verybigman Автор
            06.02.2019 15:09

            Мало кто на российском рынке умеет в опенсорс хорошо. Главное, что мы не перестаем пытаться выносить полезное не только нам в компании, но и другим людям. Делится опытом, вот что главное, и то, что помогает развиваться. Я искренне надеюсь, что в опенсорс у нас это будет получаться всё лучше и лучше.


    1. verybigman Автор
      06.02.2019 14:07

      В статье около 5 ссылок на данный репозиторий. Я специально разделил статью на части, потому что практическая часть будет такой же объемной. Читать двойной объем за один раз не так удобно.


  1. softshape
    06.02.2019 19:16

    Не холивара ради, а любопытства из — почему не Angular & БЭМ или Vue.js & БЭМ? Т.е. почему именно Реакт?


    1. halfcupgreentea
      06.02.2019 19:53

      Скорее всего потому, что react это «просто» библиотека, которую можно использовать достаточно гибко вместе с BEM, в отличие от vue. Ну а angular это фреймворк, как он будет с сочетаться с другим фреймворком (BEM) — не очевидно.


  1. Vlad_IT
    06.02.2019 23:05

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


    Для чего делают ховеры на js?
    При ховере на кнопку "Сохранить на яндекс.диск", на неё вешается класс "button2_hovered_yes" и через него меняется бэкграунд на псевдоэлементе before, почему сделанно имеено так, а не на css?
    https://toster.ru/q/602071

    Правда, почему? Я не слышал о таких особенностях в БЭМ. Не могу найти причину, по которой так нужно было сделать. Очень интересно.


    1. kashey
      07.02.2019 00:21
      +1

      БЕМ _рекомендует_ использовать один уровень определения селекторов(специфичность), чтобы они друг с другом не дрались. Никаких каскадов.
      К сожалению как результат получается «вот это». К счастью это очень хорошо ложиться на модель работы React(точнее state), и дает возможность отобразить компонент в любом нужном состоянии(в стори буке).

      Можно пойти еще дальше, от БЕМ к BEViS, где у элемента вообще остается только __один__ класс — github.com/bevis-ui/docs/blob/master/faq/bem-vs-bevis.md. Зачем это нужно хорошо обьясняет вот этот слайд — bevis-ui.github.io/bevis-and-bt-speech/?full#41


      1. Vlad_IT
        07.02.2019 09:38

        БЕМ рекомендует использовать один уровень определения селекторов(специфичность), чтобы они друг с другом не дрались. Никаких каскадов.

        Но нет же принципиально разницы, между


        .block__element:hover


        и


        .block__element_hover


        только в первом случае, нам не нужно менять DOM, не нужно писать симуляцию hover на js, мы не теряем удобную штуку для отладки в devtools хрома "force element state". Свойства переопределять придется в обоих случаях.
        Я на той странице яндекса, нашел только одно частично оправданное использование такой штуки, где нужно было разделить ховер по родителю и ховер по блоку, чтобы они не срабатывали вместе.
        Еще там же, много использований обычного :hover.


        Не поймите неправильно, я не критикую, мне просто очень интересно разобраться. Спасибо за ответ!


    1. RubaXa
      07.02.2019 09:16

      Ответ достаточно прост, это по БЭМу и так работает i-bem, логика состояния блока описывается в JS, кроме того, доступность hover может зависеть от других состояний, например disabled и т.п. Поэтому чтоб не писать :not(.disabled):hover, :not(.bla-bla-bal):hover { ... } (ну или отменять эти стили), логику ховера проще завернуть в js.