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

14.08.2022

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

Redux — это способ управления состоянием приложения. Redux не привязан непосредственно к React.js и может также использоваться с другими js фреймворками. Чтобы понять, как работает Redux, создадим простое приложение и реализуем простое хранилище состояния на чистом javascript. А потом посмотрим, что нам предлагает библиотека Redux.

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

Нам потребуются файлы src/index.html и src/js/index.js, для оформления используем Bootstrap.

<!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">
            <span><%= htmlWebpackPlugin.options.title %></span>
            <button class="btn btn-info" id="theme">Сменить тему</button>
        </h1>
        <hr>
        <div class="card">
            <div class="card-body">
                <h5 class="card-title">Счетчик: <span id="counter"></span></h5>
                <button class="btn btn-primary" id="plus">Плюс</button>
                <button class="btn btn-danger" id="minus">Минус</button>
                <button class="btn btn-success" id="reset">Сбросить</button>
            </div>
        </div>
    </div>
</body>
</html>
import 'bootstrap/dist/css/bootstrap.css';
import '../css/style.css';

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

// Здесь будем хранить состояние счетчика
let state = 0;
render();

function render() {
    counter.textContent = state.toString();
}

plusBtn.addEventListener('click', () => {
    state++;
    render();
});

minusBtn.addEventListener('click', () => {
    state--;
    render();
});

resetBtn.addEventListener('click', () => {
    state = 0;
    render();
});

Для сборки проекта создаем файл конфигурации webpack.config.js:

const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');

const buildMode = process.env.NODE_ENV === 'production' ? 'production' : 'development';
// production-сборка в директорию build, development-сборка в директорию dist
const outputDir = process.env.NODE_ENV === 'production' ? 'build' : 'dist';
const sourceMap =
    process.env.NODE_ENV === 'production' ? 'nosources-source-map' : 'eval-source-map';

const loaderCSS =
    process.env.NODE_ENV === 'production' ? MiniCssExtractPlugin.loader : 'style-loader';

const config = {
    mode: buildMode,
    devtool: sourceMap,
    entry: {
        main: path.resolve(__dirname, 'src/js/index.js'),
    },
    output: {
        path: path.resolve(__dirname, outputDir),
        filename: '[name].[contenthash].js',
        // здесь будут файлы ресурсов, для которых не задан путь в настройках загрузчика
        assetModuleFilename: 'asset/[hash][ext][query]',
        clean: true,
    },
    plugins: [
        new HtmlWebpackPlugin({
            title: 'Learn Redux',
            template: path.resolve(__dirname, 'src/index.html'), // файл шаблона
            filename: 'index.html', // выходной файл
        }),
    ],
    module: {
        rules: [
            {
                test: /\.(png|jpe?g|gif|webp)$/i, // изображения
                type: 'asset',
                parser: {
                    dataUrlCondition: {
                        maxSize: 4096, // ограничение 4kb
                    },
                },
                generator: {
                    filename: 'img/[hash][ext][query]', // все изображения в dist/img или build/img
                },
            },
            {
                test: /\.s?css$/, // файл css-стилей
                use: [
                    loaderCSS, // style или link в head
                    'css-loader',
                    {
                        loader: 'postcss-loader',
                        options: {
                            postcssOptions: {
                                plugins: {
                                    'postcss-preset-env': {
                                        browsers: 'last 3 versions',
                                    },
                                },
                            },
                        },
                    },
                    'sass-loader',
                ],
            },
            {
                test: /\.(woff2?|eot|ttf|otf)$/i, // файлы шрифтов
                type: 'asset/resource',
                generator: {
                    filename: 'font/[hash][ext][query]', // все шрифты в dist/font или build/font
                },
            },
            {
                test: /\.js$/, // js-файлы, транспиляция
                exclude: /node_modules/,
                use: {
                    loader: 'babel-loader',
                    options: {
                        presets: ['@babel/preset-env'],
                    },
                },
            },
        ],
    },
    target: ['web', 'es5'], // сборка для браузера, es6+ будет преобразован в es5
    devServer: {
        port: 9000,
        open: true, // открыть браузер
    },
    watchOptions: {
        ignored: /node_modules/, // не отслеживать node_modules
        poll: 1000, // проверять изменения каждую секунду
    },
};

