Реактивный фронтенд. История о том, как мы снова всё переписали
Привет, это снова Катя из Яндекс.Денег. Продолжаю свою историю о том, как я перестала верстать и начала жить. В первой части я рассказала, как меня сюда занесло и чем занимаются наши фронтендеры. Сегодня — про фронтовый стек, откуда там React и куда делся БЭМ.
Погнали! Спойлер: БЭМ пока никуда не делся ¯\_(ツ)_/¯.
Много текста, картинок и кода, как обещала.
Внимание: высокая концентрация фронтенда.
Далекий 2016. Пытаюсь писать на React, выходит довольно сносно. Еще не подозреваю, что через год буду переводить на React целые сервисы. 2017 начинается в Яндекс.Деньгах, у меня начинается БЭМ головного мозга, и я все еще не подозреваю.
Для знакомства с проектом новый разработчик получает тестовое задание. Мне повезло: у меня это была задача из бэклога. И в первый же день я столкнулась с Node.js.
Задача приложения – оркестрация данных от Java-бэкенда для подготовки во view-ориентированном виде, а также серверный рендеринг и роутинг. Фронтендер в Яндекс.Деньгах отвечает не только за клиентскую часть, но и за серверную прослойку в виде Node.js-приложения. В качестве серверного фреймворка мы используем Express, а для разработки внутренних приложений без завязки на legacy решили использовать Koa2. Сказали бы мне такое пару лет назад, я бы ничего не поняла, а все довольно просто: когда на сервер приходит запрос из браузера, Node.js формирует HTTP-запросы на бэкенд, получает необходимые данные и шаблонизирует веб-странички. Но мы не раскатываем на внешних пользователей код на Koa2: у фреймворка нет достаточной поддержки, зато есть открытые уязвимости. Разработчики полюбили дизайн фреймворка, и мы решили не даунгрейдиться до Express, так Koa2 остался в стеке.
Node.js 8 стала LTS и уже работает на наших production-серверах. Мы уже писали про место Node.js в нашем фронтенде, но с тех пор кое-что изменилось. Также мы хотим отказаться от Nginx-серверов, которые поднимаем на каждом хосте для раздачи статики — их заменят отдельные сервера с Nginx, а когда-нибудь и CDN.
Еще мы используем локальный реестр пакетов и благодаря этому не ходим во внешнюю сеть — это ускоряет сборку и повышает безопасность всей системы. Чтобы шарить код между проектами, но не выкладывать его в открытый доступ, используем целый набор инструментов: храним модули в Bitbucket и собираем их в Jenkins. Любите своих бэкендеров 😉 Такой подход нам подсказали джависты, они классные.
Он помог с кластеризацией, а еще избавил нас от одного старого bash-скрипта, который запускал приложения. А еще мы провели эксперимент — внедрили в одно из приложений диспетчер процессов, который упростил администрирование сервисов на Node.js.
У нас во фронтенде везде javascript. И на сервере, и на клиенте, и под капотом внутренних инструментов. Мы знаем и другие языки, но javascript отлично со всем справляется.
А вот БЭМ в рамках наших задач справляется не со всем.
Что такое БЭМ
БЭМ – подход к веб-разработке, придуманный Яндексом во времена статических HTML-страниц и CSS-каскадов длиною в жизнь. Никакого компонентного подхода еще не было, а поддерживать единообразие множества сервисов было нужно. Яндекс не растерялся и разработал свой собственный компонентный подход, который сегодня позволяет создавать изолированные компоненты и писать гибкий декларативный код.
Часть из них заточена под специфику БЭМ, а некоторые вполне могут использоваться в отрыве от БЭМ-архитектуры. БЭМ не только методология, но и большой набор технологий и библиотек. Если вам в проекте понадобится мощный шаблонизатор или достойный пример компонентной абстракции над DOM, вы знаете, где их можно найти 😉
Поэтому мы начали переводить сервисы на React. Некоторые из них уже живут в двух приложениях, построенных на разных стеках:
– характерная для Яндекса БЭМ платформа;
– молодая и модная экосистема React.
Пришло время рассказать, за что я полюбила БЭМ.
Уровни переопределения
Уровни, уровни, уровни… БЭМ! Профит!
Уровни переопределения – одна из основных фишек БЭМ методологии. Чтобы понять, как они работают, посмотрим на картинку:
Каждый слой изменяет конечную картинку, но не изменяет другие слои. Картинка формируется наложением слоев. Слой можно легко выдернуть или добавить поверх, и картинка изменится.
Уровни переопределения делают то же самое с кодом:
Чтобы добавить дополнительное поведение достаточно подключить в сборку нужный уровень. Поведение компонента формируется при сборке кода. При этом исходный код не меняется, а мы получаем разное поведение, сочетая разные уровни. Код модуля с разных уровней как бы наслаивается друг на друга.
Какие бывают уровни
На картинке выше несколько уровней переопределения:
- Базовый уровень – библиотека – поставляет исходный модуль кода;
- Следующий уровень – проект – модифицирует этот модуль под нужды проекта;
- Уровень выше – платформа – делает тот же модуль специфичным для разных устройств;
- Вишенка на торте – уровень экспериментов – изменяет модуль для проведения A/B тестирования.
Уровень платформы позволяет использовать разную сборку для разных устройств. Уровень проекта не зависит от уровня библиотеки, поэтому библиотеку легко обновлять. А уровень с экспериментом подключается для тестирования на пользователях и также легко выключается, когда получены результаты.
Разработчик сам решает, какие уровни ему нужны: можно создать уровень с темой оформления или уровень с тем же кодом на другом фреймворке.
Уровни позволяют писать сложные модули на основе простых, легко комбинировать поведение и шарить один и тот же код между сервисами. А собирает этот код ENB – Webpack в мире БЭМ.
Мы расширяем эти компоненты в рамках новых библиотек и шарим их между проектами. При знакомстве с БЭМ меня особенно порадовали UI-библиотеки, в которых лежат уже готовые компоненты. Это здорово облегчает жизнь: я редко верстаю, не пишу однотипного JS и быстро собираю интерфейсы из готовых блоков.
Теперь подробнее рассмотрим инструменты БЭМ платформы, чтобы понять, с чем БЭМ справляется недостаточно хорошо и почему он не подошел для решения наших задач.
BEM-XJST
Начну с любимого – шаблонизатора bem-xjst. До Яндекс.Денег я использовала Jade, и Bem-xjst отлично проиллюстрировал минусы Jade, которых я тогда не видела. Шаблоны bem-xjst декларативные [1], в них нет if hell [2], и они отлично соответствуют требованиям компонентного подхода [3]. Все это хорошо видно на примере:
В песочнице можно посмотреть результат шаблонизации и поиграться с ним.
Как это работает. Внутри – секрет идеальной архитектуры 😉
- пишем BEMJSON. BEMJSON это JSON, описывающий БЭМ-дерево. БЭМ-дерево это представление DOM-дерева в виде независимых компонент;
- bem-xjst принимает на вход BEMJSON и применяет шаблоны. Этот процесс можно сравнить с рендерингом в браузере. Браузер обходит DOM-дерево и постепенно применяет CSS правила к его DOM-узлам: размер, цвет текста, отступы. Так же bem-xjst обходит BEMJSON, ищет соответствующие его узлам шаблоны и постепенно применяет их: тег, атрибуты, содержимое. «Применить шаблон» значит сгенерировать из него HTML-строку. Генерацией HTML из BEMJSON занимается один из движков шаблонизатора – BEMHTML.
Самое сложное – выделить сущность. Писать шаблоны просто: выделяем сущность и пишем функции, которые шаблонизатор вызовет для рендера частей HTML-строки. Правильные сущности – залог хорошей архитектуры!
Он использует принципы из XSLT и потому такой декларативный. Чем длиннее ваша борода, тем выше шанс, что вы уже заметили отсылку в названии шаблонизатора: XSLT (eXtensible Stylesheet Language Transformations) => XJST (eXtensible JavaScript Transformations). Если вы не знаете, что такое XSLT, считайте, что вам повезло 🙂
Bem-xjst изоморфный. Мы рендерим HTML страницы на сервере и динамически меняем его на клиенте. Для шаблонизации в рантайме bem-xjst предоставляет API, которым мы пользуемся при написании клиентского javascript-кода.
I-BEM
С помощью bem-xjst мы описываем представление, а логику – с помощью i-bem. I-bem – абстракция над DOM, которая предоставляет высокоуровневый API для работы с компонентами. Проще говоря, позволяет писать это:
вместо этого:
Мы оперируем сущностями, которые описали в шаблоне: все равно, будет это jQuery-селектор или DOM-элемент. Чтобы писать код, не нужно знать про внутреннюю реализацию компонента. Там же описана низкоуровневая логика, а значит мы не нагружаем код с основной логикой лишними проверками. Мы можем создавать кастомные события, подходящие для конкретной объектной модели, а работа с нативными событиями и интерфейсами будет скрыта во внутренней реализации. В итоге, код легко читается и не зависит от конкретной технологии.
Это и есть декларативный javascript. I-bem позволяет описывать логику работы компонента как набор состояний [1]. В i-bem реализован собственный Event Emitter: при изменении состояний компоненты автоматически генерируют события, на которые может подписаться другая компонента [2].
Так выглядит большая часть клиентского javascript-кода на БЭМ:
Как это работает
- по событию domReady i-bem находит в DOM-дереве компоненты (блоки) и инициализирует их – создает в памяти браузера js-объект, соответствующий блоку;
- при наступлении нужных событий мы устанавливаем блоку маркеры, отражающие состояние. Роль маркеров выполняют CSS-классы. Например, при клике на input, мы добавляем ему класс «input_focused», который служит маркером;
- при выставлении таких маркеров i-bem запускает коллбэки, заданные в javascript-реализации блока.
Писать логику просто: нужно описать возможные состояния блока (те самые маркеры) и задать обработчики изменения этих состояний (те самые коллбэки).
С i-bem мы без труда переопределяем поведение компонент, создаем стройный API их взаимодействия и динамически изменяем их в рантайме. Так чего же не хватает?
Мы любим БЭМ за декларативность, легкую масштабируемость и высокоуровневые абстракции, но не готовы больше мириться с его ограничениями. Ниже мы рассмотрим проблему клиентского рендеринга, хранения данных и другие ограничения БЭМ-платформы. Со временем эти проблемы, возможно, будут решены БЭМ-контрибьюторами, но мы не готовы ждать.
Поэтому мы решили перейти на собственный стек. Современный веб c SPA и адаптивностью под мобильные устройства требует адаптивности и от нас. И выбрали React.
React принес в нашу жизнь virtual DOM, hot reload, CSS in JS и большое комьюнити, частью которого мы стали.
Мы знакомимся с новыми подходами и инструментами и улучшаем архитектуру наших приложений. Миграция наших сервисов на React идет полным ходом, часть приложений уже полностью или частично переписаны на React.
Библиотеки
Разбиение интерфейсных сущностей на независимые БЭМ-блоки отлично дружит с компонентным подходом React. Разработчики Яндекса написали bem-react-core и перевели на React базовую UI-библиотеку компонент. Мы написали над ней библиотеку-адаптер, которая учитывает специфику этих компонент и поставляет их в качестве HOC:
Такие библиотеки подключаются не в приложении, а в основной библиотеке компонент:
Приложение зависит только от основной библиотеки и получает все компоненты из нее:
Это сокращает количество зависимостей приложения, и библиотеки не попадают в бандл дважды под разными версиями.
Технологии
React не завязан на конкретные технологии и мы сами выбираем инструменты и библиотеки. В моем вооружении есть axios, redux, redux form, redux thunk, styled-components, TypeScript, flow, jest и другие классные штуки. Чтобы не допускать зоопарка, мы согласовываем использование новых технологий с другими разработчиками – отправляем в специальный репозиторий pull-реквест с анализом того, чем полезна технология и почему выбрали именно ее.
Заходит фронтендер в бар, а бармен ему и говорит
Для приложений на React мы создаем платформу, которая объединит библиотеки и процессы для их создания и поддержки. Сердце этой платформы – консольная утилита Frontend Bar. Bar умеет готовить много вкусных штук.
В меню:
- Конфиг со льдом: bar смешает и встряхнет ваши yml переменные и приготовит шаблон конфига для ansible.
- Сок с ароматом конфигураторов: bar создаст новое приложение на основе модульной заготовки – Juice.
- Сет базовых настроек библиотеки. Ожидается в скором времени.
Создать сочное приложение теперь легко – «frontend-bar make juice». Make juice, not war! Когда Bar разворачивает новое приложение, он выполняет набор конфигураций из Juice: генерируется package.json, .babelrc, код ключевых мидлвар и роутов, код корневой компоненты. Frontend Bar облегчит выделение новых микросервисов и поможет соблюдать единые правила написания кода.
Сегодня мы решаем, какой будет новая серверная архитектура. При переходе на новый стек мы начали улучшать серверную архитектуру приложений – написали новый логгер для клиента и библиотеку с набором абстракций для реализации MVC.
Спойлер: выберем луковую.
Динамические интерфейсы
Было
Выше я писала, что bem-xjst предоставляет API для шаблонизации в рантайме. I-bem, в свою очередь, умеет работать с DOM-деревом. Подружим их и сможем динамически генерировать и изменять HTML. Попробуем изменить кнопку по событию:
Она добавляет блоку класс, но не применяет шаблон [1]. В этом примере видно слабую сторону БЭМ: i-bem не хочет дружить с bem-xjst и не хочет ничего знать про шаблоны. Мы перерендериваем компонент вручную [2]:
- описываем новый кусочек БЭМ-дерева [3];
- затем применяем новый шаблон [4];
- и инициализируем еще один компонент на текущей DOM-ноде [5].
Кроме того, i-bem не создает diff БЭМ-деревьев, поэтому происходит перерендер всей компоненты, а не изменившихся частей. Рассмотрим простой пример: перерендерить содержимое модального окошка по требованию. Оно состоит из трех элементов:
Для простоты будем считать, что может измениться только один элемент.
Но i-bem не станет разбираться, что изменилось, полностью перерендерит весь компонент и тоже расслабится. Хочется сделать [1] и расслабиться. Это ухудшает производительность и вызывает неприятные сайд-эффекты: где-то мелькает инпут, где-то зависает бесхозный тултип. В этом-то примере серьезных последствий не будет, но что, если вот так неаккуратно перерендеривать целые формы? Это усложняет разработку, и мы опять грустим. Мы из-за этого грустим и вручную управляем частями компоненты, чтобы сделать точечный перерендер [2].
Стало
React пришел и все разрулил. Он сам отслеживает состояние компонент, мы больше не управляем рендерингом вручную и не задумываемся о взаимодействии с DOM. React содержит имплементацию virtual DOM. При вызове React.createElement создается js-объект DOM-узла с его свойствами и наследниками – virtual DOM этого компонента, который сохраняется внутри React. При изменении компонента React вычисляет новый virtual DOM, а затем diff сохраненного и нового, и обновляет только ту часть DOM, которая изменилась. Все летает, а нам остается лишь оптимизировать сложную логику, используя shouldComponentUpdate. Это успех!
Хранение данных
Было
В БЭМ мы готовим все данные на сервере и передаем их в компоненты страницы:
Мы не сможем получить их на клиенте, поэтому каждый компонент заранее принимает набор данных, которые нужны для всех возможных сценариев его работы. Компоненты изолированы и не будут делиться данными друг с другом, а значит, в разные компоненты придется прокидывать одни и те же данные [1]. Это значит, что мы нагружаем компонент данными, которые могут ему не понадобиться [2].
Поэтому мы написали bem-redux, который адаптирует Redux для БЭМ. Иногда нас выручает глобальная сущность, в которой хранится часть общих данных, но глобальное хранение переменных плохо вписывается в концепцию БЭМ. Он отлично управляет нашими данными в рамках простых интерфейсов, но при разработке сложной компоненты мы упираемся в проблему рендеринга, которую я описала выше. Redux – стейт-менеджер, управляющий потоком данных. Redux не дружит с i-bem, мы фиксим баги и грустим.
Стало
Redux + React = <3
Redux хранит данные для всего приложения в одном месте [1]:
Компонента сама решает, когда и какие данные ей нужны [2]:
Нам нужно только описать сценарии работы компоненты [3] и указать, откуда брать данные для его выполнения [4]:
А React сделает все остальное [5]:
Это успех! Такой подход позволяет следовать принципу единой ответственности и инкапсулировать логику компоненты в самой компоненте, а не размазывать ее в коде страницы.
За все приходится платить
За успех мы расплатились нехилым количеством legacy на React. Больно видеть, как твой код, написанный всего пару месяцев назад, плавно превращается в deprecated.
Ты можешь выбрать все инструменты, но тебе придется выбрать все инструменты. Дело в том, что React это view-layer библиотека, а не полноценный фреймворк. Мы пишем собственные валидаторы для redux-форм и до сих пор не научились работать со сложными анимациями. А также самому организовать код, сформировать подходы к решению типичных задач, выработать набор соглашений и написать недостающие плагины. А переписываем не всегда, отчего растет наш бэклог. И мы пробуем и выкидываем, пишем и переписываем.
И пока мы учились его готовить, перепачкали всю свою кухню и сами замарались по локоть. React достаточно молод и не готов к enterprise-разработке, в отличие от БЭМ. Пишем как придется и ездим на конференции, чтобы узнать, как надо. И мы по-прежнему ведем споры о том, нужен нам flow или все-таки нет, и все еще не до конца понимаем, что хранить в сторе, а что в локальном стейте. Бьем шишки, но уверенно движемся вперед.
Новый стек позволил по-новому взглянуть на ряд задач и предоставил простые пути их решения.
CSS in JS
Было
Рассмотрим простой кейс из жизни: раскрасить и анимировать иконку по событию, примерно так:
Кода всего ничего:
Правда, по правилам БЭМ, придется разнести его аж по трем директориям:
Спорный вопрос. Оверхэд? Обычная ситуация, но чем кастомнее или сложнее интерфейс, тем чаще придется добавлять и удалять классы. Важнее то, что в js мы добавляем эти классы вручную при наступлении нужных событий. Не совсем та логика, которую хочется видеть в js-коде: А если изменить нужно не только иконку, но и текст?
Тогда мы перепишем СSS-анимацию на jQuery и немного погрустим. А что, если длительность анимации от чего-то зависит и устанавливается динамически?
Стало
Styled-components, я люблю тебя! СSS in JS – one love! Мой внутренний верстальщик ликует:
Приятный бонус нового стека. Модульность сохранилась, CSS-анимация работает и никакой ручной работы с классами.
Типизация
Было
Раньше мы писали тонны jsDoc. Давайте посмотрим, полезен ли он:
Что содержит state? Этот пример взят из продакшен кода. Да, есть readme, но увы, он немного устарел. Понятия не имею. Придется углубиться в код. Да, нам стыдно, но с документацией и комментариями так бывает часто, они ненадежны. Спешим, не углубляемся, ломаем и грустим. Или не углубляться и нечаянно все сломать.
Стало
На помощь пришла типизация. «Тык» на тип, и вся подноготная метода перед глазами. Лень разбираться? Precommit checker запустит flow, и разобраться все равно придется.
Сроки горят, менеджер пингует, а у тебя и там «cannot get property», и тут «property is missing». Я невзлюбила flow с первого взгляда. Примерно вот так: Но недавно мне рассказали, что типами можно проектировать О_о Как проектировать типами?
Flow перестал быть кошмаром. Мой мир перевернулся. Надежный код – приятный бонус! Описывать API модулей типами до написания кода оказалось удобно и полезно.
Нет. БЭМ жив, а мы продолжаем поддерживать приложения на БЭМ-стеке. Со временем и они переедут на React, а пока мы готовим для этого почву: переводим библиотеки компонент, формируем набор инструментов и соглашений, учимся планировать сроки миграции.
Мы готовим письма на сервере, и описанные выше ограничения БЭМ-платформы не затрагивают это приложение. На БЭМ реализован наш шаблонизатор e-mail рассылки. Использование БЭМ для его разработки – уместное элегантное решение.
И даже если мы перестанем писать на БЭМ, он все равно найдет нас 🙂 К тому же наши дизайнеры прототипируют с помощью БЭМ и иногда приносят нам сверстанные компоненты вместо макетов.
Я участвовала в переводе одного из приложений с БЭМ на React и уяснила важную вещь.
Я не воспринимала всерьез фронтенд-сообщество и его изменчивый мир. До прихода в Яндекс.Деньги я была простым верстальщиком и потратила не один год, верстая тонны HTML и JSX. Не понимала, зачем менять jQuery. Не понимала, зачем изучать первый Angular, чтобы завтра забыть о нем и изучать второй. Ajax на Fetch, чтобы заменить потом Fetch на Axios.
Приходится анализировать и улучшать архитектуру приложения, выпрямлять логику, рефакторить. Оказалось, что когда переводишь проект с одного фреймворка на другой, ты не просто переносишь код. Динамично развивающаяся область как ничто другое способствует развитию твоего продукта и твоему профессиональному развитию соответственно. А постоянная смена инструментов – это не попытка прокатиться на волне хайпа, а постоянный поиск лучшего решения, которое отвечает требованиям времени. Давайте биться за него вместе! И фронтенд именно такая область.
Всем React!