Хабрахабр

Redux Toolkit как средство эффективной Redux-разработки

Данная библиотека является самой популярной реализацией FLUX-архитектуры и, несмотря на ряд очевидных преимуществ, имеет весьма существенные недостатки, такие как: image
В настоящее время разработка львиной доли веб-приложений, основанных на фреймворке React, ведется с использованием библиотеки Redux.

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

Этот инструмент представляет собой набор практических решений и методов, предназначенных для упрощения разработки приложений с использованием Redux. Для устранения этих недостатков разработчики Redux представили библиотеку Redux Toolkit. Данный инструмент не является универсальным решением в каждом из возможных случаев использования Redux, но позволяет упростить тот код, который требуется написать разработчику. Разработчики данной библиотеки преследовали цель упростить типичные случаи использования Redux.

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

Кратко о библиотеке

Краткая информация о Redux Toolkit:

  • до релиза библиотека называлась redux-starter-kit;
  • релиз состоялся в конце октября 2019 года;
  • библиотека официально поддерживается разработчиками Redux.

Согласно заявлению разработчиков, Redux Toolkit выполняет следующие функции:

  • помогает быстро начать использовать Redux;
  • упрощает работу с типичными задачами и кодом Redux;
  • позволяет использовать лучшие практики Redux по умолчанию;
  • предлагает решения, которые уменьшают недоверие к бойлерплейтам.

Такой подход позволяет разработчику решить как и какие инструменты использовать в своем приложении. Redux Toolkit предоставляет набор как специально разработанных, так и добавляет ряд хорошо себя зарекомендовавших инструментов, которые обычно используются совместно с Redux. Более полную информацию и зависимостях Redux Toolkit можно получить из описания пакета @reduxjs/toolkit. По ходу данной статьи мы будем отмечать какие заимствования использует данная библиотека.

Наиболее значимыми функциями, предоставляемыми библиотекой Redux Toolkit являются:

  • #configureStore — функция, предназначенная упростить процесс создания и настройки хранилища;
  • #createReducer — функция, помогающая лаконично и понятно описать и создать редьюсер;
  • #createAction — возвращает функцию создателя действия для заданной строки типа действия;
  • #createSlice — объединяет в себе функционал createAction и createReducer;
  • createSelector — функция из библиотеки Reselect, переэкспортированная для простоты использования.

Более подробную информацию об этом можно получить из раздела Usage With TypeScript официальной документации. Также, стоит отметить, что Redux Toolkit полностью интегрирован с TypeScript.

Применение

Далее в статье будет приводиться исходный код как без использования Redux Toolkit, так и с использованием, что позволит лучше оценить положительные и отрицательные стороны использования данной библиотеки. Рассмотрим использование библиотеки Redux Toolkit на примере фрагмента реально используемого React Redux приложения.
Примечание.

Задача

Для каждого из этих действий были разработаны отдельные функции API, результаты выполнения которых и требуется добавлять в Redux store. В одном из наших внутренних приложений возникла необходимость добавлять, редактировать и отображать информацию о релизах выпускаемых нами программных продуктов. В качестве средства управления асинхронным поведением и побочными эффектами будем использовать Thunk.

Создание хранилища

Первоначальный вариант исходного кода, осуществляющего создание хранилища выглядел следующим образом:

import { createStore, applyMiddleware, combineReducers, compose,
} from 'redux';
import thunk from 'redux-thunk';
import * as reducers from './reducers'; const ext = window.__REDUX_DEVTOOLS_EXTENSION__;
const devtoolMiddleware = ext && process.env.NODE_ENV === 'development' ? ext() : f => f; const store = createStore( combineReducers(), compose( applyMiddleware(thunk), devtoolMiddleware )
);

Redux Toolkit содержит инструмент, призванный упростить данную процедуру, а именно: функцию configureStore. Если внимательно взглянуть на приведенный код, можно увидеть довольно длинную последовательность действий, которую необходимо совершить чтобы хранилище было полностью сконфигурировано.

Функция configureStore

В качестве входных параметров функция configureStore принимает объект со следующими свойствами: Данный инструмент позволяет автоматически комбинировать редьюсеры, добавить мидлвары Redux (по умолчанию включает redux-thunk), а также использовать расширение Redux DevTools.

  • reducer — набор пользовательских редьюсеров,
  • middleware — опциональный параметр, задающий массив мидлваров, предназначенных для подключения к хранилищу,
  • devTools — параметр логического типа, позволяющий включить установленное в браузер расширение Redux DevTools (значение по умолчанию — true),
  • preloadedState — опциональный параметр, задающий начальное состояние хранилища,
  • enhancers — опциональный параметр, задающий набор усилителей.

