8
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

React + Redux + flowで非同期処理

Last updated at Posted at 2016-12-30

こちらの記事の続編です。
React + Redux + flowの最小構成

ソースコードはこちらになります。
https://github.com/uryyyyyyy/react-redux-js-sample/tree/async/

この記事のゴール

前回の記事でreduxやテストの導入をしたので、非同期処理の書き方をそのテストをまとめていきます。

環境

  • React 15.4
  • webpack 2.2-rc
  • flow 0.37
  • NodeJS 6.X~
  • mocha
  • enzyme
  • isomorphic-fetch

構成

こちらと全く同じです。

React + Redux + flowの最小構成

非同期を扱うにあたって

ここでは、クライアントサイドでの非同期処理をサーバーとの通信に限ります。
サーバーとのやりとりをする上で、XmlHttpRequestの代わりにfetch APIというものが徐々にブラウザに実装されているので、ここではそれを使います。
また、非対応の実行環境のためにisomorphicなfetch polyfillを導入します。

npm install --save isomorphic-fetch

テストでもバンドル時にも使うので、polyfillは下記のようにまとめて置いておきましょう。Index.jsxやテストコードの中でこれをimportするだけで実行環境の差異をなくせます。

polyfill.js
import "babel-polyfill";
import "isomorphic-fetch";

また、現在ECMA Scriptの方でstage3まで進んでいて、TypeScriptでも実装されておりChromeでも最近搭載されたasync/awaitの構文でjsコードを書けるようにしましょう。

npm install --save-dev babel-preset-es2016 babel-preset-es2017

これを.babelrcのpresetに突っ込み、バンドルする前にbabel-polyfillを噛ませれば async/awaitが使えるようになります。

ソースコード

mock server

fetchした時の動作確認用に、サーバーを立てます。ここでは高機能なexpressを用います。

dev-server.js
const path = require('path');
const express = require('express');
const app = express();

app.use('/dist', express.static('dist'));

app.get('/api/count', (req, res) => {
  res.contentType('application/json');
  const obj = {"amount": 100};
  setTimeout(() => res.json(obj), 500);
  //res.status(400).json(obj); //for error testing
});

app.get('/', (req, res) => {
  res.sendFile(path.join(__dirname, 'index.html'));
});

app.listen(3000, (err) => {
  if (err) {
    console.log(err);
  }
  console.log("server start at port 3000")
});

ここではあえて500ms待つことで、読み込みに時間かかってる感を演出しています。

ActionDispacher

さて、reduxでは非同期を扱う箇所はActionCreator(多くは、ミドルウェアというところに隠蔽する)で行うことになっています。なので、前回からの差分は主にcounter/Actions.jsの部分です。

counter/Actions.js
...
export const FETCH_REQUEST_START = 'counter/fetch_request_start';
export const FETCH_REQUEST_FINISH = 'counter/fetch_request_finish';

const myHeaders = {
  "Content-Type": "application/json",
  'Accept': 'application/json',
  'X-Requested-With': 'XMLHttpRequest'
};

export class ActionDispatcher {
  dispatch: (action: any) => any;

  constructor(dispatch: (action: any) => any) {
    this.dispatch = dispatch
  }

...

  //①
  async fetchAmount(): Promise<void> {
    //②
    this.dispatch({type: FETCH_REQUEST_START});

    try {
      //③
      const response: Response = await fetch('/api/count', {
        method: 'GET',
        headers: myHeaders,
        credentials: 'include'
      });

      if (response.status === 200) { //2xx
        //④
        const json: JsonObject = await response.json();
        this.dispatch({type: INCREMENT, amount: json.amount})
      } else {
        //⑤
        throw new Error(`illegal status code: ${response.status}`);
      }
    } catch (err) { //⑥
      console.error(err.message);
    } finally { //⑦
      this.dispatch({type: FETCH_REQUEST_FINISH})
    }
  }
}

前回からの差分として見るべきところはfetchAmount() の部分です。
async/awaitを用いてそこそこわかりやすく書けたものの、順番に説明していきます。

