SPAっぽいのをどういう構成で作っているかの記録
お仕事コードなので参照するコード一式はないです
前書き的なお気持ち
構成は書いている内にすぐ変わります。正解なんてありません。よりよくしていく気持ちを持つしかないです。
今は一回ビッグバンリライトしたところなのでその結果をこの記事で書いておこうと思いました。
もしかしたら参考にはなるかもしれません。が時間かけて一から自分で作っていけばいいじゃんと思ってます
Elmの悪いところの一つはボイラープレートなところですが、他人の構成を真似しようと思うとよく効いてきます。段階的に育てていきましょう
モジュール構成
/
┣━ Main.elm
┣━ Types.elm
┣━ Route.elm
┣━ ComponentA.elm
┣━ ComponentA/
┃ ┣━ ...
┃ ┗━ ...
┣━ ComponentB.elm
┣━ ComponentB/
┃ ┣━ ...
┃ ┗━ ...
┣━ View.elm
┗━ Views/
┣━ ...
注:ここでいうコンポーネントはModel, Msg, update等が定義されたモジュールのことです
わたしの構成だとviewは切り離して考えてるのでviewなしです
- Main.elmがエントリーポイント
- init, update, subscriptionsが定義されている
- 各コンポーネントをMainのModelにつなげている
- ボイラープレート万歳
- RouteやFlagなどのグローバルな処理をしている
- Type.elmにはMainのModelとMsgが入っている
- Viewを分けてる関係で型定義は外に置いておかないと循環参照になるのでしょうがないのだ
- Route.elmはRoute型とルーティングのもろもろ、URL生成関数などが入っている
- ComponentA.elmにComponentAのModel, Msg, update等が入っています
- Componentの分割単位はやってるうちにわかるの方針です。最初からきりません
- ComponentA/にはComponentAに関するいろいろが入ってます。
- View.elmにトップレベルのviewが定義されています
- elm-uiを使っているのでElement -> Htmlもやります
- routingや認証状態による出し分けもやります
- Views/内の分け方は別の項で
- 大体いくつかのPageが一つのコンポーネントのModelを受け取るようなviewになってます
viewのモジュール構成
atomic designは「viewをいい感じに分割しようね」としか言ってないと思ってるのでわたしもatomic designな感じで分割してます
分割軸は引数の型です
フォルダ名 | 引数の型 |
---|---|
Pages | 各コンポーネントのModel |
Customs | アプリケーション固有の型 |
Basics | 汎用的な型 |
分割軸は一貫していてviewを触るひとにとって使いやすければいいので好きにするといいと思います
デザイナーさんと一緒にやってるならそれも制約条件に入るはずです。デザイナーさんと働いたことないです
基本的にはPageとBasicの上から下から作って、場合によってはCustomが生えるみたいな感じです
正直Customがあんまり生えてないので別の分割したほうが利便性上がるかなとたまに考えているところです
Modelとの対応について
ElmでコンポーネントっていうとModel, view, updateがそろったやつのことですがミヤモ風ではviewは切り離して考えます
updateとviewを同じファイルに書かないよ、くらいの意味に実質的にはなっていると思いますが
1つのModelが1つのPageに対応するというのだけはやりません。Modelの分割とPageの分割は別のことなので対応するわけないです
Modelの配管
ミヤモ風ではコンポーネント分割で生じる種々のボイラープレートコードを配管工事と表現します。今しました
通常、各コンポーネントのModel, init, Msg, subscriptions, updateをMainのものにつなげることは必須になります
ミヤモ風では以下の関数と型を使用しています
module Spam exposing
( Model
, Msg(..)
, hasAuthFailure
, init
, onRouteChange
, subscriptions
, update
)
Model
ここからそのままコードを持ってきてたりするのでノイズが増えます
type alias Model =
{ auth : Auth.Model
, apiRoot : ApiRoot
, route : Route
, key : Key
, spam : Spam.Model
, ham : Ham.Model
, egg : Egg.Model
}
各コンポーネントのModelをMainのModelにそのまま入れます。ページごとにModelの形が変わるとかはしないです
init : Value -> Url -> Key -> ( Model, Cmd Msg )
init flags url key =
let
route =
Route.parse url
{ apiRoot } =
Flags.init flags
( auth, authCmd ) =
Auth.init
( model, cmd ) =
{ auth = auth
, apiRoot = apiRoot
, route = route
, key = key
, spam = Spam.init
, ham = Ham.init
, egg = Egg.init
}
|> onRouteChange route
in
( model
, Cmd.batch
[ Cmd.map AuthMsg authCmd
, cmd
]
)
各モジュールのinitはCmdをだしません。これはonRouteChangeで該当のページに来たら初期化処理が改めて走っているためです
つまりこのinitはemptyみたいな意味です
onRouteChange
onRouteChange : Api.Config -> Route -> Model -> ( Model, Cmd Msg )
onRouteChangeはMsgではなくRouteを受け取るupdateみたいなものです
該当のページにきたら使うデータのAPIを取ってくるCmdを発行する感じです
Main.onRouteChangeは上記のようにMain.initとMain.updateのonUrlChangeに渡すMsgの枝の2か所で使われます
Msg
type Msg
= NoOp
| ClickedLink UrlRequest
| UrlChanged Url
| AuthMsg Auth.Msg
| SpamMsg Spam.Msg
| HamMsg Ham.Msg
| EggMsg Egg.Msg
普通です
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
let
config = ...
in
case msg of
SpamMsg subMsg ->
let
( new, subCmd ) =
Spam.update config subMsg model.spam
in
( { model | spam = new }
, Cmd.batch
[ Cmd.map SpamMsg subCmd
, Cmd.Extra.when (Spam.hasAuthFailure new) <|
Cmd.map AuthMsg <|
Auth.onAuthFailure model.apiRoot model.auth
]
)
...
Cmdの下の部分以外は普通かなと思います
hasAuthFailure : Model -> Bool
hasAuthFailure { greens, reds, oranges} =
(greens.status == Status.AuthFailure)
|| (reds.status == Status.AuthFailure)
|| (oranges.status == Status.AuthFailure)
hasAuthFailureはAPIの結果が認証エラーになったらTrueになります。Mainではそれを判定して認証のリトライをします
グローバルなイベントを扱う
Spam.hasAuthFailureからAuth.onAuthFailureの流れは、各コンポーネントで発火したイベントを上を経由してAuthコンポーネントで処理するってことをしています。現在このようなグローバルイベントは他にやってないのでこのような形になっていますが、増えてきたらやり方が変わるかもしれません
Spam内で直接リトライしないのはMsgの型を揃える都合です。揃えにくいので一番上を経由してます
各コンポーネント内
これはSPAとは関係なくそうなっているんですが、型ごとにモジュールを作る感じにしています
type Id = Id Int
fromInt : Int -> Id
fromInt = Id
...
こんな感じにId型から一個ずつモジュール作ってます
今見たらId型だけで10個ありました
プリミティブ型を排除するのと、Listなどのコレクション型に包んだ型もモジュールにして使うときになんの型を使っているか意識しないようにしています
setterとかgetterとかJson.DecoderとかForm.Decoderとかを型定義の隣に置いています
たぶんカプセル化ってやつです。ただ厳密に隠してはいないです。type Spams = List Spam
こういう定義なので触ろうと思えば触れます。あんまりがんばっても大変なので
終わり
これを読んだ人もどういう構成でやってるか書いてみてください