if (process.env.NODE_ENV === 'production') {
    config.plugins.push(new MiniCssExtractPlugin({ filename: '[name].[contenthash].css' }));
}

module.exports = config;

Исходные коды здесь, директория without-redux. Можно клонировать репозиторий, установить все пакеты, выполнить сборку в build и запустить LiveServer, чтобы обслуживал эту директорию. В файле package.json много зависимостей — но в основном, они нужны для сборки с использованием webpack, проверки кода (ESlint) и форматирования (Prettier).

{
    "name": "learn-redux",
    "version": "1.0.0",
    "description": "Изучение Redux",
    "main": "src/index.js",
    "private": true,
    "scripts": {
        "start": "webpack serve --open",
        "watch": "webpack watch",
        "build": "webpack build",
        "build:prod": "webpack --node-env production"
    },
    "keywords": [
        "redux",
        "javascript"
    ],
    "author": "Evgeniy Tokmakov",
    "devDependencies": {
        "@babel/core": "^7.18.10",
        "@babel/preset-env": "^7.18.10",
        "babel-loader": "^8.2.5",
        "css-loader": "^6.7.1",
        "eslint": "^8.21.0",
        "eslint-config-prettier": "^8.5.0",
        "eslint-config-standard": "^17.0.0",
        "eslint-plugin-import": "^2.26.0",
        "eslint-plugin-n": "^15.2.4",
        "eslint-plugin-prettier": "^4.2.1",
        "eslint-plugin-promise": "^6.0.0",
        "html-webpack-plugin": "^5.5.0",
        "mini-css-extract-plugin": "^2.6.1",
        "postcss-loader": "^7.0.1",
        "postcss-preset-env": "^7.7.2",
        "prettier": "^2.7.1",
        "sass": "^1.54.4",
        "sass-loader": "^13.0.2",
        "style-loader": "^3.3.1",
        "webpack": "^5.74.0",
        "webpack-cli": "^4.10.0",
        "webpack-dev-server": "^4.10.0"
    },
    "dependencies": {
        "bootstrap": "^5.2.0"
    }
}
$ cd learn-redux/without-redux
$ npm install # установить все пакеты
$ npm run build:prod # сброка проекта

Хранилище состояния

Теперь переделаем наше приложение, чтобы хранить состояние отдельно от представления. Нам потребуется функция createStore, которая будет хранить состояние. И функция rootReducer, которая будет изменять состояние. Редюсер — «чистая» функция, это значит, что у нее нет побочных эффектов. Она возвращает одно и то же значение, если задать одни и те же аргументы.

export function createStore(rootReducer, initState) {
    let state = rootReducer(initState, { type: 'INIT' });
    const subsribers = [];
    return {
        dispatch(action) {
            state = rootReducer(state, action);
            subsribers.forEach(callback => callback());
        },
        subscribe(callback) {
            subsribers.push(callback);
        },
        getState() {
            // возвращаем копию объекта хранилища, чтобы вызывающий
            // код не мог случйно изменить объект прямым доступом
            return { ...state };
        },
    };
}
export function rootReducer(state, action) {
    const newState = { ...state };
    switch (action.type) {
        case 'PLUS':
            newState.count++;
            return newState;
        case 'MINUS':
            newState.count--;
            return newState;
        case 'RESET':
            newState.count = 0;
            return newState;
        default:
            return state;
    }
}

