Reduxを使用したJavaScriptの不変性

公開: 2022-03-11

リッチで複雑なJavaScriptアプリケーションの成長を続けるエコシステムでは、現在のユーザー、読み込まれた投稿のリストなど、これまでになく多くの状態を管理する必要があります。

イベントの履歴を必要とするデータのセットは、ステートフルと見なすことができます。 状態の管理は困難でエラーが発生しやすい場合がありますが、(可変ではなく)不変のデータと特定のサポートテクノロジー(この記事の目的ではRedux)を使用すると非常に役立ちます。

不変データには制限があります。つまり、作成後に変更することはできませんが、特に参照と値の同等性において多くの利点があり、データを頻繁に比較する(更新が必要かどうかを確認する)アプリケーションを大幅に高速化できます。 、 例えば)。

不変の状態を使用すると、データを再帰的に比較する必要がなく、状態が変化したかどうかをすばやく判断できるコードを記述できます。これは通常、はるかに高速です。

この記事では、アクションクリエーター、純粋関数、構成されたレデューサー、Redux-sagaとRedux Thunkを使用した不純なアクション、そして最後にReactを使用したReduxの使用を通じて状態を管理する場合のReduxの実用的なアプリケーションについて説明します。 とはいえ、MobX、Relay、Fluxベースのライブラリなど、Reduxに代わるものはたくさんあります。

なぜReduxなのか?

ReduxをMobX、Relay、および他のほとんどのFluxベースの実装など、他のほとんどの状態コンテナーから分離する重要な側面は、Reduxが単一の状態を持ち、「アクション」(プレーンなJavaScriptオブジェクト)を介してのみ変更できることです。 Reduxストア。 他のほとんどのデータストアには、Reactコンポーネント自体に含まれる状態があり、複数のストアを持つことや、可変状態を使用することができます。

これにより、ストアのレデューサー(不変データを操作する純粋関数)が実行され、状態が更新される可能性があります。 このプロセスにより、一方向のデータフローが適用されます。これにより、理解が容易になり、より決定論的になります。

Reduxフロー。

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は、次のような、一般的に変異状態では簡単に実行できない多くのユースケースを簡単にサポートできます。

  • タイムトラベル(以前の状態に戻る)
  • ロギング(ストアで変異を引き起こした原因を特定するために、すべてのアクションを追跡します)
  • コラボレーション環境(アクションがプレーンなJavaScriptオブジェクトであり、シリアル化、ネットワーク経由での送信、別のマシンでの再生が可能なGoogleDocsなど)
  • 簡単なバグレポート(ディスパッチされたアクションのリストを送信し、それらを再生してまったく同じ状態を取得するだけです)
  • 最適化されたレンダリング(少なくとも、Reactなどの状態の関数として仮想DOMをレンダリングするフレームワークでは、不変性のため、オブジェクトを再帰的に比較するのではなく、参照を比較することで何かが変更されたかどうかを簡単に判断できます)
  • 純粋関数は簡単に単体テストできるため、レデューサーを簡単にテストできます

アクションクリエーター

Reduxのアクションクリエーターは、コードをクリーンでテスト可能な状態に保つのに役立ちます。 Reduxの「アクション」は、発生するはずのミューテーションを記述する単なるJavaScriptオブジェクトにすぎないことを忘れないでください。 そうは言っても、同じオブジェクトを何度も書き出すことは繰り返しであり、エラーが発生しやすくなります。

Reduxのアクションクリエーターは、ミューテーションを説明するプレーンなJavaScriptオブジェクトを返す単なるヘルパー関数です。 これにより、繰り返しのコードを減らし、すべてのアクションを1か所にまとめることができます。

 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 }

このコード例では、以前の状態が現在の状態と同じになるため、タイムトラベルが中断されます。状態への参照が変更されていないため、純粋なコンポーネントが更新(または再レンダリング)されない可能性があります。含むが変更され、突然変異を推論するのは非常に困難です。

不変性ライブラリがないと、Reduxが提供するすべてのメリットが失われます。 したがって、特に複数の手がコードに触れる大規模なチームで作業する場合は、immutable.jsやtransient-immutableなどの不変性ヘルパーライブラリを使用することを強くお勧めします。

