このファイルは README.md の翻訳です。 commit 7d5d75bc7a2ecb87c0d7ed182a94ff4d128f722f 時点の README.md に基づいています。
このチュートリアルでは "Elm アーキテクチャ" を概説する。 "Elm アーキテクチャ" は全ての Elm プログラムに見出す事が出来る。例えば、 TodoMVC や dreamwriter といったものから、NoRedInk や CircuitHub などの商用製品の中で動作しているものにもである。基本的なパターンはフロントエンドを Elm や JS や、他の何かで記述する際にも有用である。
Elm アーキテクチャは無限にネストされるコンポーネントの為の単純なパターンであり、モジュール性、コードの再利用、テストの観点で優れている。究極的には、このパターンは複雑なウェブアプリケーションを、モジュラー方式で簡単に構築する事を可能にする。このチュートリアルでは以下の 8 つの例を、核となる原理とパターンの上に構築し、動作させる。
このチュートリアルは本当に役立つだろう!例 7 と 8 を超簡単に作るために必要な概念と考え方を明らかにする。 Elm の基礎に時間を費やすのはその価値があるよ!
これらのプログラム全てに見られる Elm アーキテクチャの非常に興味深い性質として一つ上げられるのは、それが Elm から自然に現れる事である。 あなたがこのチュートリアルを読んでいるかどうかや、 Elm アーキテクチャのご利益を知っているかどうかに関わらず、言語のデザインそれ自体があなたを Elm アーキテクチャへと導く。事実私はこのパターンを Elm を使っているだけで発見し、その単純明快さと応用範囲の広さに衝撃を受けた。
注意: このチュートリアルをコードを試しながら進めるには、Elm をインストールして、このレポジトリをフォークした方がいいだろう。このチュートリアルのそれぞれの例はどうやってコードを動作させれば良いか教えてくれる。
全ての Elm プログラムのロジックは、以下の 3 つの部品に明確に分割する事ができる。
- model
- update
- view
まずは拠り所としてふさわしい以下のスケルトンから初めて、それに個別のケースのコードを追加するかたちですすめていこう。
もしあなたが Elm コードを初めて読むなら、言語のドキュメントを参照するとよい。ドキュメントは言語の文法から、"関数プログラミング脳"の身につけ方まで、全てのカバーしている。完全ガイドの最初の 2 節は、理解の速度を速めるだろう!
-- MODEL
type alias Model = { ... }
-- UPDATE
type Action = Reset | ...
update : Action -> Model -> Model
update action model =
case action of
Reset -> ...
...
-- VIEW
view : Model -> Html
view =
...
このチュートリアルはこのパターンの詳細と、小さな変更や拡張について説明する。
デモ / ローカルホストで実行
最初の例は 1 の加算と 1 の減算のできる単純なカウンターである。
コードには始めに非常に単純な Model を定義する。カウンタを作るためには一つの数字を追跡し続ける事ができればよい。
type alias Model = Int
この Model を更新するとしても、コードは比較的単純なままである。実行されうる Action の集合を定義して、実際に Action を実行する update
関数を定義する。
type Action = Increment | Decrement
update : Action -> Model -> Model
update action model =
case action of
Increment -> model + 1
Decrement -> model - 1
注意点としては、 Action
union 型は何かするわけではなく、あくまで取りうる Action の記述であるという事である。誰かがあるボタンを押されたらこのカウンタが 2 倍されるようにしようとした場合、それは Action
の取りうる新しい値となる。これはつまり、このコードがモデルがどのように変形され得るか非常に明確になるという事である。このコードを読む人は、何ができて何ができないのか、すぐに知る事ができる。さらには、新しい機能を追加する一環した方法も正確に知る事ができる。
最後に、 Model
を view
(見る) 方法を作る。ここでは elm-html を利用してブラウザでHTMLを表示する。 div 要素を作成して、その中に 1 の減算ボタンと、現在のカウントを格納する div 要素と、 1 の加算ボタンを格納する。
view : Signal.Address Action -> Model -> Html
view address model =
div []
[ button [ onClick address Decrement ] [ text "-" ]
, div [ countStyle ] [ text (toString model) ]
, button [ onClick address Increment ] [ text "+" ]
]
countStyle : Attribute
countStyle =
...
この view
関数のトリッキーな部分として、 Address
がある。これについては次の節で詳しく説明する!今の所は、このコードは完全に宣言的 であるという事を理解してほしい。このコードで Model
を決めて、 Html
を提供した。これがつまりそうである。どの部分でも DOM を手動で変化することがない。これはライブラリが賢い最適化を行う為の自由度を提供し、全体としてより速いレンダリングを現実のものとする。これはイかれたやり方だ。さらには view
は昔ながらの普通の関数なので、これを作る時には、 Elm のモジュールシステム、テスト、フレームワーク、ライブラリの全ての力を借りる事ができる。
このパターンは Elm プログラムの構造化の本質である。これから見て行く全ての例はこの基本パターンの Model
、 update
、 view
を、ほんの少し変えたものである。
ほぼ全ての Elm プログラムは、アプリケーション全体を駆動する小さなコードを含む。このコードは、このチュートリアルの例それぞれで、 Main.elm
というファイルに分離して格納されている。カウンタの例では、この興味深いコードは以下のようになる。
import Counter exposing (update, view)
import StartApp.Simple exposing (start)
main =
start { model = 0, update = update, view = view }
ここでは最初の Model と同時に update と view 関数を記述するために、 StartApp
パッケージを利用している。これは Elm の signal に関係する部分の小さなパッケージで、さしあたって signal の事を知らずとも良いようにしてくれる。
アプリケーションを書き上げるための鍵は、 Address
の概念である。この view
関数の中のすべてのイベントハンドラは、特定のアドレスを通知する。アドレスはデータの塊と一緒に送られる。 StartApp
パッケージはこのアドレスに送られてくる全てのメッセージをモニタし、 update
関数に送り込む。モデルは更新され、 elm-html は効率的な変更のレンダリングの面倒を見る。
この事はつまり、 Elm プログラムの値の流れは、以下のように一方向だということである。
青い部品が我々の Elm プログラムのコアであるが、これはまさに何度も議論されてきた model/update/view パターンである。 Elm でプログラミングを行なう際には、この青い箱の中身に集中する事が出来るので、開発を大きく進める事ができる。
注意点として、アクションがアプリケーションに送り返されるのと同じように、このプログラムはアクションを実行しているわけではないという事である。プログラムはただデータを送るだけである。この分割がアプリケーションを書き上げるための鍵の詳細であり、ロジックを view のコードから完全に分離しておく事である。
デモ / ローカルホストで実行
例 1 では、基本的なカウンタを作成した。では、このパターンをスケールして、カウンタを 2 つにするにはどうしたら良いのだろう?モジュール性を維持出来るのだろうか?
例 1 のコード全てを再利用できたら素晴らしくはないだろうか? Elm アーキテクチャはイかれた事に、コードは全く変更する事なく再利用できる。例 1 で Counter
モジュールを作成したが、細々とした実装が全てカプセル化されていたので、どこであっても再利用できる。
module Counter (Model, init, Action, update, view) where
type Model
init : Int -> Model
type Action
update : Action -> Model -> Model
view : Signal.Address Action -> Model -> Html
モジュール化されたコードを作成することは、協力な抽象レイヤを作成する事と同義である。正しく機能性を提供し、実装を隠す境界が欲しいところだが、たった今 Counter
モジュールの外面から、基本的な値のセット、 Model
、 init
、 Action
、 update
、 view
を見て来たばかりだ。これらの実装がどのようであるかは少しも気にする必要はない。実際のところ、それらがどのような実装かを知る事は不可能である。このことはつまり、公開されない実装の詳細を気にする必要はないという事だ。
Counter
モジュールを再利用するのは、 CounterPair
モジュールを作成するためである。いつも通り、まず Model
からはじめよう。
type alias Model =
{ topCounter : Counter.Model
, bottomCounter : Counter.Model
}
init : Int -> Int -> Model
init top bottom =
{ topCounter = Counter.init top
, bottomCounter = Counter.init bottom
}
Model
は二つのフィールドを持つレコードであり、 1 つ 1 つがスクリーンに表示したいカウンタのものである。この Model
はアプリケーションの全ての状態を完全に記述できる。同様に必要に応じて新しい Model
を作成するために init
関数を作成する。
次にサポートしたい Action
の集合を記述する。今度の機能は、全てのカウンターのリセット、上のカウンタの更新、下のカウンタの更新、である。
type Action
= Reset
| Top Counter.Action
| Bottom Counter.Action
注意点として、 union 型は Counter.Action
型を参照しているが、これらの action の詳細について知らないという事だ。 update
関数を作るときには、 Counter.Actions
を適切な場所に引き回してやる。
update : Action -> Model -> Model
update action model =
case action of
Reset -> init 0 0
Top act ->
{ model |
topCounter = Counter.update act model.topCounter
}
Bottom act ->
{ model |
bottomCounter = Counter.update act model.bottomCounter
}
さて、最後やらなければならない事は、 view
関数を作る事である。これは両方のカウンタをリセットボタンと一緒にスクリーンに表示する。
view : Signal.Address Action -> Model -> Html
view address model =
div []
[ Counter.view (Signal.forwardTo address Top) model.topCounter
, Counter.view (Signal.forwardTo address Bottom) model.bottomCounter
, button [ onClick address Reset ] [ text "RESET" ]
]
両方のカウンターに Counter.view
関数を再利用出来ている事に注意してほしい。それぞれのカウンタについて、転送 address を作成している。このコードでしている事は本質的に次のように言う事ができる、 “これらのカウンタは送り出されるメッセージに Top
もしくは Bottom
のタグ付けを行い、違うものである事を知らせる。“
これで全てである。イカした事に、モジュールはどんどんネストしていく事ができる。今 CounterPair
モジュールを使えるようになったが、これは鍵となる値と関数を提供するので、 CounterPairPair
や必要なものを何でも作る事ができる。
デモ / ローカルホストで実行
二つのカウンタはイカしていたが、カウンタのリスト、しかも適当な数になるようカウンタの追加や削除が出来るようなものについてはどうだろう? Em アーキテクチャのパターンはまた上手く適用できるのだろうか?
例 1 、例 2 がそうだったように、今回もまた Counter
モジュールを完全に再利用する事が出来る!
module Counter (Model, init, Action, update, view)
これはつまり、 CounterList
モジュールを作成するところから始めるという事である。いつも通り Model
から始める。
type alias Model =
{ counters : List ( ID, Counter.Model )
, nextID : ID
}
type alias ID = Int
今回の Model はカウンタのリストである。それぞれのカウンタにユニークな ID が注記されている。これらの ID によりそれぞれのカウンタを区別する事が出来るので、例えば 4 番のカウンタを更新する必要がある時に、そのカウンタを参照する方法が出来た(この ID は同時に、レンダリングの最適化を考える際に依って立つキー
として役立つものであるが、この話題はこのチュートリアルの範囲外だ!)。この Model は同時に nextID
を含んでおり、これは新しく追加したカウンタにユニークな ID を割り当てる時に役立つ。
それでは Model に対して実行できる Action
の集合を定義しよう。ここでは、カウンタの追加、カウンタの削除、指定されたカウンタを更新を行ないたいと考えている。
type Action
= Insert
| Remove
| Modify ID Counter.Action
Action
union 型はビックリするほど高レベルの記述に近い。これで update
関数を定義できるようになった。
update : Action -> Model -> Model
update action model =
case action of
Insert ->
let newCounter = ( model.nextID, Counter.init 0 )
newCounters = model.counters ++ [ newCounter ]
in
{ model |
counters = newCounters,
nextID = model.nextID + 1
}
Remove ->
{ model | counters = List.drop 1 model.counters }
Modify id counterAction ->
let updateCounter (counterID, counterModel) =
if counterID == id
then (counterID, Counter.update counterAction counterModel)
else (counterID, counterModel)
in
{ model | counters = List.map updateCounter model.counters }
これは以下の各々の場合の高レベル記述である。
-
Insert
— まず、新しいカウンタを作成しカウンタのリストの最後に追加する。そしてnextID
に 1 加算して次に使える新しい ID を用意する。 -
Remove
— カウンタのリスト一番最初の内容を落とす。 -
Modify
— 全てのカウンタを走査して、 ID が一致するものを見つけたら、与えられたAction
をカウンタに適用する
もはやなすべき事は view
を定義する事だけである。
view : Signal.Address Action -> Model -> Html
view address model =
let counters = List.map (viewCounter address) model.counters
remove = button [ onClick address Remove ] [ text "Remove" ]
insert = button [ onClick address Insert ] [ text "Add" ]
in
div [] ([remove, insert] ++ counters)
viewCounter : Signal.Address Action -> (ID, Counter.Model) -> Html
viewCounter address (id, model) =
Counter.view (Signal.forwardTo address (Modify id)) model
一番面白い部分は viewCounter
関数である。これは今まで使ったのと同じ Counter.View
関数を使っているが、このケースでは全てのメッセージにレンダリングされるカウンターを指定する ID を全てのメッセージに付記した転送 address を提供している。
実際の view
関数を作成する時には、 viewCounter
を全てのカウンタに map を用いて適用して、さらに address
が直接送信される add 、 remove ボタンを作っている。
この ID のトリックは動的な数のサブコンポーネントが必要な時にはいつも使える。カウンタはとても単純だが、このパターンは ユーザプロファイル、 tweet、ニュースフィードアイテム、製品の詳細情報のリストを扱う時にも全く同じである。
デモ / ローカルホストで実行
よし、動的なカウンタのリストについては、じつにイカした感じで簡潔さとモジュール性を維持出来たね。でも、グローバルな削除ボタンの代わりにそれぞれのカウンタ毎に削除ボタンを付けられないかな?そいつは確かにコードが汚くなりそうだ!
いんや、 Elm アーキテクチャはやっぱりうまくやるよ。
このケースのゴールから考えるに、 Counter
に、 削除ボタンを追加した新しい view を作らなきゃいけない。面白い事に、前の view
関数はそのままに、基底の Model
にちょっとばかし違う view を提供してくれる viewWithRemoveButton
関数を追加出来る。実にグッとくるね。コードの複製だとか、サブタイピングやオーバーロードとか、その手のイカレたことはなんもしなくていい。単に新しい関数を追加して、公開 API として新しい機能を提供してやればいい!
module Counter (Model, init, Action, update, view, viewWithRemoveButton, Context) where
...
type alias Context =
{ actions : Signal.Address Action
, remove : Signal.Address ()
}
viewWithRemoveButton : Context -> Model -> Html
viewWithRemoveButton context model =
div []
[ button [ onClick context.actions Decrement ] [ text "-" ]
, div [ countStyle ] [ text (toString model) ]
, button [ onClick context.actions Increment ] [ text "+" ]
, div [ countStyle ] []
, button [ onClick context.remove () ] [ text "X" ]
]
viewWithRemoveButton
関数は新しくボタンを一つ追加する。気をつけたいのは 1 加算/ 1 減算ボタンはメッセージを action
アドレスに送るが、削除ボタンは remove
アドレスに送るって点だ。 remove
に向けて送ったメッセージは、つまるところこんな意味だ。 “よう、誰が俺の上に居るのかしらんけどさ、俺を削除してくれよな!” 削除するかどうかは、そのカウンタの上に居る奴次第だ。
viewWithRemoveButton
が出来たから、カウンタをまとめて配置してくれる CounterList
モジュールを作れるようになった。 Model
はほとんど例 3 とおなじで、カウンタのリストとユニークな ID だ。
type alias Model =
{ counters : List ( ID, Counter.Model )
, nextID : ID
}
type alias ID = Int
Action の集合はちょいと変わって来る。古いカウンタのどれかをどかす代わりに、特定のカウンタを削除するようにしたい。そういうわけで、 Remove
ケースは ID を持つようになる。
type Action
= Insert
| Remove ID
| Modify ID Counter.Action
update
関数は例 3とほとんど同じだ。
update : Action -> Model -> Model
update action model =
case action of
Insert ->
{ model |
counters = ( model.nextID, Counter.init 0 ) :: model.counters,
nextID = model.nextID + 1
}
Remove id ->
{ model |
counters = List.filter (\(counterID, _) -> counterID /= id) model.counters
}
Modify id counterAction ->
let updateCounter (counterID, counterModel) =
if counterID == id
then (counterID, Counter.update counterAction counterModel)
else (counterID, counterModel)
in
{ model | counters = List.map updateCounter model.counters }
Remove
ケースは、 ID を頼りに削除することになってるカウンタをとりだすようになっている。でもまあ、ほとんど前と同じだ。
最後にこいつらを view
に入れ込んでやろう。
view : Signal.Address Action -> Model -> Html
view address model =
let insert = button [ onClick address Insert ] [ text "Add" ]
in
div [] (insert :: List.map (viewCounter address) model.counters)
viewCounter : Signal.Address Action -> (ID, Counter.Model) -> Html
viewCounter address (id, model) =
let context =
Counter.Context
(Signal.forwardTo address (Modify id))
(Signal.forwardTo address (always (Remove id)))
in
Counter.viewWithRemoveButton context model
この viewCounter
関数では、メッセージを渡し込むために必要な転送 Address として Counter.Context
を構築してる。注記を付加した Counter.Action
のどの場合も、どのカウンタが更新されたり削除されれば良いのか分かるようになっている。
基本パターン — 全ては Model
の周りに作られていく、 Model を update
する方法、 Model を view
する方法。全てこの基本パターンから派生する。
モジュールのネスト — 転送 Address は基本パターンを簡単にネストできるようにしてくれる。基本パターンは好きな深さにネストできるし、それぞれの階層では一つ下の階層で何が起きているのかさえ分かっていればいい。
Context の追加 — Model に対して update
もしくは view
するときに、追加の情報が必要になることがある。そのときは、 Context
をそれらの関数に追加して、追加情報と一緒に引き渡す。そうすると Model
を複雑にする必要がなくなる。
update : Context -> Action -> Model -> Model
view : Context' -> Model -> Html
ネストの全ての階層でそれぞれのサブモジュールのための特別な Context
を作り出す事ができる。
テストが簡単 — ここまでで作った関数は全て純粋な関数だった。こうすることで、 update
関数のテストがとんでもなく簡単になる。特別な初期化やモックの準備や設定は必要なくなり、テストしたい関数に引数を渡して呼び出せばテストが出来る。
デモ / ローカルホストで実行
無限にネスト出来るコンポーネントをどう作ればいいかはわかったけど、 HTTP リクエストを投げたい場合はどうしたら良いんだろう?あるいはデータベースと対話するばあいは?この例では、 elm-effects
パッケージを使って、 giphy.com から “funny cats” というトピックでランダムな GIF ファイルを取って来る簡単なコンポーネントを作ってみる。
実装をざっと見ると、カウンタを作った例 1 とほとんどコードが同じだって事に気付くだろう。 Model
はとってもありきたりだ。
type alias Model =
{ topic : String
, gifUrl : String
}
この例を作るためには、 GIF ファイルを探すための topic
は何か、表示する gifUrl
は何か、この二つを知る必要がある。この例で唯一新しい部分は、 init
と update
で、ちょっとだけ凝ったデータ型になっている。
init : String -> (Model, Effects Action)
update : Action -> Model -> (Model, Effects Action)
このコードは単に新しい Model
を返す代わりに、実行したい Effects も一緒に返す。これにより、 Effects
API を使えるようになる。
module Effects where
type Effects a
none : Effects a
-- don't do anything
task : Task Never a -> Effects a
-- request a task, do HTTP and database stuff
Effects
型は本質的にはデータ構造であり、後で走らせる予定のひとまとまりの Task を保持している。 update
の中を見て、どのように動くのか感じをつかもう。
type Action
= RequestMore
| NewGif (Maybe String)
update : Action -> Model -> (Model, Effects Action)
update msg model =
case msg of
RequestMore ->
( model
, getRandomGif model.topic
)
NewGif maybeUrl ->
( Model model.topic (Maybe.withDefault model.gifUrl maybeUrl)
, Effects.none
)
-- getRandomGif : String -> Effects Action
ユーザは “More Please!” ボタンを押して RequestMore
アクションを発動できる。サーバが応答を返すと、 NewGif
アクションが発動する。 update
関数ではこれらのアクションの両方のシナリオを取り扱う。
RequestMore
については、まず既に存在する Model を返す。ユーザがボタンをクリックしたあと、すぐには何も起こらない。同様に getRandomGif
関数を使って、 Effect Action
を作る。どうやって getRandomGif
を定義するかについてはすぐに説明する。今はいつ Effects Action
が走り、アプリケーション全体に引き回される一連の Action
の値が発生するのか知っておけばいい。さて、 getRandomGif model.topic
は動作すると次のような結果を返す。
NewGif (Just "http://s3.amazonaws.com/giphygifs/media/ka1aeBvFCSLD2/giphy.gif")
Action は Maybe
を返す。これはサーバへの要求が失敗する事があるからだ。 Action
は update
関数にフィードバックされる。そして、 update 関数中の NewGif
のケースを通ると、それが出来る場合に現在の gifUrl
を更新する。もし要求が失敗した場合には、現在の model.gifUrl
がそのまま返される。
init
も同様の内容で、 Model の初期状態を定義し、現在の topic で giphy.com の API から GIF ファイルを探す。
init : String -> (Model, Effects Action)
init topic =
( Model topic "assets/waiting.gif"
, getRandomGif topic
)
-- getRandomGif : String -> Effects Action
ランダム GIF 取得 Effect が処理を終えた時は、いままでと同じように Action
が update
関数に引き回される。
注意: これまでは start-app パッケージの
StartApp.Simple
モジュールを使っているだけだったが、今度はStartApp
モジュールを使っている。これはより実際に近いの web アプリケーションの複雑さを扱う事ができて、少し凝った API を持っている。重要な違いは、この例の新しいinit
型とupdate
型を取り扱えるようになった事だ。
この例の重要な部分の一つは、 getRandomGif
関数で、この関数こそにランダムな GIF を取って来る方法が記述されている。この関数は tasks と Http
パッケージを使っており、これらがどのように使われるのかおいおい説明してみる。まずは定義を見てみよう。
getRandomGif : String -> Effects Action
getRandomGif topic =
Http.get decodeImageUrl (randomUrl topic)
|> Task.toMaybe
|> Task.map NewGif
|> Effects.task
-- この最初の行は HTTP GET リクエストを作成している。このリクエストは `randomUrl topi` の中で
-- いくばくかの JSON を取り出し、 `decodeImageUrl` をつかって結果を復号する。
-- どちらも以下で定義されている!
--
-- つづいて `Task.toMaybe` を使っている。これは起こりうる失敗を捕捉したり、
-- 結果を `Action` に変換するために `NewGif` タグを適用したりする。
-- 最後に、 `init` 関数や `update` 関数で使われる `Effects` 値に変換される。
-- 渡されたトピックに基づいて giphy API に渡す URL を構築
randomUrl : String -> String
randomUrl topic =
Http.url "http://api.giphy.com/v1/gifs/random"
[ "api_key" => "dc6zaTOxFJmzC"
, "tag" => topic
]
-- JSON 複合器。 giphy から吐き出される大きな JSON の塊を受け取り、
-- `json.data.image_url` の文字列を引き出す。
decodeImageUrl : Json.Decoder String
decodeImageUrl =
Json.at ["data", "image_url"] Json.string
これらを書きあげたら、先の init
関数と update
関数で getRandomGif
関数を再利用できる。
getRandomGif
によって返される Task の興味深い点の一つは、 Never
により絶対に失敗しないという事である。これは、起こりうる失敗は明示的に対応されなければならない、という考え方である。どんな Task も知らないうちに失敗していてほしくはない。
そのうちこの仕組みがどのように働くのか説明しようと思うが、使うだけなら全部を知ることは重要じゃない!でだ、そういうわけで全ての Task
が失敗の変数型と成功の変数型を持っている。例えば、 HTTP Task は Task Http.Error String
という型を持っていて、 Http.Error
を返して失敗したり、 String
を返して成功したりする。これによりエラーをそんなに気にする事なく、いくつもの Task を繋げやすくなっている。では、この例のコンポーネントが Task をリクエストしたが、失敗したとしよう。この時なにが起こるだろうか?だれが失敗の通知を受けるのだろうか?どうやって復旧したらいいのだろうか?失敗の型 Never
を作る事で、起こりうるエラーを成功の型に押し込み、コンポーネントにより明示的に扱えるようになる。このケースでは、 Task.toMaybe : Task x a -> Task y (Maybe a)
を使い、 update
関数は明示的に HTTP の失敗を取り扱う。これはつまり Task は何も知らせずに失敗する事がないということであり、起こりうるエラーを常に明示的に処理しなければならないという事だ。
デモ / ローカルホストで実行
やったね、 Effect を使えるようになった。でも Effect のネストはどうだろう?その事を考えていたかな?!この例では例 5 の GIF 取得器のコードをそのまま再利用して、二つの GIF 取得器を作る。
実装をみれば分かるように、例 2 の二つのカウンタ場合とコードはほとんど同じだ。 Model
は二つの RandomGif.Mode
値として定義される。
type alias Model =
{ left : RandomGif.Model
, right : RandomGif.Model
}
これで、それぞれを独立に追跡し続けられるようになった。 Action は、適切なサブコンポーネントに引き回すためのメッセージだ。
type Action
= Left RandomGif.Action
| Right RandomGif.Action
興味深いのは、 Left
と Rigth
タグは update
関数と init
関数のなかで実際にすこし使われるということだ。
-- Effects.map : (a -> b) -> Effects a -> Effects b
update : Action -> Model -> (Model, Effects Action)
update action model =
case action of
Left msg ->
let
(left, fx) = RandomGif.update msg model.left
in
( Model left model.right
, Effects.map Left fx
)
Right msg ->
let
(right, fx) = RandomGif.update msg model.right
in
( Model model.left right
, Effects.map Right fx
)
それぞれの分岐で RandomGif.update
関数を呼び出し、新しい Model と Effect が返される。 Effect はここでは fx
と名付ける。このあと Model は通常通り更新されたものを返すが、 Effect には少し追加の処理が必要だ。 Effect はそのまま返す代わりに、 Effects.map
関数を適用する。これは Effect を同じ種類の Action
に変換するためだ。この働きは Signal.forwardTo
とほとんど同じで、値にタグ付けをしてどのように引き回されるべきか明確にしてくれる。
init
関数でも同様だ。それぞれのランダム GIF 取得器にトピックを設定し、 Model の初期状態といくばくかの Effect を定義する。
init : String -> String -> (Model, Effects Action)
init leftTopic rightTopic =
let
(left, leftFx) = RandomGif.init leftTopic
(right, rightFx) = RandomGif.init rightTopic
in
( Model left right
, Effects.batch
[ Effects.map Left leftFx
, Effects.map Right rightFx
]
)
-- Effects.batch : List (Effects a) -> Effects a
このケースでは、 Effects.map
を結果に適切にタグ付けするために使うだけではなく、それらを全部まとめるために Effects.batch
関数を使っている。全ての要求された Task は別個に起動され、実行される。そして、 Left と Right の Effect の処理は同時に進む。
デモ / ローカルホストで実行
この例はランダム GIF 取得器のリストの作り方を説明する。このリストではトピックを自分で作り出せる。これまで通り、 RandomGif
モジュールをそのまま再利用する。
実装に目を通せば、例 3 と完全に対応している事が見て取れるだろう。サブモジュールを ID と紐づけてリストに入れて、 ID に基づいて操作を行なう。唯一の新しい部分としては、 init
関数と update
関数のなかで Effects
を扱っている点だ。 Effects.map
と Effects.batch
を使って操作をしている。
こいつがどのように動いているかもっと詳しく説明が必要なら、 issue を開いてくれ!
デモ / ローカルホストで実行
ここまでで、色々な方法でネスト出来るコンポーネントとそれに対する Task を見て来た。しかしアニメーションについてはどうだろう?
面白いことに、これまでとほとんど同じだ!(まあでも、もう驚かないかもしれないね、同じパターンが他の例でも動く事を見て来たし... つまりそれだけ Elm アーキテクチャは良いパターンだってことさ!)
この例では二つのクリック可能な四角形を作る。四角形をクリックすると、クリックした四角形は 90 度回転する。コードの全体は例 2 と例 6 をこの例に合うように変えたもので、アニメーションのための全てのロジックは SpinSquare.elm
に格納されていて、 SpinSquarePair.elm
から何度も再利用される。
さて、新しく興味深い要素の全ては、 SpinSquare
の中にある。なので、このコードについて詳しく見てみよう。まず最初に必要なのは Model だ。
type alias Model =
{ angle : Float
, animationState : AnimationState
}
type alias AnimationState =
Maybe { prevClockTime : Time, elapsedTime: Time }
rotateStep = 90
duration = second
さて、 Model の核は angle
で、これは四角形の現在の傾きだ。そして animationState
により、現在進行中のアニメーションで何が起こっているのか追跡できる。アニメーションしていなければ、この値は Nothing
になる。もし何かが起こっていれば、その何か
が保持される。
prevClockTime
— もっとも最近の時刻。時間の差分を算出するために使う。最後のフレームから何ミリ秒たったか正確に知る助けになる。elapsedTime
— 0 からduration
までの値。アニメーションをどのくらいしているか教えてくれる。
定数 rotateStep
は、一回のクリック毎にどれだけ回転するかを宣言しているだけだ。値をいじってもこの例はちゃんと動く。
では update
の興味深い部分を見て行こう。
type Action
= Spin
| Tick Time
update : Action -> Model -> (Model, Effects Action)
update msg model =
case msg of
Spin ->
case model.animationState of
Nothing ->
( model, Effects.tick Tick )
Just _ ->
( model, Effects.none )
Tick clockTime ->
let
newElapsedTime =
case model.animationState of
Nothing ->
0
Just {elapsedTime, prevClockTime} ->
elapsedTime + (clockTime - prevClockTime)
in
if newElapsedTime > duration then
( { angle = model.angle + rotateStep
, animationState = Nothing
}
, Effects.none
)
else
( { angle = model.angle
, animationState = Just { elapsedTime = newElapsedTime, prevClockTime = clockTime }
}
, Effects.tick Tick
)
ここでは 2 種類の Action
を処理しなくてはならない。
Spin
はユーザが四角形をクリックした事を知らせ、回転するよう要求する。update
関数では、アニメーションしていない時は clock tick を要求し、既に進行中のものをそのままにしておく。Tick
は clock tick の到着を知らせるので、これが到着するとアニメーションの次の段階に進めなければならない。これはつまりupdate
関数でanimationState
を更新しなければならないということだ。なので、まずアニメーションが進行中かどうか調べる。進行中なら、現在のelapsedTime
を取得して、そして時間の差をそれに足して、newElapsedTime
を知る事ができる。 elapsed Time がduration
より大きい場合、アニメーションを止めて、 clock tick の要求を止める。そうでなければ、 アニメーションの状態(animationState) を更新して、また別の clock tick を要求する。
今回もまた、コードをきりつめる事が出来そうだ。こういうふうにコードを書くにつれて、一般的なパターンを見出しはじめている。パターンを見つけるのはワクワクするね!
最後の view
関数はちょっと興味深い!この例はいい感じの跳ねるアニメーションをするけれど、コードに書いたのは Model の字面の上で elapsedTime
を増やしているだけだ。何がおきているんだろうか?
view
コードそれ自体は、完璧に標準的な elm-svg
のもので、これは凝ったクリック可能な図形を作るためのものだ。これのイケてるところは、 view のコードは toOffset
で、これは現在の AnimationState
に対する回転オフセットを算出する。
-- import Easing exposing (ease, easeOutBounce, float)
toOffset : AnimationState -> Float
toOffset animationState =
case animationState of
Nothing ->
0
Just {elapsedTime} ->
ease easeOutBounce float 0 rotateStep duration elapsedTime
ここでは、 @Dandandan の easing パッケージを使う。このパッケージは数字、点、さらに望むなら他のイカれた何かに、あらゆる種類のイカす平滑化を施す事ができる。
ease
関数は 0 から duration
の間の数を取る。そして、 0 から rotateStep
の間の数字に変換する。 rotateStep
はプログラムの最初で 90 度に設定した。さらに平滑化を行なおう。このケースでは easeOutBouce
を使っている。これに 0 から duration
へと値を変えながら入力すると、 0 から 90 までの値に平滑化のための値が足されて出力される。これはイカレてるね! easeOutBounce
を他の平滑化に変えみて、どんな風になるか見てみよう!
ここまでで、 SpinSquarePair
の全てが繋がった。でも、コードは例 2 や 例 6 ともうほとんど同じだった。
よし、これがこのライブラリを使ってアニメーションをやる場合の基本だ!このやり方が一番いいかどうかは、まだわからない。なので、あなたがもっと経験を積んだら、どうやったら良いか教えてほしい。願わくばもっと簡単に作れるようになっている事を!
注意: 僕はこの問題についてなにがしかの抽象階層を、核となるアイディアの上に設けることができるとおもっている。この例はちょっと低い階層の処理をしているけれど、このもっと問題と付き合っていれば、なんか良いパターンを見つけてこいつをもっと簡単にできると思っている。もし今なんかおかしい部分が見つかったら、改良にチャレンジして、それを僕らに教えてくれよな!