Данная функция возвращает массив с включенными по умолчанию в библиотеку Redux Toolkit мидлварами. Для получения наиболее популярного списка мидлваров можно воспользоваться специальной функцией getDefaultMiddleware, также входящей в состав Redux Toolkit. В production режиме массив состоит только из одного элемента — thunk. Перечень этих мидлваров отличается в зависимости от того, в каком режиме выполняется ваш код. В режиме development на момент написания статьи список пополняется следующими мидлварами:

  • serializableStateInvariant — инструмент, специально разработанный для использования в Redux Toolkit и предназначенный для проверки дерева состояний на предмет наличия несериализуемых значений, таких как функции, Promise, Symbol и другие значения, не являющиеся простыми JS-данными;
  • immutableStateInvariant — мидлвар из пакета redux-immutable-state-invariant, предназначенный для обнаружения мутаций данных, содержащихся в хранилище.

Подробнее с данной информацией можно ознакомиться в соответствующем разделе официальной документации. Для задания возващаемого перечня мидлваров функция getDefaultMidlleware принимает объект, определяющий перечень включенных мидлваров и настройки для каждого из них.

В результате получим следующее: Теперь перепишем участок кода, отвечающий за создание хранилища, воспользовавшись описанными выше инструментами.

import { configureStore, getDefaultMiddleware } from '@reduxjs/toolkit';
import * as reducers from './reducers'; const middleware = getDefaultMiddleware({ immutableCheck: false, serializableCheck: false, thunk: true,
}); export const store = configureStore({ reducer: { ...reducers }, middleware, devTools: process.env.NODE_ENV !== 'production',
});

На примере данного участка кода хорошо видно, что функция configureStore решает следующие проблемы:

  • необходимость комбинировать редьюсеры, автоматически вызывая combineReducers,
  • необходимость комбинировать мидлвары, автоматически вызывая applyMiddleware.

Все вышесказанное свидетельствует о том, что использование данной функции позволяет сделать код более компактным и понятным. А также позволяет более удобно включить расширение Redux DevTools, используя функцию composeWithDevTools из пакета redux-devtools-extension.

Передаем его в провайдер и переходим далее. На этом создание и настройка хранилища завершены.

Действия, создатели действий и редьюсер

Первоначальный вариант кода без использования Redux Toolkit был организован в виде файлов actions.js и reducers.js. Теперь рассмотрим возможности Redux Toolkit в части разработки действий, создателей действий и редьюсера. Содержимое файла actions.js выглядело следующим образом:

import * as productReleasesService from '../../services/productReleases'; export const PRODUCT_RELEASES_FETCHING = 'PRODUCT_RELEASES_FETCHING';
export const PRODUCT_RELEASES_FETCHED = 'PRODUCT_RELEASES_FETCHED';
export const PRODUCT_RELEASES_FETCHING_ERROR = 'PRODUCT_RELEASES_FETCHING_ERROR'; … export const PRODUCT_RELEASE_UPDATING = 'PRODUCT_RELEASE_UPDATING';
export const PRODUCT_RELEASE_UPDATED = 'PRODUCT_RELEASE_UPDATED';
export const PRODUCT_RELEASE_CREATING_UPDATING_ERROR = 'PRODUCT_RELEASE_CREATING_UPDATING_ERROR'; function productReleasesFetching() { return { type: PRODUCT_RELEASES_FETCHING };
} function productReleasesFetched(productReleases) { return { type: PRODUCT_RELEASES_FETCHED, productReleases };
} function productReleasesFetchingError(error) { return { type: PRODUCT_RELEASES_FETCHING_ERROR, error }
} … export function fetchProductReleases() { return dispatch => { dispatch(productReleasesFetching()); return productReleasesService.getProductReleases().then( productReleases => dispatch(productReleasesFetched(productReleases)) ).catch(error => { error.clientMessage = "Can't get product releases"; dispatch(productReleasesFetchingError(error)) }); }
} … export function updateProductRelease( id, productName, productVersion, releaseDate
) { return dispatch => { dispatch(productReleaseUpdating()); return productReleasesService.updateProductRelease( id, productName, productVersion, releaseDate ).then( productRelease => dispatch(productReleaseUpdated(productRelease)) ).catch(error => { error.clientMessage = "Can't update product releases"; dispatch(productReleaseCreatingUpdatingError(error)) }); }
}

Содержимое файла reducers.js до использования Redux Toolkit:

