Elm 界隈で「コンポーネントをどう作るべきか」みたいな話がよく出る。日本に限った話ではなくメーリングリストとか Slack でも頻出の話題で、その度に熟練者が説明するのだが、すんなり理解されることもあれば喧嘩になることもある。
ちょうど昨日 Twitter で盛り上がってたので、可能な限りわかりやすく現状を説明してみる。
@nobkz @m2ym (言っている意味が分かってしまった…多分かつての推しアーキテクチャで今は基本的に避けるべきだけど場合によってはやっても良い的な位置付けのやつです…割り込み失礼)
— Yosuke Torii / ジンジャー (@jinjor) 2017年5月11日
TL;DR
出来る限りコンポーネントを作らずにビューの関数で済まそう。
コンポーネントとは何か
最初に言ってしまうと、 Elm にはいわゆる「コンポーネント」という画期的なシステムはない。ただ関数があるだけだ。
ここでいうコンポーネントというのは、例えば date picker のような HTML 内に埋め込める便利な UI のようなもので、厳密な定義はない。JavaScript 的な感覚としては、画面内にポンと設置すればあとはよろしく動いて欲しいのだが、 Elm だとそのようには行かない。なぜかというと Elm のビューは純粋な関数で状態を持てない、言い換えると UI の状態をビューが管理できないからだ。代わりに UI の状態はモデルで管理する。例えば、date picker なら現在選択されている日付や表示されている月なども全部モデルが管理することになる。
まずここで、そんな UI の状態なんかいちいち管理してられるかーという話になる。だがこれは仕方がない。その代わりにビューが純粋な関数であることで得られるメリットがある。Virtual DOM 描画の最適化だ。次のようなビュー関数があったとする。
view model = viewSomething model.something
ここで、something
の値が変わらなければ、viewSomething
関数で生成される値は常に同じだ。なぜなら全ての関数は純粋であって状態に依存しないことが保証されているからだ。この性質を使うと、 something
の値が変わった時だけ viewSomething
を走らせて実際の描画を行うということが簡単にできる。具体的には次のようにする。
view mode = lazy viewSomething model.something
この lazy はキーワードではなく、 Elm の HTML ライブラリが提供する関数(lazy : (model -> Html msg) -> model -> Html msg
)だ。こうしておくと、 Elm ランタイムは something
の値が変わったと判定するまで関数の評価を遅延する。もし仮に viewSomething
の中のどこかに状態を持つコンポーネントがあったとすれば、このようなことはできない。例えば、現在時刻に依存する時計コンポーネントを置いて lazy で評価をスキップすれば、動かない時計の出来上がりだ。ところが
Elm ではそもそも全て純粋だと分かっているから lazy をつけるに当たってそのような心配をする必要はないし、内部に状態が含まれていないかどうかを調べて回る必要もない。
話を戻すと、 UI の状態をモデルで管理しなければいけないという問題は依然として残っている。そのこと自体は避けられないのだが、様々な工夫によってその負担を軽減することができる。
かつて推奨された方法
コンポーネントの話に入る前に、Elm アーキテクチャについて触れておく必要がある。
Elm アプリケーションは model, update, view という3つの部分に分けて記述する。以下はシンプルなカウンターの例。(※シンタックスハイライトが Haskell なのでちょっと色がおかしい)
-- モデルの定義と初期値 type alias Model = Int model : Model model = 0 -- メッセージの定義と更新 type Msg = Increment | Decrement update : Msg -> Model -> Model update msg model = case msg of Increment -> model + 1 Decrement -> model - 1 -- 描画 view : Model -> Html Msg view model = div [] [ button [ onClick Decrement ] [ text "-" ] , div [] [ text (toString model) ] , button [ onClick Increment ] [ text "+" ] ]
まず、モデルとしてカウンターの型を定義(ここではInt
)して初期値を 0 とする。更新処理は、コンポーネントから発火したイベント(メッセージと呼ぶ)がインクリメントなら +1 デクリメントなら -1 とする。最後にビューとイベントハンドリングを書く。
「なんだ MVC か」と思ったら大体その理解で良いと思う。今の状態だと単に画面にひとつカウンターをおいたアプリケーションを作っただけで、コンポーネントにはなっていない。そこで、このカウンターをコンポーネントにするために「独立した3つのカウンターが必要」という想定でアプリケーションを作ってみる。
実は上のコードをそのまま再利用することができる。上のコードをCounter
という名前のモジュールにして、Main
モジュールから呼び出すと、次のようになる。
-- モデルの定義と初期値 type alias Model = List Counter.Model model : Model model = [ Counter.model, Counter.model, Counter.model ] -- メッセージの定義と更新 type Msg = CouterMsg Couter.Msg update : Msg -> Model -> Model update msg model = case msg of CouterMsg index counterMsg -> List.indexedMap (\i counter -> if i == index then Counter.update counter else counter ) model -- 描画 view : Model -> Html Msg view model = div [] ( List.indexedMap (\i conter -> Html.map (CouterMsg i) (Counter.view counter) ) )
まず、3つ分のカウンターのモデルを作る(すべて初期値 0)。更新処理は、いくつ目のカウンターから来たメッセージかを判定して、対応するカウンターのモデルを更新する。最後に3つのカウンターを描画する。
ここで面白いのは、実装詳細が完全に Counter モジュール内に隠蔽されていることだ。Counter.Model
が Int
であること、インクリメント・デクリメントというメッセージ、ビューの具体的な中身は外からは全く意識しなくていいようになっている。これによって、機能追加があっても変更はモジュール内に閉じることができる。たとえば「リセットボタンを追加してくれ」なら、Counter モジュールのメッセージに Reset
を生やして更新処理を追加、さらにリセットボタンをビューに追加すれば良い。
もうひとつ面白い点は、Main モジュール、 Counter モジュールともに model, update, view の構成になっており、一種の階層構造と見ることができる点だ。この構造の美しさが、多くの人を魅了すると同時にアンチパターンに陥れる原因になった。それは後で触れる。
さて、ここでまともなプログラマからは「いやちょっと待て」というツッコミが入る。なぜなら、コンポーネントから受け取ったメッセージを元に対応するコンポーネントの状態を更新するという処理はあまりに退屈だからだ。それこそがこの記事で扱う「論争」の火種である。
ボイラープレートを消す努力
このボイラープレートをなんとか手で書かなくて良いようにしようと積極的に取り組んできたのがelm-mdlというライブラリだ。マテリアルデザインは Ripple のような視覚エフェクトのために CSS ではなく JavaScript のロジックを使ったりする。その是非はここでは置いておくとして、こういうことをしようとするとコンポーネントは状態の宝庫になる。それで UI を置くたびにボイラープレートが増えるのが嫌なので、elm-mdl では一意なキーを割り当てることによって、パイプラインをライブラリ側に任せるという方法をとっている。
elm-mdl は使ったことがないので細かいことは分からないが、まあそうなるだろうなという感想。ちなみにこういう用途に対しては、WebComponents の Custom Element が有効に使えるという話もある。
というわけで、こういうコミュニティの努力が一応ないことはない。ただこういう仕組みを導入することによって生じる副次的な複雑さがあるのは事実で、まあ我慢して書いてもいいんじゃないのみたいな気分になったりする。
過度のコンポーネント化
もうひとつ議論になるのが「そもそも状態を持つコンポーネントってそんなに必要?」という話で、言い換えると「ほとんどの場合は純粋なビューの関数で済むんじゃないか」ということだ。
たとえば、先ほどの3つのカウンターの合計値を知りたいとする。3つの値はそれぞれのカウンターが持っているので、合計を求めるには次のようにする必要がある。上の例だと List.sum model
で済むのでそんなに問題にはならないのだが、カウンターのモデルがレコードになったり、もっと複雑になってくるとなんらかの API を解して値を取得する形になってきて、結構面倒になる。
もうひとつは、コンポーネントに良くある「○○が起きたときに△△イベントを発生させる」というもの。考慮すべき点は、クリック時ではなくモデルの更新時にそれが分かる場合がある(たとえば「カウントが10の倍数になった時にイベントが発生」)ことで、これを実装すると Coutner.update : Msg -> Model -> (Model, Event)
のようになる。これも少々煩雑だし、だんだんボイラープレートも機械的に書けなくなってくる。
ただ、ここでの問題は面倒なことではなく「必要以上に面倒」なことだ。 そもそもこのコンポーネントは必要だろうか? 今ここにあるのは「増減ボタンとリセットボタンがあり値が10の倍数になった時にイベントを発生させるカウンター」だ。汎用的に使えるとは思えないし、明らかにアプリケーションの特定機能を意識している。だとすれば、普通に Main のモデルに数値を持たせて、ビューはそれを描画することに徹したらどうだろう。そうすれば、 Counter モジュールに必要なのは view
だけになってすっきりする。それ以外はアプリケーションロジックだ。
これは馬鹿馬鹿しい例だが、実際には結構やってしまう。なぜなら「すべてはコンポーネントだ」という前提で設計を始めてしまうと必然的にボトムアップになり、親子のコミュニケーションが発生してしまうからだ。「親子のコンポーネントでどうやってコミュニケーションを取ればいいのか」という質問はコミュニティでは頻出で、Slack でこれを質問すると必ず「やあ、君が作ってるのはどういうタイプのアプリケーションで、どこでそんなコミュニケーションが必要になるんだい?」という質問返しから始まって、最終的には「それコンポーネントにする必要ないからビューの関数でいいよ」になる。
そもそも上の3つのカウンターの例は、かつて Elm アーキテクチャのドキュメントにあったサンプルそのもので、あまりに多くの人がこのアンチパターンにはまるのである日きれいさっぱり削除された。
Remove all the nesting/ examples
This was leading people astray. Need to have examples that let folks work up to these concepts so they do not overuse them in inappropriate situations.
「不適切な状況で使いすぎる」とあるように、この方法が全面的に駄目なわけではなく、使いどころによっては良いが間違った使い方をされやすいということだ。
コンポーネントの設計方針
というわけで、Elm アーキテクチャをスケールさせることに関して最新のドキュメントはこちら。
Scaling The Elm Architecture · An Introduction to Elm
雑な要約:コンポーネントじゃなくて再利用可能なビューの関数を作るんだ
雑な要約:その状況に応じた一番シンプルな方法で解決するんだ、必要以上に汎用化するんじゃない
雑な要約:参考までに一番複雑なパターンも紹介するけど、こんなの実際ほとんどないからね
この通り、相当懲りているらしくかなり口すっぱく書いてある。これが書かれた頃からコミュニティでは「コンポーネント」という言葉自体が禁句のようになっていて、話を持ち出すと何かと炎上する(個人的には過剰反応気味な気はするが)。ちなみにドキュメントはまだ書き途中で、複雑なパターンの紹介は今後また増えるとのこと。それまでは、 elm-sortable-table が一番参考になる。
あくまでひとつのサンプルという位置付けなので銀の弾丸ではない。実装も面白いが README に設計の観点が書いてあるので、まずそっちを読んでほしい。おそらく一番重要なポイントは データ本体と UI 自体の状態を明確に区別し、データは UI に持たせないということで、結構複雑に見える UI も隠蔽すべき状態というのは意外と少ない。
ここまでが Elm 作者の Evan Czaplicki 氏の見解。同じ NoRedInk 社の Richard Feldman 氏による回答例は以下。
YAGNI
たぶん気づかれた方も多いと思うが、上で言ってることはほとんどYAGNIの原則そのままだ。要するに「本当に必要になるまで作るな」を徹底して幸せになれるということで、個人的な実感としてもこれは正しいと感じる。最近作った個人のホームページでは、MIDI プレイヤーが必要だったのだが、これも 必要が生じて後から分割した。
この分割は機械的にできる。以下は Main モジュールから MidiPlayer モジュールに関係ありそうな部分をモデルから引き剥がした例。
Main.elm
type alias Model = { midiContents : Dict String MidiContent , selected : Maybe Content - , playing : Bool - , startTime : Time - , currentTime : Time - , futureNotes : List (Detailed Note) + , midiPlayer : MidiPlayer , gitHub : GitHub , fullscreen : Bool , error : Error }
MidiPlayer.elm
+type alias MidiPlayer = + { playing : Bool + , startTime : Time + , currentTime : Time + , futureNotes : List (Detailed Note) + }
これと同じことを update と view についてもそれぞれやれば完了。Elm では強力な型の力でリファクタリングが安全に行えるので、この変更で実際に起きたバグは0件だった。
正しい手順で正しく TEA コンポーネントを分割できて、勝った…という感じ。
— Yosuke Torii / ジンジャー (@jinjor) 2017年5月5日
実際にアプリを作っている人ほどこれで幸せになっているのでこの方法が良いと感じていて、それでも良く燃える原因は「プログラミング言語なんだから無限にスケールする汎用部品を作れて当然だろ」みたいな思想と YAGNI 的な世界観が衝突しているせい。自分も Eclipse とか Excel みたいな複雑・大規模・高機能なやつを作りたいのでそれは分かる。ただ、実際に1ページに10000行詰め込んだ感想としては、汎用コンポーネントは実際ほとんどないし、フラットに書き直した方が簡単だったなーだった。もちろん世の中には汎用コンポーネントを多く必要とするアプリも沢山あると思うので、そういうのをまずは作ろうとしてみて無理だとなってから文句を言うのでいいんじゃないかなと思っている。 Elm の開発はプラクティス重視で、実例を持ってきて十分よくあるパターンだと判断されると優先度が上がる傾向にあるので。
課題とまとめ
現状の問題は、まだ古い Elm アーキテクチャに引きづられている人が多いことと、消えた分のドキュメントを補完する情報が足りなくて新規ユーザーにとっては道が途切れたようになっていることだと思う。この記事も、古い情報を上書きするために書いている。
で、ここまでの内容を1行でまとめるとこうなる。
出来る限りコンポーネントを作らずにビューの関数で済まそう。
以上です。