
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-а.
Основные компоненты
Надеюсь, что удалось чем-то вас заинтриговать и вы дочитали до этого места, ведь тут начинается самое вкусное. Для более наглядного примера будет рассмотрено тестовое приложение:
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
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 является 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
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:
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")
var incrementClickStream = eventStream.filter(function(arg) {
return arg.action === "increment_click_count";
});
Для более полного понимания код выше можно наглядно изобразить так:
На изображении видим 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())
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()})
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
)
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()
var getViewState = function(store) {
return {
clicksCount: store.getClicksCount()
};
};
Почему не передать storage напрямую во view? Затем, чтобы не было соблазна что-либо записать напрямую из view, вызвать не нужные методы и т.д. View получает данные подготовленные именно для отображения в визуальной части приложения, ни больше ни меньше.
Схематически мерж источников выглядит так:
Выходит, в придачу к тому, что нам не нужно вызывать всякие «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
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 на схему, имеем полную структурную схему архитектуры приложения:
Инициализация компонентов
Далее нам остается каким-то образом инициализировать: 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
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 для построения пользовательских приложений.
Несклько ссылок из статьи
P.S Все демки из статьи используют server side prerendering для React.js, для этого создал специальный gulp плагин — gulp-react-render.
Автор: MostovenkoAlexander