りだっくすさが(redux-saga)に入門する

Webアプリを構築したくて久し振りにReactを触ってみると、前に少し触っていたのにすっかり忘れてしまっていました。圧倒的に記憶力が低いので、継続的に触っていないと中々覚えられません…。

今だとAngular2がグイグイ来てたりしてますが、それよりも一度触ったことのあるReact+Reduxを使った方が学習コストを抑えられるな、という訳で再度勉強中です。
改めて色々と調べていると、redux-sagaというReduxのMiddlewareが非同期処理を書きやすく出来るぞ!との事だったので、まずはシンプルなカウンターサンプルの実装をして感じを掴んでいきたいと思います。

最後の参考にもあげていますが、そもそもredux-sagaとは一体何者なんだ?というところにおいて、以下の記事が大変参考になりました。

redux-sagaで非同期処理と戦う - Qiita

また、今回使用したサンプルコードは以下のリポジトリに公開しています。

tsuyoshiwada/redux-saga-sandbox/counter

環境のセットアップ

browserify(watchify)を使ってバンドルし、開発中はbrowser-syncでファイルの変更とブラウザを同期するような環境を構築します。

インストール

まずはnpm initから初めて、必要となる各モジュールのインストールを行います。

# package.jsonを適当に作成
$ npm init

# dependeciesからインストール
$ npm i -S babel-polyfill react react-dom react-dom react-redux redux redux-actions redux-logger redux-saga

# devDependenciesをインストール
$ npm i -D babel-preset-es2015 babel-preset-react babel-preset-stage-0 babelify browser-sync browserify watchify

stage-0を入れるのは賛否両論ありそうですが、function-bindを使いたいので入れています。

最低限必要なファイルを作成

インストールが終わったら、HTMLファイル、エントリーポイントとなるJSファイルを作成。

$ mkdir src
$ touch index.html src/entry.js

作成が完了したら最低限の中身を書いておきます。

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <title>Counter example</title>
</head>
<body>
  <div id="app"></div>
  <script src="./bundle.js"></script>
</body>
</html>
console.log("Hello world");

div#appに対して、React+Reduxのアプリケーションをマウントする予定です。

npm scriptsの編集

browserify, browser-syncなどの設定を行います。

{
  //...
  "scripts": {
    "start": "npm run server & npm run watchify",
    "build": "npm run browserify",
    "server": "browser-sync start -s -f 'index.html, bundle.js' --no-notify --no-open --no-ghost-mode",
    "browserify": "browserify -e src/entry.js -o bundle.js -v",
    "watchify": "watchify -e src/entry.js -o bundle.js -v -w"
  },
  "browserify": {
    "transform": [
      "babelify"
    ]
  },
  //...
}

変換にbabelを使うので設定ファイルを作成して、最初にインストールしたpresetを設定します。

$ touch .babelrc
{
  "presets": [
    "es2015",
    "stage-0",
    "react"
  ]
}

設定が終わったので、ここで一旦startを実行して動作を確認してみます。

$ npm start

browser-syncが起動したらhttp://localhost:3000/にアクセスしてみて、コンソールにHello worldと出ていれば準備完了です。

ファイルを確認してみると、bundle.jsが生成されて以下の様なファイル構成になっています。

.
├── bundle.js
├── index.html
├── package.json
└── src
    └── entry.js

カウンターサンプルの方針を整理

公式のサンプルは最低限のファイル構成で構築されています。
「まずは簡単な動作をさせて理解を進める」という事を踏まえると正しいアプローチだと思いますが、出来れば実際アプリの書いていくことを想定して、最低限ファイルの分割くらいはしておきたいです。

なので今回作成するサンプルでは、実装内容こそ公式に沿っていきますが、ファイル構成や細かい点において自分なりの変更を加えつつ進めてみます。

生DOMとコンポーネントを結びつける

StoreやReducerなどの作成の前に、ContainerコンポーネントとDOMを結びつける箇所の実装をしておきたいと思います。

import "babel-polyfill";
import React from "react";
import { render } from "react-dom";
import { Provider } from "react-redux";
import App from "./containers/app";
import configureStore from "./store/configureStore";

const store = configureStore();

render(
  <Provider store={store}>
    <App />
  </Provider>,
  document.getElementById("app")
);

redux-sagaではGeneratorを駆使した実装を行うため、babel-polyfillをimportしています。それ以外は、至って普通のReduxな実装なので問題ありません。

Storeを作成

Storeの中身を実装します。createSagaMiddlewareを使用して後で定義するsagaをRedux上に乗っけていきます。

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

export default function configureStore(initialState) {
  const sagaMiddleware = createSagaMiddleware();
  const store = createStore(
    rootReducer,
    initialState,
    applyMiddleware(
      sagaMiddleware,
      logger()
    )
  );

  sagaMiddleware.run(rootSaga);

  return store;
}

プロダクションコードの場合はloggerは必要ないですが、勉強用のサンプルなので分岐など入れずにこのまま進めていきます。

