Разработка javascript приложений на базе Rx.js и React.js (RxReact)

rxreactlogo React.js позволяет очень эффективно и быстро работать с DOM-ом, активно развивается и с каждым днем набирает все больше популярности. Недавно открыл для себя концепцию реактивного программирования, в частности, не менее популярную библиотеку Rx.js. Данная библиотека выводит на новый уровень работу с событиями и асинхронным кодом, которого в UI логике javascript приложений предостаточно. Пришла идея объединить мощь данных библиотек в одно целое и посмотреть что из этого выйдет. В этой статье вы узнаете о том как удалось подружить Rx.js и React.js.RxReact — новая библиотека? Может кто-то останется разочарован —, но нет. Одним из позитивных моментов данного подхода является то, что вам не нужно устанавливать дополнительно никаких новых библиотек. Поэтому сильно не заморачивался и назвал данный подход RxReact.Для нетерпеливых — репо с тестовыми примерами.Зачем? Изначально, когда только знакомился с React, совершенно не стеснялся фаршировать компоненты бизнес логикой, ajax запросами и т.д. Но как показала практика, мешать это все внутрь React компонентов, подписываясь на различные хуки, сохраняя промежуточное мутабельное состояние — крайне плохая идея. Становится сложно вносить изменения и разбираться в таких компонентах — монстрах. React в моем представлении идеально подходит только для отрисовки конкретного состояния (слепка) приложения в определенный момент времени, но сама логика того, как и когда будет меняется это состояние, совсем не его дело и должна находиться в другом слое абстракции. Чем меньше об этом знает слой представления, тем спокойнее мы спим. Хотелось максимально приблизить React компоненты к pure функциям без мутабельного, хранимого состояния, лишних сайд эффектов и т.д. В то же время, хотелось усовершенствовать работу с событиями, желательно вынести в отдельный слой логики декларативное описание того, как должно взаимодействовать приложение с пользователем, реагировать на различные события и изменять свое состояние. Кроме того, хотелось иметь возможность компоновать цепочки последовательностей действий из синхронных и асинхронных операций.Нет, это не совсем Flux Любознательный читатель, который дочитал до этого пункта, уже несколько раз мог подумать: «Так есть же Flux — бери и пользуйся». Совсем недавно взглянул на него и, к своему удивлению, нашел очень много похожего с концепцией, про которую хочу вам рассказать. На данный момент уже видел несколько реализаций Flux. RxReact — не исключение, но в свою очередь имеет несколько иной подход. Вышло так, что сам непроизвольно пришел к почти тем же архитектурным составляющим как: dispatcher, storage, actions. Они во многом похожи на те, что описываются в архитектуре Flux-а.Основные компоненты Надеюсь, что удалось чем-то вас заинтриговать и вы дочитали до этого места, ведь тут начинается самое вкусное. Для более наглядного примера будет рассмотрено тестовое приложение: demoDemo сайт — demo1.Исходник — тут.Само приложение не делает ничего полезного, просто счетчик кликов по кнопке.

View Слой представления является React компонентом, главная цель которого — отрисовать текущее состояние и сигнализировать о событиях в UI.Итак, что же должен уметь view?

рисовать UI сигнализировать о событиях в UI Ниже код view из примера (view.coffee): React = require 'react' {div, button} = React.DOM

HelloView = React.createClass getDefaultProps: → clicksCount: 0

incrementClickCount: → @props.eventStream.onNext action: «increment_click_count»

render: → div null, div null, «You clicked #{@props.clicksCount} times» button onClick: @incrementClickCount «Click»

module.exports = HelloView javascript версия файла view.coffee var React = require ('react'); var div = React.DOM.div; var button = React.DOM.button;

HelloView = React.createClass ({ getDefaultProps: function () { return { clicksCount: 0 }; },

incrementClickCount: function () { return this.props.eventStream.onNext ({ action: «increment_click_count» }); },

render: function () { return div (null, div (null, «You clicked » + this.props.clicksCount + » times»), button ({onClick: this.incrementClickCount}, «Click»)); }});

