Mrr — функционально-реактивная библиотека для React'а (извиняюсь за мнимую тавтологию).
При слове «реактивность» обычно вспоминают Rx.js, как эталонный образец FRP. Однако серия последних статей на эту тему на Хабре([1], [2], [3]) показала громоздкость решений на Rx, которые на несложных примерах проигрывали в ясности и простоте почти любому другому подходу. Rx велик и могуч, и прекрасно подходит для решения проблем, в которых абстракция потока напрашивается сама собой (на практике это преимущественно координация асинхронных задач). Но стали бы вы писать, к примеру, простую синхронную валидацию формы на Rx? Сэкономил бы он ваше время, по сравнению с обычными императивными подходами?
mrr — это попытка доказать, что ФРП может быть удобным и эффективным решением не только в специфических «потоковых» проблемах, но и в самых обычных рутинных задачах фронтенда.
Реактивное программирование — очень мощная абстракция, на данный момент на фронтенде она присутствует в двух ипостасях:
Mrr совмещает плюсы этих подходов. В отличии от Rх.js, mrr имеет краткий API, который пользователь может расширять своими дополнениями. Вместо десятков методов и операторов — четыре базовых оператора, вместо Observable (горячих и холодных), Subject и т.д. — одна абстракция: поток. Также в mrr отсутствуют некоторые сложные концепции, которые могут существенно усложнить читабельность кода, например, метапотоки.
Однако mrr — это не «упрощенный Rx на новый лад». Отталкиваясь от тех же базовых принципов, что и Rx, mrr претендует на бóльшую нишу: управление глобальным и локальным (на уровне компонента) состоянием приложения. Хотя изначально концепция реактивного программирования предназначалась для работы с асинхронными задачами, mrr с успехом использует подходы реактивности и для обычных, синхронных задач. Это и есть принцип «тотального ФРП».
Часто при создании приложения на Реакте используется несколько разнородных технологий: recompose (или в скором времени — хуки) для состояния компонента, Redux/mobx для глобального состояния, Rx посредством redux-observable (или thunk/saga) для управления сайд-эффектами и координации асинхронных задач в Редаксе. Вместо такого «салата» из разных подходов и технологий внутри одного приложения, с mrr вы можете использовать единую технологию и парадигму.
Интерфейс mrr также существенно отличается от Rx и подобных библиотек — он более декларативен. Благодаря абстракции реактивности и декларативному подходу, mrr позволяет писать выразительный и краткий код. К примеру, стандартное TodoMVC на mrr занимает менее 50 строк кода(не считая JSX шаблона).
Но довольно рекламы. Получилось ли совместить преимущества «легкого» и «тяжелого» RP в одном флаконе — судить вам, но сначала прошу ознакомиться с примерами кода.
TodoMVC уже изрядно набил оскомину, а пример с загрузкой данных о пользователях Github слишком примитивен, чтобы на нем можно было прочувствовать особенности библиотеки. Мы будем рассматривать mrr на примере условного приложения для покупки ж/д билетов. В нашем UI будут поля для выбора начальной и конечной станций, даты. Затем, после отправки данных, будет возвращен список доступных поездов и мест в них. Выбрав конкретный поезд и тип вагона, пользователь введет данные пассажиров, и затем добавит билеты в корзину. Поехали.
Нам нужна форма с выбором станций и даты:
![](https://habrastorage.org/webt/sm/he/aj/smheajucvbemykx4g-fvvqeygrc.gif)
Создадим поля с автодополнением для ввода станций.
mrr-компоненты создаются с помощью функции withMrr, которая принимает схему реактивных связей (описание потоков) и render-функцию. Render-функции передаются props компонента, а также state, которым теперь полностью управляет mrr. В нем и будут находится начальные(блок $init) и вычисляемые по формулам значения реактивных ячеек.
Сейчас у нас есть две ячейки (либо два потока, что то же самое): stationFromInput, значения в которую попадают из пользовательского ввода с помощью хелпера $ (передающего по умолчанию event.target.value для элементов ввода данных), и производная от нее ячейка stationFromOptions, содержащая массив подходящих по названию станций.
Значение stationFromOptions автоматически вычисляется каждый раз при изменении родительской ячейки с помощью функции (в терминологии mrr называемой "формула" — по аналогии с формулами Экселя). Синтаксис выражений mrr прост: на первом месте идет функция (либо оператор), по которой высчитывается значение ячейки, затем идет список ячеек, от которых данная ячейка зависит: их значения передаются в функцию. Такой странноватый, на первый взгляд, синтаксис имеет много преимуществ, которые мы позже рассмотрим. Пока что логика mrr тут напоминает обычный подход с computable variables, используемый в Vue, Svelte и других библиотеках, с той лишь разницей, что вы можете использовать чистые функции.
Реализуем подстановку выбранной из списка станции в поле ввода. Также необходимо скрывать список станций после того как юзер кликнет по одной из них.
Обработчик событий, созданный с помощью хелпера $, в списке станций будет эмитировать фиксированные для кажной опции значения.
mrr последователен в своем декларативном подходе, чуждом любым мутациям. Мы не можем после выбора станции «принудительно» изменить значение ячейки. Вместо этого мы создаем новую ячейку stationFrom, которая, с помощью оператора объединения потоков merge (приблизительный аналог на Rx — combineLatest), будет собирать значения двух потоков: пользовательского ввода (stationFromInput) и выбора станции (selectStationFrom).
Мы должны показывать список опций после того, как пользователь что-то вводит, и скрывать после того, как он выбрал одну из опций. За видимость списка опций будет отвечать ячейка optionsShown, которая будет принимать булевы значения в зависимости от изменения других ячеек. Это очень распостраненный паттерн, для которого существует синтаксический сахар — оператор toggle. Он устанавливает значение ячейки в true при любом изменении первого аргумента (потока), и в false — второго.
Добавим кнопку для очистки введенного текста.
Теперь наша ячейка stationFrom, отвечающая за содержимое текста в поле ввода, собирает свои значения не с двух, а с трех потоков. Этот код можно упростить. Конструкция mrr вида [*формула*, *… ячейки-аргументы*] аналогична S-выражениям в Lisp'е, и как и в Лиспе, вы можете произвольно вкладывать такие конструкции друг в друга.
Давайте избавимся от малополезной ячейки clearVal и сократим код:
Программы, написанные в императивном стиле, можна сравнить с плохо организованным коллективом, где все постоянно что-то приказывают друг другу (намекаю на вызовы методов и мутирующие изменения), при чем как руководители подчиненным, так и наоборот. Декларативные же программы похожи на противоположную утопическую картину: коллектив, где каждый четко знает, как он должен действовать в любой ситуации. В таком коллективе нет нужды в приказах, все просто находятся на своих местах и работают, реагируя на происходящее.
Вместо того, чтобы описывать всевозможные последствия какого-то события (читай — делать те или иные мутации), мы описываем все случаи, при которых данное событие может наступить, т.е. какие значение примет ячейка при тех или иных изменениях других ячеек. В нашем небольшом пока примере мы описали ячейку stationFrom и три ситуации, которые влияют на ее значение. Для привыкшего к императивному коду программиста такой подход может показаться непривычным (или даже «костылем», «извращением»). На самом деле он позволяет экономить усилия за счет краткости (и стабильности) кода, в чем мы убедимся на практике.
А что насчет асинхронности? Можно ли подтягивать список предложенных станций ajax'ом? Без проблем! В сущности, для mrr все равно, вернет ли функция значение или промис. При возврате промиса mrr дождется его resolv'а и «протолкнет» полученные данные в поток.
Это также означает, что вы можете использовать асинхронные функции в качестве формул. Более сложные случаи (обработка ошибок, статус промиса) мы рассмотрим позже.
Функционал для выбора станции отправления готов. Дублировать то же самое для станции прибытия нет смысла, стоит вынести это в отдельный компонент, который можно повторно использовать. Это будет обобщенный компонент инпута с автодополнением, поэтому мы переименуем поля и сделаем, чтобы функция получения подходящих вариантов задавалась в props'ах.
Как видите, вы можете задать структуру ячеек mrr как функцию от props компонента (однако выполнится она лишь один раз — при инициализации, и не будет реагировать на изменение props).
Теперь подключим этот компонент в родительском компоненте и посмотрим, как mrr позволяет родственным компонентам обмениваться данными.
Чтобы связать родительский компонент с дочерним, мы должны передать ему параметры с помощью функции connectAs (четвёртый аргумент render-функции). При этом мы указываем имя, которое хотим дать дочернему компоненту. Присоединив таким образом компонент, по этому имени мы можем обращаться к его ячейкам. В данном случае, мы слушаем ячейки val. Возможно и обратное — слушать из дочернего компонента ячейки родительского.
Как видите, и здесь mrr следует декларативному подходу: не нужно никаких onChange колбэков, нам достаточно указать имя для дочернего компонента в функции connectAs, после чего мы получаем доступ к его ячейкам! При этом, опять же вследствии декларативности, угрозы вмешательства в работу другого компонента нет — мы не имеем возможности ничего в нем «изменить», мутировать, можно лишь «слушать» данные.
Следующий этап — поиск подходящих поездов по выбранным параметрам. В императивном подходе мы бы наверняка написали некий обрабочик на отправку формы onSubmit, который бы инициировал дальнейшие действия — ajax запрос и отображение полученных результатов. Но, как вы помните, нам нельзя ничего «приказывать»! Мы можем только создать еще набор ячеек, производных от ячеек формы. Напишем еще один запрос.
Однако, такой код не будет работать как ожидалось. Калькуляция (пересчет значения ячейки) запускается при изменении любого из аргументов, поэтому запрос будет отправляться, например, сразу после выбора первой станции, а не только по клику на «Поиск». Нам нужно, с одной стороны, чтобы станции и дата передавались в аргументы формулы, но с другой стороны, не реагировать на их изменение. В mrr для этого существует элегантный механизм, называемый «пассивное слушание».
Просто добавляем минус перед именем ячейки, и вуаля! Теперь results будет реагировать только на изменение ячейки searchTrains.
В этом случае ячейка searchTrains выступает как «ячейка-сигнал», а ячейки stationFrom и др. — как «ячейки-значения». Для ячейки-сигнала существенным является только момент, когда по ней «протекает» значние, при этом, какие именно данные это будут — все равно: это может быть просто true, «1» или что угодно (в нашем случае это будут объекты DOM Event). Для ячейки-значения важным есть именно ее значение, но при этом моменты ее изменения несущественны. Эти два типа ячеек не взаимоисключающи: многи ячейки являются и сигналами, и значениями. На уровне синтаксиса в mrr эти два вида ячеек никак не различаются, но само концептуальное понимание такого различия очень важно при написании реактивного кода.
Запрос на поиск мест в поезде может занять некоторое время, поэтому мы должны показывать лоадер, а также реагировать в случае ошибки. Для этого дефолтного подхода с автоматическим резолвингом промисов уже мало.
Оператор nested позвозяет «раскладывать» данные по подъячейкам, для этого первым аргументом в формулу передается callback, с помощью которого можно «протолкнуть» данные в подъячейку (одну или несколько). Теперь у нас есть отдельные потоки, которые отвечают за ошибку, статус промиса и за полученные данные. Оператор nested — очень мощный инструмент и один из немногих императивных в mrr (мы сами указываем, в какие ячейки класть данные). В то время как оператор merge объединяет несколько потоков в один, nested расщепляет поток на несколько подпотоков, таким образом являясь его противоположностью.
Приведенный пример является стандартным способом работы с промисами, в mrr он обобщен в виде оператора promise и позволяет сократить код:
Также оператор promise следит за тем, чтобы использовались результаты только самого последнего промиса.
![](https://habrastorage.org/webt/in/ew/2d/inew2diajzwvzh6lzcie3saledw.gif)
Компонент для отображения наличных мест (откажемся для простоты от разных типов вагонов)
Чтобы обращаться к props в формуле, можно подписаться на специальную ячейку $props.
Мы опять используем пассивное слушание чтобы подхватить количество выбранных мест при нажатии на кнопку «Выбрать». Каждый дочерний компонент мы связываем с родительским с помощью функции connectAs. Пользователь может выбрать места в любом из предложенных поездов, поэтому мы слушаем изменения во всех дочерних компонентах с помощью маски "*".
Но вот незадача: пользователь может добавить места сначала в одном поезде, потом в другом, так что новые данные перетрут предыдущие. Как «аккумулировать» данные потока? Для этого существует оператор closure, который вместе с nested и funnel составляет основу mrr (все остальные — не более чем синтаксический сахар на основе этих трех).
При использовании closure сначала (на componentDidMount) создается замыкание, которое возвращает формулу. Она таким образом имеет доступ к переменным замыкания. Это позволяет сохранять данные между вызовами безопасным способом — не скатываясь в пучину глобальных переменных и shared mutable state. Таким образом, closure позволяет реализовать функциональность таких операторов Rx, как scan и прочие. Однако этот способ хорош для сложных случаев. Если же нам нужно только сохранять значение одной переменной, мы можем просто использовать ссылку на предыдущее значение ячейки с помощью специального имени "^":
Теперь пользователь должен ввести имя и фамилию для каждого выбранного билета.
Ячейка selectedSeats содержит массив выбранных мест. По мере того, как пользователь вводит имя и фамилию к каждому билету, мы должны изменять данные в соотвествующих элементах массива.
Стандартный подход нам не подойдет: в формуле мы должны знать, какая именно ячейка изменилась и реагировать соответствующе. Нам поможет одна из форм оператора merge.
Это немного напоминает редьюсеры Redux'а, но с более гибким и мощным синтаксисом. И можно не бояться мутировать массив, ведь контроль над ним имеет только формула одной ячейки, соответственно параллельные изменения исключены (а вот мутировать массивы, которые передаются в качестве аргументов, конечно же не стоит).
Паттерн, когда ячейка хранит в себе и изменят массив, очень распостранен. Все операции с массивом при этом бывают трех типов: вставка, изменение, удаление. Чтобы описать это, существует элегантный оператор "coll". Используем его чтобы упростить вычисление selectedSeats.
Было:
стало:
при этом формат данных в потоке setDetails нужно немного изменить:
Используя оператор coll, мы описываем три потока, которые будут влиять на наш массив. При этом поток create должен содержать сами элементы, которые должны быть добавлены в массив (обычно — объекты). Поток delete принимает либо индексы элементов, которые нужно удалить (как в '*/removeSeat'), так и маски. Маска {} удалит все элементы, а, к примеру, маска { name: 'Carl' } удалила бы все элементы с именем Carl. Поток update принимает пары значений: изменение, которое нужно сделать с элементом (маска либо функция), и индекс или маску элементов, которые нужно изменить. Например, [{ surname: 'Johnson' }, {}] установит фамилию Johnson всем элементам массива.
Оператор coll использует что-то вроде внутреннего языка запросов, позволяя упростить работу с коллекциями и сделать ее более декларативной.
Полный код нашего приложения на JsFiddle.
Мы ознакомились практически со всем необходимым базовым функционалом mrr. Довольно существенная тема, которая осталась за бортом — глобальное управление состоянием, возможно она будет рассмотрена в следующих статьях. Но уже сейчас вы можете начать использовать mrr для управления состоянием внутри компонента или группы родственных компонентов.
mrr позволяет писать приложения на React в функционально-реактивном стиле (mrr можно расшифровать как Make React Reactive). mrr очень выразителен — вы тратите меньше времени на написание строчек кода.
mrr предоставляет небольшой набор базовых абстракций, которого достаточно для всех случаев — в этой статье описаны практически все основные фичи и приемы mrr. Также есть инструменты для расширения этого базового набора (возможность создавать кастомные операторы). Вы сможете писать красивый декларативный код, не читая сотни страниц мануала и даже не изучая теоретические глубины функционального программирования — врядли вам придется использовать, скажем, монады, т.к. mrr сам представляет собой гигантскую монаду, отделяющую чистые вычисления от мутаций состояния.
В то время как в других библиотеках зачастую соседствуют разнородные подходы (императивный с помощью методов и декларативный с помощью реактивных биндингов), из которых программист произвольно смешивает «салат», в mrr существует единая базовая сущность — поток, что способствует гомогенности и единообразию кода. Комфорт, удобство, простота, экономия времени программиста — основные преимущества mrr (отсюда еще одна расшифровка mrr как «мр-р-р», то есть мурчание удовлетворенного жизнью кота).
Программирование «строками» имеет как свои преимущества, так и недостатки. У вас не будет работать автокомплит имени ячейки, а также поиск места, где она определена. С другой стороны, в mrr всегда есть одно и только одно место, где определяется поведение ячейки, и его несложно найти простым текстовым поиском, в то время как поиск места, где определяется значение поля Redux стора, или тем более поля state при использовании нативного setState, может быть более долгим.
В первую очередь, адептам функционального программирования — людям, для которых преимущество декларативного подхода очевидно. Конечно, уже существуют кошерные решения на ClojureScript, но все же они остаются нишевым продуктом, в то время как React правит бал. Если в вашем проекте уже используется Redux, вы можете начать использовать mrr для управления локальным состоянием, и в перспективе перейти к глобальному. Даже если вы не планируете использование новых технологий в данный момент, вы можете разобраться с mrr чтобы «размять мозг», взглянув на привычные задачи в новом свете, ведь mrr существенно отличается от распостраненных библиотек управления состоянием.
В принципе, да :) Библиотека молодая, пока что активно использовалась на нескольких проектах, но API базового функционала уже устоялось, сейчас работа ведется преимущественно над разными примочками (синтаксический сахар), призванными еще больше ускорить и облегчить разработку. К слову, в самих принципах mrr нет ничего специфично React'овского, его возможно адаптировать для использования с любой компонетной библиотекой (React был выбран в силу отсутствия у него встроенной реактивности или общепринятой библиотеки для этого).
Спасибо за внимание, буду благодарен за отзывы и конструктивную критику!
При слове «реактивность» обычно вспоминают Rx.js, как эталонный образец FRP. Однако серия последних статей на эту тему на Хабре([1], [2], [3]) показала громоздкость решений на Rx, которые на несложных примерах проигрывали в ясности и простоте почти любому другому подходу. Rx велик и могуч, и прекрасно подходит для решения проблем, в которых абстракция потока напрашивается сама собой (на практике это преимущественно координация асинхронных задач). Но стали бы вы писать, к примеру, простую синхронную валидацию формы на Rx? Сэкономил бы он ваше время, по сравнению с обычными императивными подходами?
mrr — это попытка доказать, что ФРП может быть удобным и эффективным решением не только в специфических «потоковых» проблемах, но и в самых обычных рутинных задачах фронтенда.
Реактивное программирование — очень мощная абстракция, на данный момент на фронтенде она присутствует в двух ипостасях:
- реактивные переменные (computed variables): просто, надежно, интуитивно понятно, но потенциал РП раскрыт далеко не полностью
- библиотеки для работы с потоками, такие как Rx, Bacon и т.д.: мощно, но достаточно сложно, сфера практического использования ограничена специфическими задачами.
Mrr совмещает плюсы этих подходов. В отличии от Rх.js, mrr имеет краткий API, который пользователь может расширять своими дополнениями. Вместо десятков методов и операторов — четыре базовых оператора, вместо Observable (горячих и холодных), Subject и т.д. — одна абстракция: поток. Также в mrr отсутствуют некоторые сложные концепции, которые могут существенно усложнить читабельность кода, например, метапотоки.
Однако mrr — это не «упрощенный Rx на новый лад». Отталкиваясь от тех же базовых принципов, что и Rx, mrr претендует на бóльшую нишу: управление глобальным и локальным (на уровне компонента) состоянием приложения. Хотя изначально концепция реактивного программирования предназначалась для работы с асинхронными задачами, mrr с успехом использует подходы реактивности и для обычных, синхронных задач. Это и есть принцип «тотального ФРП».
Часто при создании приложения на Реакте используется несколько разнородных технологий: recompose (или в скором времени — хуки) для состояния компонента, Redux/mobx для глобального состояния, Rx посредством redux-observable (или thunk/saga) для управления сайд-эффектами и координации асинхронных задач в Редаксе. Вместо такого «салата» из разных подходов и технологий внутри одного приложения, с mrr вы можете использовать единую технологию и парадигму.
Интерфейс mrr также существенно отличается от Rx и подобных библиотек — он более декларативен. Благодаря абстракции реактивности и декларативному подходу, mrr позволяет писать выразительный и краткий код. К примеру, стандартное TodoMVC на mrr занимает менее 50 строк кода(не считая JSX шаблона).
Но довольно рекламы. Получилось ли совместить преимущества «легкого» и «тяжелого» RP в одном флаконе — судить вам, но сначала прошу ознакомиться с примерами кода.
TodoMVC уже изрядно набил оскомину, а пример с загрузкой данных о пользователях Github слишком примитивен, чтобы на нем можно было прочувствовать особенности библиотеки. Мы будем рассматривать mrr на примере условного приложения для покупки ж/д билетов. В нашем UI будут поля для выбора начальной и конечной станций, даты. Затем, после отправки данных, будет возвращен список доступных поездов и мест в них. Выбрав конкретный поезд и тип вагона, пользователь введет данные пассажиров, и затем добавит билеты в корзину. Поехали.
Нам нужна форма с выбором станций и даты:
![](https://habrastorage.org/webt/sm/he/aj/smheajucvbemykx4g-fvvqeygrc.gif)
Создадим поля с автодополнением для ввода станций.
import { withMrr } from 'mrr';
const stations = [
'Абакан',
'Алматы',
'Альметьевск',
'Белая Церковь',
...
]
const Tickets = withMrr({
// начальные значения потоков
$init: {
stationFromOptions: [],
stationFromInput: '',
},
// вычисляемый поток - "ячейка"
stationFromOptions: [str => stations.filter(s => s.indexOf(str)===0), 'stationFromInput'],
}, (state, props, $) => {
return (<div>
<h3>
Поиск жд билетов
</h3>
<div>
Станция отправления:
<input onChange={ $('stationFromInput') } />
</div>
<ul className="stationFromOptions">
{ state.stationFromOptions.map(s => <li>{ s }</li>) }
</ul>
</div>);
});
export default Tickets;
mrr-компоненты создаются с помощью функции withMrr, которая принимает схему реактивных связей (описание потоков) и render-функцию. Render-функции передаются props компонента, а также state, которым теперь полностью управляет mrr. В нем и будут находится начальные(блок $init) и вычисляемые по формулам значения реактивных ячеек.
Сейчас у нас есть две ячейки (либо два потока, что то же самое): stationFromInput, значения в которую попадают из пользовательского ввода с помощью хелпера $ (передающего по умолчанию event.target.value для элементов ввода данных), и производная от нее ячейка stationFromOptions, содержащая массив подходящих по названию станций.
Значение stationFromOptions автоматически вычисляется каждый раз при изменении родительской ячейки с помощью функции (в терминологии mrr называемой "формула" — по аналогии с формулами Экселя). Синтаксис выражений mrr прост: на первом месте идет функция (либо оператор), по которой высчитывается значение ячейки, затем идет список ячеек, от которых данная ячейка зависит: их значения передаются в функцию. Такой странноватый, на первый взгляд, синтаксис имеет много преимуществ, которые мы позже рассмотрим. Пока что логика mrr тут напоминает обычный подход с computable variables, используемый в Vue, Svelte и других библиотеках, с той лишь разницей, что вы можете использовать чистые функции.
Реализуем подстановку выбранной из списка станции в поле ввода. Также необходимо скрывать список станций после того как юзер кликнет по одной из них.
const Tickets = withMrr({
$init: {
stationFromOptions: [],
stationFromInput: '',
},
stationFromOptions: [str => stations.filter(s => str.indexOf(a) === 0), 'stationFromInput'],
stationFrom: ['merge', 'stationFromInput', 'selectStationFrom'],
optionsShown: ['toggle', 'stationFromInput', 'selectStationFrom'],
}, (state, props, $) => {
return (<div>
<div>
Станция отправления:
<input onChange={ $('stationFromInput') } value={ state.stationFrom }/>
</div>
{ state.optionsShown && <ul className="stationFromOptions">
{ state.stationFromOptions.map(s => <li onClick={ $('selectStationFrom', s) }>{ s }</li>) }
</ul> }
</div>);
});
Обработчик событий, созданный с помощью хелпера $, в списке станций будет эмитировать фиксированные для кажной опции значения.
mrr последователен в своем декларативном подходе, чуждом любым мутациям. Мы не можем после выбора станции «принудительно» изменить значение ячейки. Вместо этого мы создаем новую ячейку stationFrom, которая, с помощью оператора объединения потоков merge (приблизительный аналог на Rx — combineLatest), будет собирать значения двух потоков: пользовательского ввода (stationFromInput) и выбора станции (selectStationFrom).
Мы должны показывать список опций после того, как пользователь что-то вводит, и скрывать после того, как он выбрал одну из опций. За видимость списка опций будет отвечать ячейка optionsShown, которая будет принимать булевы значения в зависимости от изменения других ячеек. Это очень распостраненный паттерн, для которого существует синтаксический сахар — оператор toggle. Он устанавливает значение ячейки в true при любом изменении первого аргумента (потока), и в false — второго.
Добавим кнопку для очистки введенного текста.
const Tickets = withMrr({
$init: {
stationFromOptions: [],
stationFromInput: '',
},
stationFromOptions: [str => stations.filter(s => str.indexOf(a) === 0), 'stationFromInput'],
clearVal: [a => '', 'clear'],
stationFrom: ['merge', 'stationFromInput', 'selectStationFrom', 'clearVal'],
optionsShown: ['toggle', 'stationFromInput', 'selectStationFrom'],
}, (state, props, $) => {
return (<div>
<div>
Станция отправления:
<input onChange={ $('stationFromInput') } value={ state.stationFrom }/>
{ state.stationFrom && <button onClick={ $('clear') }>Х</button> }
</div>
{ state.optionsShown && <ul className="stationFromOptions">
{ state.stationFromOptions.map(s => <li onClick={ $('selectStationFrom', s) }>{ s }</li>) }
</ul> }
</div>);
});
Теперь наша ячейка stationFrom, отвечающая за содержимое текста в поле ввода, собирает свои значения не с двух, а с трех потоков. Этот код можно упростить. Конструкция mrr вида [*формула*, *… ячейки-аргументы*] аналогична S-выражениям в Lisp'е, и как и в Лиспе, вы можете произвольно вкладывать такие конструкции друг в друга.
Давайте избавимся от малополезной ячейки clearVal и сократим код:
stationFrom: ['merge', 'stationFromInput', 'selectStationFrom', [a => '', 'clear']],
Программы, написанные в императивном стиле, можна сравнить с плохо организованным коллективом, где все постоянно что-то приказывают друг другу (намекаю на вызовы методов и мутирующие изменения), при чем как руководители подчиненным, так и наоборот. Декларативные же программы похожи на противоположную утопическую картину: коллектив, где каждый четко знает, как он должен действовать в любой ситуации. В таком коллективе нет нужды в приказах, все просто находятся на своих местах и работают, реагируя на происходящее.
Вместо того, чтобы описывать всевозможные последствия какого-то события (читай — делать те или иные мутации), мы описываем все случаи, при которых данное событие может наступить, т.е. какие значение примет ячейка при тех или иных изменениях других ячеек. В нашем небольшом пока примере мы описали ячейку stationFrom и три ситуации, которые влияют на ее значение. Для привыкшего к императивному коду программиста такой подход может показаться непривычным (или даже «костылем», «извращением»). На самом деле он позволяет экономить усилия за счет краткости (и стабильности) кода, в чем мы убедимся на практике.
А что насчет асинхронности? Можно ли подтягивать список предложенных станций ajax'ом? Без проблем! В сущности, для mrr все равно, вернет ли функция значение или промис. При возврате промиса mrr дождется его resolv'а и «протолкнет» полученные данные в поток.
stationFromOptions: [str => fetch('/get_stations?str=' + str).then(res => res.toJSON()), 'stationFromInput'],
Это также означает, что вы можете использовать асинхронные функции в качестве формул. Более сложные случаи (обработка ошибок, статус промиса) мы рассмотрим позже.
Функционал для выбора станции отправления готов. Дублировать то же самое для станции прибытия нет смысла, стоит вынести это в отдельный компонент, который можно повторно использовать. Это будет обобщенный компонент инпута с автодополнением, поэтому мы переименуем поля и сделаем, чтобы функция получения подходящих вариантов задавалась в props'ах.
const OptionsInput = withMrr(props => ({
$init: {
options: [],
},
val: ['merge', 'valInput', 'selectOption', [a => '', 'clear']],
options: [props.getOptions, 'val'],
optionsShown: ['toggle', 'valInput', 'selectOption'],
}), (state, props, $) => <div>
<div>
<input onChange={ $('valInput') } value={ state.val } />
</div>
{ state.optionsShown && <ul className="options">
{
state.options.map(s => <li onClick={ $('selectOption', s) }>{ s }</li>)
}
</ul> }
{ state.val && <div className="clear" onClick={ $('clear') }>
X
</div> }
</div>)
Как видите, вы можете задать структуру ячеек mrr как функцию от props компонента (однако выполнится она лишь один раз — при инициализации, и не будет реагировать на изменение props).
Обмен данными между компонентами
Теперь подключим этот компонент в родительском компоненте и посмотрим, как mrr позволяет родственным компонентам обмениваться данными.
const getMatchedStations = str => fetch('/get_stations?str=' + str).then(res => res.toJSON());
const Tickets = withMrr({
stationTo: 'selectStationFrom/val',
stationFrom: 'selectStationTo/val',
}, (state, props, $, connectAs) => {
return (<div>
<OptionsInput { ...connectAs('selectStationFrom') } getOptions={ getMatchedStations } />
-
<OptionsInput { ...connectAs('selectStationTo') } getOptions={ getMatchedStations } />
<input type="date" onChange={ $('date') } />
<button onClick={ $('searchTrains') }>Поиск</button>
</div>);
});
Чтобы связать родительский компонент с дочерним, мы должны передать ему параметры с помощью функции connectAs (четвёртый аргумент render-функции). При этом мы указываем имя, которое хотим дать дочернему компоненту. Присоединив таким образом компонент, по этому имени мы можем обращаться к его ячейкам. В данном случае, мы слушаем ячейки val. Возможно и обратное — слушать из дочернего компонента ячейки родительского.
Как видите, и здесь mrr следует декларативному подходу: не нужно никаких onChange колбэков, нам достаточно указать имя для дочернего компонента в функции connectAs, после чего мы получаем доступ к его ячейкам! При этом, опять же вследствии декларативности, угрозы вмешательства в работу другого компонента нет — мы не имеем возможности ничего в нем «изменить», мутировать, можно лишь «слушать» данные.
Сигналы и значения
Следующий этап — поиск подходящих поездов по выбранным параметрам. В императивном подходе мы бы наверняка написали некий обрабочик на отправку формы onSubmit, который бы инициировал дальнейшие действия — ajax запрос и отображение полученных результатов. Но, как вы помните, нам нельзя ничего «приказывать»! Мы можем только создать еще набор ячеек, производных от ячеек формы. Напишем еще один запрос.
const getTrains = (from, to, date) => fetch('/get_trains?from=' + from + '&to=' + to + '&date=' + date).then(res => res.toJSON());
const Tickets = withMrr({
stationFrom: 'selectStationFrom/val',
stationTo: 'selectStationTo/val',
results: [getTrains, 'stationFrom', 'stationTo', 'date', 'searchTrains'],
}, (state, props, $, connectAs) => {
return (<div>
<OptionsInput { ...connectAs('selectStationFrom') } getOptions={ getMatchedStations } />
-
<OptionsInput { ...connectAs('selectStationTo') } getOptions={ getMatchedStations } />
<input type="date" onChange={ $('date') } />
<button onClick={ $('searchTrains') }>Поиск</button>
</div>);
});
Однако, такой код не будет работать как ожидалось. Калькуляция (пересчет значения ячейки) запускается при изменении любого из аргументов, поэтому запрос будет отправляться, например, сразу после выбора первой станции, а не только по клику на «Поиск». Нам нужно, с одной стороны, чтобы станции и дата передавались в аргументы формулы, но с другой стороны, не реагировать на их изменение. В mrr для этого существует элегантный механизм, называемый «пассивное слушание».
results: [getTrains, '-stationFrom', '-stationTo', '-date', 'searchTrains'],
Просто добавляем минус перед именем ячейки, и вуаля! Теперь results будет реагировать только на изменение ячейки searchTrains.
В этом случае ячейка searchTrains выступает как «ячейка-сигнал», а ячейки stationFrom и др. — как «ячейки-значения». Для ячейки-сигнала существенным является только момент, когда по ней «протекает» значние, при этом, какие именно данные это будут — все равно: это может быть просто true, «1» или что угодно (в нашем случае это будут объекты DOM Event). Для ячейки-значения важным есть именно ее значение, но при этом моменты ее изменения несущественны. Эти два типа ячеек не взаимоисключающи: многи ячейки являются и сигналами, и значениями. На уровне синтаксиса в mrr эти два вида ячеек никак не различаются, но само концептуальное понимание такого различия очень важно при написании реактивного кода.
Расщепление потоков
Запрос на поиск мест в поезде может занять некоторое время, поэтому мы должны показывать лоадер, а также реагировать в случае ошибки. Для этого дефолтного подхода с автоматическим резолвингом промисов уже мало.
const Tickets = withMrr({
$init: {
results: {},
}
stationFrom: 'selectStationFrom/val',
stationTo: 'selectStationTo/val',
searchQuery: [(from, to, date) => ({ from, to, date }), '-stationFrom', '-stationTo', '-date', 'searchTrains'],
results: ['nested', (cb, query) => {
cb({
loading: true,
error: null,
data: null
});
getTrains(query.from, query.to, query.date)
.then(res => cb('data', res))
.catch(err => cb('error', err))
.finally(() => cb('loading', false))
}, 'searchQuery'],
availableTrains: 'results.data',
}, (state, props, $, connectAs) => {
return (<div>
<div>
<OptionsInput { ...connectAs('selectStationFrom') } getOptions={ getMatchedStations } />
-
<OptionsInput { ...connectAs('selectStationTo') } getOptions={ getMatchedStations } />
<input type="date" onChange={ $('date') } />
<button onClick={ $('searchTrains') }>Поиск</button>
</div>
<div>
{ state.results.loading && <div className="loading">Загрузка...</div> }
{ state.results.error && <div className="error">Произошла ошибка. Возможно, сервер перегружен. Попробуйте еще раз.</div> }
{ state.availableTrains && <div className="results">
{ state.availableTrains.map((train) => <div />) }
</div> }
</div>
</div>);
});
Оператор nested позвозяет «раскладывать» данные по подъячейкам, для этого первым аргументом в формулу передается callback, с помощью которого можно «протолкнуть» данные в подъячейку (одну или несколько). Теперь у нас есть отдельные потоки, которые отвечают за ошибку, статус промиса и за полученные данные. Оператор nested — очень мощный инструмент и один из немногих императивных в mrr (мы сами указываем, в какие ячейки класть данные). В то время как оператор merge объединяет несколько потоков в один, nested расщепляет поток на несколько подпотоков, таким образом являясь его противоположностью.
Приведенный пример является стандартным способом работы с промисами, в mrr он обобщен в виде оператора promise и позволяет сократить код:
results: ['promise', (query) => getTrains(query.from, query.to, query.date), 'searchQuery'],
// используем один из подпотоков
availableTrains: 'results.data',
Также оператор promise следит за тем, чтобы использовались результаты только самого последнего промиса.
![](https://habrastorage.org/webt/in/ew/2d/inew2diajzwvzh6lzcie3saledw.gif)
Компонент для отображения наличных мест (откажемся для простоты от разных типов вагонов)
const TrainSeats = withMrr({
selectSeats: [(seatsNumber, { id }) => new Array(Number(seatsNumber)).fill(true).map(() => ({ trainId: id })), '-seatsNumber', '-$props', 'select'],
seatsNumber: [() => 0, 'selectSeats'],
}, (state, props, $) => <div className="train">
Поезд №{ props.num } { props.from } - { props.to }. Количество свободных мест: { props.seats || 0 }
{ props.seats && <div>
Выберите необходимое Вам количество мест:
<input type="number" onChange={ $('seatsNumber') } value={ state.seatsNumber || 0 } max={ props.seats } />
<button onClick={ $('select') }>Выбрать</button>
</div> }
</div>);
Чтобы обращаться к props в формуле, можно подписаться на специальную ячейку $props.
const Tickets = withMrr({
...
selectedSeats: '*/selectSeats',
}, (state, props, $, connectAs) => {
...
<div className="results">
{ state.availableTrains.map((train, i) => <TrainSeats key={i} {...train} {...connectAs('train' + i)}/>) }
</div>
}
Мы опять используем пассивное слушание чтобы подхватить количество выбранных мест при нажатии на кнопку «Выбрать». Каждый дочерний компонент мы связываем с родительским с помощью функции connectAs. Пользователь может выбрать места в любом из предложенных поездов, поэтому мы слушаем изменения во всех дочерних компонентах с помощью маски "*".
Но вот незадача: пользователь может добавить места сначала в одном поезде, потом в другом, так что новые данные перетрут предыдущие. Как «аккумулировать» данные потока? Для этого существует оператор closure, который вместе с nested и funnel составляет основу mrr (все остальные — не более чем синтаксический сахар на основе этих трех).
selectedSeats: ['closure', () => {
let seats = [];
// эта функция станет формулой
return selectedSeats => {
seats = [...seats, selectedSeats];
return seats;
}
}, '*/selectSeats'],
При использовании closure сначала (на componentDidMount) создается замыкание, которое возвращает формулу. Она таким образом имеет доступ к переменным замыкания. Это позволяет сохранять данные между вызовами безопасным способом — не скатываясь в пучину глобальных переменных и shared mutable state. Таким образом, closure позволяет реализовать функциональность таких операторов Rx, как scan и прочие. Однако этот способ хорош для сложных случаев. Если же нам нужно только сохранять значение одной переменной, мы можем просто использовать ссылку на предыдущее значение ячейки с помощью специального имени "^":
selectedSeats: [(seats, prev) => [...seats, ...prev], '*/selectSeats', '^']
Теперь пользователь должен ввести имя и фамилию для каждого выбранного билета.
const SeatDetails = withMrr({}, (state, props, $) => {
return (<div>Поезд { props.trainId }
<input name="name" value={ props.name } onChange={ $('setDetails', e => ['name', e.target.value, props.i]) } />
<input name="surname" value={ props.surname } onChange={ $('setDetails', e => ['surname', e.target.value, props.i]) }/>
<a href="#" onClick={ $('removeSeat', props.i) }>X</a>
</div>);
})
const Tickets = withMrr({
$init: {
results: {},
selectedSeats: [],
}
stationFrom: 'selectStationFrom/val',
stationTo: 'selectStationTo/val',
searchQuery: [(from, to, date) => ({ from, to, date }), '-stationFrom', '-stationTo', '-date', 'searchTrains'],
results: ['promise', (query) => getTrains(query.from, query.to, query.date), 'searchQuery'],
availableTrains: 'results.data',
selectedSeats: [(seats, prev) => [...seats, ...prev], '*/selectSeats', '^']
}, (state, props, $, connectAs) => {
return (<div>
<div>
<OptionsInput { ...connectAs('selectStationFrom') } getOptions={ getMatchedStations } />
-
<OptionsInput { ...connectAs('selectStationTo') } getOptions={ getMatchedStations } />
<input type="date" onChange={ $('date') } />
<button onClick={ $('searchTrains') }>Поиск</button>
</div>
<div>
{ state.results.loading && <div className="loading">Загрузка...</div> }
{ state.results.error && <div className="error">Произошла ошибка. Возможно, сервер перегружен. Попробуйте еще раз.</div> }
{ state.availableTrains && <div className="results">
{ state.availableTrains.map((train, i) => <TrainSeats key={i} {...train} {...connectAs('train' + i)}/>) }
</div> }
{ state.selectedSeats.map((seat, i) => <SeatDetails key={i} i={i} { ...seat } {...connectAs('seat' + i)}/>) }
</div>
</div>);
});
Ячейка selectedSeats содержит массив выбранных мест. По мере того, как пользователь вводит имя и фамилию к каждому билету, мы должны изменять данные в соотвествующих элементах массива.
selectedSeats: [(seats, details, prev) => {
// ???
}, '*/selectSeats', '*/setDetails', '^']
Стандартный подход нам не подойдет: в формуле мы должны знать, какая именно ячейка изменилась и реагировать соответствующе. Нам поможет одна из форм оператора merge.
selectedSeats: ['merge', {
'*/selectSeats': (seats, prev) => {
return [...prev, ...seats];
},
'*/setDetails': ([field, value, i], prev) => {
prev[i][field] = value;
return prev;
},
'*/removeSeat': (i, prev) => {
prev.splice(i, 1);
return prev;
},
}, '^'/*, здесь также могут быть любые другие аргументы*/],
Это немного напоминает редьюсеры Redux'а, но с более гибким и мощным синтаксисом. И можно не бояться мутировать массив, ведь контроль над ним имеет только формула одной ячейки, соответственно параллельные изменения исключены (а вот мутировать массивы, которые передаются в качестве аргументов, конечно же не стоит).
Реактивные коллекции
Паттерн, когда ячейка хранит в себе и изменят массив, очень распостранен. Все операции с массивом при этом бывают трех типов: вставка, изменение, удаление. Чтобы описать это, существует элегантный оператор "coll". Используем его чтобы упростить вычисление selectedSeats.
Было:
selectedSeats: ['merge', {
'*/selectSeats': (seats, prev) => {
return [...prev, ...seats];
},
'*/setDetails': ([field, value, i], prev) => {
prev[i][field] = value;
return prev;
},
'*/removeSeat': (i, prev) => {
prev.splice(i, 1);
return prev;
},
'addToCart': () => [],
}, '^']
стало:
selectedSeats: ['coll', {
create: '*/selectSeats',
update: '*/setDetails',
delete: ['merge', '*/removeSeat', [() => ({}), 'addToCart']]
}]
при этом формат данных в потоке setDetails нужно немного изменить:
<input name="name" onChange={ $('setDetails', e => [{ name: e.target.value }, props.i]) } />
<input name="surname" onChange={ $('setDetails', e => [{ surname: e.target.value }, props.i]) }/>
Используя оператор coll, мы описываем три потока, которые будут влиять на наш массив. При этом поток create должен содержать сами элементы, которые должны быть добавлены в массив (обычно — объекты). Поток delete принимает либо индексы элементов, которые нужно удалить (как в '*/removeSeat'), так и маски. Маска {} удалит все элементы, а, к примеру, маска { name: 'Carl' } удалила бы все элементы с именем Carl. Поток update принимает пары значений: изменение, которое нужно сделать с элементом (маска либо функция), и индекс или маску элементов, которые нужно изменить. Например, [{ surname: 'Johnson' }, {}] установит фамилию Johnson всем элементам массива.
Оператор coll использует что-то вроде внутреннего языка запросов, позволяя упростить работу с коллекциями и сделать ее более декларативной.
Полный код нашего приложения на JsFiddle.
Мы ознакомились практически со всем необходимым базовым функционалом mrr. Довольно существенная тема, которая осталась за бортом — глобальное управление состоянием, возможно она будет рассмотрена в следующих статьях. Но уже сейчас вы можете начать использовать mrr для управления состоянием внутри компонента или группы родственных компонентов.
Выводы
В чем сила mrr?
mrr позволяет писать приложения на React в функционально-реактивном стиле (mrr можно расшифровать как Make React Reactive). mrr очень выразителен — вы тратите меньше времени на написание строчек кода.
mrr предоставляет небольшой набор базовых абстракций, которого достаточно для всех случаев — в этой статье описаны практически все основные фичи и приемы mrr. Также есть инструменты для расширения этого базового набора (возможность создавать кастомные операторы). Вы сможете писать красивый декларативный код, не читая сотни страниц мануала и даже не изучая теоретические глубины функционального программирования — врядли вам придется использовать, скажем, монады, т.к. mrr сам представляет собой гигантскую монаду, отделяющую чистые вычисления от мутаций состояния.
В то время как в других библиотеках зачастую соседствуют разнородные подходы (императивный с помощью методов и декларативный с помощью реактивных биндингов), из которых программист произвольно смешивает «салат», в mrr существует единая базовая сущность — поток, что способствует гомогенности и единообразию кода. Комфорт, удобство, простота, экономия времени программиста — основные преимущества mrr (отсюда еще одна расшифровка mrr как «мр-р-р», то есть мурчание удовлетворенного жизнью кота).
Каковы минусы?
Программирование «строками» имеет как свои преимущества, так и недостатки. У вас не будет работать автокомплит имени ячейки, а также поиск места, где она определена. С другой стороны, в mrr всегда есть одно и только одно место, где определяется поведение ячейки, и его несложно найти простым текстовым поиском, в то время как поиск места, где определяется значение поля Redux стора, или тем более поля state при использовании нативного setState, может быть более долгим.
Кому это может быть интересно?
В первую очередь, адептам функционального программирования — людям, для которых преимущество декларативного подхода очевидно. Конечно, уже существуют кошерные решения на ClojureScript, но все же они остаются нишевым продуктом, в то время как React правит бал. Если в вашем проекте уже используется Redux, вы можете начать использовать mrr для управления локальным состоянием, и в перспективе перейти к глобальному. Даже если вы не планируете использование новых технологий в данный момент, вы можете разобраться с mrr чтобы «размять мозг», взглянув на привычные задачи в новом свете, ведь mrr существенно отличается от распостраненных библиотек управления состоянием.
Это уже можно использовать?
В принципе, да :) Библиотека молодая, пока что активно использовалась на нескольких проектах, но API базового функционала уже устоялось, сейчас работа ведется преимущественно над разными примочками (синтаксический сахар), призванными еще больше ускорить и облегчить разработку. К слову, в самих принципах mrr нет ничего специфично React'овского, его возможно адаптировать для использования с любой компонетной библиотекой (React был выбран в силу отсутствия у него встроенной реактивности или общепринятой библиотеки для этого).
Спасибо за внимание, буду благодарен за отзывы и конструктивную критику!