const initialState = { productReleases: [], loadedProductRelease: null, fetchingState: 'none', creatingState: 'none', updatingState: 'none', error: null,
}; export default function reducer(state = initialState, action = {}) { switch (action.type) { case productReleases.PRODUCT_RELEASES_FETCHING: return { ...state, fetchingState: 'requesting', error: null, }; case productReleases.PRODUCT_RELEASES_FETCHED: return { ...state, productReleases: action.productReleases, fetchingState: 'success', }; case productReleases.PRODUCT_RELEASES_FETCHING_ERROR: return { ...state, fetchingState: 'failed', error: action.error }; … case productReleases.PRODUCT_RELEASE_UPDATING: return { ...state, updatingState: 'requesting', error: null, }; case productReleases.PRODUCT_RELEASE_UPDATED: return { ...state, updatingState: 'success', productReleases: state.productReleases.map(productRelease => { if (productRelease.id === action.productRelease.id) return action.productRelease; return productRelease; }) }; case productReleases.PRODUCT_RELEASE_UPDATING_ERROR: return { ...state, updatingState: 'failed', error: action.error }; default: return state; }
}

Частично от этого бойлерплейта можно избавиться, если воспользоваться функциями createAction и createReducer, которые также входят в состав Redux Toolkit. Как мы можем видеть, именно здесь содержится большая часть бойлерплейта: константы типов действий, создатели действий, снова константы, но уже в коде редьюсера на написание всего этого кода приходится тратить время.

Функция createAction

Функция createAction объединяет эти два объявления в одно. В приведенном участке кода используется стандартный способ определения действия в Redux: сначала отдельно объявляется константа, определяющая тип действия, после чего — функция создателя действия этого типа. Создатель действия может быть вызван либо без аргументов, либо с некоторым аргументом (полезная нагрузка), значение которого будет помещено в поле payload, созданного действия. На вход она принимает тип действия и возвращает создателя действия для этого типа. Кроме того, создатель действия переопределяет функцию toString(), так что тип действия становится его строковым представлением.

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

export const productReleasesFetching = createAction('PRODUCT_RELEASES_FETCHING');
export const productReleasesFetched = createAction('PRODUCT_RELEASES_FETCHED');
export const productReleasesFetchingError = createAction('PRODUCT_RELEASES_FETCHING_ERROR'); … export function fetchProductReleases() { return dispatch => { dispatch(productReleasesFetching()); return productReleasesService.getProductReleases().then( productReleases => dispatch(productReleasesFetched({ productReleases })) ).catch(error => { error.clientMessage = "Can't get product releases"; dispatch(productReleasesFetchingError({ error })) }); }
}
...

Функция createReducer

Как и в нашем примере, редьюсеры часто реализуются с помощью оператора switch, с одним регистром для каждого обработанного типа действия. Теперь рассмотрим редьюсер. Например, легко забыть описать случай default или не установить начальное состояние. Этот подход работает хорошо, но не лишен бойлерплейта и подвержен ошибкам. Она также позволяет существенно упростить логику иммутабельного обновления, написав код в “мутабельном” стиле внутри редьюсеров. Функция createReducer упрощает создание функций редьюсера, определяя их как таблицы поиска функций для обработки каждого типа действия.

Функция обработчик может либо “мутировать” переданный state для изменения свойств, либо возвращать новый state, как при работе в иммутабельном стиле, но, благодаря Immer, реальная мутация объекта не осуществляется. “Мутабельный” стиль обработки событий доступен благодаря использованию библиотеки Immer. Первый вариант куда проще для работы и восприятия, особенно при изменении объекта с глубокой вложенностью.

Одновременное применение обоих методов обновления состояния не сработает. Будьте внимательны: возврат нового объекта из функции перекрывает “мутабельные” изменения.

В качестве входных параметров функция createReducer принимает следующие аргументы:

  • начальное состояние хранилища,
  • объект, устанавливающий соответствие между типами действий и редьюсерами, каждый из которых обрабатывает какой-то определенный тип.

Воспользовавшись методом createReducer, получим следующий код:

const initialState = { productReleases: [], loadedProductRelease: null, fetchingState: 'none', creatingState: 'none', loadingState: 'none', error: null,
}; const counterReducer = createReducer(initialState, { [productReleasesFetching]: (state, action) => { state.fetchingState = 'requesting' }, [productReleasesFetched.type]: (state, action) => { state.productReleases = action.payload.productReleases; state.fetchingState = 'success'; }, [productReleasesFetchingError]: (state, action) => { state.fetchingState = 'failed'; state.error = action.payload.error; }, … [productReleaseUpdating]: (state) => { state.updatingState = 'requesting' }, [productReleaseUpdated]: (state, action) => { state.updatingState = 'success'; state.productReleases = state.productReleases.map(productRelease => { if (productRelease.id === action.payload.productRelease.id) return action.payload.productRelease; return productRelease; }); }, [productReleaseUpdatingError]: (state, action) => { state.updating = 'failed'; state.error = action.payload.error; },
});

