Sun, Oct 7, 2018

React + Reduxアプリケーションプロジェクトのテンプレートを作る ― その8: Redux-Saga

React + Reduxアプリケーションプロジェクトのテンプレートを作る ― その8: Redux-Saga

ReactReduxを学ぶために、開発環境というかプロジェクトテンプレートをスクラッチから作っている。 (最終的な成果はGitHubに置いた。)

前回React Reduxをセットアップした。

(2018/11/21更新)

ReduxのMiddleware

Redux単体では同期的なデータフローしか実装できない。 つまり、Actionを発生させたら、即座にディスパッチされ、stateが更新される。 一方、非同期なフローとは、REST APIを呼んでその結果でstateを更新するような処理。 REST API呼び出しが非同期なわけだが、これをReduxのピュアなフローのどこで実行するのかというと、Middlewareで実行する。

MiddlewareはStoreのdispatch()をラップして、Actionをトラップして副作用を含む任意の処理をするための機能。 Middlewareの仕組みについてはこの記事が分かりやすい。

Middlewareには例えば、発生したActionの内容と、それによるstateの変化をログに出力するredux-loggerがある。 デバッグに有用そうなので入れておく。

yarn add redux-logger

v3.0.6が入った。

Middlewareは、ReduxのapplyMiddleware()というAPIを使って、createStore()実行時に適用できる。

src/configureStore.js:

-import { createStore } from 'redux';
+import { createStore, applyMiddleware } from 'redux';
+import { logger } from 'redux-logger';
 import rootReducer from './reducers/rootReducer';

 export default function configureStore(initialState = {}) {
+  const middlewares = [];
+  if (process.env.NODE_ENV === `development`) {
+    middlewares.push(logger);
+  }
+
   const store = createStore(
     rootReducer,
     initialState,
+    applyMiddleware(...middlewares),
   );
   return store;
 }

これだけ。 これで、HOGEボタンをクリックしたときにコンソールに以下のようなログが出るようになる。 (ログはyarn startとかの開発モードの時だけでる。)

action HOGE_BUTTON_CLICKED @ 23:19:35.190
 prev state Object { hoge: {…} }
 action Object { type: "HOGE_BUTTON_CLICKED", payload: undefined }
 next state Object { hoge: {…} }

非同期処理

非同期処理をするためのMiddlewareにはredux-thunkとかredux-promiseとかがあるけど、なかでもGitHubのスター数が一番多いRedux Sagaを使うことにする。

yarn add redux-saga

v0.16.2が入った。


因みに次にスター数が多いのがredux-thunkで、これはActionをfunctionオブジェクトで書けるようにするMiddleware。 そのfunctionの中で非同期処理をすることで、非同期なReduxフローを実現できる。 redux-sagaはredux-thunkに比べて以下の特長を持つ。

  • コールバック地獄に悩まされることが無い
  • Actionをプレーン且つピュアに保てるのでテストしやすい

Redux Sagaの使い方

Redux Sagaでは、非同期処理はSagaというコンポーネントに書く。 Sagaでは、

  1. ディスパッチされるActionをWatcherが監視し、
  2. 特定のActionが来たらWorkerを起動し、
  3. Workerが非同期処理などのTaskを実行し、
  4. その結果を通知するActionをディスパッチする、

といった処理を実行する。

これらの処理は、Saga Middlewareから呼ばれるジェネレータ関数のなかで、EffectというオブジェクトをSaga Middlewareに返すことで、Saga Middlewareに指示して実行させる。 このEffectを生成するAPIがRedux Sagaからいろいろ提供されている。

上記処理の1~4はそれぞれ以下のAPIで実装できる。

  • take(pattern): ディスパッチされるActionを監視して、patternにマッチしたら取得するEffectを生成する。
  • fork(fn, ...args): 渡された関数fnをノンブロッキングで呼び出すEffectを生成する。fnはジェネレータかPromiseを返す関数。
  • call(fn, ...args): 渡された関数fnを同期的に呼び出すEffectを生成する。fnはジェネレータかPromiseを返す関数。
  • put(action): ActionオブジェクトのactionをディスパッチするEffectを生成する。

REST API呼び出し

非同期実行で最もよくあるのがREST API呼び出しであろう。 REST API呼び出し処理はcall()で実行するわけだけど、call()にはPromiseを返す必要があるので、使うライブラリはそこを考慮しないといけない。

ざっと調べたところ、axiosSuperAgentr2あたりが選択肢。 最も人気のあるaxiosを使うことにする。

yarn add axios

v0.18.0が入った。


REST API呼び出しのコードはsrc/services/に置く。

src/services/api.js:

import axios from 'axios';

export const HOGE_URL = 'https://httpbin.org/get';

export function getHoge() {
  return axios.get(HOGE_URL);
}