module.exports = HelloView; Как видим, все данные о кликах приходят нам «сверху» через объект props. При клике на кнопку мы посылаем action через канал eventStream. View сигнализирует нам о кликах на кнопку с помощью eventStream.onNext, где eventStream — инстанс Rx.Subject. Rx.Subject — канал, в который можно как посылать сообщения, так и создавать из него подписчиков. Дальше будет более подробно рассмотрено как работать c Rx.Subject.

После того, как мы четко определили функции view и канала сообщений, их можно выделить на структурной схеме: view_layerКак видно, view является React компонентом, получает на вход текущее состояние приложения (app state), отправляет сообщения о событиях через event stream (actions). В данной схеме Event Stream является каналом связи между view и остальной частью приложения (изображена тучкой). Постепенно мы будем определять конкретные функции компонентов и выносить из общего js application блока.

Storage (Model) Следующий компонент — Storage. Изначально я называл это Model, но всегда думал о том что model не совсем подходящее название. Так как моделью в моем представлении является некая конкретная сущность (User, Product), а тут мы имеем набор различных данных (много моделей, флаги), с которым работает наше приложение. В реализациях Flux-а, которые приходилось видеть, storage был реализован в виде singleton модуля. В моей реализации такой необходимости нет. Это дает теоретическую возможность безболезненного существования нескольких инстансов приложения на одной странице.Что умеет storage?

хранить данные менять данные возвращать данные В моем примере storage реализован через coffee класс с некими свойствами (storage.coffee):

class HelloStorage constructor: → @clicksCount = 0

getClicksCount: → @clicksCount

incrementClicksCount: → @clicksCount += 1

module.exports = HelloStorage javascript версия storage.coffee var HelloStorage;

HelloStorage = (function () { function HelloStorage () { this.clicksCount = 0; }

HelloStorage.prototype.getClicksCount = function () { return this.clicksCount; };

HelloStorage.prototype.incrementClicksCount = function () { return this.clicksCount += 1; };

return HelloStorage;

})();

module.exports = HelloStorage; Сам по себе storage понятия не имеет о UI, о том что есть какой-то Rx и React. Хранилище делает то, что должно делать по определению — хранить данные (состояние приложения).

На структурной схеме можем выделить storage: storage layer

Dispatcher Итак, у нас есть view — отрисовывает приложение в определенный момент времени, storage — в котором хранится текущее состояние. Не хватает связывающего компонента, который будет слушать события из view, при необходимости менять состояние и давать команду обновить view. Таким компонентом как раз является dispatcher.Что должен уметь dispatcher?

реагировать на события из view обновлять данные в storage инициировать обновления view С точки зрения Rx.js, мы можем рассматривать view как бесконечный источник неких событий, на который мы можем создавать подписчиков. В примере из demo у нас всего один подписчик в dispatcher-е — подписчик на клики по кнопке увеличения значений.

Вот как будет выглядеть подписка на клики по кнопке в коде dispatcher-а:

incrementClickStream = eventStream.filter (({action}) → action is «increment_click_count») javascript версия var incrementClickStream = eventStream.filter (function (arg) { return arg.action === «increment_click_count»; }); Для более полного понимания код выше можно наглядно изобразить так: imageНа изображении видим 2 канала сообщений. Первый — eventStream (базовый канал) и второй, полученный из базового — incrementClickStream. Кружочками изображена последовательность событий в канале, в каждом событии передается аргумент action, по которому мы можем фильтровать (диспатчить). Напомню, что сообщения в канал посылает view с помощью вызова:

eventStream.onNext ({action: «increment_click_count»}) Полученный incrementClickStream является инстансом Observable и мы можем работать с ним так же, как и с eventStream, что мы в принципе и сделаем. А дальше мы должны указать, что на каждый клик по кнопке мы хотим увеличивать значение в storage (изменять состояние приложения). incrementClickStream = eventStream.filter (({action}) → action is «increment_click_count») .do (→ store.incrementClicksCount ()) javascript версия var incrementClickStream = eventStream.filter (function (arg) { return arg.action === «increment_click_count»; }).do (function () { return store.incrementClicksCount (); }); Схематически выглядит так: streamdo

