Elm Architectureの応用 ⑴:コンポーネントを作る
次は、何かUIコンポーネントを作ってみましょう。 ここでは、先ほど作ったカウンタを、再利用可能なモジュール(Counterモジュール)として切り出してみます。
Counterモジュールを作る
そういえば、モジュールを作る方法をまだ説明していませんでしたね。 先ほどの例のステップ1で書いた部分を、Counter.elmに切り出してみましょう。
module Counter where -- モジュールの宣言 import Html exposing (..) import Html.Attributes exposing (style) import Html.Events exposing (onClick) -- MODEL (Main.elmからカット&ペースト) -- UPDATE (Main.elmからカット&ペースト) -- VIEW (Main.elmからカット&ペースト)
ここに定義された関数は、Counter.関数名で外部から使用することができます。
MainモジュールもMODEL・UPDATE・VIEWにする
それでは、Main.elmからカウンタ(Counter.elm)を利用してみましょう。 Main.elmから先ほどのCounterモジュールをインポートし、メイン画面のために新たにMODEL・UPDATE・VIEWを定義します。
import Html exposing (..)
import Counter -- モジュールのインポート
{- actions, state, main は変わらないため省略 -}
-- MODEL
type alias Model =
{ title : String
, counter : Counter.Model -- CounterのModelをMainのModelでラップする
}
init : Model
init =
{ title = "My Counter"
, counter = Counter.init
}
-- UPDATE
type Action
= NoOp
| CounterAction Counter.Action -- CounterのActionをMainのActionでラップする
update : Action -> Model -> Model
update action model =
case action of
NoOp -> model
CounterAction action ->
{ model |
counter <- Counter.update action model.counter
}
-- VIEW
view : Signal.Address Action -> Model -> Html
view address model =
div
[]
[ h1 [] [text model.title]
, Counter.view (Signal.forwardTo address CounterAction) model.counter
]
それではこのコードの内容を、順に見ていきましょう。
【コードの解説】
-
MODELでは、Main自身の状態(タイトル)とCounterコンポーネントの状態を持った
Model型を定義し、初期値を宣言しています。 -
UPDATEでは、
Counter.Action型をCounterActionでラップしたAction型を定義しています。さらに、update関数では、カウンタからのアクションを受け取り、カウンタの状態を更新しています。 -
VIEWでは、タイトルとカウンタを描画しています。
Signal.forwardTo(定義)はAddressを変換するための関数で、ここではカウンタから受け取ったModel.Action型のアクションをCounterActionでラップするように指示しています[3]。
見てのとおり、CounterモジュールとMainモジュールの構造はまったく同じになっています。 これはElm Architectureの最も大きな特徴の1つといえるでしょう。 どの階層でも、MODEL・UPDATE・VIEWだけを考えてコーディングし、あとは適切に入れ子にするだけです。
[3]: Union Typeも関数と同じように部分適用することができます。CounterAction Counter.ActionはAction型ですが、CounterActionはCounter.Action -> Action型となります。
カウンタを増やしてみる
今度は「カウンタを4つに増やし、さらにその合計を表示する」ということをしてみます。
-- MODEL
type alias Model =
{ title : String
, counters : List (ID, Counter.Model) -- それぞれIDとペアにする
}
type alias ID = Int
init : Model
init =
{ title = "My Counter"
, counters = List.indexedMap (,) (List.repeat 4 Counter.init)
}
-- UPDATE
type Action
= NoOp
| CounterAction ID Counter.Action
update : Action -> Model -> Model
update action model =
case action of
NoOp -> model
CounterAction id action ->
let
-- IDが一致するものを更新
update' (id', counter) =
if id == id'
then (id', Counter.update action counter)
else (id', counter)
in
{ model |
counters <- List.map update' model.counters
}
-- VIEW
view : Signal.Address Action -> Model -> Html
view address model =
let
viewEach (id, counter) =
Counter.view (Signal.forwardTo address (CounterAction id)) counter
-- 合計を計算
sum = List.sum (List.map (\(id, counter) -> counter.count) model.counters)
in
div
[]
[ h1 [] [text model.title]
, div [] (List.map viewEach model.counters)
, div [] [text ("total: " ++ toString sum)]
]
先ほどとの違いは、異なるカウンタ同士を区別するためにIDを割り振っている点です。 その分だけコードのかさが増してはいますが、無事に合計値を計算することができました。
カウンタに機能を追加する
ところで、個々のカウンタの状態は、コンポーネントの外側、つまりMain.elmで管理されています。 「コンポーネントの状態を外で管理する」と聞くと、コンポーネントに機能を追加するたびに外部で扱うコードが増えていきそうですが、実際にはそうはなりません。 先ほどの例ではカウンタの数字を1つずつ増やしていましたが、どういうアクションが起こったとき(例えばクリック)にどのように増やすか(例えば+1する)という情報は、外からは全く見えません。それらは全てコンポーネントのモジュール内に書かれています。 そのため、コンポーネントの使用者は「Actionが来たらupdateする」ということだけ覚えておけば、その具体的な内容を知る必要はないのです。 Elm流のカプセル化と言えるかもしれません。
実装の詳細が隠蔽されていることを検証するため、カウンタに1つ機能を追加してみましょう。 次の例では、マウスカーソルが乗ったらボタンの色を変えるようにしています[4](変更箇所を★で示しています)。
module Counter where
import Html exposing (..)
import Html.Attributes exposing (style)
import Html.Events exposing (onClick, onMouseEnter, onMouseLeave) -- ★
-- MODEL
type alias Model =
{ count : Int
, hover : Bool -- ★
}
init : Model
init =
{ count = 0
, hover = False -- ★
}
-- UPDATE
type Action
= NoOp
| Increment
| Hover Bool -- ★
update : Action -> Model -> Model
update action model =
case action of
NoOp -> model
Increment ->
{ model |
count <- model.count + 1
}
Hover hover ->
{ model |
hover <- hover
} -- ★
-- VIEW
view : Signal.Address Action -> Model -> Html
view address model =
button
[ onClick address Increment
, onMouseEnter address (Hover True) -- ★
, onMouseLeave address (Hover False) -- ★
, style
[ ("width", "100px")
, ("height", "100px")
, ("font-size", "large")
, ("background", if model.hover then "#abc" else "") -- ★
]
]
[ text (toString model.count) ]
いかがでしょうか。 Counter.elmは書き換えていますが、Main.elmは全く書き換えていません。 カウンタの使用者は、ActionにHover Boolが追加されたことも、Modelにhoverが追加されたことも知らないまま、カウンタを使い続けることができます。
[4]: もちろんCSSでもできますが、ここでは置いておきましょう。
コンポーネント作成の特徴と考察
- コンポーネントと、その使用者側にそれぞれMODEL・UPDATE・VIEWを定義する
- コンポーネントは自身に状態を持たない。状態は外(使用者側)で管理し、コンポーネントから発行されたActionをコンポーネント自身にフィードバックする必要がある
- このとき、コンポーネントの使用者はコンポーネントの実装の詳細を気にする必要がない
これらのうち、2番目の特徴は、いわゆるオブジェクト指向的なコンポーネントに慣れているとわずらわしく感じるかもしれません。 カウンタをクリックしたときにカウンタ自身の数値を増やすのだから、わざわざカウンタの外部にアクションを発行しなくてもよいような気もします。 しかし、最初は内部に閉じていたアクションが後に外部で必要になったとき、onXXX()のようなコールバックが次々と必要になるでしょう。 そうではなく、VIEWを純粋な関数にし、描画に必要なデータを全て外から渡してもらうようにすることで、アプリケーションの構造をシンプルに保つことができます。
一風変わったコンポーネントの作り方を見たところで、次ページでは非同期通信を扱う方法を見ていきます。
