React и Redux вместе. Часть 2 из 7

26.08.2022

Теги: FrontendHookJavaScriptReact.jsWeb-разработкаСостояниеСписокТеорияФункция

Будем создавать приложение списка задач — почти такое же, как в первой части — но уже с использованием 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} />
                &nbsp;
                <span>{title}</span>
            </span>
            <span className="remove" onClick={remove}>
                &times;
            </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))}
                />
                &nbsp;
                <span>{title}</span>
            </span>
            <span className="remove" onClick={() => dispatch(actions.todo.remove(id))}>
                &times;
            </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>
                    &nbsp;
                    <button onClick={logout}>Выйти</button>
                </>
            ) : (
                <>
                    <span>Пользователь не авторизован</span>
                    &nbsp;
                    <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

Каталог оборудования
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
Производители
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
Функциональные группы
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.