①で、メソッドの頭にasyncが付いていて返り値がPromiseであることから、このメソッドが非同期処理をasync/awaitで取り扱うものであると宣言します。
②では非同期処理する前に、「Loading...」の画面を出すようにActionを飛ばします。
③でfetch APIを用いてサーバーへの通信処理(非同期)を行います。awaitを使うことで、Promiseを明示的に扱わなくて良くなります。

通信が上手く行って200が返ってきた場合は、④でデータを受け取って、reducerへIncrementのActionを飛ばしています。
通信は上手く行ったものの200以外(400など)が返ってきた場合は、そのステータスコードをエラーメッセージとして例外を投げています。
fetchが失敗した(サーバーの応答が無い、通信がそもそもできてないなど)の場合や、上記のエラーが投げられた時は⑥でそれを記録します。
最後に⑦で、①で行った「Loading...」表示を取り消すようにActionを発行しています。

Counter, Reducerはここでは省略しますが、特に変わってないので見ればすぐに分かるかと思います。

テストコード

大きく変わったのがActionDispatcherなので、テストコードもそこがメインです。

counter/__tests__/Actions.spec.js
import assert from 'assert';
import {ActionTypes} from "../Entities";
import {ActionDispatcher, INCREMENT, FETCH_REQUEST_START, FETCH_REQUEST_FINISH} from "../Actions";
import fetchMock from 'fetch-mock';
import {spy} from "sinon";

describe('ActionDispatcher', () => {

  //①
  beforeEach(() => {
    fetchMock.restore();
  });

...

  //②
  it('fetchAmount success',  async () => {
    //③
    fetchMock.get('/api/count', {body: {amount: 100}, status: 200});

    const spyCB:any = spy();
    const actions = new ActionDispatcher(spyCB);

    //④
    await actions.fetchAmount();

    const calls = spyCB.getCalls();
    assert(calls.length === 3);
    assert(deepEqual(calls[0].args, [{ type: FETCH_REQUEST_START }]));
    assert(deepEqual(calls[1].args, [{ type: INCREMENT, amount: 100 }]));
    assert(deepEqual(calls[2].args, [{ type: FETCH_REQUEST_FINISH }]));
  });

  it('fetchAmount fail',  async () => {
    //⑤
    fetchMock.get('/api/count', {body: {}, status: 400});

    const spyCB:any = spy();
    const actions = new ActionDispatcher(spyCB);

    await actions.fetchAmount();

    const calls = spyCB.getCalls();
    assert(calls.length === 2);
    assert(deepEqual(calls[0].args, [{ type: FETCH_REQUEST_START }]));
    assert(deepEqual(calls[1].args, [{ type: FETCH_REQUEST_FINISH }]));
  });
});

(polyfillはnpmスクリプトの中で読んでいるので、各種APIは使えるものとして進めて良いです。)

ここで、fetch-mockというライブラリを使っています。これは、テスト時に実際のサーバーへfetchしては困るので、それのmockをするためのライブラリです。
これを①で各テストの前に初期化していますね。

②では、テストが非同期処理を含むため、asyncを書いています。
ちなみにmochaでは、テストコードの返り値がPromiseだった場合、その処理が終わるまで待機してくれる性質があります。ここではasync定義のため必然的に返り値がPromise(returnを書かなければPromise<void>)になるため、何もする必要はありません。

③ではfetchの結果として返したいものを用意しています。
④で実際に非同期処理を起こし、awaitで待機します。
そして、「loadingを出すためのアクション」「結果を取得したのでINCREMENTするアクション」「loloadingを消すアクション」が順番に発火していることをテストしています。

⑤ではfetchの結果が失敗だった時にも正しく処理されるかの確認となります。

まとめ

reduxだと非同期でもテストが書きやすいですね。
また、async/awaitは読みやすくてすごく良いです。
Scalaをやっているのであれば、Futureでのfor式みたいなものと思えば理解しやすいと思います。

8
7
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?