※更新履歴
こちらの記事の続編です。
http://qiita.com/uryyyyyyy/items/7d4b0ede3f2b973d6951
問題提起
今時、型チェックのない言語とか使いたくないですよね!(4回目)
reduxの非同期処理をTypeScriptで書いてみました。
環境
- NodeJS 8.2
- React 16.0-beta
- TypeScript 2.4
- webpack
- Fetch API
構成
コードはこちら。
https://github.com/uryyyyyyy/react-redux-sample/tree/async
設定ファイル群
reduxのミドルウェアは必要になるまで入れません!
入れないと問題になる場合に初めて検討しましょう。僕はまだ使ってないです。
今回は非同期処理としてサーバーとのやりとりを含めたいので、XMLHttpRequestの代わりとして標準化されているfetch APIを用います。おかげ様で主要ブラウザでは実装されていますのでそのまま使いますが、IEサポートとかするのであればpolyfillを入れてください
また、サーバーとの通信のために仮サーバーとしてexpressを用います。高機能なので実際のプロジェクトでのフロントコードの動作確認用としても僕は重宝しています。
npm install express --save-dev
コードを見ていきましょう。
APIサーバー
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")
});
/api/count
へリクエストが来たら0.5秒待ってjsonを返すという単純なものです。わざと遅らせているのは、「Loading...」的なものを表示するUIにしたいので、それがわかりやすいようにです。
src
ここからreduxのコードになります。(繰り返しますが、ミドルウェアなしで進めます。)
Counter.tsx
import * as React from 'react'
import {CounterState} from './module'
import {ActionDispatcher} from './Container'
interface Props {
value: CounterState
actions: ActionDispatcher
}
export class Counter extends React.Component<Props, {}> {
render() {
return (
<div>
<p>{`score: ${this.props.value.num}`}</p>
<button onClick={() => this.props.actions.increment(3)}>Increment 3</button>
<button onClick={() => this.props.actions.decrement(2)}>Decrement 2</button>
<button onClick={() => this.props.actions.asyncIncrement()}>async Increment 100</button>
{(this.props.value.loadingCount === 0) ? null : <p>loading</p>}
</div>
)
}
}
ここで、asyncIncrementというのが今回の対象の非同期処理とします。
もしloadingCountが1以上の時は非同期処理中ということで「Loading...」が画面に見えるようにしています。
中身であるActionDispatcherの実装を見ていきましょう。
ActionDispatcher
// ActionDispatcher部分のみ抜粋
export class ActionDispatcher {
constructor(private dispatch: (action: ReduxAction) => void) {}
myHeaders = new Headers({
"Content-Type": "application/json",
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
});
//①
public async asyncIncrement(): Promise<void> {
//②
this.dispatch(fetchRequestStart());
try {
//③
const response: Response = await fetch('/api/count', {
method: 'GET',
headers: this.myHeaders
});
if (response.status === 200) { //2xx
//④
const json: {amount: number} = await response.json();
this.dispatch(incrementAmount(json.amount))
} else {
//⑤
throw new Error(`illegal status code: ${response.status}`);
}
} catch (err) { //⑥
console.error(err);
} finally { //⑦
this.dispatch(fetchRequestFinish())
}
}
}
さて、ここで前回からの差分として見るべきところとしては asyncIncrement()
の部分です。
①で、メソッドの頭にasyncが付いていて返り値がPromiseであることから、このメソッドが非同期処理をasync/awaitで取り扱うものであると宣言します。
②では非同期処理する前に、「Loading...」の画面を出すようにActionを飛ばします。
③でfetch APIを用いてサーバーへの通信処理(非同期)を行います。awaitを使うことで、Promiseを明示的に扱わなくて良くなります。
通信が上手く行って200が返ってきた場合は、④でデータを受け取って、reducerへIncrementのActionを飛ばしています。
通信は上手く行ったものの200以外(400など)が返ってきた場合は、そのステータスコードをエラーメッセージとして例外を投げています。
fetchが失敗した(サーバーの応答が無い、通信がそもそもできてないなど)の場合や、上記のエラーが投げられた時は⑥でそれを記録します。
最後に⑦で、①で行った「Loading...」表示を取り消すようにActionを発行しています。
reducerの実装はここでは省略しますが、見ればすぐに分かるかと思います。
Buildしてみる
npm run build
してから npm run server
と実行してサーバを立てましょう。(Web APIを呼び出せるようにするためです。)
async Increment 100
と書かれているボタンを押せば裏でjsonをfetchして{amount: 100}
が返ってきて、それをIncrementするため100点が加算されるはずです。
テスト
ActionDispatcherのテスト
準備
テスト時にfetchのリクエストが飛んでしまうのは避けたい&サーバーのエラーもシミュレートしたい、という要望はあると思うので、ここではfetchをmockするライブラリをいれます。
npm install fetch-mock --save-dev
また、このままだと import fetchMock from 'fetch-mock'
としたときに型定義がなくて怒られてしまうので、とりあえずanyで取り込めるようにしておきます。(TSのimplicitAnyを設定した場合、筆者はこのような回避策を取っています。)
declare module "fetch-mock";
ActionDispatcher.spec.ts
...
it('asyncIncrement success', async (done) => {
fetchMock.get('/api/count', {body: {amount: 100}, status: 200});
const spy: any = {dispatch: null};
spyOn(spy, 'dispatch');
const actions = new ActionDispatcher(spy.dispatch);
await actions.asyncIncrement();
expect(spy.dispatch.calls.count()).toEqual(3);
expect(spy.dispatch.calls.argsFor(0)[0]).toEqual(fetchRequestStart());
expect(spy.dispatch.calls.argsFor(1)[0]).toEqual(incrementAmount(100));
expect(spy.dispatch.calls.argsFor(2)[0]).toEqual(fetchRequestFinish());
done();
});
it('asyncIncrement fail', async (done) => {
fetchMock.get('/api/count', {body: {}, status: 400});
const spy: any = {dispatch: null};
spyOn(spy, 'dispatch');
const actions = new ActionDispatcher(spy.dispatch);
await actions.asyncIncrement();
expect(spy.dispatch.calls.count()).toEqual(2);
expect(spy.dispatch.calls.argsFor(0)[0]).toEqual(fetchRequestStart());
expect(spy.dispatch.calls.argsFor(1)[0]).toEqual(fetchRequestFinish());
done();
});
asyncIncrement successでは、通信処理を含むAPIの正常系をテストしています。
asyncIncrement failでは異常系のテストで、通信で400を返すようにしています。
ここでも、async/awaitを使っているため、テストが同期っぽく読みやすく書けていますね。
テスト内容としては同期の場合と同じで、各処理の時にActionがdispatchされているかをテストしています。
まとめ
特にミドルウェアを使わなくても非同期処理が出来ましたし、テストもバッチリです。
次はreact-routerを入れてみます。