banner
cos

cos

愿热情永存,愿热爱不灭,愿生活无憾
github
tg_channel
bilibili

Redux学習の道(1)Reduxの三原則、createStoreの原理と実装

新しい挑戦を始めます……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から始まるからです。渡されるactionReducer が認識できないものである場合(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 のみが必要です

パラメータ

  1. reducer (Function): 2 つのパラメータを受け取り、** 現在の state ツリーと処理するアクション** を受け取り、** 新しいstate ツリー** を返します。
  2. [preloadedState] (any): 初期の state。同構成アプリケーションでは、サーバーから送られた state を水和(hydrate)して渡すか、以前保存されたユーザーセッションから復元されたものを渡すかを決定できます。combineReducersを使用してreducerを作成する場合、それは通常のオブジェクトであり、渡された keys と同じ構造を保持する必要があります。そうでなければ、任意のreducerが理解できる内容を自由に渡すことができます。
  3. enhancer (Function): ストアエンハンサー、オプション。サードパーティの機能(ミドルウェア、時間旅行、永続化など)を使用してストアを強化できます。ストアクリエイターを組み合わせる高階関数で、新しい強化されたストアクリエイターを返します。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 () 登録#

変化リスナーを追加します。アクションを dispatch するたびに実行され、state ツリーの一部が変更されている可能性があります。コールバック関数内でgetState()を呼び出して現在の state を取得できます。

変化リスナー内でdispatch()を行うことができますが、以下の点に注意する必要があります:

  1. リスナーがdispatch()を呼び出すのは、ユーザーのアクションに応じてまたは特定の条件の下でのみ行うべきです(例えば、ストアに特定のフィールドがあるときにアクションを dispatch する)。技術的には、条件なしにdispatch()を呼び出すことは可能ですが、毎回dispatch()がストアを変更すると無限ループに陥る可能性があります
  2. サブスクリプション(subscriptions)は、毎回dispatch()が呼び出される前にスナップショットを保存します。リスナーを呼び出している間にサブスクリプション(subscribe)やサブスクリプションの解除(unsubscribe)を行っても、現在のdispatch()には影響しません。しかし、次回のdispatch()では、ネストされていても、サブスクリプションリストの最近のスナップショットが使用されます
  3. サブスクリプションはすべての state の変化に気づくべきではなく、リスナーが呼び出される前に、しばしばネストされたdispatch()によって state が複数回変更されることがあります。すべてのリスナーがdispatch()が開始される前に登録されていることを保証することで、リスナーが呼び出されるときに最新の state が渡されます

これは低レベルの API です。ほとんどの場合、直接使用することはなく、React(または他のライブラリ)のバインディングを使用します。コールバック関数が現在の state を使用するようにしたい場合は、カスタムのobserveStoreツールを書くことができます。StoreObservableでもあるため、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をレンダリングするために最初に呼び出し、その後のレンダリングは毎回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 つのメソッドを持つ必要があります:getStatedispatch、およびsubscribe

    • getStateメソッドは現在の state を返します。
    • dispatch関数は内部 state を変更する唯一の方法で、アクションを渡し、内部の現在の state とアクションをreducer関数(createStore の引数)に渡して新しい state を計算します。更新後、すべての変化リスナーに通知します(それらを呼び出すことによって)。
    • subscribeはリスナー関数を引数として受け取り、それを内部のリスナー配列に追加します。イベントリスナーの購読を解除するために、subscribeは関数を返す必要があります。この返された関数を呼び出すことでリスナーを解除できます。この関数は、filter()を使用して、リスナー配列を新しいリスナー配列(現在のリスナーと同じ参照を除外した新しいリスナー配列)に設定します。
    • storeを返すとき、初期状態を埋め込む必要があります。初期値を返すために、偽の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 コンポーネントを作成します。このコンポーネントは「ダンプ」コンポーネントです(dump component)。ビジネスロジックを含まないコンポーネントです。

ダンプコンポーネントは、現在の状態を出力にどのように表示するか、プロップで渡されたコールバック関数をイベントハンドラーにどのようにバインドするかを指定するだけです。

// 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 のストアに dispatch されます。

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()関数が state が変更されるたびに実行され、私たちのCounterが現在の state を取得します。」とありますが、上文の createStore の実装からすると、state が値が変わらなくてもトリガーされるように感じます。実際に試してみたところ、毎回 dispatch が行われるたびに render が呼び出されることが確認できました(おそらく、Redux は毎回 dispatch が状態を変更する意図を持っていると理解できるかもしれません。後の動画で言及される場合は、再度修正します)。

Redux:React Counter example |egghead.io

プロセスの確認#

以下の例を通じてこのプロセスを理解してみましょう:

試してみてください: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 つのメソッドを持つ必要があります:getStatedispatch、およびsubscribe

    • getStateメソッドは現在の state を返します。
    • dispatch関数はアクションを渡し、内部の現在の state とアクションをreducer関数(createStore の引数)に渡して新しい state を計算します。更新後、すべての変化リスナーに通知します(それらを呼び出すことによって)。
    • subscribeはリスナー関数を引数として受け取り、それを内部のリスナー配列に追加します。イベントリスナーの購読を解除するために、subscribeは関数を返す必要があります。この返された関数を呼び出すことでリスナーを解除できます。この関数は、filter()を使用して、リスナー配列を新しいリスナー配列(現在のリスナーと同じ参照を除外した新しいリスナー配列)に設定します。
    • storeを返すとき、初期状態を埋め込む必要があります。初期値を返すために、偽のactionを発起する必要があります。
読み込み中...
文章は、創作者によって署名され、ブロックチェーンに安全に保存されています。