使用するライブラリに関係なく、Reduxは同じように動作します。 両方の長所と短所を比較して、ユースケースに最適な方を選択できるようにしましょう。

Immutable.js

Immutable.jsは、Facebookによって構築されたライブラリであり、マップ、リスト、セット、シーケンスなどのデータ構造をより機能的なスタイルで取り入れています。 不変の永続データ構造のライブラリは、異なる状態間で可能な限り最小限のコピーを実行します。

長所:

  • 構造の共有
  • 更新でより効率的
  • よりメモリ効率が高い
  • 更新を管理するための一連のヘルパーメソッドがあります

短所:

  • 既存のJSライブラリ(つまり、lodash、ramda)とシームレスに連携しません
  • 特にハイドレーション/デハイドレーションおよびレンダリング中に、(toJS / fromJS)との間で変換する必要があります

シームレス-不変

シームレス-不変は、ES5まで下位互換性のある不変データ用のライブラリです。

これは、オブジェクトの変更を無効にするdefineProperty(..)などのES5プロパティ定義関数に基づいています。 そのため、lodashやRamdaなどの既存のライブラリと完全に互換性があります。 また、本番ビルドで無効にすることもでき、パフォーマンスが大幅に向上する可能性があります。

長所:

  • 既存のJSライブラリ(つまり、lodash、ramda)とシームレスに連携します
  • 変換をサポートするために追加のコードは必要ありません
  • 実稼働ビルドでチェックを無効にして、パフォーマンスを向上させることができます

短所:

  • 構造的な共有なし-オブジェクト/配列は浅くコピーされ、大規模なデータセットの場合は遅くなります
  • メモリ効率が悪い

Reduxと複数のレデューサー

Reduxのもう1つの便利な機能は、レデューサーを一緒に作成する機能です。これにより、はるかに複雑なアプリケーションを作成でき、かなりのサイズのアプリケーションでは、必然的に複数のタイプの状態(現在のユーザー、ロードされた投稿のリスト、等)。 Reduxは、 combineReducers関数を自然に提供することにより、このユースケースをサポート(および推奨)します。

 import { combineReducers } from 'redux' import currentUserReducer from './currentUserReducer' import postsListReducer from './postsListReducer' export default combineReducers({ currentUser: currentUserReducer, postsList: postsListReducer, })

上記のコードを使用すると、 currentUserに依存するコンポーネントと、 postsListに依存する別のコンポーネントを作成できます。 これにより、パフォーマンスも向上します。これは、単一のコンポーネントが、ツリーのブランチに関係するものにのみサブスクライブするためです。

Reduxの不純なアクション

デフォルトでは、プレーンなJavaScriptオブジェクトのみをReduxにディスパッチできます。 ただし、ミドルウェアを使用すると、Reduxは、現在時刻の取得、ネットワークリクエストの実行、ディスクへのファイルの書き込みなどの不純なアクションをサポートできます。

「ミドルウェア」は、ディスパッチされるアクションをインターセプトできる関数に使用される用語です。 インターセプトされると、他のフレームワーク(Express.jsなど)のミドルウェアと同様に、アクションの変換や非同期アクションのディスパッチなどを実行できます。

2つの非常に一般的なミドルウェアライブラリは、ReduxThunkとRedux-sagaです。 Redux Thunkは命令型で書かれていますが、Redux-sagaは機能的なスタイルで書かれています。 両方を比較してみましょう。

Redux Thunk

Redux Thunkは、他のチェーン可能な関数を返す関数であるサンクを使用することにより、Redux内の不純なアクションをサポートします。 Redux-Thunkを使用するには、最初にReduxThunkミドルウェアをストアにマウントする必要があります。

 import { createStore, applyMiddleware } from 'redux' import thunk from 'redux-thunk' const store = createStore( myRootReducer, applyMiddleware(thunk), // here, we apply the thunk middleware to R )

これで、サンクをReduxストアにディスパッチすることで、不純なアクション(API呼び出しの実行など)を実行できます。

 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-saga

Redux-sagaは、ジェネレーターと呼ばれるES6(ES2015)機能と、関数型/純粋ヘルパーのライブラリーを介して不純なアクションをサポートします。 ジェネレーターの優れている点は、ジェネレーターを再開および一時停止できることです。また、APIコントラクトにより、ジェネレーターのテストが非常に簡単になります。

sagasを使用して以前のサンクメソッドの読みやすさとテスト容易性をどのように改善できるか見てみましょう!

まず、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(..)関数は、実行を開始するためにsagaで呼び出す必要があることに注意してください。

それでは、サガを作成しましょう。

 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) }