Поэтому рассмотрим более мощный вариант, объединяющий в себе генерацию и создателей действий и редьюсера — функция createSlice. Как мы видим, использование функций createAction и createReducer существенно решает проблему написания лишнего кода, но проблема предварительного создания констант всё равно остается.

Функция createSlice

В качестве входных параметров функция createSlice принимает объект со следующими полями:

  • name — пространство имен создаваемых действий (${name}/${action.type});
  • initialState — начальное состояние редьюсера;
  • reducers — объект с обработчиками. Каждый обработчик принимает функцию с аргументами state и action, action содержит в себе данные в свойстве payload и имя события в свойстве name. Кроме того, имеется возможность предварительного изменения данных, полученных из события, перед их попаданием в редьюсер (например, добавить id к элементам коллекции). Для этого вместо функции необходимо передать объект с полями reducer и prepare, где reducer — это функция-обработчик действия, а prepare — функция-обработчик полезной нагрузки, возвращающая обновленный payload;
  • extraReducers — объект, содержащий редьюсеры другого среза. Данный параметр может потребоваться в случае необходимости обновления объекта, относящегося к другому срезу. Подробнее про данную функциональную возможность можно узнать из соответствующего раздела официальной документации.

Результатом работы функции является объект, называемый "срез", со следующими полями:

  • name — имя среза,
  • reducer — редьюсер,
  • actions — набор действий.

Использовав данную функцию для решения нашей задачи, получим следующий исходный код:

const initialState = { productReleases: [], loadedProductRelease: null, fetchingState: 'none', creatingState: 'none', loadingState: 'none', error: null,
}; const productReleases = createSlice({ name: 'productReleases', initialState, reducers: { productReleasesFetching: (state) => { state.fetchingState = 'requesting'; }, productReleasesFetched: (state, action) => { state.productReleases = action.payload.productReleases; state.fetchingState = 'success'; }, productReleasesFetchingError: (state, action) => { state.fetchingState = 'failed'; state.error = action.payload.error; }, … productReleaseUpdating: (state) => { state.updatingState = 'requesting' }, productReleaseUpdated: (state, action) => { state.updatingState = 'success'; state.productReleases = state.productReleases.map(productRelease => { if (productRelease.id === action.payload.productRelease.id) return action.payload.productRelease; return productRelease; }); }, productReleaseUpdatingError: (state, action) => { state.updating = 'failed'; state.error = action.payload.error; }, },
});

Теперь извлечем из созданного среза создатели действий и редьюсер.

const { actions, reducer } = productReleases; export const { productReleasesFetched, productReleasesFetching, productReleasesFetchingError,
… productReleaseUpdated, productReleaseUpdating, productReleaseUpdatingError
} = actions; export default reducer;

Исходный код создателей действий, содержащих вызовы API, не изменился, за исключением способа передачи параметров при отправке действий:

export const fetchProductReleases = () => (dispatch) => { dispatch(productReleasesFetching()); return productReleasesService .getProductReleases() .then((productReleases) => dispatch(productReleasesFetched({ productReleases }))) .catch((error) => { error.clientMessage = "Can't get product releases"; dispatch(productReleasesFetchingError({ error })); });
}; … export const updateProductRelease = (id, productName, productVersion, releaseDate) => (dispatch) => { dispatch(productReleaseUpdating()); return productReleasesService .updateProductRelease(id, productName, productVersion, releaseDate) .then((productRelease) => dispatch(productReleaseUpdated({ productRelease }))) .catch((error) => { error.clientMessage = "Can't update product releases"; dispatch(productReleaseUpdatingError({ error })); });

Приведенный выше код, показывает, что функция createSlice позволяет избавиться от значительной части бойлерплейта при работе с Redux, что позволяет не только сделать код более компактным, лаконичным и понятным, но и тратить меньше времени на его написание.

Итог

Данные средства позволяют не только сделать процесс разработки более удобным, понятным и быстрым, но и более эффективным, за счет наличия в библиотеке ряда хорошо зарекомендовавших себя ранее инструментов. В завершении данной статьи, хотелось бы сказать, что несмотря на то, что библиотека Redux Toolkit не вносит ничего нового в управление хранилищем, она предоставляет ряд гораздо более удобных средств для написания кода чем были до этого. Мы, в Инобитек, планируем и дальше использовать данную библиотеку при разработке наших программных продуктов и следить за новыми перспективными разработками в области Web-технологий.

Надеемся, что наша статья окажется полезной. Спасибо за внимание. Более подробную информацию о библиотеке Redux Toolkit можно получить из официальной документации.

Показать больше

Похожие публикации

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»