これは Redux Advent Calendar 2016 の1日目の記事になります。
作ったもの
カーソルキーで移動、スペースキーでポーズ、エンターキーでドロップ。回転は未実装。
構成
- React.js + Redux + redux-saga + Obelisk.js
- Webpack
- Babel (ES2016, React, Stage-2)
- flowtype
- mocha + power-assert
なぜ3Dテトリス?
業務で扱っている機能やアプリケーションではReduxを使っていてあまり困ったことがなく、1年以上のメンテナンスに耐えることができました。一方でReduxつらい、わからんという声は今でも一定数あります。そこで普段とは違ったタイプのアプリケーションを題材にしたら、これまで気付かなかった問題に出会えるかもしれないという期待をもってゲームを作ってみることにしました。
ちなみに、最初に取り組んだゲームは3Dテトリスではなくライフゲームでした。少々脱線してしまいますが、Reduxのミドルウェア選定に悩む人には役に立つかもしれないので紹介しておきます。
脱線: ライフゲーム
lifegame-redux はReduxで副作用を扱うミドルウェアを比較する目的で作ったライフゲーム実装です。さすがにもうTodoMVCを書くのは嫌だし、ショッピングカートを実装しても面白くないし、悩んだ末のライフゲームになります。現在実装済みのミドルウェアは以下になります。
- redux-saga
- redux-logic Vanilla, RxJS, async/await の3種類
- redux-observable
- redux-ship
今のところ副作用といってもタイマーくらいしか扱っていなくて、少なくとも通信系を追加しないと比較にならないので頑張ります。
得られた知見
さて、話を戻してOberisを作る過程で悩んだ部分について書いていこうと思います。
2種類のAction
Redux、というかFluxではViewにStoreの状態を直接変更させる代わりにActionを介してStore自身に状態変更を任せます。このようなデータフローを採用することで、ViewがStore内部の知識を持たないようになり、Viewの再利用性が向上します。デザインパターンで言うところのコマンドパターンですね。
これまで作ったアプリケーションではActionを使った分離で困ったことはあまりなかったのですが、ゲームを作ってみるとすぐに問題が出てきました。キー入力の取り扱いです。もしキー入力をActionとしてdispatchした場合、状況によってそのActionの意味が変わってしまいます。例えばスタート画面でスペースキーを押したらゲームの開始ですが、ゲーム中にスペースキーを押したら一時中断になり、ゲームオーバー時に押したら新規ゲームになります。
この問題解決にはいくつかアプローチがあると思います。
- キー入力をActionとしてdispatchし、Reducerで状況に応じて処理を分ける
- キー入力を受け取るハンドラーで状況に応じた適切なActionを投げ分ける
- キー入力を低レベルActionとし、それに応じた高レベルActionとして分離する
1つ目はキー入力のハンドラでは何も工夫せずにほぼそのままActionとしてdispatchして、Actionを処理するReducer(とかSaga)で状況を判断して処理を分けます。素直なアプローチですが、コマンドパターン的にはある程度ドメインにおける意味を持ったものをActionとして扱いたい気持ちがあって、生のイベントをそのままdispatchするのはいかがなものか、という懸念があります。
2つ目はキー入力のハンドラ側で状況を判断して適切なActionをdispatchします。ある程度の意味を持ったActionが流れることになるので1つ目のアプローチよりは筋が良いように感じます。ただ、生のイベントを扱うハンドラに状況判断のためのロジックが詰め込まれていくので密結合しすぎな気がします。
3つ目は1つ目のアプローチと同様にキー入力のハンドラでは素直にActionをdispatchしますが、Reducerでそれらを直接処理しません。代わりに設けた中間層(Saga)で低レベルActionを受け取り、適切な高レベルActionをdispatchします。Reducerでは基本的に高レベルActionだけを処理するようにします。レイヤーを分けることで生のイベントを扱うレイヤーは他のアプリケーションで再利用できますし、ドメインの知識がそのレイヤーに漏れ出すこともなさそうです。
このアプローチは Using React (-Native) with Redux and Redux-Saga. A new proposal? という記事で提案されており、読んだときは「へー、そんな方法もあるんだー」程度の認識でしたが、実際の問題に直面してみるとその利点が実感できました。
Reduxではこういった問題に直面しなかったとしても、Actionをどう定義するかはとても大事なトピックです。例えば TodoMVC を考えたとき、Todoを追加するActionは ADD_ITEM
と ADD_TODO
のどちらが良いのでしょうか? 大半の人は後者を選ぶと思います。が、実際のアプリケーションを開発しているとドメインとして意味のあるActionではなく、物理的なユーザー入力に対応したActionだったり、Storeに格納してある状態のデータ構造に依存したActionを定義してしまいがちです。できるだけそういったものに依存しない高レベルのActionを定義するようにしましょう。
過疎ったReducer
ゲームの開発が進むにつれて Reducer 分割問題が出てきました。StateのネストはReducerが複雑化してバグの温床になるので避けたいところです。とはいえ、分割を工夫してどうにかなる感じでもなくとても困りました。combineReducers
以外の何かを使うという手もありましたが、どうにもイレギュラー感が拭えませんし、そもそもReducerだけですべてのロジックを書き切れるわけでもないので、諦めて redux-saga を導入しました。その結果、もともとReducerにあったロジックのかなりの部分がSagaに移動してしまい、Reducerがスカスカになってしまったわけです。
さて、この状況が悪いのかどうか、というのは私にもわかりません。そもそもReducerは状態変更の門番ではありますが、当然のことながら状態変更だけがアプリケーションロジックではありません。つまり、アプリケーションによってどういうタイプのロジックがどのような割合で占めるのかは変わってくるため、Reducerで処理する以外のロジックが多いからそれが悪いとも言えないわけです。
ということでこれに関しては有用な結論が出ませんでした。というのも、Reducerが過疎っても何か困ったことが起きているわけでもないですし、将来メンテナンス性が損なわれるような状況が生まれる気もしません。問題があるとしたら「それはもはやReduxなのか?」というツッコミに答えられないことでしょうか。
さいごに
1日目からなんだか気の抜けた記事になってしまいましたが、当初の目論見どおり、いつもと違ったアプリケーションを作ることで、いつもと違った問題に直面することができました。これによってあまり実感の湧かなかったトピックについて考える良い機会となり、視野が広がった気がします。問題は解決することでまた別の問題を生み出しているはずなので、また進展があったら記事にしようと思います。
明日はまだ埋まっていません。あとからでも埋めてくれる方、大歓迎です!!