2つのジェネレーター関数を定義しました。1つはユーザーリストとrootSagaをフェッチします。 api.fetchUsersを直接呼び出さず、代わりに呼び出しオブジェクトで生成したことに注意してください。 これは、Redux-sagaが呼び出しオブジェクトをインターセプトし、そこに含まれる関数を実行して純粋な環境を作成するためです(ジェネレーターに関する限り)。

rootSagaは、 takeEvery,という関数への単一の呼び出しを生成します。この関数は、タイプfetchUsersでディスパッチされたすべてのアクションを実行し、実行したアクションでUSERS_FETCHを呼び出します。 ご覧のとおり、これによりReduxの非常に予測可能な副作用モデルが作成され、テストが容易になります。

Sagasのテスト

ジェネレーターがどのように私たちのサガをテストしやすくするか見てみましょう。 この部分では、ユニットテストとアサーションのチャイを実行するためにモカを使用します。

sagasはプレーンなJavaScriptオブジェクトを生成し、ジェネレーター内で実行されるため、モックなしで正しい動作を実行することを簡単にテストできます。 calltakeputなどは、Redux-sagaミドルウェアによってインターセプトされる単なるJavaScriptオブジェクトであることに注意してください。

 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)) }) })

Reactでの作業

Reduxは特定のコンパニオンライブラリに関連付けられていませんが、Reactコンポーネントは状態を入力として受け取り、仮想DOMを出力として生成する純粋関数であるため、React.jsで特にうまく機能します。

React-Reduxは、ReactとReduxのヘルパーライブラリであり、2つを接続するためのハードワークのほとんどを排除します。 React-Reduxを最も効果的に使用するために、プレゼンテーションコンポーネントとコンテナコンポーネントの概念を見ていきましょう。

プレゼンテーションコンポーネントは、レンダリングする小道具のみに応じて、物事が視覚的にどのように見えるかを記述します。 小道具からコールバックを呼び出してアクションをディスパッチします。 それらは手作業で書かれており、完全に純粋であり、Reduxのような状態管理システムに結び付けられていません。

一方、コンテナコンポーネントは、物事がどのように機能するかを記述し、Reduxを認識し、Reduxアクションを直接ディスパッチしてミューテーションを実行し、通常はReact-Reduxによって生成されます。 それらはしばしばプレゼンテーションコンポーネントとペアになって、その小道具を提供します。

Reduxのプレゼンテーションコンポーネントとコンテナコンポーネント。

プレゼンテーションコンポーネントを作成し、React-Reduxを介して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は、Reduxを認識する「ダム」Reactコンポーネントから高次コンポーネントを作成するconnect( .. )と呼ばれるヘルパー関数を提供します。

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

mapStateToPropsmapDispatchToPropsの2つの関数を定義したことに注意してください。

mapStateToPropsは、Redux状態から計算されたオブジェクトを返す(state:Object)の純粋関数です。 このオブジェクトは、ラップされたコンポーネントに渡された小道具とマージされます。 これは、コンポーネントの小道具にマージされるRedux状態の一部を選択するため、セレクターとも呼ばれます。

mapDispatchToPropsも純粋関数ですが、Reduxディスパッチ関数から計算されたオブジェクトを返す(dispatch:(Action)=> void)の1つです。 このオブジェクトは、同様に、ラップされたコンポーネントに渡された小道具とマージされます。

コンテナーコンポーネントを使用するには、React-ReduxのProviderコンポーネントを使用して、コンテナーコンポーネントに使用するストアを指示する必要があります。

 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ストアにサブスクライブするすべての子コンポーネントにストアを伝播し、すべてを1つの場所に保持し、エラーや変更のポイントを減らします。

Reduxでコードの信頼性を構築する

Reduxに関するこの新たな知識、多数のサポートライブラリ、およびReact.jsとのフレームワーク接続により、状態制御を通じてアプリケーション内のミューテーションの数を簡単に制限できます。 強力な状態制御により、より速く移動し、より自信を持って堅実なコードベースを作成できます。