Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
Доброго времени суток, друзья!
Предлагаю вашему вниманию результаты небольшого исследования, посвященного сравнению Redux
и Vuex
.
Введение
Redux
и Vuex
— это библиотеки для управления состоянием приложений, написанных на React
и Vue
, соответственно. Каждая из них по-своему реализует архитектуру для создания пользовательских интерфейсов, известную под названием Flux
.
Обратите внимание: Flux-архитектура предназначена для работы с глобальным или распределенным (global, shared) состоянием, т.е. состоянием, которое используется двумя и более автономными компонентами приложения. Автономными являются компоненты, между которыми не существует отношений или связи “предок-потомок” или “родитель-ребенок”, т.е. это компоненты из разных поддеревьев дерева компонентов. Состояние, которое используется одним компонентом или передается от родительского компонента дочерним и обратно (в пределах одного поддерева), является локальным (local), оно должно храниться и управляться соответствующим компонентом. Разумеется, это не относится к корневому (root) компоненту.
Архитектура Flux
(в Redux
) предполагает следующее:
- наличие единственного источника истины или места для хранения состояния — хранилища (store);
- состояние изменяется только с помощью чистых функций — операций (actions);
- операции изменяют состояние не напрямую, а через редуктор (reducer), который модифицирует состояние на основе типа (type) и опциональной (необязательной) полезной нагрузки (payload) операции;
- операции отправляются в редуктор из слоя представления (view), пользовательского интерфейса, с помощью диспетчера (dispatcher);
- выборка определенной части состояния или вычисление производных данных осуществляется с помощью селекторов (selectors). Вы можете думать о селекторах как об инструкциях
SELECT
из языкаSQL
- асинхронные операции, такие как HTTP- или AJAX-запросы, выполняются с помощью преобразователей (thunks).
Это выглядит примерно так (без учета селекторов и преобразователей):
Наша задача состоит в том, чтобы определить, кто справляется с реализацией Flux
лучше, Redux
или Vuex
.
Для того, чтобы это выяснить, мы создадим простое приложение — список задач — со следующим функционалом:
- получение задач от сервера — асинхронная операция;
- добавление новой задачи — синхронная;
- обновление задачи: ее текста, состояния завершенности и, собственно, состояния редактирования — синхронные;
- удаление задачи — синхронная;
- фильтры: для отображения всех, только завершенных или только активных задач, — синхронная;
- кнопки для завершения всех активных задач, удаления завершенных задач и сохранения задач в "базе данных", здесь последняя операция будет асинхронной, остальные — синхронными;
- статистика: общее количество, количество завершенных и количество активных, а также процент активных задач — синхронная;
- сообщения: о загрузке, сохранении задач или возникшей ошибке — асинхронная.
В первой части статьи мы поговорим (много) про Redux Toolkit
и (немного) про React Redux
, а также реализуем хранилище для нашего React-приложения, во второй — поговорим о Vuex 4
, реализуем хранилище для нашего Vue-приложения, сравним реализацию компонентов приложений и измерим производительность операций, выполняемых с помощью Redux
и Vuex
.
Предполагается, что вы знакомы хотя бы с одним из названных фреймворков для фронтенда. Также предполагается, что вы немного знакомы с Node.js
.
О самих фреймворках я рассказывать не буду, но почти каждая строка кода компонентов будет снабжена подробным комментарием. Вместо рассказа о фреймворках, каждый из которых имеет документацию на русском языке (правда, документация по React
сильно устарела, но тем не менее), я постараюсь дать вам исчерпывающее представление о Redux
и Vuex
.
На выходе вы получите два готовых приложения, написанных с использованием самого последнего синтаксиса двух наиболее популярных фронтенд-фреймворков.
В качестве БД мы будем использовать JSON Server
, а для стилизации — Bootstrap
.
Выглядеть наше приложение будет так:
Если вас интересует только код, то вот ссылка на репозиторий.
Демо React-приложения можно посмотреть здесь, а Vue-приложения — здесь.
Вы готовы? Тогда вперед!
Redux Toolkit
Redux Toolkit
— это библиотека, существенно упрощающая работу с Redux
. Она была разработана для решения трех главных проблем, связанных с использованием Redux
:
- сложная настройка хранилища,
- необходимость использования дополнительных библиотек,
- большое количество шаблонного кода (boilerplate).
В состав Redux Toolkit
входит следующее (из того, что мы будем использовать):
configureStore()
— обертка дляcreateStore()
, метода для создания хранилища изRedux
, которая упрощает создание и настройку хранилища. Данный метод позволяет автоматически объединять отдельные редукторы (slice reducers), отвечающие за изменение определенной части состояния (частичные редукторы), с помощью методаcombineReducers()
изRedux
. Он также позволяет добавлять посредников (middlewares) и интегрирует в хранилищеRedux Thunk
— преобразователя для обработки результатов асинхронных операций;createSlice()
— данный метод принимает объект, содержащий редуктор, название части состояния (state slice), начальное значение состояния, и автоматически генерирует частичный редуктор с соответствующими создателями операций (action creators);createAsyncThunk()
— данный метод предназначен для выполнения асинхронных операций: он принимает тип операции и функцию, возвращающую промис, и генерирует преобразователь операции (thunk
), который, в свою очередь, отправляет типы операцийpending/fulfilled/rejected
в частичный редуктор;createEntityAdapter()
— данный метод генерирует набор повторно используемых (reusable) редукторов и селекторов для управления нормализованными данными в хранилище;createSelector()
— метод для создания селекторов из библиотекиReselect
.
Не волнуйтесь, мы со всем разберемся. Сначала мы рассмотрим сигнатуру каждого метода, а затем используем их для создания хранилища нашего React-приложения.
configureStore()
Метод configureStore()
принимает объект со следующими свойствами:
{
// редуктор или несколько редукторов, объединяемых в один (корневой, root reducer)
// символ `|` означает ИЛИ, а символ `?` означает, что свойство является опциональным (необязательным)
reducer: function | object,
// массив посредников (использоваться не будет)
middleware?: array,
// интеграция с инструментами разработчика `Redux` (использоваться не будет)
devTools?: boolean | object,
// начальное состояние
preloadedState: any,
// массив так называемых усилителей (использоваться не будет)
enhancers?: array | function
}
Таким образом, единственным обязательным свойством объекта, передаваемого в configureStore()
, является reducer
:
import { configureStore } from '@reduxjs/toolkit'
// импортируем редуктор
import rootReducer from './reducer'
// создаем и экспортируем хранилище
export const store = configureStore({ reducer: rootReducer })
Пример с несколькими редукторами, начальным состоянием и инструментами разработчика Redux
, включенными только в режиме для разработки:
import { configureStore } from '@reduxjs/toolkit'
// импорт частичных редукторов
import todoReducer from './todoSlice'
import filterReducer from './filterSlice'
// объект с редукторами — корневой редуктор
const reducer = {
todos: todoReducer,
filter: filterReducer
}
// начальное состояние
const preloadedState = {
todos: [
{
id: '1',
text: 'Eat',
done: true
},
{
id: '2',
text: 'Sleep',
done: false
}
],
filter: 'all'
}
// создание и экспорт хранилища
export const store = configureStore({
reducer,
devTools: process.env.NODE_ENV === 'development',
preloadedState
})
createSlice()
Метод createSlice()
принимает объект со следующими свойствами:
{
// название части состояния, которое используется в качестве префикса в типах операций
name: string,
// начальное состояние
initialState: any,
// объект с редукторами — названия ключей этого объекта используются в качестве названий создателей операций
reducers: object,
// дополнительные редукторы для обработки результатов асинхронных операций
extraReducers?: function | object
}
createSlice()
под капотом использует два других метода — createAction()
и createReducer()
— для создания операций и редуктора, соответственно, что позволяет использовать библиотеку immer
для "мутирования" состояния, т.е. для его прямой модификации.
Рассматриваемый метод возвращает такой объект:
{
name: string,
reducer: function,
actions,
caseReducers
}
Каждая функция, переданная в свойство reducers
метода createSlice()
, становится одноименным создателем операции в свойстве actions
возвращаемого объекта.
Одной из ключевых концепций Redux
является то, что каждый частичный редуктор "владеет" определенной частью состояния, и несколько таких редукторов могут обрабатывать один и тот же тип операции. extraReducers
предназначены для обработки внешних по отношению к редукторам операций, например, HTTP-запросов.
Простой пример со счетчиком:
import { createSlice } from '@reduxjs/toolkit'
// начальное состояние
const initialState = { value: 0 }
// часть состояния для счетчика
const counterSlice = createSlice({
name: 'counter',
initialState,
reducers: {
// операция для увеличения значения счетчика на 1
increment(state) {
state.value++
},
// операция для уменьшения значения счетчика на 1
decrement(state) {
state.value--
},
// операция для увеличения значения счетчика на число, переданное в качестве полезной нагрузки (payload)
incrementByAmount(state, action) {
state.value += action.payload
}
}
})
// экспорт создателей операций
export const { increment, decrement, incrementByAmount } = counterSlice.actions
// экспорт редуктора
export default counterSlice.reducer
Если полезная нагрузка, используемая для изменения состояния, требует предварительной подготовки, то значением соответствующего поля свойства reducers
должен быть объект, а не функция. Такой объект должен содержать два свойства: reducer
и prepare
. Значением reducer
должна быть функция для изменения состояния, а значением prepare
— колбэк для преобразования полезной нагрузки.
// `nanoid` входит в состав `Redux Toolkit`, мелочь, а приятно
import { createSlice, nanoid } from '@reduxjs/toolkit'
const toodSlice = createSlice({
name: 'todos',
initialState: [],
reducers: {
// операция для добавления новой задачи
// предположим, что в качестве полезной нагрузки передается только текст задачи,
// а нам еще нужен идентификатор
addTodo: {
// функция для изменения состояния
reducer: (state, action) => {
state.push(action.payload)
},
// функция для преобразования полезной нагрузки
prepare: (text) => {
// генерируем `id`
const id = nanoid(5)
return { payload: { id, text } }
}
}
}
})
Полный пример со счетчиком и пользователем:
// метод `createAction()` позволяет создавать внешние операции, интегрируемые в редуктор с помощью так называемого строителя (builder)
import { configureStore, createSlice, createAction } from '@reduxjs/toolkit'
// внешние операции
const incrementBy = createAction('incrementBy')
const decrementBy = createAction('decrementBy')
// часть состояния для счетчика
const counter = createSlice({
name: 'counter',
initialState: 0,
reducers: {
increment: (state) => state + 1,
decrement: (state) => state - 1,
multiply: {
reducer: (state, action) => state * action.payload,
// страховка на случай, если `value` отсутствует
prepare: (value) => ({ payload: value || 2 })
}
},
// дополнительные редукторы
// рекомендуемый синтаксис
extraReducers: (builder) => {
builder
.addCase(incrementBy, (state, action) => state + action.payload)
.addCase(decrementBy, (state, action) => state - action.payload)
}
})
// частичный редуктор для пользователя
const user = createSlice({
name: 'user',
initialState: { name: 'John', age: 20 },
reducers: {
setName: (state, action) => {
state.name = action.payload
}
},
// дополнительные редукторы
// альтернативный синтаксис
// при увеличении значения счетчика на 1,
// также увеличиваем на 1 возраст пользователя
extraReducers: {
[counter.actions.increment]: (state, action) => {
state.age += 1
}
}
})
// хранилище
const store = configureStore({
reducer: {
counter: counter.reducer,
user: user.reducer
}
})
// операции для счетчика
const { increment, decrement, multiply } = counter.actions
// операция для пользователя
const { setName } = user.actions
// для ручной отправки операций используется метод хранилища `dispatch()`
store.dispatch(increment())
// { counter: 1, user: {name : 'John', age: 21} }
store.dispatch(increment())
// { counter: 2, user: {name: 'John', age: 22} }
store.dispatch(multiply(3))
// { counter: 6, user: {name: 'John', age: 22} }
store.dispatch(multiply())
// { counter: 12, user: {name: 'John', age: 22} }
console.log(`${decrement}`)
// "counter/decrement"
store.dispatch(setUserName('Jane'))
// { counter: 6, user: { name: 'Jane', age: 22} }
createAsyncThunk()
Метод createAsyncThunk()
принимает тип операции, колбэк, который должен возвращать промис и объект с настройками. Также этот метод генерирует типы операций, соответствующие жизненному циклу промиса, и возвращает преобразователь (thunk), который запускает колбэк промиса и отправляет в редуктор соответствующие операции. Для обработки этих операций используются дополнительные редукторы. Да, проще показать.
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'
import { userAPI } from './userAPI'
// создание `thunk`
// запрос на получение данных пользователя по идентификатору
const fetchUserById = createAsyncThunk(
'users/fetchUserById',
async (userId) => {
const response = await userAPI.fetchById(userId)
return response.data
}
)
const userSlice = createSlice({
name: 'users',
initialState: {
entities: [],
loading: 'idle'
},
reducers: {
// обычные редукторы
},
extraReducers: {
// обработка результата запроса
[fetchUserById.fullfilled]: (state, action) => {
// добавляем пользователя в массив сущностей
state.entities.push(action.payload)
}
}
})
Рассмотрим параметры, которые принимает createAsyncThunk()
— type
, payloadCreator
и options
.
type
— строка, которая используется для генерации дополнительных типов операций. Например,type
со значениемusers/requestStatus
сгенерирует такие типы операций:pending
: users/requestStatus/pendingfulfilled
: users/requestStatus/fulfilled
rejected
: users/requestStatus/rejected
payloadCreator
— колбэк, возвращающий промис, содержащий результат некоторой асинхронной операции. Данный колбэк принимает два аргумента:arg
— любое значение, переданноеthunk
при отправке в редуктор с помощью диспетчера;thunkAPI
— объект, содержащий стандартные дляthunk
параметры и некоторые дополнительные опцииoptions
— объект, содержащий следующие необязательные свойства:condition
— колбэк, который может использоваться для пропуска выполненияpayloadCreator
dispatchConditionRejection
— используется для отправки отклоненной операции при отменеthunk
с помощьюcondition
.
Пример получения данных пользователя по его идентификатору с изменением индикатора загрузки и обеспечением отправки только одного запроса за раз:
// функция `unwrapResult()` может использоваться для извлечения полезной нагрузки операции `fulfilled` или для того, чтобы выбросить исключение
import { createAsyncThunk, createSlice, unwrapResult } from '@reduxjs/toolkit'
import { userAPI } from './userAPI'
const fetchUserById = createAsyncThunk(
'users/fetchUserById',
// `getState()` — метод хранилища для получения текущего состояния
// `requestId` — автоматически генерируемый уникальный идентификатор запроса
async (userId, { getState, requestId }) => {
// извлекаем `id` текущего запроса и состояние загрузки из состояния пользователей
const { currentRequestId, loading } = getState().users
// если значением состояния загрузки НЕ является `pending` или
// `id` запроса НЕ совпадает с `id` текущего запроса,
// значит, текущий запрос еще не завершен
if (loading !== 'pending' || requestId !== currentRequestId) {
return
}
const response = await userAPI.fetchById(userId)
return response.data
}
)
// состояние пользователей
const userSlice = createSlice({
name: 'users',
initialState: {
entities: [],
loading: 'idle',
currentRequestId: undefined,
error: null
},
reducers: {},
extraReducers: {
// запрос находится в процессе выполнения
[fetchUserById.pending]: (state, action) => {
if (state.loading === 'idle') {
// изменяем индикатор загрузки
state.loading = 'pending'
// сохраняем текущий идентификатор запроса
state.currentRequestId = action.meta.requestId
}
},
// запрос выполнен успешно
[fetchUserById.fulfilled]: (state, action) => {
const { requestId } = action.meta
if (state.loading === 'pending' && state.currentRequestId === requestId) {
// изменяем индикатор загрузки
state.loading = 'idle'
// добавляем пользователя в массив сущностей
state.entities.push(action.payload)
// очищаем значение текущего идентификатора запроса
state.currentRequestId = undefined
}
},
// запрос провалился
[fetchUserById.rejected]: (state, action) => {
if (state.loading === 'pending' && state.currentRequestId === requestId) {
// изменяем индикатор загрузки
state.loading = 'idle'
// записываем ошибку в соответствующее свойство
state.error = action.error
// очищаем значение текущего идентификатора запроса
state.currentRequestId = undefined
}
}
}
})
// соответствующий компонент
const UserComponent = () => {
// извлекаем пользователя, индикатор загрузки и ошибку из состояния пользователей
// про хуки `useSelector()` и `useDispatch()` см. ниже
const { user, loading, error } = useSelector((state) => state.users)
// получаем диспетчер
const dispatch = useDispatch()
const fetchUser = async (userId) => {
try {
// получаем данные пользователя
const result = await dispatch(fetchUserById(userId))
// извлекаем полезную нагрузку
const user = unwrapResult(result)
// показываем сообщение об успехе операции
showToast('success', `Получены данные пользователя ${user.name}`)
} catch (err) {
// показываем сообщение о провале операции
showToast('error', `Запрос завершился ошибкой: ${err.message}`)
}
}
// рендеринг пользовательского интерфейса
}
createEntityAdapter()
createEntityAdapter()
— это адаптер сущностей, функция, генерирующая набор встроенных редукторов и селекторов для выполнения GRUD-операций с нормализованными данными.
Сущность — это уникальный объект, содержащий определенную часть данных. Например, в блоге такими объектами могут быть User
, Post
и Comment
. Каждый объект может иметь несколько экземпляров. Каждый экземпляр должен иметь уникальный идентификатор.
Методы, генерируемые createEntityAdapter()
, манипулируют нормализованной структурой, которая выглядит так:
{
// уникальные `id` элементов, должны быть строками или числами
ids: [],
// поисковая таблица (lookup table), связывающая `id` с объектами
entities: {}
}
createEntityAdapter()
принимает 2 параметра:
selectId
— функция, принимающая сущность и возвращающая значение уникального поля. Используется в случае, когда уникальные значения хранятся в поле, отличающемся отid
. Реализацией по умолчанию являетсяentity => entity.id
;sortComparer
— колбэк, принимающий два экземпляра сущности и возвращающий числовой результат (1, 0 или -1), который определяет порядок сортировки (по аналогии с методомArray.sort()
).
Возвращаемым значением рассматриваемого метода является адаптер сущностей, объект, содержащий редукторы, селекторы, selectId
, sortComparer
и метод для инициализации начального состояния.
Простой пример с книгами:
import {
createEntityAdapter,
createSlice,
configureStore
} from '@reduxjs/toolkit'
// создаем адаптер сущностей
const bookAdapter = createEntityAdapter({
// предположим, что идентификаторы книг хранятся не в `book.id`, а в `book.bookId`
selectId: (book) => book.bookId,
// сортируем `id` по названиям книг
sortComparer: (a, b) => a.title.localeCompare(b.title)
})
const bookSlice = createSlice({
name: 'books',
// метод `getInitialState()` возвращает новый объект состояния сущности вида { ids: [], entities: {} }
initialState: bookAdapter.getInitialState(),
reducers: {
// адаптер может передаваться в частичный редуктор напрямую
addBook: bookAdapter.addOne,
setBooks(state, action) {
// или может вызываться как вспомогательная функция для изменения состояния (mutation helper)
bookAdapter.setAll(state, action.payload)
}
}
})
const store = configureStore({
reducer: {
books: bookSlice.reducer
}
})
// получаем начальное состояние — нормализованную структуру
console.log(store.getState().books)
// { ids: [], entities: {} }
// получаем набор мемоизированных селекторов на основе состояния сущности
const bookSelectors = bookAdapter.getSelectors((state) => state.books)
// и используем их для извлечения соответствующих значений
const allBooks = bookSelector.selectAll(store.getState())
Основным содержимым адаптера сущности является набор редукторов для добавления, обновления и удаления экземпляров из объекта состояния:
addOne
— принимает единичную сущность и добавляет ее;addMany
— принимает массив сущностей или объект определенной формы и добавляет их;setAll
— принимает массив сущностей или объект определенной формы и заменяет ими существующие сущности;removeOne
— принимает единичныйid
и удаляет соответствующую сущность, если она имеется;removeMany
— принимает массивid
и удаляет соответствующие сущности;updateOne
— принимает объект, содержащийid
сущности и объектchanges
с одним или более изменениями, и выполняет поверхностное обновление сущности;updateMany
— принимает массив объектов для обновления и выполняет поверхностное обновление сущностей;upsertOne
— принимает единичную сущность. Если сущность с указаннымid
существует, выполняется ее поверхностное обновление с объединением полей. Значения совпадающих полей перезаписываются. Если сущность с указаннымid
отсутствует, она добавляется;upsertMany
— принимает массив сущностей или объект определенной формы и выполняетupsertOne()
для каждой сущности.
Каждый из указанных методов имеет такую сигнатуру:
(state, argument) => newState
Другими словами, каждый метод принимает состояние вида {ids: [], entities: {}}
, вычисляет новое состояние на основе аргумента и возвращает его.
Адаптер сущностей также содержит функцию getSelectors()
, возвращающую набор мемоизированных селекторов, которые умеют читать содержимое объекта состояния:
selectIds
— возвращает массивids
;selectEntities
— возвращает поисковую таблицуentities
;selectAll
— проходится по массивуids
и возвращает массив сущностей в том же порядке;selectTotal
— возвращает общее количество сущностей;selectById
— принимает состояние иid
, возвращает сущность с даннымid
илиundefined
.
Расширенный пример с книгами:
import {
createEntityAdapter,
createSlice,
configureStore
} from '@reduxjs/toolkit'
// поскольку мы не указываем `selectId`, уникальным полем будет `book.id`
const bookAdapter = createEntityAdapter({
sortComparer: (a, b) => a.title.localeCompare(b.title)
})
const bookSlice = createSlice({
name: 'books',
initialState: bookAdapter.getInitialState({
// дополнительное поле — индикатор загрузки
loading: 'idle'
}),
reducers: {
addBook: bookAdapter.addOne,
loadBooks(state) {
if (state.loading === 'idle') {
state.loading = 'pending'
}
},
setBooks(state, action) {
if (state.loading === 'pending') {
bookAdapter.setAll(state, action.payload)
state.loading = 'idle'
}
},
updateBook: bookAdapter.updateOne
}
})
const { addBook, loadBooks, setBooks, updateBook } = bookSlice.actions
const store = configureStore({
reducer: {
books: bookSlice.reducer
}
})
// проверяем начальное состояние
console.log(store.getState().books)
// { ids: [], entities: {}, loading: 'idle' }
const { selectIds, selectAll } = bookAdapter.getSelectors(
(state) => state.books
)
store.dispatch(addBook({ id: 'a', title: 'Паттерны проектирования' }))
console.log(store.getState().books)
/*
{
ids: ["a"],
entities: {
a: { id: "a", title: "Паттерны проектирования"}
},
loading: 'idle'
}
*/
store.dispatch(updateBook({ id: 'a', changes: { title: 'Грокаем алгоритмы' } }))
store.dispatch(loadBooks())
console.log(store.getState().books)
/*
{
ids: ["a"],
entities: {
a: { id: "a", title: "Грокаем алгоритмы"}},
loading: 'pending'
}
*/
store.dispatch(
setBooks([
{ id: 'b', title: 'Вы не знаете JS' },
{ id: 'c', title: 'JavaScript. Подробное руководство' }
])
)
console.log(booksSelectors.selectIds(store.getState()))
// книга с идентификатором "a" была удалена из-за вызова `setAll()`
// поскольку книги сортируются по названиям, "JavaScript. Подробное руководство" будет находиться перед "Вы не знаете JS"
// ["c", "b"]
console.log(booksSelectors.selectAll(store.getState()))
// Все сущности в отсортированном порядке
/*
[
{ id: "c", title: "JavaScript. Подробное руководство" },
{ id: "b", title: "Вы не знаете JS" }
]
*/
createSelector()
createSelector()
— это метод из библиотеки Reselect
для создания селекторов на основе других селекторов. Он принимает селекторы через запятую или в виде массива и возвращает новый селектор. Этот новый селектор, в свою очередь, принимает состояние, производит выборку с помощью переданных селекторов и вычисляет конечный результат.
Сигнатура:
// создаем селектор
const mySelector = createSelector(
// первый селектор
(state) => state.values.value1,
// второй селектор
(state) => state.values.value2,
// селектор для вычисления конечного результата
(value1, value2) => value1 + value2
)
// селекторы также могут передаваться в виде массива
const totalSelector = createSelector(
[(state) => state.values.value1, (state) => state.values.value2],
(value1, value2) => value1 + value2
)
Пример:
import { createSelector } from '@reduxjs/toolkit'
// селектор для выборки товаров
const shopItemsSelector = (state) => state.shop.items
// селектор для выборки налоговой ставки (в процентах)
const taxPercent = (state) => state.shop.taxPercent
// селектор для вычисления общей стоимости товаров без учета налога
const subtotalSelector = createSelector(shopItemsSelector, (items) =>
items.reduce((subtotal, item) => subtotal + item.value, 0)
)
// селектор для вычисления налога
const taxSelector = createSelector(
subtotalSelector,
taxPercentSelector,
(subtotal, taxPercent) => subtotal * (taxPercent / 100)
)
// селектор для вычисления общей стоимости товаров с учетом налога
const totalSelector = createSelector(
subtotalSelector,
taxSelector,
(subtotal, tax) => ({ total: subtotal + tax })
)
// состояние
const state = {
shop: {
taxPercent: 8,
items: [
{ name: 'apple', value: 1.2 },
{ name: 'orange', value: 0.95 }
]
}
}
console.log(subtotalSelector(state)) // 2.15
console.log(taxSelector(state)) // 0.172
console.log(totalSelector(state)) // { total: 2.322 }
Итак, мы разобрались с методами, предоставляемыми Redux Toolkit
, которые мы будем использовать для создания хранилища нашего React-приложения. Далее я предлагаю взглянуть на React Redux
.
React Redux
React Redux
— это "официальный слой для связывания пользовательского интерфейса React-приложений с Redux". Он позволяет компонентам читать данные из хранилища и отправлять операции в редуктор для обновления состояния.
API
Провайдер
Данный компонент делает хранилище доступным для остальной части приложения. Другими словами, он делает состояние приложения доступным в компонентах, независимо от уровня их вложенности.
import React from 'react'
import ReactDOM from 'react-dom'
// импортируем провайдер
import { Provider } from 'react-redux'
// импортируем хранилище
import store from './store'
import App from './App'
const rootEl = document.getElementById('root')
ReactDOM.render(
// делаем хранилище доступным для компонентов приложения
<Provider store={store}>
<App />
</Provider>,
rootEl
)
Хуки
React Redux
предоставляет 2 хука, позволяющих компонентам взаимодействовать с хранилищем:
useSelector()
— принимает селектор и извлекает с его помощью часть состояния. При этом, выполняется подписка на обновления этой части — любое изменение этой части состояния влечет повторное вычисление селектора и обновление результата;useDispatch()
— возвращает диспетчер, позволяющий отправлять операции в редуктор.
import React from 'react'
// импортируем хуки
import { useSelector, useDispatch } from 'react-redux'
// предположим, что у нас имеются такие операции и селектор
import { decrement, increment, selectCount } from './counterSlice'
// CSS-модуль
import styles from './Counter.module.css'
export function Counter() {
// вычисляем состояние счетчика
const count = useSelector(selectCount)
// получаем диспетчер
const dispatch = useDispatch()
return (
<div>
<div className={styles.row}>
<button
className={styles.button}
aria-label='Увеличение значения на 1'
// при нажатии этой кнопки в редуктор отправляется операция `increment()`
onClick={() => dispatch(increment())}
>
+
</button>
{/* значение `count` будет обновляться при выполнении любой операции */}
<span className={styles.value}>{count}</span>
<button
className={styles.button}
aria-label='Уменьшение значения на 1'
// при нажатии этой кнопки в редуктор отправляется операция `decrement()`
onClick={() => dispatch(decrement())}
>
-
</button>
</div>
</div>
)
}
Хранилище React-приложения
Создание и настройка проекта
Инициализируем проект:
yarn create react-app redux-todo
# или
npx create react-app redux-todo
Переходим в директорию проекта и устанавливаем необходимые зависимости:
cd redux-todo
yarn add @reduxjs/toolkit axios concurrently json-server react-redux
axios
— утилита для отправки HTTP-запросов;concurrently
— утилита для одновременного выполнения нескольких команд вpackage.json
;json-server
— утилита для запуска сервера для разработки.
Удаляем ненужные файлы, создаем нужные, приводя проект к такой структуре:
- public
- index.html
- src
- components - компоненты
- List
- Edit.jsx - редактируемая задача
- index.jsx - список задач
- Regular.jsx - обычная задача
- Controls.jsx - кнопки для управления
- Filters.jsx - фильтры
- Loader.jsx - индикатор загрузки
- New.jsx - форма для добавления новой задачи
- Stats.jsx - статистика
- store
- index.jsx - хранилище
- App.jsx
- index.jsx
- ...
Подключаем bootstrap
и bootstrap-icons
в public/index.html
:
<head>
<link
href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.1/dist/css/bootstrap.min.css"
rel="stylesheet"
integrity="sha384-+0n0xVW2eSR5OomGNYDnhzAbDsOXxcvSN1TPprVMTNDbiYZCxYbOOl7+AMvyTG2x"
crossorigin="anonymous"
/>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.5.0/font/bootstrap-icons.css"
/>
</head>
Создаем в корневой директории файл db.json
следующего содержания (наша БД):
{
"todos": [
{
"id": "1",
"text": "Eat",
"done": true,
"edit": false
},
{
"id": "2",
"text": "Code",
"done": true,
"edit": false
},
{
"id": "3",
"text": "Sleep",
"done": false,
"edit": false
},
{
"id": "4",
"text": "Repeat",
"done": false,
"edit": false
}
]
}
Каждая задача имеет идентификатор, текст, индикатор выполнения и индикатор редактирования.
Добавляем в раздел scripts
файла package.json
команду для запуска серверов для разработки: одного для БД, другого для React
:
"dev": "concurrently \"json-server -w db.json -p 5000 -d 1000\" \"yarn start\""
Также в package.json
необходимо добавить такую строку для перенаправления запросов (по умолчанию запросы в режиме для разработки отправляются на localhost:3000
):
"proxy": "http://localhost:5000"
- флаг
-w
(или--watch
) означает наблюдение за файломdb.json
— перезапуск сервера при обновлении этого файла; -p
(или--port
) — номер порта, на котором запускается сервер (по умолчанию используется порт3000
, но он будет занят сервером дляReact
);-d
(или--delay
) означает задержку в 1 секунду перед возвращением ответа на запрос (для большей схожести с реальным сервером).
Для того, чтобы убедиться в правильной настройке проекта, можно добавить такой код в файл scr/index.jsx
:
import axios from 'axios'
axios('http://localhost:5000/todos').then((response) => {
console.log(response.data)
})
и выполнить команду:
yarn dev
# или
npm run dev
В консоли инструментов разработчика должно появиться такое сообщение:
[
{ id: "1", text: "Eat", done: true, edit: false }
{ id: "2", text: "Code", done: true, edit: false }
{ id: "3", text: "Sleep", done: false, edit: false }
{ id: "4", text: "Repeat", done: false, edit: false }
]
Проектирование хранилища
Перед тем, как приступать к непосредственной реализации хранилища, подумаем о том, как оно должно выглядеть.
Состояние
Первый вопрос, который необходимо решить: что из себя будет представлять состояние нашего приложения.
Очевидно, что нам потребуется состояние для задач, в котором, кроме самих задач, могут храниться индикатор загрузки и текст сообщения.
Также нам потребуется отдельное состояние для фильтра, изменение которого будет влиять на список отображаемых задач. Начальным значением фильтра будет all
— отображение всех задач.
Операции
Второй вопрос: какие операции потребуются для реализации функционала нашего приложения.
В случае с фильтром нам нужна всего лишь одна операция — операция для установки значения фильтра. Назовем ее setFilter()
.
Что касается задач, то нам потребуются следующие операции:
addTodo()
— добавление задачи;removeTodo()
— удаление задачи;updateTodo()
— обновление задачи (сигнатура методаupdateOne()
адаптера сущностей позволяет выполнять любые (поверхностные) обновления);completeAllTodos()
— завершение всех активных задач;clearCompletedTodos()
— удаление завершенных задач.
Преобразователи
Поскольку мы будем получать и сохранять задачи в БД, нам потребуется 2 преобразователя: fetchTodos()
и saveTodos()
.
Также, поскольку мы хотим показывать пользователю сообщение о загрузке, сохранении задач или о возникшей ошибке в течение определенного времени, нам понадобится еще один преобразователь — для реализации задержки перед очисткой сообщения. Назовем его giveMeSomeTime()
.
Таким образом, у нас будет 3 асинхронных операции. Для обработки их результатов нам нужны 3 дополнительных редуктора. Однако, учитывая, что мы хотим переключать индикатор загрузки при получении и сохранении задач в БД, у нас будет не 3, а 5 дополнительных редукторов:
fetchTodos.pending
— операция для загрузки задач находится в процессе выполнения;fetchTodos.fulfilled
— операция для загрузки задач завершена;saveTodos.pending
— операция для сохранения задач находится в процессе выполнения;saveTodos.fulfilled
— операция для сохранения задач завершена;giveMeSomeTime.fulfilled
— операция для задержки завершена.
Селекторы
Список отображаемых задач будет зависеть от состояния фильтра. Для выборки отфильтрованных задач мы будем использовать селектор selectFilteredTodos()
.
Для сбора статистики нам потребуется еще один селектор. Назовем его selectTodoStats()
.
Пожалуй, это все, что нам нужно для настройки хранилища. Можно приступать к его реализации.
Обратите внимание: обычно, для каждой части состояния создается отдельный файл, например, todoSlice.js
, filterSlice.js
и т.д. Однако, учитывая небольшой размер кодовой базы, мы ограничимся одним файлом — src/store/index.js
.
Реализация хранилища
Импортируем необходимые инструменты:
import {
configureStore,
createAsyncThunk,
createEntityAdapter,
createSelector,
createSlice
} from '@reduxjs/toolkit'
import axios from 'axios'
Определяем константу для адреса сервера:
const SERVER_URL = 'http://localhost:5000/todos'
Создаем адаптер сущностей для задач:
const todoAdapter = createEntityAdapter()
Инициализируем начальное состояние для задач:
const initialTodoState = todoAdapter.getInitialState({
// индикатор загрузки
status: 'idle',
// сообщение
message: {}
})
Создаем и экспортируем преобразователь для получения задач от сервера:
export const fetchTodos = createAsyncThunk('todos/fetchTodos', async () => {
try {
// получаем данные
const { data: todos } = await axios(SERVER_URL)
// возвращаем задачи и сообщение об успехе операции
return {
todos,
message: { type: 'success', text: 'Todos has been loaded' }
}
} catch (err) {
console.error(err.toJSON())
// возвращаем сообщение о провале операции
return {
message: { type: 'error', text: 'Something went wrong. Try again later' }
}
}
})
Создаем и экспортируем преобразователь для сохранения задач в БД.
Реализация этого преобразователя стала для меня интересной задачей. Я пытался свести к минимуму взаимодействие приложения с сервером и ограничиться двумя асинхронными операциями: получение задач от сервера и сохранение (текущего состояния) задач в БД. Все остальные операции выполняются синхронно и локально. С первой асинхронной операцией все просто: отправляем запрос, получаем ответ. Но в случае с сохранением задач в БД ситуация несколько сложнее по следующим причинам:
- мы не можем заменить все задачи, хранящиеся в БД, одной операцией (просто отправить задачи на сервер методом
POST
); - мы также не можем удалить все задачи, хранящиеся в БД, одной операцией;
- стоимость каждой (любой) операции — 1 секунда (из-за установленной искусственной задержки), поэтому даже если бы у нас была возможность удалить старые задачи одной операцией, запись каждой новой задачи — отдельная операция; допустим, что у нас имеется 4 новых задачи: удаление старых задач — это 1 секунда, запись новых задач — 4 секунды, итого 5 секунд — непозволительная роскошь;
- задачи, хранящиеся на сервере, могут быть модифицированы локально;
- задачи, хранящиеся на сервере, могут быть удалены локально;
- локально могут быть добавлены новые задачи;
- мы не знаем каких задач будет больше: тех, что хранятся на сервере, или тех, что отправляются на сервер, поэтому не можем ограничиться одной итерацией по массиву;
- задачи, отправляемые на сервер, могут быть идентичны задачам, хранящимся на сервере, и т.д.
Я постарался свести количество выполняемых запросов к минимуму. Вот что у меня получилось:
export const saveTodos = createAsyncThunk(
'todos/saveTodos',
async (newTodos) => {
try {
// Получаем данные — существующие задачи
const { data: existingTodos } = await axios(SERVER_URL)
// Перебираем существующие задачи
for (const todo of existingTodos) {
// формируем `URL` текущей задачи
const todoUrl = `${SERVER_URL}/${todo.id}`
// пытаемся найти общую задачу
const commonTodo = newTodos.find((_todo) => _todo.id === todo.id)
// Если получилось
if (commonTodo) {
// определяем наличие изменений
if (
!Object.entries(commonTodo).every(
([key, value]) => value === todo[key]
)
) {
// если изменения есть, обновляем задачу на сервере,
// в противном случае, ничего не делаем
await axios.put(todoUrl, commonTodo)
}
} else {
// Если общая задача отсутствует, удаляем задачу на сервере
await axios.delete(todoUrl)
}
}
// Перебираем новые задачи и сравниваем их с существующими
for (const todo of newTodos) {
// если новой задачи нет среди существующих
if (!existingTodos.find((_todo) => _todo.id === todo.id)) {
// сохраняем ее на сервере
await axios.post(SERVER_URL, todo)
}
}
// возвращаем сообщение об успехе
return { type: 'success', text: 'Todos has been saved' }
} catch (err) {
console.error(err.toJSON())
// возвращаем сообщение об ошибке
return {
type: 'error',
text: 'Something went wrong. Try again later'
}
}
}
)
Создаем и экспортируем преобразователь для задержки в 2 секунды.
Искусственную задержку для очистки сообщения также можно реализовать с помощью хука useEffect()
в App.jsx
, но мне хотелось управлять всем состоянием централизованно. И да, это своего рода костыль.
export const giveMeSomeTime = createAsyncThunk(
'todos/giveMeSomeTime',
async () =>
await new Promise((resolve) => {
const timerId = setTimeout(() => {
resolve()
clearTimeout(timerId)
}, 2000)
})
)
Создаем часть состояния для задач:
const todoSlice = createSlice({
// название
name: 'todos',
// начальное состояние в виде нормализованной структуры
initialState: initialTodoState,
// Обычные редукторы
reducers: {
// для добавления задачи
addTodo: todoAdapter.addOne,
// для обновления задачи
updateTodo: todoAdapter.updateOne,
// для удаления задачи
removeTodo: todoAdapter.removeOne,
// для завершения всех активных задач
completeAllTodos(state) {
Object.values(state.entities).forEach((todo) => {
todo.done = true
})
},
// для удаления завершенных задач
clearCompletedTodos(state) {
const completedTodoIds = Object.values(state.entities)
.filter((todo) => todo.done)
.map((todo) => todo.id)
todoAdapter.removeMany(state, completedTodoIds)
}
},
// Дополнительные редукторы для обработки результатов асинхронных операций
extraReducers: (builder) => {
builder
// запрос на получение задач от сервера находится в процессе выполнения
.addCase(fetchTodos.pending, (state) => {
// обновляем индикатор загрузки
state.status = 'loading'
})
// запрос выполнен
.addCase(fetchTodos.fulfilled, (state, { payload }) => {
if (payload.todos) {
// обновляем состояние задач
todoAdapter.setAll(state, payload.todos)
}
// записываем сообщение
state.message = payload.message
// обновляем индикатор загрузки
state.status = 'idle'
})
// запрос на сохранение задач в БД находится в процессе выполнения
.addCase(saveTodos.pending, (state) => {
// обновляем индикатор загрузки
state.status = 'loading'
})
// запрос выполнен
.addCase(saveTodos.fulfilled, (state, { payload }) => {
// записываем сообщение
state.message = payload
// обновляем индикатор загрузки
state.status = 'idle'
})
// запрос на задержку в 2 секунды выполнен
.addCase(giveMeSomeTime.fulfilled, (state) => {
// очищаем сообщение
state.message = {}
})
}
})
Экспортируем операции для работы с задачами:
export const {
addTodo,
updateTodo,
removeTodo,
completeAllTodos,
clearCompletedTodos
} = todoSlice.actions
Определяем начальное состояние, часть состояния для фильтра и экспортируем операцию для работы с ним:
// Начальное состояние фильтра
const initialFilterState = {
status: 'all'
}
// Часть состояния для фильтра
const filterSlice = createSlice({
// название
name: 'filter',
// начальное состояние
initialState: initialFilterState,
// обычные редукторы
reducers: {
// для установки фильтра
setFilter(state, action) {
state.status = action.payload
}
}
})
// Экспортируем операцию для установки фильтра
export const { setFilter } = filterSlice.actions
Создаем и экспортируем селекторы:
// Встроенные селекторы для выборки всех задач и общего количества задач
export const { selectAll, selectTotal } = todoAdapter.getSelectors(
(state) => state.todos
)
// Кастомный селектор для выборки задач на основе текущего состояния фильтра
export const selectFilteredTodos = createSelector(
// встроенный селектор
selectAll,
// селектор для выборки значения фильтра
(state) => state.filter,
// функция для вычисления конечного результата
(todos, filter) => {
const { status } = filter
// значением фильтра может быть `all`, `active` или `completed`
// в принципе, возможные значения фильтра можно определить в виде констант
if (status === 'all') return todos
return status === 'active'
? todos.filter((todo) => !todo.done)
: todos.filter((todo) => todo.done)
}
)
// Селектор для выборки статистики
export const selectTodoStats = createSelector(
// встроенный селектор
selectAll,
// встроенный селектор
selectTotal,
// функция для вычисления конечного результата
(todos, total) => {
// нас интересует общее количество, количество активных и количество завершенных задач, а также процент активных задач
const completed = todos.filter((todo) => todo.done).length
const active = total - completed
const percent = total === 0 ? 0 : Math.round((active / total) * 100)
return {
total,
completed,
active,
percent
}
}
)
Наконец, создаем и экспортируем хранилище:
export const store = configureStore({
reducer: {
todos: todoSlice.reducer,
filter: filterSlice.reducer
}
})
import {
createEntityAdapter,
createSlice,
createSelector,
createAsyncThunk,
configureStore
} from '@reduxjs/toolkit'
import axios from 'axios'
const SERVER_URL = 'http://localhost:5000/todos'
const todoAdapter = createEntityAdapter()
const initialTodoState = todoAdapter.getInitialState({
status: 'idle',
message: {}
})
export const fetchTodos = createAsyncThunk('todos/fetchTodos', async () => {
try {
const { data: todos } = await axios(SERVER_URL)
return {
todos,
message: { type: 'success', text: 'Todos has been loaded' }
}
} catch (err) {
console.error(err.toJSON())
return {
message: { type: 'error', text: 'Something went wrong. Try again later' }
}
}
})
export const saveTodos = createAsyncThunk(
'todos/saveTodos',
async (newTodos) => {
try {
const { data: existingTodos } = await axios(SERVER_URL)
for (const todo of existingTodos) {
const todoUrl = `${SERVER_URL}/${todo.id}`
const commonTodo = newTodos.find((_todo) => _todo.id === todo.id)
if (commonTodo) {
if (
!Object.entries(commonTodo).every(
([key, value]) => value === todo[key]
)
) {
await axios.put(todoUrl, commonTodo)
}
} else {
await axios.delete(todoUrl)
}
}
for (const todo of newTodos) {
if (!existingTodos.find((_todo) => _todo.id === todo.id)) {
await axios.post(SERVER_URL, todo)
}
}
return { type: 'success', text: 'Todos has been saved' }
} catch (err) {
console.error(err.toJSON())
return {
type: 'error',
text: 'Something went wrong. Try again later'
}
}
}
)
export const clearMessage = createAsyncThunk(
'todos/clearMessage',
async () =>
await new Promise((resolve) => {
const timerId = setTimeout(() => {
resolve()
clearTimeout(timerId)
}, 2000)
})
)
const todoSlice = createSlice({ё
name: 'todos',
initialState: initialTodoState,
reducers: {
addTodo: todoAdapter.addOne,
updateTodo: todoAdapter.updateOne,
removeTodo: todoAdapter.removeOne,
completeAllTodos(state) {
Object.values(state.entities).forEach((todo) => {
todo.done = true
})
},
clearCompletedTodos(state) {
const completedTodoIds = Object.values(state.entities)
.filter((todo) => todo.done)
.map((todo) => todo.id)
todoAdapter.removeMany(state, completedTodoIds)
}
},
extraReducers: (builder) => {
builder
.addCase(fetchTodos.pending, (state) => {
state.status = 'loading'
})
.addCase(fetchTodos.fulfilled, (state, { payload }) => {
if (payload.todos) {
todoAdapter.setAll(state, payload.todos)
}
state.message = payload.message
state.status = 'idle'
})
.addCase(saveTodos.pending, (state) => {
state.status = 'loading'
})
.addCase(saveTodos.fulfilled, (state, { payload }) => {
state.message = payload
state.status = 'idle'
})
.addCase(clearMessage.fulfilled, (state) => {
state.message = {}
})
}
})
export const {
addTodo,
updateTodo,
removeTodo,
completeAllTodos,
clearCompletedTodos
} = todoSlice.actions
const initialFilterState = {
status: 'all'
}
const filterSlice = createSlice({
name: 'filter',
initialState: initialFilterState,
reducers: {
setFilter(state, action) {
state.status = action.payload
}
}
})
export const { setFilter } = filterSlice.actions
export const { selectAll, selectTotal } = todoAdapter.getSelectors(
(state) => state.todos
)
export const selectFilteredTodos = createSelector(
selectAll,
(state) => state.filter,
(todos, filter) => {
const { status } = filter
if (status === 'all') return todos
return status === 'active'
? todos.filter((todo) => !todo.done)
: todos.filter((todo) => todo.done)
}
)
export const selectTodoStats = createSelector(
selectAll,
selectTotal,
(todos, total) => {
const completed = todos.filter((todo) => todo.done).length
const active = total - completed
const percent = total === 0 ? 0 : Math.round((active / total) * 100)
return {
total,
completed,
active,
percent
}
}
)
export const store = configureStore({
reducer: {
todos: todoSlice.reducer,
filter: filterSlice.reducer
}
})
Для того, чтобы сделать состояние из хранилища доступным в компонентах приложения, а также обновить состояние задачами, полученными от сервера, необходимо добавить такой код в src/index.jsx
:
import React, { StrictMode } from 'react'
import { render } from 'react-dom'
// Провайдер для передачи состояния в дочерние компоненты
import { Provider } from 'react-redux'
// Хранилище и операции для получения задач от сервера и выполнения задержки перед очисткой хранилища
import { store, fetchTodos, giveMeSomeTime } from './store'
// Основной компонент приложения
import App from './App'
// Отправляем в редуктор операцию для получения задач
// и следом за ней операцию для очистки сообщения с задержкой в 2 секунды
store.dispatch(fetchTodos()).then(() => store.dispatch(giveMeSomeTime()))
render(
<StrictMode>
{/* Передаем хранилище в качестве пропа `store` */}
<Provider store={store}>
<App />
</Provider>
</StrictMode>,
document.getElementById('root')
)
Таким образом, современный Redux
в лице Redux Toolkit
предоставляет относительно простой и понятный (по крайней мере, по сравнению с оригинальным Redux
) функционал для управления состоянием приложения. Но является ли Redux Toolkit
апогеем развития Flux-архитектуры? Посмотрим, что на этот счет скажет Vuex
, но уже в следующей части статьи.
Благодарю за внимание и хорошего дня!