Reducerを作成

こちらはまんまReduxなのでさくっと進めます。

import { combineReducers } from "redux";
import counter from "./counter";

const rootReducer = combineReducers({
  counter
});

export default rootReducer;
export default function counter(state = 0, action) {
  switch (action.type) {
    case "INCREMENT":
      return state + 1;
    case "INCREMENT_IF_ODD":
      return (state % 2 !== 0) ? state + 1 : state;
    case "DECREMENT":
      return state - 1;
    default:
      return state;
  }
}

Action Creatorを作成

公式のサンプルではActionTypeの指定を文字列を使っていたので、定数へと置き換えました。
また、redux-sagaを導入することでAction Creatorがやるべきは、Actionを生成して戻り値として返す、という責務のみにできるみたいです。

import { createAction } from "redux-actions";

export const INCREMENT = "INCREMENT";
export const DECREMENT = "DECREMENT";
export const INCREMENT_ASYNC = "INCREMENT_ASYNC";
export const INCREMENT_IF_ODD = "INCREMENT_IF_ODD";

export const increment = createAction(INCREMENT);
export const decrement = createAction(DECREMENT);
export const incrementAsync = createAction(INCREMENT_ASYNC);
export const incrementIfOdd = createAction(INCREMENT_IF_ODD);

より簡略したAction Creatorにするため、redux-actionsというユーティリティを使ってみました。

createActionは引数に渡した文字列をtypeとして、Fluxの標準的なActionオブジェクトを返す関数を生成してくれます。
例えば、上記のincrementアクションを実行すると以下の様なオブジェクトを返します。

console.log(increment());
// {
//   type: "INCREMENT",
//   payload: undefined
// }

Sagaを作成

いよいよSagaの登場です。INCREMENT_ASYNCアクションが呼び出された時に動作する中身を実装していきます。

import { takeEvery, delay } from "redux-saga";
import { put, call } from "redux-saga/effects";
import {
  INCREMENT_ASYNC,
  increment
} from "../actions";

export function* incrementAsync() {
  yield call(delay, 1000);
  yield put(increment());
}

export default function* rootSaga() {
  yield* takeEvery(INCREMENT_ASYNC, incrementAsync);
}

正直まだほとんど理解が追いついていないので、詳しい内容については把握できていませんが、メモレベルに纏めます。

  • export defaultしたrootSagaは起動時に一度だけ実行される (Generatorとして実装)
  • takeEvery - 指定したActionTypeのdispatchがあった際に、第二引数に指定したタスクを起動 (実行タスクの引数にActionオブジェクトが入る)
  • call - 第一引数に実行する関数、以降の引数を指定した関数へ渡し、Promiseの完了を待つ
  • put - Actionのdispatchを担当
  • delay - setTimeoutをPromiseでラップしたユーティリティ関数

実際に起きる想定の動作は以下。

  • INCREMENT_ASYNCアクションが呼び出される
  • 1000ms待ってからincrementを実行

Generator/yieldのおかげで非同期処理が途中に入っているとは思えない、同期的な感じで書けました。まだ簡単なサンプルなのでその恩恵が分かりづらいですが、外部APIとの連携が出てきたり、少し複雑な処理が必要になった時に本領を発揮しそうです。

Containerコンポーネントの作成

Reduxからpropsを受け取るコンポーネントを実装します。

import React, { Component } from "react";
import { connect } from "react-redux";
import { increment, decrement, incrementIfOdd, incrementAsync } from "../actions";

class App extends Component {
  handleIncrement() {
    this.props.dispatch(increment());
  }

  handleDecrement() {
    this.props.dispatch(decrement());
  }

  handleIncrementIfOdd() {
    this.props.dispatch(incrementIfOdd());
  }

  handleIncrementAsync() {
    this.props.dispatch(incrementAsync());
  }

  render() {
    return (
      <div>
        <h1>Counter example</h1>
        <p>
          Clicked: { this.props.counter } times
          {" "}
          <button className="increment" onClick={ ::this.handleIncrement }>+</button>
          {" "}
          <button className="decrement" onClick={ ::this.handleDecrement }>-</button>
          {" "}
          <button className="incrementIfOdd" onClick={ ::this.handleIncrementIfOdd }>Increment if odd</button>
          {" "}
          <button className="incrementAsync" onClick={ ::this.handleIncrementAsync }>Increment async</button>
        </p>
      </div>
    );
  }
}

function mapStateToProps(state) {
  return {
    counter: state.counter
  };
}

export default connect(mapStateToProps)(App);

通常通りReduxのお作法に沿って実装するだけなので、特に問題無さそうです。

動作確認

ここまで実装できたら、$ npm startしてからhttp://localhost:3000/をブラウザで開いて動作を確認してみます。

動作イメージ

ちゃんと動作している模様です。

テストする

ここまで動作する事を優先としてきましたが、じゃあ実際のアプリとしてガツガツ開発していくとなるとテストしていく必要が出てきます。これも公式のサンプルを参考に書いてみます。


