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

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

818

Пришла мысль объединить мощь данных библиотек в одно целое и поглядеть что из этого выйдет. React.js дозволяет чрезвычайно отлично и быстро работать с DOM-ом , активно развивается и с каждым деньком набирает все больше популярности. Данная библиотека выводит на новейший уровень работу с событиями и асинхронным кодом, которого в UI логике javascript приложений предостаточно. В данной статье вы узнаете о том как удалось подружить Rx.js и React.js. Не так давно открыл для себя концепцию реактивного программирования, в частности, не наименее популярную библиотеку Rx.js.

RxReact — новенькая библиотека?
Потому сильно не заморачивался и именовал данный подход RxReact. Может кто-то остается разочарован — но нет. Одним из положительных моментов данного подхода является то, что для вас не необходимо устанавливать дополнительно никаких новейших библиотек.
Для нетерпеливых — репо с тестовыми примерами.

Для чего?
Вначале, когда лишь знакомился с React, совсем не смущался фаршировать составляющие бизнес логикой, ajax запросами и т.д. Становится трудно вносить конфигурации и разбираться в таковых компонентах — чудовищах. Хотелось очень приблизить React составляющие к pure функциям без мутабельного, хранимого состояния, излишних сайд эффектов и т.д. React в моем представлении совершенно подступает лишь для отрисовки определенного состояния (слепка) приложения в определенный момент времени, но сама логика того, как и когда будет изменяется это состояние, совершенно не его дело и обязана находиться в другом слое абстракции. В то же время, хотелось усовершенствовать работу с событиями, лучше вынести в отдельный слой логики декларативное описание того, как обязано взаимодействовать приложение с юзером, реагировать на разные действия и изменять свое состояние. Чем меньше о этом знает слой представления, тем спокойнее мы спим. Не считая того, хотелось иметь возможность компоновать цепочки последовательностей действий из синхронных и асинхронных операций. Но как показала практика, мешать это все вовнутрь React компонентов, подписываясь на разные хуки, сохраняя промежуточное мутабельное состояние — очень нехорошая мысль.

Нет, это не совершенно Flux
На данный момент уже лицезрел несколько реализаций Flux. Совершенно не так давно посмотрел на него и, к собственному удивлению, отыскал чрезвычайно много схожего с концепцией, про которую желаю для вас поведать. RxReact — не исключение, но в свою очередь имеет несколько другой подход. Любознательный читатель, который прочитал до этого пт, уже несколько раз мог поразмыслить: «Так есть же Flux — бери и пользуйся». Вышло так, что сам непроизвольно пришел к практически тем же строительным составляющим как: dispatcher, storage, actions. Они во многом похожи на те, что описываются в архитектуре Flux-а.

Главные составляющие
Надеюсь, что удалось чем-то вас заинтересовать и вы прочитали до этого места, ведь здесь начинается самое вкусное. Для наиболее приятного примера будет рассмотрено тестовое приложение:

Demo веб-сайт — 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.coffeevar 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;

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

Опосля того, как мы верно определили функции view и канала сообщений, их можно выделить на структурной схеме:

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

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

Что умеет storage?

хранить данные
поменять данные
возвращать данные

В моем примере storage реализован через coffee класс с некоторыми качествами (storage.coffee):
class HelloStorage
constructor: ->
@clicksCount = 0

getClicksCount: -> @clicksCount

incrementClicksCount: ->
@clicksCount += 1

module.exports = HelloStorage
javascript версия storage.coffeevar 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:

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

Что должен уметь 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";
});

Для наиболее полного осознания код выше можно наглядно изобразить так:

На изображении лицезреем 2 канала сообщений. Кружочками изображена последовательность событий в канале, в каждом событии передается аргумент action, по которому мы можем фильтровать (диспатчить). 1-ый — eventStream (базисный канал) и 2-ой, приобретенный из базисного — incrementClickStream.
Напомню, что сообщения в канал отправляет view с помощью вызова:
eventStream.onNext({action: "increment_click_count"})

А далее мы должны указать, что на каждый клик по кнопочке мы желаем наращивать значение в storage (изменять состояние приложения). Приобретенный incrementClickStream является инстансом Observable и мы можем работать с ним так же, как и с eventStream, что мы в принципе и создадим.

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();
});

Схематически смотрится так:

На этот раз мы получаем источник значений, который должен обновлять 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

Эта функция всего только вынимает нужные для view данные из storage и возвращает их. В примере из demo она смотрится так: В данном коде возникает функция getViewState.

getViewState = (store) ->
clicksCount: store.getClicksCount()
javascript версияvar getViewState = function(store) {
return {
clicksCount: store.getClicksCount()
};
};

View получает данные приготовленные конкретно для отображения в зрительной части приложения, ни больше ни меньше. Почему не передать storage впрямую во view? Потом, чтоб не было соблазна что-или записать впрямую из view, вызвать не нужные способы и т.д.

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

Выходит, в придачу к тому, что нам не необходимо вызывать всякие «onUpdate» ивенты из модели для обновления view, мы еще также имеем возможность обработки ошибок в одном месте. Работает по такому же принципу как и в Promise. Вторым аргументом в subscribe передается функция для обработки ошибок. 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
console.error(err) : void 0;
});
};

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"?

Полный код файла — dispatcher.coffee

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

view — инстанс React компонента
storage — инстанс storage
eventStream — канал сообщений

Поместив dispatcher на схему, имеем полную структурную схему архитектуры приложения:

Инициализация компонентов
Создадим это в отдельном файле — app.coffe: Дальше нам остается каким-то образом инициализировать: view, storage и dispatcher.
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 секунды

В итоге, должны получить таковой итог:

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 для построения пользовательских приложений.

Несклько ссылок из статьи

github репозиторий с примерами
demo1
demo2
Пример реализации TODO приложения
rxreact-generator

habrahabr.ru P.S Все демки из статьи употребляют server side prerendering для React.js, для этого сделал особый gulp плагин — gulp-react-render.