getHoge()はGETリクエストを送ってPromiseオブジェクトを返す。 このPromiseオブジェクトはレスポンスボディやステータスコードを保持するResponseオブジェクトに解決される。

REST API呼び出しを表現するAction

REST API呼び出しをする場合、呼び出し開始、呼び出し成功、呼び出し失敗の3種類のActionで表現するのが一つのプラクティス。 これら3種類を、同一のtypeのActionのプロパティ値を変えて表現するやりかたもあるけど、ここでは別々のtypeのアクションとする。

src/actions/actionTypes.js:

 export const HOGE_BUTTON_CLICKED = 'HOGE_BUTTON_CLICKED';
+export const HOGE_FETCH_SUCCEEDED = 'HOGE_FETCH_SUCCEEDED';
+export const HOGE_FETCH_FAILED = 'HOGE_FETCH_FAILED';

src/actions/actions.js:

 import {
   HOGE_BUTTON_CLICKED,
+  HOGE_FETCH_SUCCEEDED,
+  HOGE_FETCH_FAILED,
 } from './actionTypes';

 export function hogeButtonClicked() {
   return {
     type: HOGE_BUTTON_CLICKED,
   };
 }
+
+export function hogeFetchSucceeded(payload, meta) {
+  return {
+    type: HOGE_FETCH_SUCCEEDED,
+    payload,
+    meta,
+  };
+}
+
+export function hogeFetchFailed(payload) {
+  return {
+    type: HOGE_FETCH_FAILED,
+    error: true,
+    payload,
+  };
+}

Sagaの実装

Sagaのソースはsrc/sagas/に置く。

HOGE_BUTTON_CLICKEDが来たらgetHoge()を実行するSagaは以下のような感じ。

src/sagas/hoge.js:

import { call, fork, put, take } from 'redux-saga/effects';
import { getHoge } from '../services/apis';
import { HOGE_BUTTON_CLICKED } from '../actions/actionTypes';
import { hogeFetchSucceeded, hogeFetchFailed } from '../actions/actions';

// Task
function* fetchHoge() {
  try {
    const response = yield call(getHoge);
    const payload = response.data;
    const meta = { statusCode: response.status, statusText: response.statusText };
    yield put(hogeFetchSucceeded(payload, meta));
  } catch (ex) {
    yield put(hogeFetchFailed(ex));
  }
}

// Watcher
export function* watchHogeButtonClicked(): Generator<any, void, Object> {
  while (true) {
    const action = yield take(HOGE_BUTTON_CLICKED);
    yield fork(fetchHoge, action); // actionはfetchHogeの引数に渡される。使ってないけど…
  }
}

Watcherはtakeしてforkするのを無限ループで回すのが常なので、これをもうちょっときれいに書けるAPIが用意されていて、以下のように書ける。

import { takeEvery } from 'redux-saga/effects'

// Watcher
export function* watchHogeButtonClicked(): Generator<any, void, Object> {
  yield takeEvery(HOGE_BUTTON_CLICKED, fetchHoge)
}

この場合、fetchHoge()の最後の引数にtakeしたActionオブジェクトが渡される。


で、今後Watcherはモジュールを分けていくつも書いていくことになるので、それらをまとめて起動するためのモジュールrootSaga.jsを作って、そこで各Watcherをimportしてcall()したい。 call()はブロッキングなAPIなので、パラレルに実行するためにall()を使う。

src/sagas/rootSaga.js:

import { call, all } from 'redux-saga/effects';
import { watchHogeButtonClicked } from './hoge';

export default function* rootSaga() {
  yield all([
    call(watchHogeButtonClicked),
    // call(watchAnotherAction),
    // call(watchYetAnotherAction),
  ]);
}

そもそもブロッキングなcall()を使うのがだめなので、代わりにfork()を使ってもいい。

src/sagas/rootSaga.js:

import { fork } from 'redux-saga/effects';
import { watchHogeButtonClicked } from './hoge';

export default function* rootSaga() {
  yield fork(watchHogeButtonClicked);
  // yield fork(watchAnotherAction);
  // yield fork(watchYetAnotherAction);
}

どっちがいいんだろう。

Saga Middlewareの追加と起動

Saga Middlewareは以下のように追加して起動する。

src/configureStore.js:

 import { createStore, applyMiddleware } from 'redux';
+import createSagaMiddleware from 'redux-saga';
 import { logger } from 'redux-logger';
+import rootSaga from './sagas/rootSaga';
 import rootReducer from './reducers/rootReducer';

+const sagaMiddleware = createSagaMiddleware();

 export default function configureStore(initialState = {}) {
   const middlewares = [];
   if (process.env.NODE_ENV === `development`) {
     middlewares.push(logger);
   }
+  middlewares.push(sagaMiddleware);

   const store = createStore(
     rootReducer,
     initialState,
     applyMiddleware(...middlewares),
   );
+  sagaMiddleware.run(rootSaga);
   return store;
 }


次回React Router