Redux. Начало работы. Часть 1 из 2
14.08.2022
Теги: JavaScript • Web-разработка • Состояние • Теория • Функция
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