若干乗り遅れた感がありますが、redux-sagaでズンドコキヨシをやりました。
redux-saga
というのは redux
で非同期処理を扱うためのMiddlewareで、平たく言うとkoaみたいにGenerator関数を使って非同期処理を同期処理のようなスタイルで書き下せます。思いつきの遊び半分で始めたズンドコキヨシが、正直ここまで redux-saga
の本質に迫るサンプルになるとは予想もしていませんでした。redux-saga
自体の詳しいは後日別の記事で解説するとして、redux-saga
が秘めているパワーを少しでも感じ取ってもらえたらと思います。
デモはこちら
このデモにはredux-devtoolsが組み込まれています。右側の黒っぽいドックがそれです。送り出されたActionのログ表示、 Reset で初期状態に戻す、 Commit で現在の状態をスナップショットして、 Revert でいつでもスナップショット時点に戻れます。こちらも同様にお楽しみ下さい。尚、表示されていない場合は Ctrl-H
で表示・非表示の切り替え、Ctrl-Q
でドックの表示場所を変更できます。
元ネタ
Javaの講義、試験が「自作関数を作り記述しなさい」って問題だったから
— てくも (@kumiromilk) March 9, 2016
「ズン」「ドコ」のいずれかをランダムで出力し続けて「ズン」「ズン」「ズン」「ズン」「ドコ」の配列が出たら「キ・ヨ・シ!」って出力した後終了って関数作ったら満点で単位貰ってた
すばらしいネタをありがとう。
実装
redux
はファイル数が多いので抜粋を掲載し、動作するサンプル全体は下記リポジトリを参照してください。
import { fork, take, put, call, select } from 'redux-saga/effects';
import {
ZUN, DOKO, zun, doko, kiyoshi
} from './actions';
function wait() {
return new Promise(resolve => {
setTimeout(() => {
resolve();
}, 750);
});
}
function sing() {
return 0.5 < Math.random() ? zun() : doko();
}
function* river() {
while (yield select(state => state.app.music)) {
yield put(sing());
yield call(wait);
}
}
function* expect(type) {
const action = yield take('*');
if (type !== action.type) {
throw 'unexpected';
}
}
function* check() {
try {
yield call(expect, ZUN);
yield call(expect, ZUN);
yield call(expect, ZUN);
yield call(expect, ZUN);
yield call(expect, DOKO);
} catch (e) {
return;
}
yield put(kiyoshi());
}
function* checkLoop() {
while (true) {
yield fork(check);
yield take('*');
}
}
export default function* rootSaga() {
yield fork(river);
yield fork(checkLoop);
}
解説
Sagaの起動
わざわざ redux-saga
を使ってズンドコキヨシするのですから、それを最大限に生かさない手はありません。というわけでズンドコキヨシの処理を2つに分けて考えてみます。
- ズンドコ川の生成
- キヨシチェック
これら2つの処理はそれぞれ river
と checkLoop
という2つのSagaに対応しています。一番下にある rootSaga
は fork
Effectでこの2つのSagaを起動します。どちらも fork
なので起動したSagaの終了を待つことなく終了します。
ズンドコ川の生成
次に ズンドコ川 を生成する river
Sagaを見てみましょう。以下のような順番で処理が進みます。
- ズンドコ川の終了チェック
- ランダムに「ズン」と「ドコ」を出力
- 一定時間待つ
ここでReducerの一部を提示します。
const handlers = {
app: {
[ZUN]: (state, action) => {
return { ...state, list: [ ...state.list, 'ズン' ] };
},
[DOKO]: (state, action) => {
return { ...state, list: [ ...state.list, 'ドコ' ] };
},
[KIYOSHI]: (state, action) => {
return { list: [ ...state.list, 'キ・ヨ・シ!' ], music: false };
}
}
};
ご覧のように KIYOSHI
Actionが来たときは「キ・ヨ・シ!」を list
に追加するだけでなく、music
を false
に変更しています。つまり、音楽が止まるとズンドコ川が終了するわけですね。
そして sing
関数によってランダムに「ズン」と「ドコ」のActionが生成されて put
EffectによってStoreに送られます。
最後に、Promiseの説明でよく例として出てくるアレ(一定時間が経過したら resolve
してくれるやつ)を使って一時停止してから while
ループで最初に戻ります。
キヨシチェック
さて、ここが redux-saga
を使ったズンドコキヨシのキモになります。
redux-saga
の take
Effectは特定のActionが来るまで停止していてくれるので、これを使ったらFSMっぽいもの作れるんじゃね?と思ったのが基本的なアイデアです。
キヨシチェック #1
これが最初に思いついた実装。
function* check() {
yield take(ZUN);
yield take(ZUN);
yield take(ZUN);
yield take(ZUN);
yield take(DOKO);
yield put(kiyoshi());
}
ただ、これだと順序関係なく4回 ZUN
Actionが呼ばれて、1回 DOKO
Actionが呼ばれると発動してしまいます。なぜかというと take
Effectは指定したActionが来るまでは他のActionを無視して待つからです。意図しない動作ですね。
キヨシチェック #2
というわけでシーケンスの連続性をチェックしないとなりません。Actionを指定するかわりに '*'
を使ってすべてのActionを監視します。もしパターンから外れた場合はすぐに return
してこのSagaを終了します。ただ、Sagaが終了してしまうと1回しかリトライできない死にゲーになってしまいキヨシが可哀想です。そこで checkLoop
Sagaでラップすることで無限にリトライできるようにします。それはそれでキヨシ可哀想ですが・・・。
function* check() {
let action = yield take('*');
if (action.type !== ZUN) { return; }
let action = yield take('*');
if (action.type !== ZUN) { return; }
let action = yield take('*');
if (action.type !== ZUN) { return; }
let action = yield take('*');
if (action.type !== ZUN) { return; }
let action = yield take('*');
if (action.type !== DOKO) { return; }
}
function* checkLoop() {
while (true) {
yield call(check);
}
}
さて、こちら一見うまく動きそうに見えますが、重大な問題があります。
「ズン」「ズン」「ズン」「ズン」「ズン」「ドコ」というシーケンスでは発動してくれないんです。「ドコ」「ズン」「ズン」「ズン」「ズン」「ドコ」でないと発動しません。
これは4回目の「ズン」が来た後、「ドコ」の出現を期待しますが、もしそうでなかった場合は状態は return
によりリセットされて、最初からやり直しになってしまうからです。これもまた意図しない動作ですね。
キヨシチェック #3
リセット直後(または初期状態)からのチェックだと5回連続して「ズン」が来るパターンに対応できないことがわかりました。つまり、リセット直後の1つあと、さらにもう1つあと、・・・というように並行してチェックする必要があります。これはFSMを複数走らせることになり、タイミングとしてはActionが来るたびにそれを起点としたチェックを走らせるイメージです。これでどこに希望するシーケンスが現れても発動してくれますね。
ここが redux-saga
の強みで、わずかな修正で終わります。check
Sagaを並行して動かすために call
Effectのかわりに fork
Efectを使います。終了を待たないのですぐに戻ってきます。新しい check
Sagaを起動するタイミングは新しいActionが来たときなので、戻り値は使いませんが take
Effectを使って待たせます。
function* check() {
let action = yield take('*');
if (action.type !== ZUN) { return; }
let action = yield take('*');
if (action.type !== ZUN) { return; }
let action = yield take('*');
if (action.type !== ZUN) { return; }
let action = yield take('*');
if (action.type !== ZUN) { return; }
let action = yield take('*');
if (action.type !== DOKO) { return; }
}
function* checkLoop() {
while (true) {
yield fork(check);
yield take('*');
}
}
これをベースにして、ヘルパー関数などを用意して理想的な見た目のコードに整えます。
今後の課題
- リズム感がよくない:「ドコ」と「キ・ヨ・シ!」の間にウェイトが必要
- そもそも「キ・ヨ・シ!」を一文字ずつ出力できないものか・・・
-
expect
関数で投げた例外をキャッチしてるのにコンソールに例外が漏れてる・・・ -
redux-saga
の利点はテストしやすいことです。テストないので書きます。