LoginSignup
11
3

More than 3 years have passed since last update.

[Elm]ミヤモ風SPA

Last updated at Posted at 2019-06-05

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のものにつなげることは必須になります

ミヤモ風では以下の関数と型を使用しています

Spam.elm
module Spam exposing
    ( Model
    , Msg(..)
    , hasAuthFailure
    , init
    , onRouteChange
    , subscriptions
    , update
    )

Model

ここからそのままコードを持ってきてたりするのでノイズが増えます

Types.elm
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の形が変わるとかはしないです

Main.elm
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

Spam.elm
onRouteChange : Api.Config -> Route -> Model -> ( Model, Cmd Msg )

onRouteChangeはMsgではなくRouteを受け取るupdateみたいなものです
該当のページにきたら使うデータのAPIを取ってくるCmdを発行する感じです

Main.onRouteChangeは上記のようにMain.initとMain.updateのonUrlChangeに渡すMsgの枝の2か所で使われます

Msg

Types.elm
type Msg
    = NoOp
    | ClickedLink UrlRequest
    | UrlChanged Url
    | AuthMsg Auth.Msg
    | SpamMsg Spam.Msg
    | HamMsg Ham.Msg
    | EggMsg Egg.Msg

普通です

Main.elm
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の下の部分以外は普通かなと思います

Spam.elm
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とは関係なくそうなっているんですが、型ごとにモジュールを作る感じにしています

Id.elm

type Id = Id Int

fromInt : Int -> Id
fromInt = Id

...

こんな感じにId型から一個ずつモジュール作ってます
今見たらId型だけで10個ありました

プリミティブ型を排除するのと、Listなどのコレクション型に包んだ型もモジュールにして使うときになんの型を使っているか意識しないようにしています
setterとかgetterとかJson.DecoderとかForm.Decoderとかを型定義の隣に置いています

たぶんカプセル化ってやつです。ただ厳密に隠してはいないです。type Spams = List Spamこういう定義なので触ろうと思えば触れます。あんまりがんばっても大変なので

終わり

これを読んだ人もどういう構成でやってるか書いてみてください

11
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
11
3