新しいことを始めます……Redux を学ぶ過程を記録します。主にRedux 日本語公式サイト、Redux 入門シリーズ動画およびその中のチュートリアルのノートとトランスクリプトから来ています。
Redux の三原則、Reducer、getState、dispatch、subscribe の原理と実装を含みます。
まず明確にしておくべきことは、Redux は非常に優れた状態管理ツールですが、それがあなたのシーンに適しているかどうかを考慮する必要があります。
誰かが Redux を使用すべきだと言ったからといって使用するのではなく、使用することの潜在的な利点とトレードオフを理解するために少し時間をかけるべきです。
この記事は、以下の教育動画の 1-8 期の動画で、Redux の三原則と Redux の Reducer、(getState、dispatch、subscribe)および createStore の原理と実装を説明し、簡易的なカウンターを実装しました。見終わった後、基本的に Redux についての大まかな理解が得られました。
なぜ学ぶのかというと、最近見たプロジェクトには多かれ少なかれ Redux が使用されているからです、学ばなければ全く理解できません
Redux 入門 — シリーズ動画
Redux の創始者 Dan Abramov が 30 本の短編(2-5 分)でさまざまな概念を示しました。リンクされた Github リポジトリには動画のノートとトランスクリプトが含まれています。
Redux 入門シリーズ動画
ノートとトランスクリプト
概要#
Redux とは何か
Redux は JavaScript アプリケーションの状態コンテナであり、予測可能な状態管理を提供します。
さまざまな環境(クライアント、サーバー、ネイティブアプリ)で動作する、安定して予測可能な動作を持つアプリケーションを開発でき、テストも容易です。それだけでなく、リアルタイムプレビューを編集できるタイムトラベルデバッガーなど、素晴らしい開発体験を提供します。
Redux は React と一緒に使用されるだけでなく、他の UI ライブラリもサポートしています。サイズは小さく(わずか 2kB、依存関係を含む)、非常に強力なプラグイン拡張エコシステムを持っています。
Redux を使用する必要がある場合#
まず明確にしておくべきことは、Redux は非常に優れた状態管理ツールですが、それがあなたのシーンに適しているかどうかを考慮する必要があります。誰かが Redux を使用すべきだと言ったからといって使用するのではなく、使用することの潜在的な利点とトレードオフを理解するために少し時間をかけるべきです。
自分のプロジェクトで次のような問題に直面した場合、Redux の使用を開始することをお勧めします:
- 多くのデータが時間とともに変化する
- 状態に唯一の確定したソース(single source of truth)があることを望む
- すべての状態をトップレベルコンポーネントで管理するのが維持不可能であると感じる
Redux の三原則#
原則 1:単一の不変状態ツリー#
Redux: The Single Immutable State Tree from @dan_abramov on @eggheadio
単一の不変状態ツリー(The Single Immutable State Tree)、いくつかのキーワードに注意してください:単一、不変、状態ツリー。
Redux の第一原則は:アプリケーションの全体の状態は 1 つの JavaScript オブジェクトによって表されます。このアプリケーションが単純な小アプリであろうと、大量の UI と状態変化を持つ複雑なアプリケーションであろうと関係ありません。この JavaScript オブジェクトは状態ツリーと呼ばれます。
これは Todo アプリケーションで考えられる状態の一例です:
"current_state:"
[object Object] {
todos: [[object Object] {
completed: true,
id: 0,
text: "hey",
}, [object Object] {
completed: false,
id: 1,
text: "ho",
}],
visibilityFilter: "SHOW_ACTIVE"
}
原則 2:状態ツリーは読み取り専用#
Redux: Describing State Changes with Actions from @dan_abramov on @eggheadio
Redux の第二原則は状態ツリーは読み取り専用です。私たちは直接それを変更したり書き込んだりすることはできず、**「アクションを発起する」** という行動を通じてのみ変更できます。
アクションは変更を記述する普通の JS オブジェクトであり、そのデータに対する変更の最小表現です。その構造は完全に私たち自身に依存し、唯一の要件は、必ずバインドされた属性 type を持つことです(通常は文字列を使用します。なぜならそれはシリアライズ可能だからです)。
アクションをディスパッチする#
例えば:Todo アプリケーションでは、Todo を表示するコンポーネントは、リストにアイテムを追加する方法を知りません。彼らが知っているのは、type:"add todo" というタイプを持つアクションをディスパッチ(dispatch)する必要があるということだけです。
タスクを切り替える場合も同様です。コンポーネントはそれがどのように発生するかを知りません。彼らがする必要があるのは、切り替えたい Todo の ID を渡して、切り替えのための type を持つアクションをディスパッチすることです。
上記のように、状態は読み取り専用であり、dispatch 操作を通じてのみ変更されます。
原則 3:状態変化を記述するには純粋な関数(Reducer)を書く必要がある#
Redux: Pure and Impure Functions | egghead.io
Redux: The Reducer Function | egghead.io
Redux の第三原則は:状態変化を記述するには純粋な関数を書く必要があり、その純粋な関数はアプリケーションの前の状態(previous state)と発起されたアクション(the action being dispatched)を受け取り、次にアプリケーションの次の状態(next state)を返します。この純粋な関数はReducerと呼ばれます。
純粋関数と非純粋関数を理解する#
まず、純粋関数 / 非純粋関数とは何かを理解する必要があります。Redux では時々純粋関数を書く必要があります。
以前のブログでも触れました:【第 2 回青訓キャンプ - 冬休みフロントエンド場】- 「月影に従って JavaScript を学ぶ」ノート、ここで再度振り返ります。
純粋関数とは、関数の戻り値がその引数のみに依存し、実行中に副作用がないことを指します。これにより、外部に影響を与えないことを意味します。
同じ引数セットで純粋関数を呼び出すと、同じ戻り値が得られることは非常に確実です。彼らは予測可能です。
さらに、純粋関数は渡された値を変更しません。例えば、配列を受け取る squareAll 関数は、その配列内の項目を上書きしません。代わりに、項目をマッピングして新しい配列を返します。
純粋関数の例:
function square(x) {
return x*x;
}
function squareAll(items) {
return items.map(square); // 注意、ここでは新しい配列を生成しており、直接itemsを返していません
}
非純粋関数の例:
function square(x) {
updateXInDatabase(x); // データベース内のxにも影響を与えています
return x * x;
}
function squareAll(items) {
for (let i = 0; i < items.length; i++) {
items[i] = square(items[i]); // そしてitemsを直接変更しています...
}
}
以前 React を学んでいたときに、不可変性の概念について理解しました。これは多くのシーンで役立ちます。例えば、React 公式ドキュメントの井字棋実装におけるタイムトラベル機能では、もしすべてのステップで元のオブジェクトを変更していたら、取り消しなどの作業が非常に複雑になってしまいます。
Reducer#
React は次のような見解を開創しました:UI 層がアプリケーション状態の純粋関数として記述されると、最も予測可能になります。
Redux はこのアプローチを補完する別の考えを持っています:アプリケーション内の状態の変化は、前の状態と現在のアクションを受け取り、アプリケーションの次の状態を返す純粋関数によって記述される必要があります。
大規模なアプリケーションでも、アプリケーションの新しい状態を計算するために必要なのは 1 つの関数だけです。これはアプリケーション全体の前の状態と現在のアクションに基づいて実行されます。
しかし、このアクションは必ずしも遅くはありません。状態の一部が変更されていない場合、その参照はそのまま保持できます。これが Redux を高速にする理由です。
以下は完全な例です。
初期状態#
初期 state では、todos は空で、フィルターはすべて表示です。
Todo を追加#
変化は次のようになります:最初の state では、todos は内容がなく、フィルターはすべて表示です。アクションを発起した後の state では、todos に 1 つの todo が追加され、フィルタービューは変わりません。
Todo を完了#
1 つの todo をクリックして完了にすると、アクションを発起したときに todos のテキストは変わらず、状態 complete が完了に設定されます……
フィルタービューを変更#
さらに 1 つの todo を追加した後、フィルター Active をクリックすると、前後の state を観察できます。visibilityFilter の状態が "SHOW_ALL" から "SHOW_ACTIVE" に変わっただけで、todos の内容は変わっていません(abcd は削除されていません)。
テスト付きのカウンター Reducer を書く#
Redux: Writing a Counter Reducer with Tests | egghead.io
私たちが書く最初の関数は、カウンターの例に対する Reducer です。私たちはまたexpect
を使用してアサーションを行います。
eggheadio-projects/getting-started-with-redux: null - Plunker (plnkr.co)
const counter = (state = 0, action) => {
switch (action.type) {
case 'INCREMENT':
return state + 1;
case 'DECREMENT':
return state - 1;
default:
return state;
}
}
expect(
counter(0, { type: 'INCREMENT' })
).toEqual(1);
expect(
counter(1, { type: 'INCREMENT' })
).toEqual(2);
expect(
counter(2, { type: 'DECREMENT' })
).toEqual(1);
expect(
counter(1, { type: 'DECREMENT' })
).toEqual(0);
expect(
counter(1, { type: 'SOMETHING_ELSE' })
).toEqual(1);
expect(
counter(undefined, {})
).toEqual(0);
上記のように、counter という Reducer は 2 つの認識可能な type(INCREMENT、DECREMENT)を設定し、それぞれカウント + 1、-1 を表します。Reducer を書く際に、渡されたstate
が未定義の場合、初期状態を表すオブジェクト(initstate)を返す必要があります。このカウンターの例では、カウントは0
から始まるため、0 を返します。渡されたaction
がReducer が認識できないものである場合(SOMETHING_ELSE)、私たちは現在のstate
を返すだけです。
ストアメソッド:getState ()、dispatch ()、subscribe ()#
Redux: Store Methods: getState(), dispatch(), and subscribe() | egghead.io
このセクションでは、Redux に組み込まれた関数を使用します。ES6 の分割代入構文を使用してcreateStore
を導入します。
const counter = (state = 0, action) => {
switch (action.type) {
case 'INCREMENT':
return state + 1;
case 'DECREMENT':
return state - 1;
default:
return state;
}
}
const { createStore } = Redux; // Redux CDNインポート構文
// import { createStore } from 'redux' // npmモジュール構文
const store = createStore(counter);
createStore
は、アプリケーション内のすべての state を保存するための Redux storeを作成します。アプリケーションには 1 つの store のみが必要です。パラメータ
reducer
(Function): 2 つのパラメータを受け取り、** 現在の state ツリーと処理するアクション** を受け取り、** 新しいstate ツリー** を返します。- [
preloadedState
] (any): 初期状態。 同構成アプリケーションでは、サーバーから送られた state を水和(hydrate)して渡すか、以前に保存されたユーザーセッションから復元されたものを渡すかを決定できます。combineReducers
を使用してreducer
を作成する場合、それは通常のオブジェクトであり、渡された keys と同じ構造を保持する必要があります。それ以外の場合、任意のreducer
が理解できる内容を自由に渡すことができます。enhancer
(Function): ストアエンハンサー、オプション。サードパーティの機能(ミドルウェア、タイムトラベル、永続化など)を使用して store を強化できます。これはストアクリエーターの高階関数であり、新しい強化されたストアクリエーターを返します。Redux に組み込まれている唯一のストアエンハンサーはapplyMiddleware()
です。戻り値
(
Store
): アプリケーションのすべての state を保存するオブジェクト。state を変更する唯一の方法はdispatchアクションです。また、state の変化をsubscribe してUI を更新することもできます。
createStore
によって作成されたstore
には 3 つの重要なメソッドがあります。
getState () 状態を取得#
getState()
は、Redux が保存している現在の状態を取得します。アプリケーションの現在の state ツリーを返します。これは store の最後の reducer の戻り値と同じです。
dispatch () ディスパッチ、アクションを発起#
dispatch()
は最も一般的に使用されます。アクションをディスパッチします。これはstate の変化を引き起こす唯一の方法です。
これは、現在のgetState()
の結果と渡されたaction
を使用して、同期的にstore の reduce 関数を呼び出します。その戻り値は次の state として使用されます。これ以降、これはgetState()
の戻り値となり、変化リスナー(change listener)がトリガーされます。
subscribe () 登録#
変化リスナーを追加します。アクションをディスパッチするたびに実行され、state ツリーの一部が変更されている可能性があります。コールバック関数内でgetState()
を呼び出して現在の state を取得できます。
変化リスナー内でdispatch()
を行うことができますが、以下の点に注意する必要があります:
- リスナーが
dispatch()
を呼び出すのは、ユーザーのアクションに応じてまたは特定の条件(例えば、store に特定のフィールドがあるときにアクションをディスパッチする)に制限されるべきです。技術的には、dispatch()
を呼び出す条件がなくても可能ですが、毎回dispatch()
が store を変更することは無限ループに陥る可能性があります。 - サブスクリプション(subscriptions)は、毎回
dispatch()
が呼び出される前にスナップショットを保存します。リスナーを呼び出している間にサブスクライブ(subscribe)したり、サブスクリプションを解除(unsubscribe)したりしても、現在のdispatch()
には影響しません。しかし、次回のdispatch()
では、ネストされていても、サブスクリプションリストの最近のスナップショットが使用されます。 - サブスクリプションはすべての state の変化に注意を払うべきではありません。リスナーが呼び出される前に、しばしばネストされた
dispatch()
によって state が何度も変更されます。すべてのリスナーがdispatch()
が開始される前に登録されていることを確認してください。これにより、リスナーが呼び出されるときに、リスナーが存在する間に最新の state が渡されます。
これは低レベルの API です。ほとんどの場合、直接使用することはなく、React(または他のライブラリ)のバインディングを使用します。コールバック関数が現在の state を使用するようにしたい場合は、カスタムのobserveStore
ツールを書くことができます。Store
はObservable
でもあるため、RxJSのようなライブラリを使用して更新をsubscribe
することができます。
この変化リスナーを解除する必要がある場合は、subscribe
が返す関数を実行します。
// ... 上記の`counter` reducer ...
const { createStore } = Redux;
const store = createStore(counter);
store.subscribe(() => {
document.body.innerText = store.getState();
});
document.addEventListener('click', () => {
store.dispatch({ type : 'INCREMENT' })
});
上記の方法では、初期状態は更新されません。なぜなら、レンダリングは subscribe のコールバックの後に発生するからです。修正後:
const render = () => {
document.body.innerText = store.getState();
};
store.subscribe(render);
render(); // 初期状態0をレンダリングするために最初に1回呼び出し、その後のレンダリングは毎回dispatchの後に行われます
document.addEventListener('click', () => {
store.dispatch({ type : 'INCREMENT' })
});
上記の公式サイトからの文書は一見難解に見えますが、次の簡易実装を見れば理解できるでしょう。
簡易版の createStore を実装する!#
Redux: Implementing Store from Scratch | egghead.io
前の学習で、createStore()
の使用方法を学びましたが、より良く理解するために、最初からそれを実装してみましょう!
まず前のケースを振り返ると、私たちは次のことを知っています。
-
createStore
関数はreducer
関数を受け取り、この reducer 関数は現在の state を返し、内部のdispatch
関数によって呼び出されます。 -
createStore
関数によって作成された store は、2 つの変数を持つ必要があります:state
現在の状態で、これは JavaScript オブジェクトです。listeners
リスナーの配列で、これは関数の配列オブジェクトです。
-
createStore
関数によって作成された store は、次の 3 つのメソッドを持つ必要があります:getState
、dispatch
、およびsubscribe
getState
メソッドは現在の state を返します。dispatch
関数は内部 state を変更する唯一の方法であり、アクションを渡し、内部の現在の state とアクションをreducer
関数(createStore の引数)に渡して新しい state を計算します。更新後、すべての変化リスナーに通知します(それらを呼び出すことによって)。subscribe
はリスナー関数を引数として受け取り、それを内部のリスナー配列に追加します。イベントリスナーのサブスクリプションを解除するために、subscribe
は関数を返す必要があります。この返された関数を呼び出すことでリスナーを解除できます。この関数内でfilter()
を使用して、リスナー配列を新しいリスナー配列に設定します(現在のリスナーと同じ参照を持つものを除外した新しいリスナー配列を返します)。subscribe
store
を返すとき、初期状態を埋める必要があります。初期値を返すために、reducer
に偽のaction
をディスパッチします。
const createStore = (reducer) => { // storeを返し、getState、dispatch、subscribeを呼び出すことができます
let state;
let listeners = [];
const getState = () => state; // 外部からgetStateを呼び出して現在のstateを取得できます
const dispatch = (action) => {
state = reducer(state, action); // reducerは純粋関数なので、毎回返されるstateは元のstateを変更しません~
listeners.forEach(listener => listener()); // dispatchイベント内で、reducerが成功した後にリスナーを呼び出します~
}
const subscribe = (listener) => { // リスナーを追加!
listeners.push(listener);
return () => { // リスナーを解除できるように、関数を返します
listeners = listeners.filter(l => l !== listener);
}
}
dispatch({}); // stateに初期状態を持たせるため!
return { getState, dispatch, subscribe };
};
カウンターを改善する#
Redux: Implementing Store from Scratch | egghead.io
改善された例:Counter (cos)
前述のようにカウンターの Reducer を実装しましたが、ここでは React を使用してそれを実際にレンダリングする方法を改善します。
Counter コンポーネントを書く#
Counter コンポーネントを作成します。このコンポーネントは「ダンプ」コンポーネントです(ビジネスロジックを含まない)。
ダンプコンポーネントは、現在の状態を出力にどのように表示するか、プロップを介して渡されたコールバック関数をイベントハンドラーにどのようにバインドするかを指定するだけです。
// Counterコンポーネント
const Counter = ({
value,
onIncrement,
onDecrement,
onElse
}) => (
<div>
<h1>{value}</h1>
<button onClick={onIncrement}>+</button>
<button onClick={onDecrement}>-</button>
<button onClick={onElse}>else</button>
</div>
);
// このコンポーネントのレンダリング関数
const render = () => {
console.log("render!");
ReactDOM.render(
<Counter
value={store.getState()}
onIncrement={() => store.dispatch({
type:'INCREMENT'
})}
onDecrement={() => store.dispatch({
type:'DECREMENT'
})}
onElse={() => store.dispatch({
type:'else'
})}
/>, document.getElementById('root')
);
}
このダンプコンポーネントがレンダリングされると、値は Redux ストアの現在の状態から取得されるべきです。ユーザーがボタンを押すと、対応するアクションが Redux のストアにディスパッチされます。
createStore を呼び出し、リスナーを追加#
createStore を呼び出してストアを作成し、store.subscribe を呼び出して render リスナーを追加します。
const counter = (state = 0, action) => {
console.log("now state:", state);
switch (action.type) {
case 'INCREMENT':
return state + 1;
case 'DECREMENT':
return state - 1;
default:
return state;
}
}
// counter reducerでストアを作成
const { createStore } = Redux;
const store = createStore(counter);
store.subscribe(render); // リスナーを追加し、状態が更新されるたびに再レンダリングされます
render();
reducer は、現在の state と渡されたアクションに基づいて次の state を計算する方法を指定します。最後に、ストアをサブスクライブ(render 関数を渡す)し、dispatch によって state が変更されるたびに render 関数が実行されるようにします。
ps: 原文では「最後に、Redux ストアにサブスクライブして、
render()
関数が状態が変わるたびに実行されるようにして、Counter
が現在の状態を取得する」と書かれていますが、上文の createStore の実装からすると、状態が変わらなくても毎回 render がトリガーされるようです。実際に試してみたところ、そうなりました(おそらく、Redux は毎回 dispatch に状態変更の意図があることを要求しているのでしょう。後の動画で言及される場合は、再度修正します)。
プロセスを確認する#
以下の例を通じて、このプロセスを理解してみましょう:
試してみてください:Counter (cos)
初期値#
何もせずに、createStore の際に 1 回 dispatch が行われ、reducer(つまり counter 関数)によって state が初期値 0 に設定された後、1 回レンダリングが行われます。(注意)
カウントを増やす#
+ を 1 回クリックすると、再度 render が呼び出されます。
else#
else を何度もクリックすると、毎回再レンダリングされますが、state の値は変わっていないように見え、コンポーネントも表面的には変わりません。
まとめ#
まず明確にしておくべきことは、Redux は非常に優れた状態管理ツールですが、それがあなたのシーンに適しているかどうかを考慮する必要があります。
- 誰かが Redux を使用すべきだと言ったからといって使用するのではなく、使用することの潜在的な利点とトレードオフを理解するために少し時間をかけるべきです。
1-8 期の動画を見終わった後、Redux を使用するのが適している時期やその欠点を基本的に理解し、Redux の三原則と Redux の Reducer、(getState、dispatch、subscribe)および createStore の原理と実装を理解し、非常に簡単なカウンターを実装しました(ついでにダンプコンポーネントが何かも知りました)。
Redux の三原則:
- アプリケーションの全体の状態は 1 つの JavaScript オブジェクトによって表され、この JavaScript オブジェクトは状態ツリーと呼ばれます。
- 状態ツリーは読み取り専用です。直接変更したり書き込んだりすることはできず、**「アクションを発起する」** という行動を通じてのみ間接的に変更できます。
- 状態変化を記述するには純粋な関数を書く必要があり、アプリケーションの前の状態 stateと発起されたアクションを受け取り、次のstate(next state)を返します。この純粋な関数はReducerと呼ばれます。
createStore
関数はreducer
関数を受け取り、このReducer
関数は現在の state を返し、内部のdispatch
関数によって呼び出されます。
-
createStore
関数によって作成された store は、2 つの変数を持つ必要があります:state
現在の状態で、これは JavaScript オブジェクトです。listeners
リスナーの配列で、これは関数の配列オブジェクトです。
-
createStore
関数によって作成された store は、次の 3 つのメソッドを持つ必要があります:getState
、dispatch
、およびsubscribe
getState
メソッドは現在の state を返します。dispatch
関数はアクションを渡し、内部の現在の state とアクションをreducer
関数(createStore の引数)に渡して新しい state を計算します。更新後、すべての変化リスナーに通知します(それらを呼び出すことによって)。subscribe
はリスナー関数を引数として受け取り、それを内部のリスナー配列に追加します。イベントリスナーのサブスクリプションを解除するために、subscribe
は関数を返す必要があります。この返された関数を呼び出すことでリスナーを解除できます。