公式ではtapeを使ったテストをしていますが、ここでは個人的な好みによりmocha+power-assertをメインに行ってみます。
また、React+Reduxにおいてテストを書くのが初めてなので間違っている箇所などあるかもしれません。もし問題あればTwitterなどで教えていただけると嬉しいです。

テスト対象は以下とします。

  • Saga
  • Reducer
  • Containerコンポーネント(App)

テスト用の各モジュールをインストール

必要となるモジュールを追加でインストールします。

$ npm i -D babel-register enzyme mocha power-assert react-addons-test-utils sinon

npm scriptsにtestを追加

テスト実行用のタスクを追加します。

{
  "scripts": {
    //...
    "test": "mocha --compilers js:babel-register --recursive --require babel-polyfill",
    "test:watch": "npm test -- -w"
  },
}

Saga

Sagaを実行することでGeneratorを返すので、値を拾って比較していきます。

import assert from "power-assert";
import { createAction } from "redux-actions";
import { delay } from "redux-saga";
import { put, call } from "redux-saga/effects";
import { incrementAsync } from "../src/sagas";
import { INCREMENT } from "../src/actions";

describe("sagas", () => {
  it("incrementAsync()", () => {
    const saga = incrementAsync();

    assert.deepStrictEqual(
      saga.next().value,
      call(delay, 1000)
    );

    assert.deepStrictEqual(
      saga.next().value,
      put(createAction(INCREMENT)())
    );

    assert.deepStrictEqual(
      saga.next(),
      { done: true, value: undefined }
    );
  });
});

Reducer

Actionオブジェクト、初期値(InitialState)を渡して、減算+加算など、それぞれ値が期待通り返ってくるかテストしていきます。

import assert from "power-assert";
import { createAction } from "redux-actions";
import counter from "../src/reducers/counter";
import { INCREMENT, INCREMENT_IF_ODD, DECREMENT } from "../src/actions";

describe("counter reducer", () => {
  it("should return the initial state", () => {
    assert(counter(undefined, {}) === 0);
  });

  it("should handle INCREMENT", () => {
    const action = createAction(INCREMENT)();
    assert(counter(undefined, action) === 1);
    assert(counter(1, action) === 2);
  });

  it("should handle INCREMENT_IF_ODD", () => {
    const action = createAction(INCREMENT_IF_ODD)();
    assert(counter(undefined, action) === 0);
    assert(counter(1, action) === 2);
  });

  it("should handle DECREMENT", () => {
    const action = createAction(DECREMENT)();
    assert(counter(undefined, action) === -1);
    assert(counter(-1, action) === -2);
  });
});

Containerコンポーネント

サンプルのコンポーネントでは、4つのボタンのクリックに応じてActionをdispatchしていました。そのため最低限dispatchが呼ばれていることをテストしていきたいと思います。

テストコードの前に、Appコンポーネントに少し変更を加えます。

export class App extends Component {
  // ...
}

export default connect(mapStateToProps)(App);

Reduxと繋ぐためのconnectで返すApp、単純なコンポーネントとしてのAppをそれぞれexportするように変更します。
これは、テスト内ではモックのpropsを渡したいためです。

それではテストコードを書いていきます。今回はレンダリングの中身については確認していません。

import assert from "power-assert";
import sinon from "sinon";
import { shallow } from "enzyme";
import React from "react";
import { App } from "../src/containers/app";
import { increment, decrement, incrementIfOdd, incrementAsync } from "../src/actions";

function setup() {
  const props = {
    dispatch: sinon.spy(),
    counter: 0
  };

  return { props };
}

describe("<App />", () => {
  it("should handle dispatch", () => {
    const { props } = setup();
    const wrapper = shallow(<App {...props} />);

    wrapper.find(".increment").simulate("click");
    assert.deepStrictEqual(props.dispatch.args[0][0], increment());

    wrapper.find(".decrement").simulate("click");
    assert.deepStrictEqual(props.dispatch.args[1][0], decrement());

    wrapper.find(".incrementIfOdd").simulate("click");
    assert.deepStrictEqual(props.dispatch.args[2][0], incrementIfOdd());

    wrapper.find(".incrementAsync").simulate("click");
    assert.deepStrictEqual(props.dispatch.args[3][0], incrementAsync());
  });
});

sinonのspyを使って、dispatchの実行を監視して渡された引数が期待通りになっているか確認してみました。


冒頭にも書きましたが、GitHubにコードを置いているので全体の確認が必要な場合は以下よりお願いします。

tsuyoshiwada/redux-saga-sandbox/counter

参考

良記事・良サンプルの公開含め、色々とご相談に乗っていただいた@kuyさんには感謝感謝です。

余談

恥ずかしい思いや失敗から学ぶこともある、という訳で記事のタイトル日本語にしてみました。(謎)

Newer Post SVGファイルをズバッとReact Componentsに変換する Older Post sweet-scroll.jsの1.0.0をリリースした