Redux Vs Vuex. Часть 1

Kate

Administrator
Команда форума
Предлагаю вашему вниманию результаты небольшого исследования, посвященного сравнению 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).

Это выглядит примерно так (без учета селекторов и преобразователей):


gmwwnqmma6rdwtf3ofir3fjrcmy.gif


Наша задача состоит в том, чтобы определить, кто справляется с реализацией Flux лучше, Redux или Vuex.


Для того, чтобы это выяснить, мы создадим простое приложение — список задач — со следующим функционалом:


  • получение задач от сервера — асинхронная операция;
  • добавление новой задачи — синхронная;
  • обновление задачи: ее текста, состояния завершенности и, собственно, состояния редактирования — синхронные;
  • удаление задачи — синхронная;
  • фильтры: для отображения всех, только завершенных или только активных задач, — синхронная;
  • кнопки для завершения всех активных задач, удаления завершенных задач и сохранения задач в "базе данных", здесь последняя операция будет асинхронной, остальные — синхронными;
  • статистика: общее количество, количество завершенных и количество активных, а также процент активных задач — синхронная;
  • сообщения: о загрузке, сохранении задач или возникшей ошибке — асинхронная.

В первой части статьи мы поговорим (много) про Redux Toolkit и (немного) про React Redux, а также реализуем хранилище для нашего React-приложения, во второй — поговорим о Vuex 4, реализуем хранилище для нашего Vue-приложения, сравним реализацию компонентов приложений и измерим производительность операций, выполняемых с помощью Redux и Vuex.


Предполагается, что вы знакомы хотя бы с одним из названных фреймворков для фронтенда. Также предполагается, что вы немного знакомы с Node.js.


О самих фреймворках я рассказывать не буду, но почти каждая строка кода компонентов будет снабжена подробным комментарием. Вместо рассказа о фреймворках, каждый из которых имеет документацию на русском языке (правда, документация по React сильно устарела, но тем не менее), я постараюсь дать вам исчерпывающее представление о Redux и Vuex.


На выходе вы получите два готовых приложения, написанных с использованием самого последнего синтаксиса двух наиболее популярных фронтенд-фреймворков.


В качестве БД мы будем использовать JSON Server, а для стилизации — Bootstrap.


Выглядеть наше приложение будет так:


pngjkrasgkocx0tstiqvlvhtw74.png

Если вас интересует только код, то вот ссылка на репозиторий.


Демо 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/pending
  • fulfilled: 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
}
})

Полный код хранилища:

Для того, чтобы сделать состояние из хранилища доступным в компонентах приложения, а также обновить состояние задачами, полученными от сервера, необходимо добавить такой код в 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, но уже в следующей части статьи.


Благодарю за внимание и хорошего дня!

Источник статьи: https://habr.com/ru/company/timeweb/blog/566682/
 
Сверху