Flux представляет архитектуру приложений, которые используют React. Flux больше представляет паттерн, чем конкретный фреймворк.
Приложения, использующие Flux, имеют три основные части: диспетчер (dispatcher) , хранилище данных (store) и представления (view) - стандартные компоненты React.
Диспетчер представляет во всей этой схеме центральное звено, которое управляет потоком данных в приложении Flux. Диспетчер регистрирует хранилища и их коллбеки - обратные вызовы. Когда диспетчер получает извне некоторое действие, то через коллбеки хранилищ диспетчер уведомляет эти хранилища о поступившем действии.
Хранилища содержат состояние приложения и его логику. По своему действию они могут напоминать модель из паттерна MVC, в то же время они не представляют один объект, а управляют группой объектов. Каждое отдельное хранилище управляет определенной областью или доменом приложения.
Как было описано выше, каждое хранилище регистрируется в диспетчере вместе со своими обратными вызовами. Когда диспетчер получает действие, то он выполняет обратный вызов, передавая ему поступившее действие в качестве параметра. В зависимости от типа действия вызывается тот или иной метод внутри хранилища, в котором происходит обновление состояния хранилища. После обновления хранилища генерируется событие, которое указывает, что хранилище было обновлено. И через это событие представления (то есть компоненты React) узнают, что хранилище было обновлено, и сами обновляют свое состояние.
Представления оформляют визуальную часть приложения. Особый вид представлений - controller-view представляет компонент самого верхнего уровня, который содержит все остальные компоненты. Controller-view прослушивает события, которые исходят от хранилища. Получив событие, controller-view передает данные, полученные от хранилища, другим компонентам.
Когда controller-view получает событие от хранилища, то вначале controller-view запрашивает у хранилища все необходимые данные. Затем он вызывает свой метод setState() или forceUpdate(), который приводит к выполнению у компонента метода render(). А это в свою очередь приводит к вызову метода render() и обновлению дочерних компонентов.
Нередко состояние хранилища передается по иерархии компонентов в виде единого объекта, а компоненты извлекают из него только те данные, которые им непосредственно нужны.
Действие представляет функцию, которая может содержать некоторые данные, которые передаются диспетчеру. Действие может быть вызвано обработчиками событий в компонентах, например, по нажатию на кнопку, либо инициатором действий может какой-нибудь другой внешний источник, например, сервер. Через диспетчер хранилище получает действие и соответствующим образом реагирует на него.
Весь механизм взаимодействия можно представить в виде однонаправленного потока от действия (action) к представлению (view):
Когда пользователь взаимодействует с представлением, то представления через диспетчер вызывают различные действия (например, добавление или обновление данных) по отношению к хранилищу, обратно от хранилища к представлению идут ответные действия, которые обновляют представление.
Рассмотрим действие архитектуры Flux на примере. Но стоит отметит, что на момент написания данной статьи (март 2022 года) самая последняя версия flux пока не поддерживала react 18, поэтому далее в проекте будет использоваться react 17.
Итак, создадим новый проект и для этого определим новый каталог fluxapp. Вначале добавим в него новый файл package.json:
{ "name": "fluxapp", "description": "A React.js project using Flux", "version": "1.0.0", "author": "metanit.com", "scripts": { "dev": "webpack serve", "build": "webpack" }, "dependencies": { "react": "17.0.0", "react-dom": "17.0.0", "flux": "4.0.0", "immutable": "4.0.0" }, "devDependencies": { "@babel/cli": "7.17.0", "@babel/core": "7.17.0", "@babel/preset-react": "7.16.0", "@babel/preset-env": "7.16.0", "babel-loader": "8.2.0", "webpack": "5.70.0", "webpack-cli": "4.10.0", "webpack-dev-server": "4.7.0" } }
Кроме зависимостей react и react-dom здесь добавлена зависимость flux. Кроме того, так как приложение будет разбито на отдельные части, то для их компиляции и сборки применяются пакеты babel и webpack.
Затем перейдем в командной строке/терминале к каталогу проекта и для установки пакетов выполним команду
npm install
Далее определим в проекте главную страницу index.html:
<!DOCTYPE html> <html> <head> <meta charset="utf-8" /> <title>Flux в React</title> </head> <body> <div id="app"></div> <script src="public/bundle.js"></script> </body> </html>
То есть все файлы приложения будут компилироваться в файл public/bundle.js, который подключается на веб-странице.
Для всей логики с использованием React создадим в проекте новую папку app.
И вначале определим, какие действия будет выполнять приложение. В нашем случае будет простейшее приложение, которое будет управлять списком объектов - добавлением и удалением. Поэтому добавим в папку app новый каталог data, в котором определим новый файл ActionTypes.js:
const ActionTypes = { ADD_ITEM: "ADD_ITEM", REMOVE_ITEM: "REMOVE_ITEM"}; export default ActionTypes;
Итак, здесь определено два типа действий. И также в каталог app/data добавим новый файл PhonesDispatcher.js, который будет содержать определение диспетчера:
import {Dispatcher} from "flux"; export default new Dispatcher();
Диспетчер представляет объект класса Dispatcher из пакета flux.
И также добавим в каталог app/data новый файл Actions.js:
import ActionTypes from "./ActionTypes.js"; import PhonesDispatcher from "./PhonesDispatcher.js"; const Actions = { addItem(text) { PhonesDispatcher.dispatch({ type: ActionTypes.ADD_ITEM, text, }); }, removeItem(text) { PhonesDispatcher.dispatch({ type: ActionTypes.REMOVE_ITEM, text, }); } }; export default Actions;
Этот файл собственно определяет действия. Каждое действие определяется в виде функции, в которую могут передаваться параметры. В нашем случае список объектов будет представлять набор строк, поэтому в действия добавления и удаления элемента передается строка - добавляемый или удаляемый объект.
В самом действии вызывается метод dispatch. В качестве параметра этот метод принимает объект, в котором передаем тип действия и собственно данные. Но вообще в объекте можно определить любые данные, которые нам необходимы. При вызове действия этот объект будет передаваться в хранилище.
Далее также в каталоге app/data определим для хранилища файл PhoneStore.js:
import Immutable from "immutable"; import {ReduceStore} from "flux/utils"; import ActionTypes from "./ActionTypes.js"; import PhonesDispatcher from "./PhonesDispatcher.js"; class PhonesStore extends ReduceStore{ constructor() { super(PhonesDispatcher); } getInitialState() { return Immutable.List.of("Apple iPhone 12 Pro", "Google Pixel 5"); } reduce(state, action) { switch (action.type) { case ActionTypes.ADD_ITEM: if (action.text) { return state.push(action.text); } return state; case ActionTypes.REMOVE_ITEM: let index = state.indexOf(action.text); if (index > -1) { return state.delete(index); } return state; default: return state; } } } export default new PhonesStore();
Хранилище представляет собой класс, унаследованный от класса ReduceStore из пакета "flux/utils". В конструкторе хранилища в конструктор базового класса передается объект диспетчера.
С помощью метода getInitialState() устанавливается состояние хранилища. В данном случае это список - объект Immutable.List. Он во многом аналогичен массиву javascript за тем исключением, что он является неизменяемым списком, а все операции с ним возвращают новый обновленный список. Подробнее про работу с такими коллекция можно посмотреть на странице immutable-js
В унаследованном методе reduce() получаем два объекта - state (текущее состояние хранилища, то, что изначально возвращается методом getInitialState) и action (тот объект, который передается в действии - то есть тип действия, добавляемый или удаляемый элемент). С помощью конструкции switch смотрим, какое действие было вызвано, и в зависимости от типа действия выполняем или добавление или удаление элемента.
Это была вся логика по работе с данными. Теперь определим визуальную часть. Для этого в каталог app добавим новую папку views. Далее в этой папке app/views создадим новый файл AppView.js:
import React from "react"; class AppView extends React.Component{ constructor(props){ super(props); this.state = {newItem: ""}; this.onInputChange = this.onInputChange.bind(this); this.onClick = this.onClick.bind(this); } onInputChange(e){ this.setState({newItem:e.target.value}); } onClick(e){ if(this.state.newItem){ this.props.onAddItem(this.state.newItem); this.setState({newItem:" "}); } } render(){ let remove = this.props.onRemoveItem; return <div> <input type="text" value={this.state.newItem} onChange={this.onInputChange} /> <button onClick={this.onClick}>Добавить</button> <h2>Список смартфонов</h2> <div> { this.props.phones.map(function(item){ return <Phone key={item} text={item} onRemove={remove} /> }) } </div> </div>; } } class Phone extends React.Component{ constructor(props){ super(props); this.state = {text: props.text}; this.onClick = this.onClick.bind(this); } onClick(e){ this.props.onRemove(this.state.text); } render(){ return <div> <p> <b>{this.state.text}</b><br /> <button onClick={this.onClick}>Удалить</button> </p> </div>; } } export default AppView;
Класс AppView представляет компонент верхнего уровня, в котором выводится список. Каждый элемент списка представлен отдельным компонентом Phone. Отдельные компоненты также можно было бы поместить в отдельные файлы, но для простоты я разместил их в одном файле.
Для соединения хранилищ, действий и представлений во Flux применяются контейнеры. Поэтому добавим в каталог app новую папку containers, в которой создадим файл AppContainer.js:
import AppView from "../views/AppView.js"; import {Container} from "flux/utils"; import React from "react"; import PhoneStore from "../data/PhoneStore.js"; import Actions from "../data/Actions.js"; class AppContainer extends React.Component { static getStores() { return [PhoneStore]; } static calculateState() { return { phones: PhoneStore.getState(), onAddItem: Actions.addItem, onRemoveItem: Actions.removeItem }; } render() { return <AppView phones={this.state.phones} onRemoveItem={this.state.onRemoveItem} onAddItem={this.state.onAddItem} />; } } export default Container.create(AppContainer);
Класс контейнера AppContainer, с одной стороны, представляет компонент React. Но в то же время он реализует два необходимых метода: getStores()
и calculateState()
.
Метод getStores() возвращает набор харнилищ, с которые используются в приложении. В нашем случае это только одно хранилище PhoneStore.
Метод calculateState() возвращает состояние контейнера. Здесь состояние контейнера включает список phones, причем этот список мы будем получать из состояния хранилища:
phones: PhoneStore.getState()
То есть, phones
будет содержать объект Immutable.List.
Также в состоянии определяются два действия:
onAddItem: Actions.addItem, onRemoveItem: Actions.removeItem
Эти действия вместе со списком phones передаются в AppView, который создается в методе render. То есть таким образом представление AppView со всеми дочерними компонентами будет связано с хранилищем и действиями и с помощью обработчиков нажатия кнопок сможем вызывать действия.
В конце файла вызывается метод Container.create(AppContainer)
, который создает сам контейнер.
И в конце определим в папке app файл app.js, в котором будет происходить загрузка контейнера:
import AppContainer from "./containers/AppContainer.js"; import React from "react"; import ReactDOM from "react-dom"; ReactDOM.render(<AppContainer />, document.getElementById("app"));
Затем в корневой папке проекта определим файл webpack.config.js, который будет соединять все файлы в одну сборку:
const path = require("path"); module.exports = { mode: "development", entry: "./app/app.js", // входная точка - исходный файл output:{ path: path.resolve(__dirname, "./public"), // путь к каталогу выходных файлов - папка public publicPath: "/public/", filename: "bundle.js" // название создаваемого файла }, devServer: { historyApiFallback: true, static: { directory: path.join(__dirname, "/"), }, port: 8081, open: true }, module:{ rules:[ //загрузчик для jsx { test: /\.jsx?$/, // определяем тип файлов exclude: /(node_modules)/, // исключаем из обработки папку node_modules loader: "babel-loader", // определяем загрузчик options:{ presets:[ "@babel/preset-env", "@babel/preset-react"] // используемые плагины } } ] } }
В итоге весь проект будет выглядеть следующим образом:
Для компиляции и упаковки файлов перейдем в терминале/командной строке к папке проекта и вызовем команду:
npm run build
Эта команда сгенерирует файл public/bundle.js, который будет подключаться на веб-страницу.
И в конце запустим проект на выполнение командой npm run dev: