React и Redux вместе. Часть 2 из 7
26.08.2022
Теги: Frontend • Hook • JavaScript • React.js • Web-разработка • Состояние • Список • Теория • Функция
Будем создавать приложение списка задач — почти такое же, как в первой части — но уже с использованием Redux. Есть несколько вариантов использования Redux в React-приложении. Пакет redux
позволяет работать с классическим Redux. Пакет @reduxjs/toolkit
позволяет работать с Redux по-новому. Пакет react-redux
обеспечивает взаимодействие компонентов с хранилищем. Во второй части будем использовать классический Redux и функцию connect
из пакета react-redux
.
1. Классический Redux
Есть два подхода связать компоненты React и хранилище Redux — использовать функцию connect
(старый, не рекомендуется) или использовать хуки (новый, рекомендуется). В любом случае, надо обернуть компонент верхнего уровня в <Provider>
, чтобы все его потомки могли получить доступ к хранилищу.
import { Provider } from 'react-redux'; import { store } from './redux/store.js'; function App() { return ( <Provider store={store}> <h1>Список задач</h1> <TodoList /> </Provider> ); }
1.1. Пакет react-redux, функция connect
Как это работает
Функция connect
подключает компонент React к хранилищу Redux. Во-первых, компонент получает данные из хранилища, которые нужны ему для работы. Во-вторых, компонент получает функции для отправки действий в хранилище. Функция connect
не изменяет переданный компонент, а возвращает новый связанный компонент, который является оболочкой для исходного. Все четыре параметра функции connect
не обязательны.
const SomeComponentConnected = connect(...)(SomeComponent);
function connect(mapStateToProps?, mapDispatchToProps?, mergeProps?, options?)
Функция mapStateToProps
Если функция mapStateToProps
указана, новый компонент-оболочка будет подписан на обновления хранилища. Это значит, что mapStateToProps
будет вызываться каждый раз, когда хранилище обновляется. Чтобы не подписываться на обновления хранилища — нужно передать connect
первым аргументом null
.
Функция может быть объявлена с одним или двумя параметрами — это state
и ownProps
. Если функция объявлена с одним параметром — она будет вызываться всякий раз, когда изменяется состояние хранилища. В качестве единственного аргумента будет передано состояние хранилища.
const mapStateToProps = (state) => ({ items: state.todo })
Если функция объявлена с двумя параметрами — она будет вызываться всякий раз, когда изменяется состояние хранилища или когда компонент-оболочка получает новые значения пропсов. В качестве первого аргумента будет передано состояние хранилища, в качестве второго — собственные пропсы обернутого компонента.
const mapStateToProps = (state, ownProps) => ({ item: state.todo.find(item => item.id === ownProps.id), })
Результатом mapStateToProps
должен быть простой объект, который будет объединен с собственными пропсами обернутого компонента.
Результат выполнения mapStateToProps
сравнивается с предыдущим результатом исполнения с использованием функции shallowEqual
. То есть содержимое объекта будет сравниваться поле за полем по ссылке или по значению, и если будет найдено различие, то компонент будет перерисован.
Функция mapDispatchToProps
Этот второй параметр функции connect
— может быть объектом, функцией или не предоставляться. Если второй параметр функции connect
будет null
— компонент-оболочка все равно получит функцию dispatch
в качестве пропса. Если второй параметр функции connect
будет функцией — она может быть объявлена с одним или двумя параметрами — это dispatch
и ownProps
.
Если функция объявлена с одним параметром — она будет вызываться c функцией dispatch
хранилища в качестве параметра.
const mapDispatchToProps = (dispatch) => { return { increment: () => dispatch({ type: 'INCREMENT' }), decrement: () => dispatch({ type: 'DECREMENT' }), } }
Если функция объявлена с двумя параметрами — она будет вызываться с dispatch
в качестве первого параметра и собственными пропсами обернутого компонента в качестве второго. При этом функция будет повторно вызываться всякий раз, когда компонент-оболочка получает новые значения пропсов.
const mapDispatchToProps = (dispatch, ownProps) => { return { toggle: () => dispatch({ type: 'TOGGLE', payload: ownProps.id }), remove: () => dispatch({ type: 'REMOVE', payload: ownProps.id }), } }
Результатом mapDispatchToProps
должен быть простой объект, который будет объединен с собственными пропсами обернутого компонента. Каждое поле объекта — это функция, вызов которой должен отправить действие в хранилище.
Как уже упоминалось, mapDispatchToProps
может быть объектом, где каждое поле является генератором действия (action creator):
import { increment, decrement } from './actions.js'; const actionCreators = { increment, decrement, }; export default connect(null, actionCreators)(Counter);
Когда мы определяем mapDispatchToProps
как функцию — нам нужно обернуть генераторы экшенов в вызов dispatch
. И поскольку делать это приходится довольно часто — React-Redux берет эту рутинную операцию на себя. В этом случае компонент больше не будет получать dispatch
через пропсы — в этом нет необходимости.
import { bindActionCreators } from 'redux'; import { increment, decrement } from './actions.js'; const actionCreators = { increment, decrement, }; function mapDispatchToProps(dispatch) { return bindActionCreators(actionCreators, dispatch); } export default connect(null, mapDispatchToProps)(Counter);
Функция mergeProps
Это третий параметр функции connect
— может быть функцией или не предоставляться. Параметр определяет, какие пропсы в конечном итоге получит компонент-оболочка. По умолчанию этот параметр имеет значение { ...ownProps, ...stateProps, ...dispatchProps }
.
function mergeProps(stateProps, dispatchProps, ownProps) { retutn { ...ownProps, ...stateProps, ...dispatchProps }; }
Простое приложение
Нам потребуется развернуть React приложение с помощью create-react-app
+ установить два пакета — redux
и react-redux
.
$ cd react-redux/react-redux-connect $ npx create-react-app . $ npm install redux react-redux --save-prod $ npm install uuid --save-prod # доп.пакет
Создаем хранилище
Для начала нам нужно хранилище, для этого создаем директорию src/redux
, внутри нее — все необходимое для работы Redux.
Файл src/redux/store.js
:
import { createStore } from 'redux'; import { reducer } from './reducer.js'; export const store = createStore(reducer);
Файл src/redux/reducer.js
:
import { combineReducers } from 'redux'; import { todoReducer } from './todoReducer.js'; import { userReducer } from './userReducer.js'; export const reducer = combineReducers({ todo: todoReducer, user: userReducer, });
Файл src/redux/todoReducer.js
:
import { TODO_CREATE, TODO_TOGGLE, TODO_REMOVE } from './types.js'; const initState = [ { id: 1, title: 'Первая задача', completed: false }, { id: 2, title: 'Вторая задача', completed: true }, { id: 3, title: 'Третья задача', completed: false }, { id: 4, title: 'Четвертая задача', completed: true }, { id: 5, title: 'Пятая задача', completed: false }, ]; export function todoReducer(state = initState, action) { let newState; switch (action.type) { case TODO_CREATE: newState = [...state, action.payload]; return newState; case TODO_TOGGLE: newState = state.map(todo => { return todo.id === action.payload ? { ...todo, completed: !todo.completed } : todo; }); return newState; case TODO_REMOVE: newState = state.filter(todo => todo.id !== action.payload); return newState; default: return state; } }
Файл src/redux/userReducer.js
:
import { USER_LOGIN, USER_LOGOUT } from './userTypes.js'; const initState = { auth: false, }; export function userReducer(state = initState, action) { switch (action.type) { case USER_LOGIN: return { auth: true }; case USER_LOGOUT: return { auth: false }; default: return state; } }
Файл src/redux/todoTypes.js
:
export const TODO_CREATE = 'TODO_CREATE'; export const TODO_TOGGLE = 'TODO_TOGGLE'; export const TODO_REMOVE = 'TODO_REMOVE';
Файл src/redux/userTypes.js
:
export const USER_LOGIN = 'USER_LOGIN'; export const USER_LOGOUT = 'USER_LOGOUT';
Файл src/redux/actions.js
:
import { todoCreate, todoToggle, todoRemove } from './todoActions.js'; import { userLogin, userLogout } from './userActions.js'; export const actions = { todo: { create: todoCreate, toggle: todoToggle, remove: todoRemove, }, user: { login: userLogin, logout: userLogout, }, };
Файл src/redux/todoActions.js
:
import { TODO_CREATE, TODO_TOGGLE, TODO_REMOVE } from './todoTypes.js'; export function todoCreate(data) { return { type: TODO_CREATE, payload: data, }; } export function todoToggle(id) { return { type: TODO_TOGGLE, payload: id, }; } export function todoRemove(id) { return { type: TODO_REMOVE, payload: id, }; }
Файл src/redux/userActions.js
:
import { USER_LOGIN, USER_LOGOUT } from './userTypes.js'; export function userLogin(data) { return { type: USER_LOGIN, }; } export function userLogout(id) { return { type: USER_LOGOUT, }; }
Объект состояния store.getState()
имеет вид:
{ todo: [ { id: 1, title: 'Первая задача', completed: false }, { id: 2, title: 'Вторая задача', completed: true }, { id: 3, title: 'Третья задача', completed: false }, { id: 4, title: 'Четвертая задача', completed: true }, { id: 5, title: 'Пятая задача', completed: false } ], user: { auth: false } }
Создаем компоненты
Создаем директорию src/component
, внутри нее — компоненты TodoList.js
, TodoItem.js
, TodoForm.js
, StatusBar.js
и Login.js
.
Файл компонента src/App.js
:
import './App.css'; import { TodoList } from './component/TodoList.js'; // import { TodoList } from './component/TodoList-2.js'; import { TodoForm } from './component/TodoForm.js'; // import { TodoForm } from './component/TodoForm-2.js'; import { StatusBar } from './component/StatusBar.js'; import { Login } from './component/Login.js'; import { Provider } from 'react-redux'; import { store } from './redux/store.js'; function App() { return ( <Provider store={store}> <div className="App"> <h1>Список задач</h1> <TodoForm /> <StatusBar /> <TodoList /> <Login /> </div> </Provider> ); } export default App;
Файл компонента src/component/TodoList.js
:
import { connect } from 'react-redux'; import { TodoItem } from './TodoItem.js'; // import { TodoItem } from './TodoItem-2.js'; function TodoList(props) { const { items } = props; return ( <div className="todo-list"> {items.length > 0 ? ( items.map(item => <TodoItem key={item.id} id={item.id} />) ) : ( <p>Список задач пустой</p> )} </div> ); } function mapStateToProps(state) { return { items: state.todo, }; } const TodoListConnected = connect(mapStateToProps)(TodoList); export { TodoListConnected as TodoList };
Файл компонента src/component/TodoItem.js
:
import { connect } from 'react-redux'; import { actions } from '../redux/actions.js'; function TodoItem(props) { const { title, completed, toggle, remove } = props; return ( <div className="todo-item"> <span> <input type="checkbox" checked={completed} onChange={toggle} /> <span>{title}</span> </span> <span className="remove" onClick={remove}> × </span> </div> ); } function mapStateToProps(state, ownProps) { // возвращаем объект типа { id: 3, title: 'Третья задача', completed: false } return state.todo.find(item => item.id === ownProps.id); } function mapDispatchToProps(dispatch, ownProps) { return { toggle: () => dispatch(actions.todo.toggle(ownProps.id)), remove: () => dispatch(actions.todo.remove(ownProps.id)), }; } const TodoItemConnected = connect(mapStateToProps, mapDispatchToProps)(TodoItem); export { TodoItemConnected as TodoItem };
// еще один вариант реализации, без использования mapDispatchToProps import { connect } from 'react-redux'; import { actions } from '../redux/actions.js'; function TodoItem(props) { const { id, title, completed, dispatch } = props; return ( <div className="todo-item"> <span> <input type="checkbox" checked={completed} onChange={() => dispatch(actions.todo.toggle(id))} /> <span>{title}</span> </span> <span className="remove" onClick={() => dispatch(actions.todo.remove(id))}> × </span> </div> ); } function mapStateToProps(state, ownProps) { // возвращаем объект типа { id: 3, title: 'Третья задача', completed: false } return state.todo.find(item => item.id === ownProps.id); } const TodoItemConnected = connect(mapStateToProps)(TodoItem); export { TodoItemConnected as TodoItem };
Файл компонента src/component/TodoForm.js
:
import { useState } from 'react'; import { connect } from 'react-redux'; import { todoCreate } from '../redux/todoActions.js'; import { v4 as uuid } from 'uuid'; function TodoForm(props) { const [text, setText] = useState(''); const handleChange = (event) => { setText(event.target.value); }; const handleClick = () => { if (text.trim().length !== 0) { const data = { id: uuid(), title: text, completed: false, }; props.create(data); } setText(''); }; return ( <div className="todo-form"> <input type="text" value={text} onChange={handleChange} placeholder="Новая задача" /> <button onClick={handleClick}>Добавить</button> </div> ); } const actionCreator = { create: (data) => todoCreate(data), }; const TodoFormConnected = connect(null, actionCreator)(TodoForm); export { TodoFormConnected as TodoForm };
// еще один вариант реализации, без использования mapDispatchToProps import { useState } from 'react'; import { connect } from 'react-redux'; import { todoCreate } from '../redux/todoActions.js'; import { v4 as uuid } from 'uuid'; function TodoForm(props) { const [text, setText] = useState(''); const handleChange = (event) => { setText(event.target.value); }; const handleClick = () => { if (text.trim().length !== 0) { const data = { id: uuid(), title: text, completed: false, }; props.dispatch(todoCreate(data)); } setText(''); }; return ( <div className="todo-form"> <input type="text" value={text} onChange={handleChange} placeholder="Новая задача" /> <button onClick={handleClick}>Добавить</button> </div> ); } const TodoFormConnected = connect()(TodoForm); export { TodoFormConnected as TodoForm };
Файл компонента src/component/StatusBar.js
:
import { connect } from 'react-redux'; function StatusBar(props) { const { total, completed, uncompleted } = props; return ( <div className="status-bar"> Всего задач {total}, не завершенных {uncompleted}, завершенных {completed}. </div> ); } function mapStateToProps(state) { return { total: state.todo.length, completed: state.todo.filter((item) => item.completed).length, uncompleted: state.todo.filter((item) => !item.completed).length, }; } const StatusBarConnected = connect(mapStateToProps)(StatusBar); export { StatusBarConnected as StatusBar };
Файл компонента src/component/Login.js
:
import { connect } from 'react-redux'; import { actions } from '../redux/actions.js'; function Login(props) { const { auth, dispatch } = props; const login = () => dispatch(actions.user.login()); const logout = () => dispatch(actions.user.logout()); return ( <div className="user-login"> {auth ? ( <> <span>Пользователь авторизован</span> <button onClick={logout}>Выйти</button> </> ) : ( <> <span>Пользователь не авторизован</span> <button onClick={login}>Войти</button> </> )} </div> ); } function mapStateToProps(state) { return { auth: state.user.auth, }; } const LoginConnected = connect(mapStateToProps)(Login); export { LoginConnected as Login };
Исходные коды здесь, директория react-redux-connect
.
$ cd react-redux/react-redux-connect $ npm install
Оптимизация
Первый этап
В Redux нельзя подписаться на изменение конкретного кусочка данных. Изначально, можно лишь узнать о том, что «где-то что-то изменилось». Поэтому, при любом изменении в store
, будут вызваны все активные mapStateToProps
. Давайте в этом убедимся.
// файл src/component/StatusBar.js function mapStateToProps(state) { console.log('Call function mapStateToProps, component StatusBar'); return { total: state.todo.length, completed: state.todo.filter((item) => item.completed).length, uncompleted: state.todo.filter((item) => !item.completed).length, }; }
Кликаем по кнопке «Войти» или «Выйти» компонента Login.js
— получаем сообщение в консоли разработчика.
Call function mapStateToProps, component StatusBar
Эту проблему решают мемоизированные селекторы, так что устанавливаем пакет reselect
:
$ npm install reselect --save
import { connect } from 'react-redux'; import { createSelector } from 'reselect'; function StatusBar(props) { const { total, completed, uncompleted } = props; return ( <div className="status-bar"> Всего задач {total}, не завершенных {uncompleted}, завершенных {completed}. </div> ); } const all = (state) => state.todo; const total = (state) => all(state).length; // Результат выполнения первой функции-аргумента (входной селектор) подается на вход второй функции-аргумента // (здесь это анонимная функция). Если результат выполнения входного селектора будет таким же, как в прошлый // раз — то вторая функция (результирующая) не вызывается, а результат возвращается из кэша. Входных селекторов // может быть два или три — тогда результирующая функция принимает два или три аргумента. const completed = createSelector( all, (items) => items.filter((item) => item.completed).length ); const uncompleted = createSelector( all, (items) => items.filter((item) => !item.completed).length ); function mapStateToProps(state) { return { total: total(state), completed: completed(state), uncompleted: uncompleted(state), }; } const StatusBarConnected = connect(mapStateToProps)(StatusBar); export { StatusBarConnected as StatusBar };
Давайте проверим, что это работает. Добавим тяжелые вычисления и будем кликать по кнопке «Войти/Выйти» и по checkbox-ам отдельных задач.
import { connect } from 'react-redux'; import { createSelector } from 'reselect'; function StatusBar(props) { const { total, completed, uncompleted } = props; return ( <div className="status-bar"> Всего задач {total}, не завершенных {uncompleted}, завершенных {completed}. </div> ); } function sleep(ms) { const nowDate = Date.now(); let curDate; do { curDate = Date.now(); } while (curDate - nowDate < ms); } const all = (state) => state.todo; const total = (state) => all(state).length; const completed = createSelector( all, (items) => { console.log('Тяжелые вычисления, функция completed'); sleep(1000); return items.filter((item) => item.completed).length; } ); const uncompleted = createSelector( all, (items) => { console.log('Тяжелые вычисления, функция uncompleted'); sleep(1000); return items.filter((item) => !item.completed).length; } ); function mapStateToProps(state) { return { total: total(state), completed: completed(state), uncompleted: uncompleted(state), }; } const StatusBarConnected = connect(mapStateToProps)(StatusBar); export { StatusBarConnected as StatusBar };
При клике по кнопке «Войти/Выйти» — никаких сообщений в консоли нет, приложение работает быстро. Это потому, что второй аргумент функции createSelector
не вызывается, а результат reselect
возвращает из кэша. С другой стороны, при клике по checkbox — приложение замирает и в консоли появляются сообщения.
Тяжелые вычисления, функция completed Задержка одна секунда... Тяжелые вычисления, функция uncompleted Задержка одна секунда...
Исходные коды здесь, директория react-redux-connect-one
.
$ cd react-redux/react-redux-connect-one $ npm install
Второй этап
У нас есть еще одно узкое место (на самом деле больше, но об этом в следующей части) — это компонент TodoItem.js
. При клике по кнопке «Войти/Выйти» — будут вызываться все активные mapStateToProps
. Но мы не можем использовать createSelector
, как делали это выше, потому что у нас несколько экземпляров TodoItem
. И каждый новый экземпляр будет перезаписывать кэш, созданный предыдущим экземпляром.
// без кэширования результата работы селектора function sleep(ms) { const nowDate = Date.now(); let curDate; do { curDate = Date.now(); } while (curDate - nowDate < ms); } function mapStateToProps(state, ownProps) { console.log('Поиск элемента списка задач, id =', ownProps.id); sleep(1000); return state.todo.find((item) => item.id === ownProps.id); }
// попытка кэширования результата работы селектора function sleep(ms) { const nowDate = Date.now(); let curDate; do { curDate = Date.now(); } while (curDate - nowDate < ms); } const itemSelector = createSelector( (state) => state.todo, // каждый раз новый id — будет вызвана результирующая функция (state, id) => id, (items, id) => { console.log('Поиск элемента списка задач, id =', id); sleep(1000); return items.find((item) => item.id === id); } ); function mapStateToProps(state, ownProps) { return itemSelector(state, ownProps.id) }
Поиск элемента списка задач, id = 1 Задержка одна секунда... Поиск элемента списка задач, id = 2 Задержка одна секунда... Поиск элемента списка задач, id = 3 Задержка одна секунда... Поиск элемента списка задач, id = 4 Задержка одна секунда... Поиск элемента списка задач, id = 5 Задержка одна секунда...
Но мы можем для каждого экземпляра компонента TodoItem
создать свой экземпляр мемоизированного селектора (вместо одного на всех). Согласно документации, функция mapStateToProps
может вернуть не только объект, но и функцию — которая будет вызвана при создании экземпляра компонента. А возвращенное этой функцией значение будет использоваться как фактическая функция mapStateToProps
.
function sleep(ms) { const nowDate = Date.now(); let curDate; do { curDate = Date.now(); } while (curDate - nowDate < ms); } const createItemSelector = () => createSelector( (state) => state.todo, (state, id) => id, (items, id) => { console.log('Поиск элемента списка задач, id =', id); sleep(1000); return items.find((item) => item.id === id) } ); const createMapStateToProps = () => { const itemSelector = createItemSelector(); // возвращаемая ф-ция будет использоваться как фактическая ф-ция mapStateToProps return (state, ownProps) => itemSelector(state, ownProps.id); } function mapDispatchToProps(dispatch, ownProps) { return { toggle: () => dispatch(actions.todo.toggle(ownProps.id)), remove: () => dispatch(actions.todo.remove(ownProps.id)), }; } const TodoItemConnected = connect(createMapStateToProps, mapDispatchToProps)(TodoItem);
Теперь при клике по кнопке «Войти/Выйти» — никаких сообщений в консоли нет, приложение работает быстро — потому что результат возвращается из кэша.
Исходные коды здесь, директория react-redux-connect-two
.
$ cd react-redux/react-redux-connect-two $ npm install
Поиск: Frontend • Hook • JavaScript • React.js • Web-разработка • Состояние • Список • Теория • Функция • connect • mapStateToProps • mapDispatchToProps