Наше приложение теперь не будет хранить свое состояние и напрямую изменять его. После создания и инициализации хранилища начальным значением, приложение будет только говорить хранилищу, как надо изменить состояние. И получать доступ к хранилищу через функцию getState. А хранилище будет извещать приложение, когда состояние изменяется.

Хранилище реализует шаблон проектирования «Наблюдатель» — внешний код подписывается на прослушивание событий изменения состояния. И когда состояние изменяется — хранилище извещает подписчиков об этом. А подписчики могут отреагировать на это — например, новым рендером.

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

import { createStore } from './createStore.js';
import { rootReducer } from './rootReducer.js';

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

// Создание и инициализация хранилища
const initState = {
    count: 0,
};
const store = createStore(rootReducer, initState);

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

function renderCounter(count) {
    counter.textContent = count.toString();
}

plusBtn.addEventListener('click', () => {
    store.dispatch({ type: 'PLUS' });
});

minusBtn.addEventListener('click', () => {
    store.dispatch({ type: 'MINUS' });
});

resetBtn.addEventListener('click', () => {
    store.dispatch({ type: 'RESET' });
});

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

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

Библиотека Redux

Давайте теперь установим пакет redux и будем импортировать createStore из redux. Соберем проект, запустим LiveServer — наше приложение продолжает работать, как и раньше.

$ npm install redux
import { createStore } from 'redux';
$ npm run build:prod

Общая схема взаимодействия элементов Redux с приложением выглядит следующим образом:

1. Действие (action)

Действие (action) — это javascript-объект, который описывает суть изменения:

{
    type: 'PLUS'
}
// подробности об изменении
{
  type: 'PLUS',
  step: 2
}

Единственное требование к объекту действия — это наличие свойства type, значением которого обычно является строка.

2. Типы действий

В простом приложении тип действия задаётся строкой. По мере разрастания функциональности приложения лучше переходить на константы. Давайте создадим файл types.js, где определим три константы.

export const COUNTER_PLUS = 'COUNTER_PLUS';
export const COUNTER_MINUS = 'COUNTER_MINUS';
export const COUNTER_RESET = 'COUNTER_RESET';

И заменим везде строки на константы — теперь вероятность допустить опечатку в строке будет меньше.

import { COUNTER_PLUS, COUNTER_MINUS, COUNTER_RESET } from './types.js';

export function rootReducer(state, action) {
    const newState = { ...state };
    switch (action.type) {
        case COUNTER_PLUS:
            newState.count++;
            return newState;
        case COUNTER_MINUS:
            newState.count--;
            return newState;
        case COUNTER_RESET:
            newState.count = 0;
            return newState;
        default:
            return state;
    }
}
import { COUNTER_PLUS, COUNTER_MINUS, COUNTER_RESET } from './types.js';

/* .......... */

plusBtn.addEventListener('click', () => {
    store.dispatch({ type: COUNTER_PLUS });
});

minusBtn.addEventListener('click', () => {
    store.dispatch({ type: COUNTER_MINUS });
});

resetBtn.addEventListener('click', () => {
    store.dispatch({ type: COUNTER_RESET });
});

3. Генераторы действий

Генераторы действий (actions creators) — это функции, создающие действия. Давайте создадим файл actions.js и добавим в него три функции.

import { COUNTER_MINUS, COUNTER_PLUS, COUNTER_RESET } from './types.js';

export function counterPlus() {
    return {
        type: COUNTER_PLUS,
    };
}

export function counterMinus() {
    return {
        type: COUNTER_MINUS,
    };
}

export function counterReset() {
    return {
        type: COUNTER_RESET,
    };
}
import 'bootstrap/dist/css/bootstrap.css';
import '../css/style.css';

import { createStore } from 'redux';
import { rootReducer } from './rootReducer.js';
import { counterMinus, counterPlus, counterReset } from './actions.js';

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

// Создание и инициализация хранилища
const initState = {
    count: 0,
};
const store = createStore(rootReducer, initState);

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

