От переводчика: посмотрев на ReactJS и вдохновившись его простотой, начал искать библиотеку, которая бы обеспечивала такой же простой обмен данными внутри моего приложения. Наткнулся на Flux, увидел примеры кода и пошел искать альтернативу. Набрел на RefluxJS, немедленно полюбил и пошел переводить официальную доку. Она написана как раз в стиле статьи, поэтому в первую очередь решил поделиться ей с читателим :) Перевод несколько вольный. Кое-где, если мне казалось, что что-то нуждается в дополнительном пояснении или примере, я не стеснялся.
В переводе ниже в качестве перевода для термина Action из Reflux иногда используется термин «событие», а иногда — термин «экшен», в зависимости от контекста. Более удачного перевода мне подобрать не удалось. Если у вас есть варианты, жду предложений в комментариях ;)
Обзор
RefluxJS — простая библиотека, обеспечивающая в вашем приложении однонаправленный поток данных, использующая концепцию Flux от Facebook.
Прочесть обзор архитектуры Flux можно тут, а в этой статье будет описан альтернативный вариант, в большей степени использующий возможности функционального программирования с уходом от MVC-архитектуры в сторону однонаправленного потока данных.
╔═════════╗ ╔════════╗ ╔═════════════════╗
║ Actions ║──────>║ Stores ║──────>║ View Components ║
╚═════════╝ ╚════════╝ ╚═════════════════╝
^ │
└──────────────────────────────────────┘
Паттерн состоит из экшенов (actions) и хранилищ данных (stores). Экшены инициируют движение данных с помощью событий через хранилища к визуальным компонентам. Если пользователь сделал что-то, с помощью экшена генерируется соответствующее событие. На это событие подписано хранилище данных. Оно обрабатывает событие и, возможно, в свою очередь генерирует какое-то свое.
Например, пользователь изменил фильтрацию списка в приложении. Компонент фильтра генерирует событие «фильтр изменился». Хранилище реагирует на это выполнением ajax-запроса с обновленным фильтром и, в свою очередь, сообщает всем подписавшимся на него, что набор данных, им поставляемый, изменился.
Содержание
- Сравнение Reflux и Facebook Flux
- Примеры
- Установка
- Использование
- События
- Хранилища
- Использование с компонентами ReactJS
- Детали
- Эпилог
Сравнение Reflux и Facebook Flux
Цель проекта RefluxJS — более простая и быстрая интеграция Flux архитектуры в ваш проект как на стороне клиента, так и на стороне сервера. Однако существуют некоторые различия между тем, как работает RefluxJS и тем, что предлагает классическая Flux-архитектура. Более подробная информация есть в этом блог-посте.
Сходства с Flux
Некоторые концепции RefluxJS сходны с Flux:
- Есть экшены
- Есть хранилища данных
- Данные движутся только в одном направлении.
Отличия от Flux
RefluxJS — улучшенная версия Flux-концепции, более динамичная и более дружелюбная к функциональному реактивному программированию:
- Диспетчера событий (dispatcher), который в Flux был синглтоном, в RefluxJS нет. Вместо этого каждое событие (экшен) является своим собственным диспетчером.
- Поскольку на экшены можно подписываться, хранилища могут это делать напрямую без использования громоздких операторов switch для отделения мух от котлет.
- Хранилища могут подписываться на другие хранилища. То есть, появляется возможность создавать хранилища, которые агрегируют и обрабатывают данные в стиле map-reduce.
- Вызов waitFor() удален. Вместо него обработка данных может производиться последовательно или параллельно.
- Хранилища, агрегирующие данные (см. выше) могут подписываться на другие хранилища, обрабатывая сообщения последовательно
- Для ожидания обработки других событий можно использовать метод join()
- Специальные фабрики экшенов (action creators) не нужны вовсе, поскольку экшены RefluxJS являются функциями, передающими нужные данные всем, кто на них подписался.
Примеры
Некоторые примеры можно найти по следующим адресам:
- Пример разработки проекта списков дел на RefluxJS
- Клон проекта HackerNews за авторством echenley
- Еще один TODO-проект, использующий для реализации бекэнда питон за авторством limelights
Установка
В настоящий момент RefluxJS можно установить с помощью npm или с помощью bower.
NPM
Для установки с помощью npm выполните следующую команду:
npm install reflux
Bower
Для установки с помощью bower:
bower install reflux
ES5
Как и React, RefluxJS требует наличия es5-shim для устаревших браузеров. Его можно взять тут
Использование
Полноценный пример можно найти тут.
Создаем экшены
Экшены создаются с помощью вызова `Reflux.createAction()`. В качестве параметра можно передать список опций.
var statusUpdate = Reflux.createAction(options);
Объект экшена является функтором, поэтому его можно вызвать, обратившись к объекту как к функции:
statusUpdate(data); // Вызываем экшен statusUpdate, передавая в качестве данных data
statusUpdate.triggerAsync(data); // Тоже самое, что выше
Если `options.sync` установлено в значение «истина», событие будет инициировано как синхронная операция. Эту настройку можно изменить в любой момент. Все следующие вызовы будут использовать установленное значение.
Для удобного создания большого количества экшенов можно сделать так:
var Actions = Reflux.createActions([
"statusUpdate",
"statusEdited",
"statusAdded"
]);
// Теперь объект Actions содержит экшены с именами, которые мы передали в вызов createActions().
// Инициировать события можно как обычно
Actions.statusUpdate();
Асинхронная работа с экшенами
Для событий, которые могут обрабатываться асинхронно (например, вызовы API) есть несколько различных вариантов работы. В самом общем случае мы рассматриваем успешное завершение обработки и ошибку. Для создания различных событий в таком варианте можно использовать `options.children`.
// Создаем экшены 'load', 'load.completed' и 'load.failed'
var Actions = Reflux.createActions({
"load": {children: ["completed","failed"]}
});
// При получении данных от экшена 'load', асинхронно выполняем операцию,
// а затем в зависимости от результата, вызываем экшены failed или completed
Actions.load.listen( function() {
// По умолчанию обработчик привязан к событию.
//Поэтому его дочерние элементы доступны через this
someAsyncOperation()
.then( this.completed )
.catch( this.failed );
});
Для рассмотренного случая есть специальная опция: `options.asyncResult`. Следующие определения экшенов эквивалентны:
createAction({
children: ["progressed","completed","failed"]
});
createAction({
asyncResult: true,
children: ["progressed"]
});
Для автоматического вызова дочерних экшенов `completed` и `failed` есть следующие методы:
- `promise` — В качестве параметра ожидает объект промиса и привязывает вызов `completed` и `failed` к этому промису с использованием `then()` и `catch()`.
- `listenAndPromise` — В качестве параметра ожидает функцию, которая возвращает объект промиса. Он (объект промиса, который вернула функция) будет вызван при наступлении события. Соответственно, по `then()` и `catch()` промиса автоматически вызваны completed и failed
Следующие три определения эквивалентны:
asyncResultAction.listen( function(arguments) {
someAsyncOperation(arguments)
.then(asyncResultAction.completed)
.catch(asyncResultAction.failed);
});
asyncResultAction.listen( function(arguments) {
asyncResultAction.promise( someAsyncOperation(arguments) );
});
asyncResultAction.listenAndPromise( someAsyncOperation );
Асинхронные экшены как промисы
Асинхронные экшены можно использовать как промисы. Особенно это удобно для рендеринга на сервере, когда вам требуется дождаться успешного (или нет) завершения некоторого события перед рендерингом.
Предположим, у нас есть экшен и хранилище и нам нужно выполнить API запрос:
// Создаем асинхронный экшен с `completed` & `failed` "подэкшенами"
var makeRequest = Reflux.createAction({ asyncResult: true });
var RequestStore = Reflux.createStore({
init: function() {
this.listenTo(makeRequest, 'onMakeRequest');
},
onMakeRequest: function(url) {
// Предположим, что `request` - какая-то HTTP библиотека
request(url, function(response) {
if (response.ok) {
makeRequest.completed(response.body);
} else {
makeRequest.failed(response.error);
}
})
}
});
В этом случае на сервере можно использовать промисы для того, чтобы либо выполнить запрос и либо отрендерить что-то, либо вернуть ошибку:
makeRequest('/api/something').then(function(body) {
// Render the response body
}).catch(function(err) {
// Handle the API error object
});
Хуки, доступные при обработке событий
Для каждого события доступно несколько хуков.
- `preEmit` — вызывается перед тем, как экшен передаст информацию о событии подписчикам. В качестве аргументов хук получает аргументы, использованнные при отправке события. Если хук вернет что-либо, отличное от undefined, возвращаемое значение будет использовано как параметры для хука `shouldEmit` и заменит собой отправленные данные
- `shouldEmit` — вызывается после `preEmit`, но до того, как экшен передаст информацию о событии подписчикам. По умолчанию этот обработчик возвращает true, что разрешает отправку данных. Это поведение можно переопределить, например, чтобы проверить аргументы и решить, должно ли событие быть отправлено в цепочку или нет.
Пример использования:
Actions.statusUpdate.preEmit = function() { console.log(arguments); };
Actions.statusUpdate.shouldEmit = function(value) {
return value > 0;
};
Actions.statusUpdate(0);
Actions.statusUpdate(1);
// Должно быть выведено: 1
Определять хуки можно прямо при объявлении экшенов:
var action = Reflux.createAction({
preEmit: function(){...},
shouldEmit: function(){...}
});
Reflux.ActionMethods
Если вам нужно, на объектах всех экшенов можно было выполнить какой-то метод, для этого вы можете расширить объект`Reflux.ActionMethods`, который автоматически подмешивается ко всем экшенам при создании.
Пример использования:
Reflux.ActionMethods.exampleMethod = function() { console.log(arguments); };
Actions.statusUpdate.exampleMethod('arg1');
// Выведет: 'arg1'
Создание хранилищ
Хранилища создаются примерно так же, как и классы компонентов ReactJS (`React.createClass`) — путем передачи объекта, определяющего параметры хранилища методу `Reflux.createStore`. Все обработчики событий можно проинициализовать в методе `init` хранилища, вызывав собственный метод хранилища `listenTo`.
// Создаем хранилище
var statusStore = Reflux.createStore({
// Начальная настройка
init: function() {
// Подписываемся на экшен statusUpdate
this.listenTo(statusUpdate, this.output);
},
// Определяем сам обработчик события, отправляемого экшеном
output: function(flag) {
var status = flag ? 'ONLINE' : 'OFFLINE';
// Используем хранилище как источник события, передавая статус как данные
this.trigger(status);
}
});
В примере выше, при вызове экшена `statusUpdate`, будет вызыван метод хранилища `output` со всеми параметрами, переданными при отправке. Например, если событие было отправлено с помощью вызова `statusUpdate(true)` в функцию `output` будет передан флаг `true`. А после этого само хранилище сработает как экшен и передаст своим подписчикам в качестве данных `status`.
Поскольку хранилища сами являются инициаторами отправки событий, у них тоже есть хуки `preEmit` и`shouldEmit`.
Reflux.StoreMethods
Если необходимо сделать так, чтобы определенный набор методов был доступен сразу во всех хранилищах, для этого можно расширить объект `Reflux.StoreMethods`, который подмешивается во все хранилища при их создании.
Пример использования:
Reflux.StoreMethods.exampleMethod = function() { console.log(arguments); };
statusStore.exampleMethod('arg1');
// Будет выведено: 'arg1'
Примеси (mixins) в хранилищах
Точно также, как вы подмешиваете объекты в компоненты React, вы можете подмешивать их к вашим хранилищам:
var MyMixin = { foo: function() { console.log('bar!'); } }
var Store = Reflux.createStore({
mixins: [MyMixin]
});
Store.foo(); // Выведет "bar!" в консоль
Методы примесей доступны точно также, как и собственные методы, объявленные в хранилищах. Поэтому `this` из любого метода будет указывать на экземпляр хранилища:
var MyMixin = { mixinMethod: function() { console.log(this.foo); } }
var Store = Reflux.createStore({
mixins: [MyMixin],
foo: 'bar!',
storeMethod: function() {
this.mixinMethod(); // Выведет "bar!"
}
});
Удобно, что если в хранилище подмешано несколько примесей, определяющих одни и те же методы жизненного цикла событий (`init`, `preEmit`, `shouldEmit`), все эти методы будут гарантировано вызваны (как и в ReactJS, собственно)
Удобная подписка на большое количество экшенов
Поскольку обычно в методе init хранилища выполняется подписка на все зарегистрированные экшены, у хранилищ имеется метод `listenToMany`, который принимает в качестве аргумента объект со всеми созданными событиями. Вместо вот такого кода:
var actions = Reflux.createActions(["fireBall","magicMissile"]);
var Store = Reflux.createStore({
init: function() {
this.listenTo(actions.fireBall,this.onFireBall);
this.listenTo(actions.magicMissile,this.onMagicMissile);
},
onFireBall: function(){
// whoooosh!
},
onMagicMissile: function(){
// bzzzzapp!
}
});
… можно использовать такой:
var actions = Reflux.createActions(["fireBall","magicMissile"]);
var Store = Reflux.createStore({
init: function() {
this.listenToMany(actions);
},
onFireBall: function(){
// whoooosh!
},
onMagicMissile: function(){
// bzzzzapp!
}
});
Подобный код добавит обработчики для всех экшенов `actionName`, для которых есть соответствующий метод хранилища `onActionName` (или `actionName` если вам так удобнее). В примере выше, если бы объект `actions` содержал также экшен `iceShard` он просто был бы проигнорирован (поскольку для него нет соответствующего обработчика).
Свойство `listenables`
Чтобы вам было еще более удобно, вы можете присвоить свойству хранилища `listenables` объект с экшенами, он он будет автоматически передан в `listenToMany`. Поэтому пример выше можно упростить до такого:
var actions = Reflux.createActions(["fireBall","magicMissile"]);
var Store = Reflux.createStore({
listenables: actions,
onFireBall: function(){
// whoooosh!
},
onMagicMissile: function(){
// bzzzzapp!
}
});
Свойство `listenables` может представлять собой и массив подобных объектов. В этом случае каждый объект будет передан в `listenToMany`.Это позволяет удобно делать следующее:
var Store = Reflux.createStore({
listenables: [require('./darkspells'),require('./lightspells'),{healthChange:require('./healthstore')}],
// остальной код удален для улучшения читаемости
});
Подписка на хранилища (обработка событий, отправляемых хранилищами)
В вашем компоненте вы можете подписаться на обработку событий от хранилища вот так:
// Хранилище данных для статуса
var statusStore = Reflux.createStore({
// Начальная настройка
init: function() {
// Подписываемся на экшен statusUpdate
this.listenTo(statusUpdate, this.output);
},
// Обработчик
output: function(flag) {
var status = flag ? 'ONLINE' : 'OFFLINE';
// Инициируем собственное событие
this.trigger(status);
}
});
// Очень простой компонент, который просто выводит данные в консоль
function ConsoleComponent() {
// Регистрируем обработчик протоколирования
statusStore.listen(function(status) {
console.log('status: ', status);
});
};
var consoleComponent = new ConsoleComponent();
Отправляем события по цепочке, используя объект экшена `statusUpdate` как функции:
statusUpdate(true);
statusUpdate(false);
Если сделать все, как указано выше, вывод должен получиться вот таким:
status: ONLINE status: OFFLINE
Пример работы с компонентами React
Подписываться на экшены в компоненте React можно в методе `componentDidMount` [lifecycle method](), а отписываться в методе `componentWillUnmount` примерно вот так:
var Status = React.createClass({
initialize: function() { },
onStatusChange: function(status) {
this.setState({
currentStatus: status
});
},
componentDidMount: function() {
this.unsubscribe = statusStore.listen(this.onStatusChange);
},
componentWillUnmount: function() {
this.unsubscribe();
},
render: function() {
// Рендеринг компонента
}
});
Примеси для удобной работы внутри компонентов React
Поскольку в компонентах необходимо постоянно подписываться / отписываться от событий в нужные моменты, для удобства использования можно использовать примесь `Reflux.ListenerMixin`. С его использованием пример выше можно переписать так:
var Status = React.createClass({
mixins: [Reflux.ListenerMixin],
onStatusChange: function(status) {
this.setState({
currentStatus: status
});
},
componentDidMount: function() {
this.listenTo(statusStore, this.onStatusChange);
},
render: function() {
// render specifics
}
});
Эта примесь делает доступным для вызова внутри компонента метод `listenTo, который работает точно также, как одноименный метод хранилищ. Можно использовать и метод `listenToMany`.
Использование Reflux.listenTo
Если вы не используете никакой специфичной логики в отношении `this.listenTo()` внутри `componentDidMount()`, вы можете использовать вызов `Reflux.listenTo()` как примесь. В этом случае `componentDidMount()` будет автоматически сконфигурирован требуемым образом, а вы получите примесь `ListenerMixin` в вашем компоненте. Таким образом пример выше может быть переписан так:
var Status = React.createClass({
mixins: [Reflux.listenTo(statusStore,"onStatusChange")],
onStatusChange: function(status) {
this.setState({
currentStatus: status
});
},
render: function() {
// Рендеринг с использованием `this.state.currentStatus`
}
});
Можно вставлять несколько вызовов `Reflux.listenTo` внутри одного и того же массива`mixins`.
Существует также `Reflux.listenToMany` который работает аналогичным образом, позволяя использовать `listener.listenToMany`.
Использование Reflux.connect
Если все, что вам нужно, это обновить состояние компонента при получении данных от хранилища, вы можете воспользоваться выражением `Reflux.connect(listener,[stateKey])` как примесью компонента ReactJS. Если передать туда необязательный ключ `stateKey`, состояние компонента будет автоматически обновлено с помощью `this.setState({:data})`. Если `stateKey` не передан, будет сделан вызов `this.setState(data)`. Вот пример выше, переписанный с учетом новых возможностей:
var Status = React.createClass({
mixins: [Reflux.connect(statusStore,"currentStatus")],
render: function() {
// render using `this.state.currentStatus`
}
});
Использование Reflux.connectFilter
`Reflux.connectFilter` можно использовать точно также, как `Reflux.connect`. Используйте `connectFilter` в качестве примеси в случае, если вам требуется передавать в компонент только часть состояния хранилища. Скажем, блог, написанный с использованием Reflux, скорее всего будет держать в хранилище все публикации. А на странице отдельного поста можно использовать `Reflux.connectFilter` для фильтрации постов.
var PostView = React.createClass({
mixins: [Reflux.connectFilter(postStore,"post", function(posts) {
posts.filter(function(post) {
post.id === this.props.id;
});
})],
render: function() {
// Отрисовываем, используя `this.state.post`
}
});
Обработка событий об изменениях от других хранилищ
Хранилище может подписаться на изменения в других хранилищах, позволяя выстраивать цепочки передачи данных между хранилищами для агрегирования данных без затрагивания других частей приложения. Хранилище может подписаться на изменения, происходящие в других хранилищах с использованием метода `listenTo` точно также, как это происходит с объектами экшенов:
// Создаем хранилище, которое реагирует на изменения, происходящие в statusStore
var statusHistoryStore = Reflux.createStore({
init: function() {
// Подписываемся на хранилище как на экшен
this.listenTo(statusStore, this.output);
this.history = [];
},
// Обработчик экшена
output: function(statusString) {
this.history.push({
date: new Date(),
status: statusString
});
// Инициируем собственное событие
this.trigger(this.history);
}
});
Дополнительные возможности
Использование альтернативной библиотеки управления событиями
Не нравится `EventEmitter`, предоставляемый по умолчанию? Вы можете переключиться на использование любого другого, в том числе и встроенного в Node вот так:
// Это нужно сделать до создания экшенов и хранилищ
Reflux.setEventEmitter(require('events').EventEmitter);
Использование альтернативной библиотеки промисов
Не нравится библиотека, реализующая функциональность промисов, предоставляемая по умолчанию? Вы можете переключиться на использование любой другой (например, Bluebird вот так:
// Это нужно сделать до вызова любых экшенов
Reflux.setPromise(require('bluebird'));
Имейте ввиду, что промисы в RefluxJS создаются с помощью вызова `new Promise(...)`. Если ваша библиотека использует фабрики, используйте вызов `Reflux.setPromiseFactory()`.
Использование фабрики промисов
Поскольку большая часть библиотек для работы с промисами не использует конструкторы (`new Promise(...)`), настраивать фабрику не нужно.
Однако, если вы используете что-нибудь вроде `Q` или какую-нибудь другую библиотеку, которая использует для создания промисов фабричный метод, используйте вызов `Reflux.setPromiseFactory` чтобы его указать.
// Это нужно сделать до использования экшенов
Reflux.setPromiseFactory(require('Q').Promise);
Использование альтернативы nextTick
Когда вызывается экшен вызывается как функтор, это происходит асинхронно. Возврат управления производится немедленно, а соответствующий обработчик вызывается через `setTimeout` (функция `nextTick`) внутри RefluxJS.
Вы можете выбрать ту реализацию отложенного вызова методов (`setTimeout`, `nextTick`, `setImmediate` и т.д.) которая вас устраивает.
// node.js env
Reflux.nextTick(process.nextTick);
В качестве альтернатив получше, вам может понадобится полифил `setImmediate` или `macrotask`
Ожидание завершения работы всех экшенов в цепочке
В Reflux API есть методы `join`, которые обеспечивают удобную агрегацию источников, отправляющих события параллельно. Это тоже самое, что делает метод `waitFor` в оригинальной реализации Flux от Facebook.
Отслеживание аргументов
Обработчик, переданный соответствующему `join()` вызову будет вызыван как только все участники отправят событие как минимум единожды. Обработчику будут переданы параметры каждого события в том порядке, в котором участники операции объявлялись при вызове `join`.
Существует четыре варианта `join`, каждый из которых представляет собой особую стратегию работы с данными:
- `joinLeading`: От каждого издателя сохраняется только результат первого вызова события. Все остальные данные игнорируются
- `joinTrailing`: От каждого издателя сохраняется только результат последнего вызова события. Все остальные данные игнорируются
- `joinConcat`: Все результаты сохраняются в массиве.
- `joinStrict`: Повторный вызов события от одного и того же издателя приводит к ошибке.
Сигнатуры всех методов выглядят одинаково:
joinXyz(...publisher, callback)
Как только `join()` выполнится, все связанные с ним ограничения будут сняты и он снова сможет сработать, если издатели снова отправят события в цепочку.
Использование методов экземпляра для управления событиями
Все объекты, использующие listener API (хранилища, компоненты React, подмешавшие `ListenerMixin`, или другие компоненты, использующие `ListenerMethods`) получают доступ к четырем вариантам метода `join`, о которых мы говорили выше:
var gainHeroBadgeStore = Reflux.createStore({
init: function() {
this.joinTrailing(actions.disarmBomb, actions.saveHostage, actions.recoverData, this.triggerAsync);
}
});
actions.disarmBomb("warehouse");
actions.recoverData("seedyletter");
actions.disarmBomb("docks");
actions.saveHostage("offices",3);
// `gainHeroBadgeStore` в этом месте кода хранилище отправит событие в цепочку с параметрами `[["docks"],["offices",3],["seedyletter"]]`
Использование статических методов
Поскольку использование методов `join`, а затем отправки события в цепочку является обычным делом для хранилища, все методы join имеют свои статические эквиваленты в объекте `Reflux`, которые возвращают объект хранилища, подписанный на указанные события. Используя эти методы пример выше можно переписать так:
var gainHeroBadgeStore = Reflux.joinTrailing(actions.disarmBomb, actions.saveHostage, actions.recoverData);
Отправка состояния по умолчанию с использованием метода listenTo
Функция `listenTo`, предоставляемая хранилищем и `ListenerMixin` имеет третий параметр, который может быть функцией. Эта функция будет вызвана в момент регистрации обработчика с результатом вызова `getInitialState` в качестве параметров.
var exampleStore = Reflux.createStore({
init: function() {},
getInitialState: function() {
return "какие-то данные по умолчанию";
}
});
// Подписываемся на события от хранилища
this.listenTo(exampleStore, onChangeCallback, initialCallback)
// initialCallback будет вызван немедленно с параметром "какие-то данные по умолчанию"
Помните метод `listenToMany`? Если вы используете его с другими хранилищами, он тоже поддерживает `getInitialState`. Данные, возвращаемые этим методом будут переданы обычному обработчику, либо в метод `this.onDefault`, если он существует.
Колофон
- Список контрибьюторов
- Лицензия проекта: BSD 3-Clause License. Copyright © 2014, Mikael Brassman.
- Подробная информация о лицензии
- Reflux использует eventemitter3, который в настоящий момент распространяется под лицензией MIT
Автор: FractalizeR