На этот раз мы получаем источник значений, который должен обновлять view, так как меняется состояние приложения (увеличивается кол-во кликов). Для того, чтобы это произошло, необходимо подписаться на источник incrementClickStream и вызвать setProps на react компоненте, который отрисовывает view.

incrementClickStream.subscribe (→ view.setProps {clicksCount: store.getClicksCount ()}) javascript версия incrementClickStream.subscribe (function () { return view.setProps ({ clicksCount: store.getClicksCount () }); }); Таким образом, мы замыкаем цепочку и наш view будет обновлен каждый раз, как мы кликнули по кнопке. Таких источников, обновляющих view, может быть много, поэтому целесообразно объединять их в один источник событий с помощью Rx.Observable.merge.

Rx.Observable.merge ( incrementClickCountStream decrementClickCountStream anotherStream # e.t.c) .subscribe ( → view.setProps getViewState (store) → # error handling ) javascript версия Rx.Observable.merge ( incrementClickCountStream, decrementClickCountStream, anotherStream) .subscribe ( function () { return view.setProps (getViewState (store)); }, function () {}); // error handling В данном коде появляется функция getViewState. Эта функция всего лишь вынимает нужные для view данные из storage и возвращает их. В примере из demo она выглядит так:

getViewState = (store) → clicksCount: store.getClicksCount () javascript версия var getViewState = function (store) { return { clicksCount: store.getClicksCount () }; }; Почему не передать storage напрямую во view? Затем, чтобы не было соблазна что-либо записать напрямую из view, вызвать не нужные методы и т.д. View получает данные подготовленные именно для отображения в визуальной части приложения, ни больше ни меньше.

Схематически мерж источников выглядит так:

stream_merge

Выходит, в придачу к тому, что нам не нужно вызывать всякие «onUpdate» ивенты из модели для обновления view, мы еще также имеем возможность обработки ошибок в одном месте. Вторым аргументом в subscribe передается функция для обработки ошибок. Работает по такому же принципу как и в Promise. Rx.Observable имеет много общего с промисами, но является более совершенным механизмом, так как рассматривает не единственное обещаемое значение, а бесконечную последовательность возвращаемых значений во времени.

Полный код dispatcher выглядит подобным образом:

Rx = require 'rx'

getViewState = (store) → clicksCount: store.getClicksCount ()

dispatchActions = (view, eventStream, storage) → incrementClickStream = eventStream # получаем источник кликов .filter (({action}) → action is «increment_click_count») .do (→ storage.incrementClicksCount ())

Rx.Observable.merge ( incrementClickStream # и еще много источников обновляющих view…

).subscribe ( → view.setProps getViewState (storage) (err) → console.error? err)

module.exports = dispatchActions javascript версия var Rx = require ('rx');

var getViewState = function (store) { return { clicksCount: store.getClicksCount () }; };

var dispatchActions = function (view, eventStream, storage) { var incrementClickStream = eventStream.filter (function (arg) { return arg.action === «increment_click_count»;}) .do (function () { return storage.incrementClicksCount (); }); return Rx.Observable.merge (incrementClickCountStream) .subscribe (function () { return view.setProps (getViewState (storage)); }, function (err) { return typeof console.error === «function» ? console.error (err) : void 0; }); };

module.exports = dispatchActions; Полный код файла — dispatcher.coffee

Вся логика диспатчинга помещается в функцию dispatchActions, которая принимает на вход:

view — инстанс React компонента storage — инстанс storage eventStream — канал сообщений Поместив dispatcher на схему, имеем полную структурную схему архитектуры приложения:

image

Инициализация компонентов Далее нам остается каким-то образом инициализировать: view, storage и dispatcher. Сделаем это в отдельном файле — app.coffe: Rx = require 'rx' React = require 'react' HelloView = React.createFactory (require './view') HelloStorage = require './storage' dispatchActions = require './dispatcher'

initApp = (mountNode) → eventStream = new Rx.Subject () # создаем канал сообщений store = new HelloStorage () # cоздаем хранилище # получаем инстанс отрисованного view view = React.render HelloView ({eventStream}), mountNode # передаем компоненты в dispatcher dispatchActions (view, eventStream, store)

module.exports = initApp javascript версия var Rx = require ('rx');

var React = require ('react');

var HelloView = React.createFactory (require ('./view'));

var HelloStorage = require ('./storage');

var dispatchActions = require ('./dispatcher');

initApp = function (mountNode) { var eventStream = new Rx.Subject (); var store = new HelloStorage (); var view = React.render (HelloView ({eventStream: eventStream}), mountNode); dispatchActions (view, eventStream, store); };

module.exports = initApp; Функция initApp принимает на вход mountNode. Mount Node, в данном контексте, является DOM элементом, в который будет отрисован корневой React компонент.

Генератор базовой структуры модуля RxRact (Yeoman) Для быстрого создания вышеперечисленных компонентов в новом приложении можно использовать Yeoman.Генератор — generator-rxreactПример посложнее Пример с одним источником событий хорошо показывает принцип взаимодействия компонентов, но совсем не демонстрирует преимущество использования Rx в связке с React. Для примера давайте представим, что по требованию мы должны усовершенствовать 1й пример из demo таким образом: возможность уменьшать значение сохранять его на сервер при изменении, но не чаще чем раз в секунду и только если оно поменялось показывать сообщение об успешном сохранении прятать сообщение об успешном сохранении через 2 секунды В итоге, должны получить такой результат: demo2

Demo сайт — demo2.Исходный код для demo2 — тут.

Не буду описывать изменения во всех компонентах, покажу самое интересное — изменения в dispatcher-е и попытаюсь максимально подробно прокомментировать происходящее в файле:

Rx = require 'rx' {saveToDb} = require './transport' # импортируем асинхронную функцию (эмуляция синхронизации с базой данных)

getViewState = (store) → clicksCount: store.getClicksCount () showSavedMessage: store.getShowSavedMessage () # в view state добавился флаг отображаить или нет # сообщение об успешном сохранении

dispatchActions = (view, eventStream, store) → # источник »+1» кликов incrementClickSource = eventStream .filter (({action}) → action is «increment_click_count») .do (→ store.incrementClicksCount ()) .share () # источник »-1» кликов decrementClickSource = eventStream .filter (({action}) → action is «decrement_click_count») .do (→ store.decrementClickscount ()) .share () # Соединяем два источника кликов в один countClicks = Rx.Observable .merge (incrementClickSource, decrementClickSource) # Обработка кликов (-1, +1) showSavedMessageSource = countClicks .throttle (1000) # ставим задержку 1 секунду .distinct (→ store.getClicksCount ()) # реагируем только если изменилось число кликов .flatMap (→ saveToDb store.getClicksCount ()) # вызываем асинхронную функцию сохранения .do (→ store.enableSavedMessage ()) # показываем сообщение об успешном сохранении # создаем подписчика, который спрячет сообщение об успешном сохранении после 2 секунд hideSavedMessage = showSavedMessageSource.delay (2000) .do (→ store.disableSavedMessage ())

# Соединяем все источники в один, который будет обновлять view Rx.Observable.merge ( countClicks showSavedMessageSource hideSavedMessage ).subscribe ( → view.setProps getViewState (store) (err) → console.error? err)

module.exports = dispatchActions Я надеюсь, что вас так же, как и меня, впечатляет возможность декларативно описывать операции, выполняемые в нашем приложении, при этом создавать компонуемые цепочки вычислений, состоящие из синхронных и асинхронных действий.На этом буду заканчивать рассказ. Надеюсь, удалось донести основную суть использования концепции реактивного программирования и React для построения пользовательских приложений.Несклько ссылок из статьи P.S Все демки из статьи используют server side prerendering для React.js, для этого создал специальный gulp плагин — gulp-react-render.

© Habrahabr.ru