エディタの実装をcycle.jsでMVIベースにしてみた話
最近Electronでエディタをつくっており、最初はReact.jsを使いながらゆるいFlux風の設計でつくっていたのを、cycle.jsを使いながら一部をMVI風の設計に置き換えてみた。400行程度の一画面のコードだったので3時間ぐらいで置き換えられて、前よりも責務が適切に分割されるようになったので、本体部分も次の機能追加時に置き換えようと思っている。
とりあえずプレビュー画面だけ置き換えた
置き換えたのは、編集中のファイルを別画面でプレビューとして表示する画面で、ただプレビューするだけの機能のほかに連続したスライドとして表示するプレゼンテーション機能もある。1つ前のブログ記事を見てもらうとわかりやすいと思うけれど、次の画像のようなやつ。ボタンをクリックしてモードを切り替えたりキーボードを使って移動したり、またエディタ側でファイルの内容が書き換わったりと、それっぽく言えば幾らか動きのある画面である。
たぶんリアクティブな感じにしたからコードが減った
リファクタリング時に参考にしたい、本質的ではないが客観的に分かりやすい変化としてコード量があるが、プレビュー兼スライド表示をする部分のコードでだいたい400行から200行に減った。どうしてコード量が減ったのか自分なりに考えてみたところ、恐らく大半の処理の流れをリアクティブな設計に変更したことで、自前でイベントを繋ぎ込む処理の大半を書かなくてよくなったからだと思われる。
各部品がイベントソースを入出力する関数として抽象化された
cycle.jsのドキュメント を読んだほうが早い。早いが、ドキュメントは読みたくないという人向けに説明する。そもそもGUIで動作する大半のアプリケーションというものは、アプリケーションの命令をうけてユーザに対して画面を表示し、ユーザからの入力を待ち、入力に対して何らかの処理を行い、ふたたびユーザに対して新しい画面を表示する、という風に雑に一般化できる。アプリケーションからのイベント (画面を更新しろという命令) を受け取り、ユーザからの入力をイベントとしてアプリケーションに伝えるというように捉えれば、これをイベントソースとして見ることができる。Webブラウザの場合、DOMを介して画面を描画し、DOMを介して入力を受け取ることになる... というのをよく表しているのがこの図。(図は http://cycle.js.org/drivers.html より)
domDriver()
というのはDOMとのやりとりを抽象化してイベントソースとして扱えるように変換してくれるやつで、この処理はどのWebブラウザ用のアプリケーションにも共通しているので、何らかのフレームワークとして提供できる。で、我々アプリケーションを開発する側としては、上図の main()
の部分を実装すると良い。具体的には、domDriver()
を使って得られるイベントソースをもとに、いろいろ振る舞ったのちに新しい描画イベントを発火するようなイベントソースを domDriver()
に返す、という処理を書けばよい。言い換えると、domDriver()
から受け取ったイベントソースを別のイベントソースに変換して返す、ということになる。つまり、main()
はイベントソースを受け取ってイベントソースを返す関数になるし、domDriver()
もまたイベントソースを受け取ってイベントソースを返す関数である。
function main(domEventSource) { const renderingEventSource = ourApplicationLogic(domEventSource); return renderingEventSource; }
Model-View-Intent
main()
関数が大きくなっていくことを考えると、適切に分割することを考えたいが、そのときの分割方法としてModel・View・Intentの3つの部分に分けることが提案されている。Model・View・Intentはどれも、入力としてイベントソースを取り、出力として別のイベントソースを返す関数の形態をとる。(図は http://cycle.js.org/model-view-intent.html より)
Model・View・Intentのそれぞれの責務を説明しておくと、IntentはDOM経由で発生したイベントをユーザが何を意図してそうしたのかに変換するやつで、ModelはIntentのイベントをもとにいろいろ振る舞ってアプリケーションの状態が更新されたことを伝えるやつで、Viewは更新された新しい状態をもとに新しい (Virtual) DOMを描画するやつ。例えば、削除ボタンがクリックされると、削除したいらしいというイベントにIntentで変換されて、削除された結果アプリケーションの状態がModelで更新されて、その結果新しいDOMがViewで生成されるという具合。実際に、アプリの main()
のコードはこうなっている。
import { makeDOMDriver } from '@cycle/dom'; import Cycle from '@cycle/core' import intent from './intent' import model from './model' import view from './view' Cycle.run( ({ DOM }) =\> { return { DOM: view(model(intent(DOM))) }; }, { DOM: makeDOMDriver('body') } );
フラクタル懐かしいですね
cycle.js を見てると、たびたび「イベントソースを受け取ってイベントソースを返す関数」という形式が設計上に何度も出てくる。こういう風に「イベントソースを変換する関数」という型を共通プロトコルとして使い回し、ある大きなコンポーネントを分割する場合にも複数の同じ型の小さなコンポーネントに分割することで、自己相似形を保っていて、直感的な設計を保ってるのはかっこいい。いかにも好きそうな人は好きそう。
他に似たようなものとして、VirtualDOMの一部を小さなMVIアプリとして構築することもできる。cycle.jsではこの手の機能が custom elements として提供されている。Web Componentsのような感じで使えて、予めmy-buttonのように要素名を登録しておくと、Viewでdivやspanなどに加えてmy-buttonが使えるようになる。描画時に入力を受け取り、また外部に対して自分で適当なイベントを発行するように仕向けられるので、複雑な動きをするUIを何度も用意する必要が出てきたときに利用できる。ビジネスロジックを含まないように気を付ければコンポーネントとして抽象化できるので、これも main()
を分割する際に役に立つと思う。
やりとり
ReactやReduxでの描画とイベントハンドラの責務の分離の話が出てちょっと会話したので参考に載せとく。
Reduxでconnectするとdispatchが手に入るのでComponentの中でバンバン使っちゃうけど、できればmapDispatchToPropsをうまく使ってComponentはほぼDumbな状態にするべきなんだろうな
— 稚魚 (@questbeat) 2015, 10月 3
@r7kamura view-event-publisherってやつですか?これ正直どうかと思います…
— 稚魚 (@questbeat) 2015, 10月 3
@r7kamura @questbeat 理性捨てて this.props.actions.clip.addClip() とかやってる……
— 偽名 (@amagitakayosi) 2015, 10月 3
@amagitakayosi @questbeat 完全に理性捨てると祖先のコンポーネントのstateにactions渡すだけでたら自動的に末代まで知れ渡るぞ http://t.co/XSHRAgTqoj
— CSSセレクタ設計技師 (@r7kamura) 2015, 10月 3
@r7kamura @amagitakayosi 最初全く理解できなかったんですが読んでるうちに理解してきて良さそうに思えてきました
— 稚魚 (@questbeat) 2015, 10月 3
@r7kamura これならViewはテンプレートエンジンから出力しても困らなそうですね
— 偽名 (@amagitakayosi) 2015, 10月 3