Еще одну форму построения архитектуры приложения на React представляет Redux. Redux представляет собой контейнер для управления состоянием приложения и во многом напоминает Flux. Redux не привязан непосредственно к React.js и может также использоваться с другими js-библиотеками и фреймворками.
Ключевые моменты Redux:
Хранилище (store): хранит состояние приложения
Действия (actions): некоторый набор информации, который исходит от приложения к хранилищу и который указывает, что именно нужно сделать. Для передачи этой информации у хранилища вызывается метод dispatch().
Создатели действий (action creators): функции, которые создают действия
Reducer : функция (или несколько функций), которая получает действие и в соответствии с этим действием изменяет состояние хранилища
Общую схему взаимодействия элементов архитектуры Redux можно выразить следующим образом:
Из View (то есть из компонентов React) мы посылаем действие, это действие получает функция reducer, которая в соответствии с действием обновляет состояние хранилища. Затем компоненты React применяют обновленное состояние из хранилища.
Рассмотрим, как все это будет работать в приложении на React.
Итак, создадим для проекта новый каталог и определим в нем файл package.json:
{ "name": "reduxapp", "description": "A React.js project using Redux", "version": "1.0.0", "author": "metanit.com", "scripts": { "dev": "webpack serve", "build": "webpack" }, "dependencies": { "react": "18.0.0", "react-dom": "18.0.0", "react-redux": "7.2.8", "redux": "4.1.2", "immutable": "4.0.0" }, "devDependencies": { "@babel/cli": "7.17.0", "@babel/core": "7.17.0", "@babel/preset-react": "7.16.0", "babel-loader": "8.2.0", "webpack": "5.70.0", "webpack-cli": "4.10.0", "webpack-dev-server": "4.7.0" } }
Для работы с Redux в React нам понадобятся зависимости "redux" и "react-redux". Кроме того, для работы с данными будем использовать тип Immutable.Map, поэтому также добавляем зависимость "immutable". А в узле "devDependencies" определены зависимости babel и webpack, которые потребуются для компиляции и сборки кода приложения.
Также добавим в каталог проекта новый файл webpack.config.js:
const path = require("path"); module.exports = { mode: "development", entry: "./app/app.jsx", // входная точка - исходный файл 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-react"] // используемые плагины } } ] } }
В данном случае мы определяем, что исходный файл приложения будет находится по пути "app/app.jsx", а компилируемый файл будет находиться по пути "public/bundle.js".
И также добавим в каталог проекта веб-страницу index.html, на которой будет подключаться скомпилированный файл:
<!DOCTYPE html> <html> <head> <meta charset="utf-8"> <title>Hello Redux</title> </head> <body> <div id="app"></div> <script src="public/bundle.js"></script> </body> </html>
Теперь определим сам код приложения. Для этого определим в проекте новую папку app. В эту папку вначале добавим новый файл actions.jsx, который будет представлять действия:
const addPhone = function (phone) { return { type: "ADD_PHONE", phone } }; const deletePhone = function (phone) { return { type: "DELETE_PHONE", phone } }; module.exports = {addPhone, deletePhone};
Здесь определены две функции, которые создают действия: для добавления и удаления объекта. Каждое действие имеет свойство type
,
которое описывает действие, и свойство phone
- непосредственно данные, которые передаются вместе с действием.
Затем создадим в папке app новый файл reducer.jsx:
const Map = require("immutable").Map; const reducer = function(state = Map(), action) { switch (action.type) { case "SET_STATE": return state.merge(action.state); case "ADD_PHONE": return state.update("phones", (phones) => [...phones, action.phone]); case "DELETE_PHONE": return state.update("phones", (phones) => phones.filter( (item) => item !== action.phone ) ); } return state; } module.exports = reducer;
Здесь описана функция reducer, которая получает действия и изменяет состояние хранилища.
Состояние хранилища будет представлять тип Immutable.Map, который представляет собой словарь, хранящий пары ключ-значение. В качестве ключей здесь используются названия свойств объекта.
В функции reducer при определении параметра присваиваем состоянию начальное значение - пустой словарь:
function(state = Map(), action)
Первый параметр - это собственно состояние хранилища. Второй параметр - action - передает действие. Так, как наши действия имеют свойство type
,
то мы можем получить это свойство и в зависимости от его значения тем или иным образом обновить состояние. Для обновления состояния применяются
методы класса Immutable.Map. Каждый такой метод возвращает новый объект Immutable.Map.
Здесь предполагается, что в состоянии будет храниться массив phones
, который будет содержать строки - название моделей телефонов.
При добавлении фактически создается новый массив, в который добавляются все элементы из старого массива phones и новый - добавляемый элемент:
return state.update("phones", (phones) => [...phones, action.phone]);
А для удаления мы просто возвращаем все те элементы, которые не равны удаляемому объекту.
Для этого применяется функция phones.filter
, которая выполняет фильтрацию:
return state.update("phones", (phones) => phones.filter( (item) => item !== action.phone ) );
Кроме того, здесь применяется действие с типом "SET_STATE", которое просто возвращает начальное состояние хранилища:
return state.merge(action.state);
И после каждого обновления состояния нам надо возвратить обновленное состояние. Таким образом произойдет обновление хранилища.
Далее добавим в папку app файл appview.jsx, который будет определять всю визуальную часть:
const React = require("react"); const connect = require("react-redux").connect; const actions = require("./actions.jsx"); class PhoneForm extends React.Component { constructor(props) { super(props); this.phoneInput = React.createRef(); } onClick() { if (this.phoneInput.current.value !== "") { const itemText = this.phoneInput.current.value; this.phoneInput.current.value =""; return this.props.addPhone(itemText); } } render() { return <div> <input ref={this.phoneInput} /> <button onClick = {this.onClick.bind(this)}>Добавить</button> </div> } }; class PhoneItem extends React.Component { constructor(props) { super(props); } render() { return <div> <p> <b>{this.props.text}</b><br /> <button onClick={() => this.props.deletePhone(this.props.text)}>Удалить</button> </p> </div> } }; class PhonesList extends React.Component { constructor(props) { super(props); } render() { return <div> {this.props.phones.map(item => <PhoneItem key={item} text={item} deletePhone={this.props.deletePhone}/> )} </div> } }; class AppView extends React.Component { render() { return <div> <PhoneForm addPhone={this.props.addPhone}/> <PhonesList {...this.props} /> </div> } }; function mapStateToProps(state) { return { phones: state.get("phones") }; } module.exports = connect(mapStateToProps, actions)(AppView);
Собственно визуальная часть состоит из трех компонентов. Компонент PhoneForm используется для добавления нового объекта. PhoneItem представляет отдельный элемент в списке, а компонент PhonesList содержит список объектов из массива phones.
Корневым компонентом является AppView, в который помещаются все остальные компоненты. Вобщем здесь идет в основном логика передачи данных между компонентами через
объект props. В частности, чтобы передать все данные из props компонента AppView в компонент PhonesList, используется выражение {...this.props}
.
Но большое значение имеет то, что идет после определения класса AppView, в частности, выражение:
connect(mapStateToProps, actions)(AppView)
Функция connect из пакета "react-redux" позволяет связать хранилище и компонент (в данном случае AppView). Благодаря этому все данные из хранилища будут передавать в компонент через
объект props. Дополнительно мы можем установить ряд настроек. Так, первая функция mapStateToProps()
, которая передается в connect,
позволяет установить сопоставление между объектами из состояния хранилища с объектам в props у компонента AppView. В данном случае мы просто устанавливаем,
что значение this.props.phones
в компоненте AppView будет передавать значение из объекта phones
из хранилища:
function mapStateToProps(state) { return { phones: state.get("phones") }; }
Второй параметр в функции connect представляет набор действий, которые вызываются в компоненте AppView или в его дочерних компонентах. И опять же эти действия
после этого мы сможем получить в компоненте AppView через значения this.props.addPhone
и this.props.deletePhone
.
Действие this.props.addPhone
передается в компонент PhoneForm и в нем уже вызывается по клику на кнопку. А действие
this.props.deletePhone
передается в компонент PhonesList, а через него далее в PhoneItem и там также вызывается по нажатию на кнопку "Удалить".
Теперь определим в папке app основной файл app.jsx:
const React = require("react"); const ReactDOM = require("react-dom/client"); const redux = require("redux"); const Provider = require("react-redux").Provider; const reducer = require("./reducer.jsx"); const AppView = require("./appview.jsx"); const store = redux.createStore(reducer); store.dispatch({ type: "SET_STATE", state: { phones: [ "Xiaomi Mi 10", "Samsung Galaxy Note20" ] } }); ReactDOM.createRoot( document.getElementById("app") ) .render( <Provider store={store}> <AppView /> </Provider> );
Вначале здесь собственно создается хранилище:
const store = redux.createStore(reducer);
В метод redux.createStore()
следует передать функцию reducer, которая используется для обновления хранилища.
Используя метод store.dispatch()
, можно выполнить какое-либо действие. В частности, здесь выполняется действие с типом "SET_STATE",
которое устанавливает начальные данные для состояния хранилища.
Чтобы связать хранилище и компонент, применяется провайдер - класс Provider из пакета "react-redux". У провайдера устанавливается
объект хранилища через свойство store: <Provider store={store}>
. Поэтому именно это хранилище и будет использоваться для
поставки данных в AppView через выше рассмотренную функцию connect.
Таким образом, весь проект будет выглядеть следующим образом:
Затем перейдем в командной строке/терминале к каталогу проекта и для установки пакетов выполним команду:
npm install
И в конце запустим проект на выполнение командой npm run dev: