Redux. Начало работы. Часть 2 из 2

17.08.2022

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

7. Middleware

Middleware позводяют выполнить код между моментом отправкой экшена и моментом, когда этот экшен достигает редюсера. Разработчики используют Redux-middleware для логирования, сообщения об ошибках, общения с асинхронным API, роутинга и т.д.

7.1. Логирование

Было бы неплохо записывать каждое действие, которое происходит в приложении, вместе с состоянием после этого действия. Когда что-то идет не так, можно просмотреть логи и понять, какой именно экшен испортил состояние приложения. Давайте попробуем логировать экшены и состояние после вызова dispatch.

/* .......... */
plusBtn.addEventListener('click', () => {
    const action = counterPlus();
    console.log('call dispatch', action);
    store.dispatch(action);
    console.log('next state', store.getState());
});
/* .......... */

Мы можем вынести логирование в отдельную функцию, чтобы вызывать ее вместо dispatch:

/* .......... */
const dispatchAndLogging = action => {
    console.log('call dispatch', action);
    store.dispatch(action)
    console.log('next state', store.getState());
};
/* .......... */
plusBtn.addEventListener('click', () => {
    // store.dispatch(counterPlus());
    dispatchAndLogging(counterPlus());
});

Сейчас для просмотра логов надо везде заменить вызов store.dispatch на вызов dispatchAndLogging — это хлопотно. Давайте просто заменим функцию dispatch в экземпляре store — и логи будут везде, где есть вызов dispatch.

const next = store.dispatch;
store.dispatch = action => {
    console.log('call dispatch', action);
    next(action);
    console.log('next state', store.getState());
};

Что мы сделали? Мы добавили к вызову store.dispatch еще немного нашего кода. Но мы можем проделать этот трюк несколько раз.

const patchStoreAddLogging = store => {
    const next = store.dispatch; // здесь «родной» dispatch
    store.dispatch = action => {
        console.log('call dispatch', action);
        next(action);
        console.log('next state', store.getState());
    };
};
const patchStoreAddSomething = store => {
    const next = store.dispatch; // здесь «родной» dispatch + logging
    store.dispatch = action => {
        console.log('something before dispatch');
        next(action);
        console.log('something after dispatch');
    };
};
const patchStoreAddBlablabla = store => {
    const next = store.dispatch; // здесь «родной» dispatch + logging + something
    store.dispatch = action => {
        console.log('blablabla before dispatch');
        next(action);
        console.log('blablabla after dispatch');
    };
};
patchStoreAddLogging(store);
patchStoreAddSomething(store);
patchStoreAddBlablabla(store);
blablabla before dispatch
something before dispatch
call dispatch {type: 'COUNTER_PLUS'}
next state {counter: {count: 2}, theme: {value: 'dark'}}
something after dispatch
blablabla after dispatch

Сейчас наши функции заменяют store.dispatch. Что если бы они вместо этого возвращали новую функцию dispatch? А разработчики Redux предоставили бы в наше распоряжение функцию applyStorePatches? Мы бы вызывали эту функцию, передвая ей экземпляр стора и массив функций patches.

const logging = store => {
    const next = store.dispatch;
    const patch = action => {
        console.log('call dispatch', action);
        next(action);
        console.log('next state', store.getState());
    };
    return patch;
};
const something = store => {
    const next = store.dispatch;
    const patch = action => {
        console.log('something before dispatch');
        next(action);
        console.log('something after dispatch');
    };
    return patch;
};
const blablabla = store => {
    const next = store.dispatch;
    const patch = action => {
        console.log('blablabla before dispatch');
        next(action);
        console.log('blablabla after dispatch');
    };
    return patch;
};
// такую функцию могли бы предоставить разработчики Redux
const applyStorePatches = (store, patches) => {
    patches.forEach(patch => {
        store.dispatch = patch(store);
    });
};
const patches = [logging, something, blablabla];
applyStorePatches(store, patches);
blablabla before dispatch
something before dispatch
call dispatch {type: 'COUNTER_PLUS'}
next state {counter: {count: 2}, theme: {value: 'dark'}}
something after dispatch
blablabla after dispatch

Здесь есть важный момент, на который надо обратить внимание. Каждый patch имеет доступ (и возможность вызвать) ранее обернутый store.dispatch. Это важно для возможности объединять патчи в цепочки.

const logging = store => {
    // замыкание, где мы храним «родной» store.dispatch
    const next = store.dispatch;
    const patch = action => {
        /* .......... */
    };
    return patch;
};
const something = store => {
    // замыкание, где мы храним обернутый store.dispatch
    const next = store.dispatch;
    const patch = action => {
        /* .......... */
    };
    return patch;
};
const blablabla = store => {
    // замыкание, где мы храним обернутый store.dispatch
    const next = store.dispatch;
    const patch = action => {
        /* .......... */
    };
    return patch;
};

Но есть еще другой метод реализации объединения патчей в цепочки. Патч мог бы принимать функцию отправки экшена next() в параметрах — вместо того, чтобы читать ее из экземпляра стора.

const logging = store => {
    const wrapDispatch = next => {
        const addLogging = action => {
            console.log('call dispatch', action);
            next(action);
            console.log('next state', store.getState());
        };
        return addLogging;
    };
    return wrapDispatch;
};
const something = store => {
    const wrapDispatch = next => {
        const addSomething = action => {
            console.log('something before dispatch');
            next(action);
            console.log('something after dispatch');
        };
        return addSomething;
    };
    return wrapDispatch;
};
const blablabla = store => {
    const wrapDispatch = next => {
        const addBlablabla = action => {
            console.log('blablabla before dispatch');
            next(action);
            console.log('blablabla after dispatch');
        };
        return addBlablabla;
    };
    return wrapDispatch;
};
// такую функцию могли бы предоставить разработчики Redux
const applyStorePatches = (store, patches) => {
    const wrappers = patches.map(patch => patch(store));
    wrappers.forEach(wrapper => {
        store.dispatch = wrapper(store.dispatch);
    });
};
const patches = [logging, something, blablabla];
applyStorePatches(store, patches);
// тот же код, что и выше — но короче
const logging = store => next => action => {
    console.log('call dispatch', action);
    next(action);
    console.log('next state', store.getState());
};
const something = store => next => action => {
    console.log('something before dispatch');
    next(action);
    console.log('something after dispatch');
};
const blablabla = store => next => action => {
    console.log('blablabla before dispatch');
    next(action);
    console.log('blablabla after dispatch');
};
// такую функцию могли бы предоставить разработчики Redux
const applyStorePatches = (store, patches) => {
    const wrappers = patches.map(patch => patch(store));
    wrappers.forEach(wrapper => {
        store.dispatch = wrapper(store.dispatch);
    });
};
const patches = [logging, something, blablabla];
applyStorePatches(store, patches);

И мы получили три middleware, которые можно использовать вместе с Redux-функцией applyMiddleware.

import 'bootstrap/dist/css/bootstrap.css';
import '../css/style.css';

import { createStore, applyMiddleware } from 'redux'; // NEW функция applyMiddleware
import { rootReducer } from './rootReducer.js';
import { counterMinus, counterPlus, counterReset, themeChange } from './actions.js';

const counter = document.getElementById('counter');
const plusBtn = document.getElementById('plus');
const minusBtn = document.getElementById('minus');
const resetBtn = document.getElementById('reset');
const themeBtn = document.getElementById('theme');

// NEW наши три middleware
const logging = store => next => action => {
    console.log('call dispatch', action);
    next(action);
    console.log('next state', store.getState());
};
const something = store => next => action => {
    console.log('something before dispatch');
    next(action);
    console.log('something after dispatch');
};
const blablabla = store => next => action => {
    console.log('blablabla before dispatch');
    next(action);
    console.log('blablabla after dispatch');
};

// Создание и инициализация хранилища
const store = createStore(
    rootReducer,
    applyMiddleware(blablabla, something, logging) // NEW функция applyMiddleware
);

// Подписываемся на событие изменения
store.subscribe(() => {
    const state = store.getState();
    // новый рендер после изменения
    renderCounter(state.counter.count);
    renderTheme(state.theme.value);
});
// Первый рендер после инициализации
store.dispatch({ type: 'START_APPLICATION' });

// Функции рендера после изменения
function renderCounter(count) {
    counter.textContent = count.toString();
}
function renderTheme(newTheme) {
    const oldTheme = newTheme === 'light' ? 'dark' : 'light';
    document.body.classList.remove(oldTheme);
    document.body.classList.add(newTheme);
}

// Обработчики клика по кнопкам
plusBtn.addEventListener('click', () => {
    store.dispatch(counterPlus());
});
minusBtn.addEventListener('click', () => {
    store.dispatch(counterMinus());
});
resetBtn.addEventListener('click', () => {
    store.dispatch(counterReset());
});
themeBtn.addEventListener('click', () => {
    const state = store.getState();
    const oldTheme = state.theme.value;
    const newTheme = oldTheme === 'light' ? 'dark' : 'light';
    store.dispatch(themeChange(newTheme));
});
blablabla before dispatch
something before dispatch
call dispatch {type: 'COUNTER_PLUS'}
next state {counter: {count: 2}, theme: {value: 'dark'}}
something after dispatch
blablabla after dispatch

Обратите внимание, что middleware применяются к store.dispatch в обратном порядке. То есть, функция applyMiddleware использует внутри себя метод массива reverse.

function applyMiddleware(store, middlewares) {
    middlewares = [...middlewares];
    middlewares.reverse();
    /* .......... */
}

Конечно, функция applyMiddleware от разработчиков Redux отличается от нашей функции applyStorePatches. Да и наши три middleware написаны без использования шаблона, см.ниже. Но общее представление, как это работает, у нас теперь есть.

Исходные коды здесь, директория use-lib-redux-logging.

$ cd learn-redux/use-lib-redux-logging
$ npm install # установить все пакеты
$ npm run build:prod # сброка проекта

7.2. Пакет logger

Разумеется, мы не первые подумали о пользе логирования — так что нет необходимости изобретать велосипед, можно использовать готовое решение redux-logger.

$ npm install redux-logger --save
import { createStore, applyMiddleware } from 'redux';
import logger from 'redux-logger';

const store = createStore(
    rootReducer,
    applyMiddleware(logger)
);

Logger должен быть последним middleware в цепочке. Для logger можно задать множество опций.

import { applyMiddleware, createStore } from 'redux';
import { createLogger } from 'redux-logger'

const logger = createLogger({
    // не логировать action THEME_CHANGE
    predicate: (getState, action) => action.type !== THEME_CHANGE,
});

const store = createStore(
    reducer,
    applyMiddleware(logger)
);

Исходные коды здесь, директория use-lib-redux-logger.

$ cd learn-redux/use-lib-redux-logger
$ npm install # установить все пакеты
$ npm run build:prod # сброка проекта

7.3. Шаблон middleware

Redux middleware должно быть написано как серия из трех вложенных функций:

function exampleMiddleware(storeAPI) {
    return function wrapDispatch(next) {
        return function handleAction(action) {
            // Do anything here: pass the action onwards with next(action),
            // or restart the pipeline with storeAPI.dispatch(action)
            // Can also use storeAPI.getState() here
            return next(action)
        }
    }
}
const anotherExampleMiddleware = storeAPI => next => action => {
    // Do something in here, when each action is dispatched
    return next(action)
}

8. Асинхронность

По умолчанию действия Redux обрабатываются синхронно, что представляет проблему для приложений, которым требуется взаимодействовать с внешними сервисами.

8.1. Простое приложение

Давайте напишем простое приложение, которое будет получать список пользователей от сервиса jsonplaceholder.typicode.com.

<!DOCTYPE html>
<html lang="ru">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title><%= htmlWebpackPlugin.options.title %></title>
</head>
<body>
    <div class="container pt-5">
        <h1 class="heading"><%= htmlWebpackPlugin.options.title %></h1>
        <hr>
        <div class="card">
            <div class="card-body">
                <h5 class="card-title">Пользователи</h5>
                <button class="btn btn-primary mb-4" id="load">Загрузить</button>
                <div id="loading" style="display:none;">
                    <div class="spinner-border text-danger" role="status">
                        <span class="visually-hidden">Загрузка...</span>
                    </div>
                </div>
                <div id="error" style="display:none;">
                    <div class="alert alert-danger" role="alert">Что-то пошло не так...</div>
                </div>
                <div id="users" class="mt-2"></div>
            </div>
        </div>
    </div>
</body>
</html>
// файл src/js/rootReducer.js
import { FETCH_USERS_STARTED, FETCH_USERS_SUCCESS, FETCH_USERS_FAILURE } from './types.js';

const initState = {
    users: [],
    loading: false,
    error: null,
};

export const rootReducer = (state = initState, action) => {
    switch (action.type) {
        case FETCH_USERS_STARTED:
            return {
                users: [],
                loading: true,
                error: null,
            };
        case FETCH_USERS_SUCCESS:
            return {
                users: action.payload,
                loading: false,
                error: null,
            };
        case FETCH_USERS_FAILURE:
            return {
                users: [],
                loading: false,
                error: action.payload,
            };
        default:
            return state;
    }
};
// файл src/js/actions.js
import { FETCH_USERS_STARTED, FETCH_USERS_SUCCESS, FETCH_USERS_FAILURE } from './types.js';

export const fetchUsersStarted = () => {
    return {
        type: FETCH_USERS_STARTED,
    };
};

export const fetchUsersSuccess = users => {
    return {
        type: FETCH_USERS_SUCCESS,
        payload: users,
    };
};

export const fetchUsersFailure = error => {
    return {
        type: FETCH_USERS_FAILURE,
        payload: error,
    };
};
// файл src/js/types.js
export const FETCH_USERS_STARTED = 'FETCH_USERS_STARTED';
export const FETCH_USERS_SUCCESS = 'FETCH_USERS_SUCCESS';
export const FETCH_USERS_FAILURE = 'FETCH_USERS_FAILURE';

При нажатии на кнопку «Загрузить» — мы должны отправить запрос на сервер и получить ответ. Перед отправкой запроса устанавливаем loading:true — чтобы показать loader. При получении списка пользователей — должны второй раз изменить состояние, задать значения для loading и users.

document.getElementById('load').addEventListener('click', () => {
    store.dispatch(fetchUsersStarted());
    fetch('https://jsonplaceholder.typicode.com/users')
        .then(response => response.json())
        .then(jsonData => {
            const users = jsonData.map(user => {
                return { id: user.id, name: user.name, email: user.email };
            });
            store.dispatch(fetchUsersSuccess(users));
        })
        .catch(error => {
            store.dispatch(fetchUsersFailure(error.message));
        });
});

Теперь можем написать код основного файла src/js/index.js:

import 'bootstrap/dist/css/bootstrap.css';
import '../css/style.css';

import { createStore } from 'redux';
import { rootReducer } from './rootReducer.js';
import { fetchUsersStarted, fetchUsersSuccess, fetchUsersFailure } from './actions.js';

// Создание и инициализация хранилища
const store = createStore(rootReducer);

// Подписываемся на событие изменения
store.subscribe(() => {
    const state = store.getState();
    // новый рендер после изменения
    renderUsers(state);
});
// Первый рендер после инициализации
store.dispatch({ type: 'START_APPLICATION' });

// Функция рендера после изменения
function renderUsers({ users, loading, error }) {
    if (users.length) {
        const content = JSON.stringify(users, null, '  ');
        document.getElementById('users').innerHTML = `<pre>${content}</pre>`;
    } else {
        document.getElementById('users').textContent = '';
    }
    if (loading) {
        document.getElementById('loading').style.display = '';
    } else {
        document.getElementById('loading').style.display = 'none';
    }
    if (error) {
        document.querySelector('#error .alert').textContent = error;
        document.getElementById('error').style.display = '';
    } else {
        document.querySelector('#error .alert').textContent = '';
        document.getElementById('error').style.display = 'none';
    }
}

// Обработчик клика по кнопке
document.getElementById('load').addEventListener('click', () => {
    store.dispatch(fetchUsersStarted());
    fetch('https://jsonplaceholder.typicode.com/users')
        .then(response => response.json())
        .then(jsonData => {
            const users = jsonData.map(user => {
                return { id: user.id, name: user.name, email: user.email };
            });
            store.dispatch(fetchUsersSuccess(users));
        })
        .catch(error => {
            store.dispatch(fetchUsersFailure(error.message));
        });
});

Исходные коды здесь, директория use-lib-redux-async-one.

$ cd learn-redux/use-lib-redux-async-one
$ npm install # установить все пакеты
$ npm run build:prod # сброка проекта

8.2. Генератор действия

По сути дела, мы здесь имеем дело со сложным экшеном, который состоит из нескольких простых. Было бы неплохо все это спрятать внутри генератора действия (action cerator) — например, fetchUsersProcess.

// Обработчик клика по кнопке
document.getElementById('load').addEventListener('click', () => {
    store.dispatch(fetchUsersProcess(store.dispatch));
});
// файл src/js/actions.js
import {
    FETCH_USERS_PROCESS,
    FETCH_USERS_STARTED,
    FETCH_USERS_SUCCESS,
    FETCH_USERS_FAILURE,
} from './types.js';

export const fetchUsersStarted = () => {
    return {
        type: FETCH_USERS_STARTED,
    };
};

export const fetchUsersSuccess = users => {
    return {
        type: FETCH_USERS_SUCCESS,
        payload: users,
    };
};

export const fetchUsersFailure = error => {
    return {
        type: FETCH_USERS_FAILURE,
        payload: error,
    };
};

export const fetchUsersProcess = dispatch => {
    dispatch(fetchUsersStarted());
    fetch('https://jsonplaceholder.typicode.com/users')
        .then(response => response.json())
        .then(jsonData => {
            const users = jsonData.map(user => {
                return { id: user.id, name: user.name, email: user.email };
            });
            dispatch(fetchUsersSuccess(users));
        })
        .catch(error => {
            dispatch(fetchUsersFailure(error.message));
        });
    // action creator должен возвращать объект action
    return {
        type: FETCH_USERS_PROCESS,
    };
};
// файл src/js/types.js
export const FETCH_USERS_STARTED = 'FETCH_USERS_STARTED';
export const FETCH_USERS_SUCCESS = 'FETCH_USERS_SUCCESS';
export const FETCH_USERS_FAILURE = 'FETCH_USERS_FAILURE';
export const FETCH_USERS_PROCESS = 'FETCH_USERS_PROCESS';

По сути, мы здесь обманываем Redux, подсовывая ему пустышку — экшен FETCH_USERS_PROCESS. Который не изменяет состояние, но вызывает новый рендер без всякой на то необходимости.

Исходные коды здесь, директория use-lib-redux-async-two.

$ cd learn-redux/use-lib-redux-async-two
$ npm install # установить все пакеты
$ npm run build:prod # сброка проекта

8.3. Middleware thunk

Давайте это исправим — перепишем функцию fetchUsersProcess таким образом, чтобы она возвращала функцию вместо объекта экшена. И напишем middleware, который будет отслеживать — получил store.dispatch объект или функцию.

// Обработчик клика по кнопке
document.getElementById('load').addEventListener('click', () => {
    /*
     * Вызов fetchUsersProcess возвращает не объект экшена, а функцию — и тогда в работу вступает
     * thunk. Middleware вызывает эту функцию, передавая ей store.dispatch и store.getState — так
     * что мы при написании этой функции можем в нужный момент вызвать action FETCH_USERS_STARTED,
     * FETCH_USERS_SUCCESS (при получении списка пользователей с сервера) или FETCH_USERS_FAILURE.
     */
    store.dispatch(fetchUsersProcess());
});
// файл src/js/actions.js
import { FETCH_USERS_STARTED, FETCH_USERS_SUCCESS, FETCH_USERS_FAILURE } from './types.js';

export const fetchUsersStarted = () => {
    return {
        type: FETCH_USERS_STARTED,
    };
};

export const fetchUsersSuccess = users => {
    return {
        type: FETCH_USERS_SUCCESS,
        payload: users,
    };
};

export const fetchUsersFailure = error => {
    return {
        type: FETCH_USERS_FAILURE,
        payload: error,
    };
};

export const fetchUsersProcess = () => {
    return (dispatch, getState) => {
        dispatch(fetchUsersStarted());
        fetch('https://jsonplaceholder.typicode.com/users')
            .then(response => response.json())
            .then(jsonData => {
                const users = jsonData.map(user => {
                    return { id: user.id, name: user.name, email: user.email };
                });
                dispatch(fetchUsersSuccess(users));
            })
            .catch(error => {
                dispatch(fetchUsersFailure(error.message));
            });
    };
};
// middleware, файл src/js/thunk.js
export const thunk = store => next => action => {
    if (typeof action === 'function') {
        return action(store.dispatch, store.getState);
    }
    return next(action);
};

С помощью этого хака мы избавились от лишнего рендера, когда в нем нет необходимости. И нам не нужно создавать middleware thunk самим — есть уже готовый пакет redux-thunk.

$ npm install redux-thunk
import 'bootstrap/dist/css/bootstrap.css';
import '../css/style.css';

import { createStore, applyMiddleware } from 'redux';
import logger from 'redux-logger';
import thunk from 'redux-thunk';
// import { thunk } from './thunk.js';
import { rootReducer } from './rootReducer.js';
import { fetchUsersProcess } from './actions.js';

// Создание и инициализация хранилища
const store = createStore(rootReducer, applyMiddleware(thunk, logger));

// Подписываемся на событие изменения
store.subscribe(() => {
    const state = store.getState();
    // новый рендер после изменения
    renderUsers(state);
});
// Первый рендер после инициализации
store.dispatch({ type: 'START_APPLICATION' });

// Функция рендера после изменения
function renderUsers({ users, loading, error }) {
    if (users.length) {
        const content = JSON.stringify(users, null, '  ');
        document.getElementById('users').innerHTML = `<pre>${content}</pre>`;
    } else {
        document.getElementById('users').textContent = '';
    }
    if (loading) {
        document.getElementById('loading').style.display = '';
    } else {
        document.getElementById('loading').style.display = 'none';
    }
    if (error) {
        document.querySelector('#error .alert').textContent = error;
        document.getElementById('error').style.display = '';
    } else {
        document.querySelector('#error .alert').textContent = '';
        document.getElementById('error').style.display = 'none';
    }
}

// Обработчик клика по кнопке
document.getElementById('load').addEventListener('click', () => {
    store.dispatch(fetchUsersProcess());
});

Исходные коды здесь, директория use-lib-redux-thunk.

$ cd learn-redux/use-lib-redux-thunk
$ npm install # установить все пакеты
$ npm run build:prod # сброка проекта

9. DevTools

Можно установить расширение для браузера и немного изменить вызов createStore, чтобы получить удобный инструмент разработчика Redux DevTools.

import { createStore } from 'redux';

const store = createStore(
    reducer,
    window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__()
);
import { createStore, applyMiddleware, compose } from 'redux';
import logger from 'redux-logger';
import thunk from 'redux-thunk';

const store = createStore(
    reducer,
    compose(
        applyMiddleware(thunk, logger),
        window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__()
    )
);

Вместо compose можно еще использовать composeWithDevTools:

$ npm install @redux-devtools/extension --save
import { createStore, applyMiddleware } from 'redux';
import { composeWithDevTools } from '@redux-devtools/extension';
import logger from 'redux-logger';
import thunk from 'redux-thunk';

const store = createStore(
    reducer,
    composeWithDevTools(
        applyMiddleware(thunk, logger)
    )
);

Поиск: JavaScript • Web-разработка • Теория • Функция • Redux • Reducer • Состояние • State

Каталог оборудования
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.