redux-sagaでズンドコキヨシ

  • 34
    いいね
  • 0
    コメント
この記事は最終更新日から1年以上が経過しています。

若干乗り遅れた感がありますが、redux-sagaでズンドコキヨシをやりました。

Screen Shot 0028-03-31 at 14.02.15.png

redux-saga というのは redux で非同期処理を扱うためのMiddlewareで、平たく言うとkoaみたいにGenerator関数を使って非同期処理を同期処理のようなスタイルで書き下せます。思いつきの遊び半分で始めたズンドコキヨシが、正直ここまで redux-saga の本質に迫るサンプルになるとは予想もしていませんでした。redux-saga 自体の詳しいは後日別の記事で解説するとして、redux-saga が秘めているパワーを少しでも感じ取ってもらえたらと思います。

デモはこちら

このデモにはredux-devtoolsが組み込まれています。右側の黒っぽいドックがそれです。送り出されたActionのログ表示、 Reset で初期状態に戻す、 Commit で現在の状態をスナップショットして、 Revert でいつでもスナップショット時点に戻れます。こちらも同様にお楽しみ下さい。尚、表示されていない場合は Ctrl-H で表示・非表示の切り替え、Ctrl-Q でドックの表示場所を変更できます。

元ネタ

すばらしいネタをありがとう。

実装

redux はファイル数が多いので抜粋を掲載し、動作するサンプル全体は下記リポジトリを参照してください。

kuy/kiyoshi

sagas.js
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つの処理はそれぞれ rivercheckLoop という2つのSagaに対応しています。一番下にある rootSagafork Effectでこの2つのSagaを起動します。どちらも fork なので起動したSagaの終了を待つことなく終了します。

ズンドコ川の生成

次に ズンドコ川 を生成する river Sagaを見てみましょう。以下のような順番で処理が進みます。

  1. ズンドコ川の終了チェック
  2. ランダムに「ズン」と「ドコ」を出力
  3. 一定時間待つ

ここでReducerの一部を提示します。

reducers.js
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 に追加するだけでなく、musicfalse に変更しています。つまり、音楽が止まるとズンドコ川が終了するわけですね。

そして sing 関数によってランダムに「ズン」と「ドコ」のActionが生成されて put EffectによってStoreに送られます。

最後に、Promiseの説明でよく例として出てくるアレ(一定時間が経過したら resolve してくれるやつ)を使って一時停止してから while ループで最初に戻ります。

キヨシチェック

さて、ここが redux-saga を使ったズンドコキヨシのキモになります。
redux-sagatake 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 の利点はテストしやすいことです。テストないので書きます。