Неизменяемость в JavaScript с использованием Redux
Опубликовано: 2022-03-11В постоянно растущей экосистеме многофункциональных и сложных приложений JavaScript существует больше состояний, которыми нужно управлять, чем когда-либо прежде: текущий пользователь, список загруженных сообщений и т. д.
Любой набор данных, для которого требуется история событий, может считаться сохраняющим состояние. Управление состоянием может быть сложным и подверженным ошибкам, но работа с неизменяемыми данными (а не с изменяемыми) и некоторыми вспомогательными технологиями, а именно Redux для целей этой статьи, могут значительно помочь.
У неизменяемых данных есть ограничения, а именно то, что они не могут быть изменены после их создания, но у них также есть много преимуществ, особенно в равенстве ссылок и значений, что может значительно ускорить работу приложений, которые полагаются на частое сравнение данных (проверка, не нужно ли что-то обновить). , Например).
Использование неизменяемых состояний позволяет нам писать код, который может быстро определить, изменилось ли состояние, без необходимости рекурсивного сравнения данных, которое обычно выполняется намного быстрее.
В этой статье будут рассмотрены практические применения Redux при управлении состоянием с помощью создателей действий, чистых функций, составных редукторов, нечистых действий с помощью Redux-saga и Redux Thunk и, наконец, использование Redux с React. Тем не менее, существует множество альтернатив Redux, таких как библиотеки на основе MobX, Relay и Flux.
Почему Редукс?
Ключевым аспектом, который отделяет Redux от большинства других контейнеров состояний, таких как MobX, Relay и большинства других реализаций на основе Flux, является то, что Redux имеет одно состояние, которое можно изменить только с помощью «действий» (обычных объектов JavaScript), которые отправляются в Магазин Редукс. Большинство других хранилищ данных имеют состояние, содержащееся в самих компонентах React, что позволяет вам иметь несколько хранилищ и/или использовать изменяемое состояние.
Это, в свою очередь, заставляет редьюсер хранилища, чистую функцию, которая работает с неизменяемыми данными, выполнять и потенциально обновлять состояние. Этот процесс обеспечивает однонаправленный поток данных, который проще для понимания и более детерминирован.
Поскольку редукторы Redux — это чистые функции, работающие с неизменяемыми данными, они всегда выдают один и тот же результат при одних и тех же входных данных, что упрощает их тестирование. Вот пример редуктора:
import Immutable from 'seamless-immutable' const initialState = Immutable([]) // create immutable array via seamless-immutable /** * a reducer takes a state (the current state) and an action object (a plain JavaScript object that was dispatched via dispatch(..) and potentially returns a new state. */ function addUserReducer(state = initialState, action) { if (action.type === 'USERS_ADD') { return state.concat(action.payload) } return state // note that a reducer MUST return a value } // somewhere else... store.dispatch({ type: 'USERS_ADD', payload: user }) // dispatch an action that causes the reducer to execute and add the user
Работа с чистыми функциями позволяет Redux легко поддерживать многие варианты использования, которые обычно нелегко реализовать с мутативным состоянием, например:
- Путешествие во времени (возвращение во времени в предыдущее состояние)
- Ведение журнала (отслеживайте каждое действие, чтобы выяснить, что вызвало мутацию в магазине)
- Среды для совместной работы (например, GoogleDocs, где действия являются простыми объектами JavaScript и могут быть сериализованы, отправлены по сети и воспроизведены на другом компьютере)
- Простая отчетность об ошибках (просто отправьте список отправленных действий и воспроизведите их, чтобы получить точно такое же состояние)
- Оптимизированный рендеринг (по крайней мере, в фреймворках, которые отображают виртуальный DOM как функцию состояния, таких как React: из-за неизменности вы можете легко определить, изменилось ли что-то, сравнивая ссылки, а не рекурсивно сравнивая объекты)
- Легко тестируйте ваши редукторы, так как чистые функции можно легко протестировать
Создатели действий
Создатели действий Redux помогают содержать код в чистоте и тестируемости. Помните, что «действия» в Redux — это не что иное, как простые объекты JavaScript, описывающие мутацию, которая должна произойти. При этом запись одних и тех же объектов снова и снова повторяется и подвержена ошибкам.
Создатель действия в Redux — это просто вспомогательная функция, которая возвращает простой объект JavaScript, описывающий мутацию. Это помогает уменьшить повторяющийся код и сохраняет все ваши действия в одном месте:
export function usersFetched(users) { return { type: 'USERS_FETCHED', payload: users, } } export function usersFetchFailed(err) { return { type: 'USERS_FETCH_FAILED', payload: err, } } // reducer somewhere else... const initialState = Immutable([]) // create immutable array via seamless-immutable /** * a reducer takes a state (the current state) and an action object (a plain JavaScript object that was dispatched via dispatch(..) and potentially returns a new state. */ function usersFetchedReducer(state = initialState, action) { if (action.type === 'USERS_FETCHED') { return Immutable(action.payload) } return state // note that a reducer MUST return a value }
Использование Redux с неизменяемыми библиотеками
Хотя сама природа редьюсеров и действий упрощает их тестирование, без вспомогательной библиотеки неизменности ничто не защитит вас от мутирующих объектов, а это означает, что тесты для всех ваших редьюсеров должны быть особенно надежными.
Рассмотрим следующий пример кода проблемы, с которой вы столкнетесь без библиотеки для защиты:
const initialState = [] function addUserReducer(state = initialState, action) { if (action.type === 'USERS_ADD') { state.push(action.payload) // NOTE: mutating action!! return state } return state // note that a reducer MUST return a value }
В этом примере кода путешествие во времени будет прервано, так как предыдущее состояние теперь будет таким же, как текущее состояние, чистые компоненты потенциально могут не обновляться (или повторно рендериться), поскольку ссылка на состояние не изменилась, даже если данные contains изменился, и мутации намного сложнее понять.
Без библиотеки неизменяемости мы теряем все преимущества, которые предоставляет Redux. Поэтому настоятельно рекомендуется использовать вспомогательную библиотеку неизменяемости, такую как immutable.js или Seamless-immutable, особенно при работе в большой команде с несколькими руками, работающими с кодом.
Независимо от того, какую библиотеку вы используете, Redux будет вести себя одинаково. Давайте сравним плюсы и минусы обоих, чтобы вы могли выбрать тот, который лучше всего подходит для вашего варианта использования:
Неизменяемый.js
Immutable.js — это библиотека, созданная Facebook, с более функциональным стилем для структур данных, таких как карты, списки, наборы и последовательности. Его библиотека неизменяемых постоянных структур данных выполняет минимально возможное количество копий между различными состояниями.
Плюсы:
- Структурный обмен
- Более эффективен при обновлениях
- Более эффективное использование памяти
- Имеет набор вспомогательных методов для управления обновлениями.
Минусы:
- Не работает без проблем с существующими библиотеками JS (например, lodash, ramda)
- Требуется преобразование в и из (toJS/fromJS), особенно во время гидратации/дегидратации и рендеринга
Бесшовный-неизменяемый
Seamless-immutable — это библиотека для неизменяемых данных, обратно совместимая вплоть до ES5.
Он основан на функциях определения свойств ES5, таких как defineProperty(..)
для отключения мутаций объектов. Таким образом, он полностью совместим с существующими библиотеками, такими как lodash и Ramda. Его также можно отключить в производственных сборках, что обеспечивает потенциально значительный прирост производительности.
Плюсы:
- Беспрепятственно работает с существующими библиотеками JS (например, lodash, ramda)
- Для поддержки преобразования не требуется дополнительный код
- Проверки могут быть отключены в производственных сборках, что повышает производительность.
Минусы:
- Нет структурного разделения — объекты/массивы копируются неглубоко, что замедляет работу с большими наборами данных.
- Не так эффективно использовать память
Redux и несколько редукторов
Еще одна полезная функция Redux — возможность компоновать редюсеры вместе. Это позволяет создавать гораздо более сложные приложения, а в приложении любого заметного размера у вас неизбежно будет несколько типов состояния (текущий пользователь, список загруженных постов, так далее). Redux поддерживает (и поощряет) этот вариант использования, естественным образом предоставляя функцию combineReducers
:
import { combineReducers } from 'redux' import currentUserReducer from './currentUserReducer' import postsListReducer from './postsListReducer' export default combineReducers({ currentUser: currentUserReducer, postsList: postsListReducer, })
С приведенным выше кодом у вас может быть компонент, который зависит от currentUser
и другой компонент, который зависит от postsList
. Это также повышает производительность, поскольку любой отдельный компонент будет подписываться только на те ветви дерева, которые их касаются.
Нечистые действия в Redux
По умолчанию вы можете отправлять в Redux только простые объекты JavaScript. Однако с промежуточным программным обеспечением Redux может поддерживать нечистые действия, такие как получение текущего времени, выполнение сетевого запроса, запись файла на диск и т. д.
«Промежуточное ПО» — это термин, используемый для функций, которые могут перехватывать отправляемые действия. После перехвата он может выполнять такие действия, как преобразование действия или отправка асинхронного действия, как промежуточное ПО в других фреймворках (например, Express.js).
Двумя очень распространенными библиотеками промежуточного программного обеспечения являются Redux Thunk и Redux-saga. Redux Thunk написан в императивном стиле, а Redux-saga — в функциональном стиле. Давайте сравним оба.

Редукс Преобразователь
Redux Thunk поддерживает нечистые действия в Redux, используя thunks, функции, которые возвращают другие функции, способные к цепочке. Чтобы использовать Redux-Thunk, вы должны сначала смонтировать промежуточное ПО Redux Thunk в хранилище:
import { createStore, applyMiddleware } from 'redux' import thunk from 'redux-thunk' const store = createStore( myRootReducer, applyMiddleware(thunk), // here, we apply the thunk middleware to R )
Теперь мы можем выполнять нечистые действия (например, выполнять вызов API), отправляя преобразователь в хранилище Redux:
store.dispatch( dispatch => { return api.fetchUsers() .then(users => dispatch(usersFetched(users)) // usersFetched is a function that returns a plain JavaScript object (Action) .catch(err => dispatch(usersFetchError(err)) // same with usersFetchError } )
Важно отметить, что использование переходников может затруднить тестирование вашего кода и усложнить анализ потока кода.
Redux-сага
Redux-saga поддерживает нечистые действия через функцию ES6 (ES2015), называемую генераторами, и библиотеку функциональных/чистых помощников. Самое замечательное в генераторах то, что их можно возобновлять и приостанавливать, а их API-контракт делает их чрезвычайно простыми для тестирования.
Давайте посмотрим, как мы можем улучшить читабельность и тестируемость предыдущего метода thunk, используя саги!
Во-первых, давайте смонтируем промежуточное ПО Redux-saga в наш магазин:
import { createStore, applyMiddleware } from 'redux' import createSagaMiddleware from 'redux-saga' import rootReducer from './rootReducer' import rootSaga from './rootSaga' // create the saga middleware const sagaMiddleware = createSagaMiddleware() // mount the middleware to the store const store = createStore( rootReducer, applyMiddleware(sagaMiddleware), ) // run our saga! sagaMiddleware.run(rootSaga)
Обратите внимание, что функция run(..)
должна быть вызвана с сагой, чтобы она начала выполняться.
Теперь давайте создадим нашу сагу:
import { call, put, takeEvery } from 'redux-saga/effects' // these are saga effects we'll use export function *fetchUsers(action) { try { const users = yield call(api.fetchUsers) yield put(usersFetched(users)) } catch (err) { yield put(usersFetchFailed(err)) } } export default function *rootSaga() { yield takeEvery('USERS_FETCH', fetchUsers) }
Мы определили две функции-генератора, одну из которых извлекает список пользователей, а другую — rootSaga
. Обратите внимание, что мы не вызывали api.fetchUsers
напрямую, а передали его в объекте вызова. Это связано с тем, что Redux-saga перехватывает объект вызова и выполняет функцию, содержащуюся внутри, для создания чистой среды (насколько это касается ваших генераторов).
rootSaga
выдает один вызов функции takeEvery,
которая принимает каждое действие, отправленное с типом USERS_FETCH
и вызывает сагу fetchUsers
с выполненным действием. Как мы видим, это создает очень предсказуемую модель побочных эффектов для Redux, которую легко тестировать!
Саги о тестировании
Давайте посмотрим, как генераторы облегчают тестирование наших саг. В этой части мы будем использовать mocha для запуска модульных тестов и chai для утверждений.
Поскольку саги дают простые объекты JavaScript и запускаются внутри генератора, мы можем легко проверить, правильно ли они ведут себя, вообще без каких-либо моков! Имейте в виду, что call
, take
, put
и т. д. — это просто объекты JavaScript, которые перехватываются промежуточным программным обеспечением Redux-saga.
import { take, call } from 'redux-saga/effects' import { expect } from 'chai' import { rootSaga, fetchUsers } from '../rootSaga' describe('saga unit test', () => { it('should take every USERS_FETCH action', () => { const gen = rootSaga() // create our generator iterable expect(gen.next().value).to.be.eql(take('USERS_FETCH')) // assert the yield block does have the expected value expect(gen.next().done).to.be.equal(false) // assert that the generator loops infinitely }) it('should fetch the users if successful', () => { const gen = fetchUsers() expect(gen.next().value).to.be.eql(call(api.fetchUsers)) // expect that the call effect was yielded const users = [ user1, user2 ] // some mock response expect(gen.next(users).value).to.be.eql(put(usersFetched(users)) }) it('should fail if API fails', () => { const gen = fetchUsers() expect(gen.next().value).to.be.eql(call(api.fetchUsers)) // expect that the call effect was yielded const err = { message: 'authentication failed' } // some mock error expect(gen.throw(err).value).to.be.eql(put(usersFetchFailed(err)) }) })
Работа с Реактом
Хотя Redux не привязан к какой-либо конкретной сопутствующей библиотеке, он особенно хорошо работает с React.js, поскольку компоненты React — это чистые функции, которые принимают состояние в качестве входных данных и создают виртуальный DOM в качестве выходных данных.
React-Redux — это вспомогательная библиотека для React и Redux, которая устраняет большую часть тяжелой работы по их соединению. Чтобы наиболее эффективно использовать React-Redux, давайте рассмотрим понятие презентационных компонентов и компонентов-контейнеров.
Презентационные компоненты описывают, как вещи должны выглядеть визуально, в зависимости исключительно от их реквизита для визуализации; они вызывают обратные вызовы из реквизита для отправки действий. Они написаны вручную, полностью чисты и не привязаны к системам управления состоянием, таким как Redux.
Компоненты-контейнеры, с другой стороны, описывают, как все должно работать, осведомлены о Redux, отправляют действия Redux непосредственно для выполнения мутаций и обычно генерируются React-Redux. Они часто сочетаются с презентационным компонентом, обеспечивая его реквизит.
Напишем презентационный компонент и подключим его к Redux через React-Redux:
const HelloWorld = ({ count, onButtonClicked }) => ( <div> <span>Hello! You've clicked the button {count} times!</span> <button onClick={onButtonClicked}>Click me</button> </div> ) HelloWorld.propTypes = { count: PropTypes.number.isRequired, onButtonClicked: PropTypes.func.isRequired, }
Обратите внимание, что это «тупой» компонент, который полностью зависит от своих реквизитов. Это здорово, потому что компонент React легко тестируется и легко компонуется. Давайте посмотрим, как подключить этот компонент к Redux, но сначала давайте рассмотрим, что такое компонент более высокого порядка.
Компоненты высшего порядка
React-Redux предоставляет вспомогательную функцию, называемую connect( .. )
, которая создает компонент более высокого порядка из «тупого» компонента React, который знает о Redux.
React делает упор на расширяемость и возможность повторного использования за счет композиции, когда вы оборачиваете компоненты в другие компоненты. Обертывание этих компонентов может изменить их поведение или добавить новые функции. Давайте посмотрим, как мы можем создать компонент более высокого порядка из нашего презентационного компонента, который знает о Redux — компонент-контейнер.
Вот как это сделать:
import { connect } from 'react-redux' const mapStateToProps = state => { // state is the state of our store // return the props that we want to use for our component return { count: state.count, } } const mapDispatchToProps = dispatch => { // dispatch is our store dispatch function // return the props that we want to use for our component return { onButtonClicked: () => { dispatch({ type: 'BUTTON_CLICKED' }) }, } } // create our enhancer function const enhancer = connect(mapStateToProps, mapDispatchToProps) // wrap our "dumb" component with the enhancer const HelloWorldContainer = enhancer(HelloWorld) // and finally we export it export default HelloWorldContainer
Обратите внимание, что мы определили две функции, mapStateToProps
и mapDispatchToProps
.
mapStateToProps
— это чистая функция (state: Object), которая возвращает объект, вычисленный из состояния Redux. Этот объект будет объединен с пропсами, переданными обернутому компоненту. Это также известно как селектор, поскольку он выбирает части состояния Redux для объединения с реквизитами компонента.
mapDispatchToProps
также является чистой функцией, но одной из (dispatch: (Action) => void), которая возвращает объект, вычисленный из функции отправки Redux. Этот объект также будет объединен с пропсами, переданными обернутому компоненту.
Теперь, чтобы использовать наш компонент контейнера, мы должны использовать компонент Provider
в React-Redux, чтобы сообщить компоненту контейнера, какое хранилище использовать:
import { Provider } from 'react-redux' import { render } from 'react-dom' import store from './store' // where ever your Redux store resides import HelloWorld from './HelloWorld' render( ( <Provider store={store}> <HelloWorld /> </Provider> ), document.getElementById('container') )
Компонент Provider
распространяет хранилище до любых дочерних компонентов, которые подписываются на хранилище Redux, сохраняя все в одном месте и уменьшая количество ошибок или мутаций!
Создайте уверенность в коде с помощью Redux
Благодаря этим новым знаниям о Redux, его многочисленных вспомогательных библиотеках и его связи с React.js вы можете легко ограничить количество мутаций в своем приложении с помощью контроля состояния. Строгий контроль состояния, в свою очередь, позволяет вам двигаться быстрее и создавать надежную базу кода с большей уверенностью.