function renderCounter(count) {
    counter.textContent = count.toString();
}

plusBtn.addEventListener('click', () => {
    store.dispatch(counterPlus());
});

minusBtn.addEventListener('click', () => {
    store.dispatch(counterMinus());
});

resetBtn.addEventListener('click', () => {
    store.dispatch(counterReset());
});

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

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

4. Редюсер (reducer)

Редюсер (reducer) — это чистая функция, которая вычисляет следующее состояние дерева на основании его предыдущего состояния и применяемого действия.

const reducer = (prevState, action) => {
    /* .......... */
    return nextState
}

Чистая функция работает независимо от состояния приложения и выдаёт выходное значение, принимая входное и ничего в нём не меняя (как и в остальном приложении). Таким образом, редюсер возвращает совершенно новый объект состояния, которым заменяется предыдущее состояние.

Редюсер — это всегда чистая функция, поэтому он не должен:

  • мутировать аргументы
  • мутировать состояние
  • иметь побочные эффекты
  • вызывать нечистые функции

5. Хранилище (store)

Хранилище (store) — это javascript-объект, который:

  • содержит состояние приложения
  • возвращает состояние через getState()
  • может обновлять состояние через dispatch()
  • позволяет регистрироваться на прослушивание изменений через subscribe()

6. Несколько редюсеров

Состояние в сложных приложениях может сильно разрастаться — так что есть смысл как-то его упорядочить. У нас есть кнопка «Сменить тему», которую мы пока не использовали. Текущую тему — светлая или темная — тоже будем хранить в состоянии.

// файл src/js/rootReducer.js
import { combineReducers } from 'redux';
import { counterReducer } from './counterReducer.js';
import { themeReducer } from './themeReducer.js';

export const rootReducer = combineReducers({
    counter: counterReducer,
    theme: themeReducer,
});
// файл src/js/counterReducer.js
import { COUNTER_PLUS, COUNTER_MINUS, COUNTER_RESET } from './types.js';

const initCounterState = {
    count: 0,
};

export function counterReducer(state = initCounterState, action) {
    const newState = { ...state };
    switch (action.type) {
        case COUNTER_PLUS:
            newState.count++;
            return newState;
        case COUNTER_MINUS:
            newState.count--;
            return newState;
        case COUNTER_RESET:
            newState.count = 0;
            return newState;
        default:
            return state;
    }
}
// файл src/js/themeReducer.js
import { THEME_CHANGE } from './types.js';

const initThemeState = {
    value: 'light',
};

export function themeReducer(state = initThemeState, action) {
    const newState = { ...state };
    switch (action.type) {
        case THEME_CHANGE:
            newState.value = action.value;
            return newState;
        default:
            return state;
    }
}
// файл src/js/types.js
export const COUNTER_PLUS = 'COUNTER_PLUS';
export const COUNTER_MINUS = 'COUNTER_MINUS';
export const COUNTER_RESET = 'COUNTER_RESET';

export const THEME_CHANGE = 'THEME_CHANGE';
// файл src/js/actions.js
import { COUNTER_MINUS, COUNTER_PLUS, COUNTER_RESET, THEME_CHANGE } from './types.js';

export function counterPlus() {
    return {
        type: COUNTER_PLUS,
    };
}
export function counterMinus() {
    return {
        type: COUNTER_MINUS,
    };
}
export function counterReset() {
    return {
        type: COUNTER_RESET,
    };
}

export function themeChange(newTheme) {
    return {
        type: THEME_CHANGE,
        value: newTheme,
    };
}
// файл src/js/index.js
import 'bootstrap/dist/css/bootstrap.css';
import '../css/style.css';

import { createStore } from 'redux';
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');

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

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

// Функции рендера после изменения
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));
});

Теперь функция getState возвращает такой объект:

{
    counter: {
        count: 5,
    },
    theme: {
        value: 'dark',
    }
}

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

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

Поиск: 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.