SPA
Elm
ElmDay 20

Elm で複数ページのアプリを作るのにおすすめな rtfeldman/elm-spa-example の紹介

Elm Advent Calendar 2017 の 20 日目の記事です!

一言で言うと


rtfeldman/elm-spa-example 大変参考になるのでソースコードを読みましょう。

elm-spa-example は Elm 0.19 のリリースに合わせて大幅に書き直されました。この記事はそれ以前のバージョンに沿った内容になっています。

はじめに

Elm で Todo アプリ以上のものを作ろうとしたときに、どういう構成で作ったらいいのか悩みませんか?僕はこれまで Elm でページが一つだけのアプリケーション(天気予報アプリとかピクセルエディタとか)しか書いたことがなかったので、最近仕事でページが複数あるちょっとした管理画面を作るのに rtfeldman/elm-spa-example を参考にしました。検索してみたところほとんど日本語の情報がなかったので簡単に紹介してみようと思います。

いろんな言語やフレームワークである程度の大きさのアプリケーション(Medium.com のクローン)を作ろうという Real World プロジェクトの Elm 版です。作者の Richard Feldman さんは、Elm を本番で使っており Elm 作者の Evan さんも在籍している NoRedInk という会社のエンジニアです。Elm を使った本番アプリケーション(十万行以上!)の開発で得た知見を各種カンファレンスや執筆中の書籍で共有しており、Elm によるアプリケーション開発の第一人者の一人といえるでしょう。

SPA (Single Page Application) という名前ですが、論理的には複数ページの構成になっています。Photoshopのようなメイン画面の上にパネルが出てきたりする 1 ページしかないアプリ用ではありません。(この 2 つを区別する用語ってあるんでしょうか?)

作者による解説 によれば

Fair warning: This is not a gentle introduction to Elm. I built this to be something I'd like to maintain, and did not hold back. This is how I'd build this application with the full power of Elm at my fingertips.

ということで、Elm 入門向けではなく、自分が実際にメンテしたいような構成で作ってあるそうです。

フォルダ構造

Elm のソースコードの入った src はこんな感じになっています。

  • Data: 各種データ型とそれらに関連する関数や JSON Decoder/Encoder
    • Article.elm
    • ...
  • Page: 各ページの Model, Msg, init, update, view
    • Article.elm
    • Article/Editor.elm
    • ...
  • Request: HTTP リクエストをする関数
    • Article.elm
    • ...
  • Views: 各 PageMain.elm で使う共通の view 関数
    • Article
      • Favorite.elm
      • Feed.elm
    • Article.elm
    • ...
  • Main.elm: エントリポイント。各 Page をここで切り替えます
  • Ports.elm: port 各種
  • Route.elm: Route とルーティング関連の関数
  • Util.elm: 省略

Data, Request, Views, Ports.elm, Util.elm あたりは名前のとおりなので、詳細はソースコードを見てみてください。この記事ではこの構成の旨味である Main.elm, Page, Route.elm, Route.elm あたりの連携について解説しようと思います。

Page は記事一覧ページ(Page/Article.elm)や記事編集ページ(Page/Article/Editor.elm)などページ毎のファイルに分かれています。そして Main.elm がそれらをうまいこと切り替える、というのが大まかな構成です。

ページの表示

各ページはそれぞれが Elm アプリケーションのように Model, Msg, init, update, view(必要なら subscriptions も)を持っています。以下は雰囲気がわかるように簡略化したソースコードです。

Page/Article.elm
module Page.Article exposing (Model, Msg, init, update, view)

type alias Model = { ... }

init : Session -> Article.Slug -> Task PageLoadError Model

view : Session -> Model -> Html Msg

type Msg
    = DismissErrors
    | ToggleFavorite
    | FavoriteCompleted (Result Http.Error (Article Body))
    | ...

update : Session -> Msg -> Model -> ( Model, Cmd Msg )

そして Main.elmModel はその時表示中のページの Model を内包しています。PageStateLoadedTransitioningFrom に分かれているのは、後述するルーティングのためです。Session はユーザのログイン状態で、Main.elm がページの外に持っている唯一のグローバルな状態です。グローバルな状態の保持についても後述します。

Main.elm
import Page.Article as Article
import Page.Article.Editor as ArticleEditor

type Page
    = Article Article.Model
    | ArticleEditor ArticleEditor.Model
    | ...

type PageState
    = Loaded Page
    | TransitioningFrom Page

type alias Model =
    { session : Session
    , pageState : PageState
    }

Msg も各ページの Msg を内包しています。

Main.elm
type Msg
    = ArticleMsg Article.Msg
    | ArticleEditorMsg ArticleEditor.Msg
    | ...

view ではその時表示中のページの view を呼びます。ページの MsgMain.elmMsg になるよう Html.map でくるんでいます。frame はヘッダーやフッターなどの共通部分の表示のための関数です。

Main.elm
view : Model -> Html Msg
view model =
    case model.pageState of
        Loaded page ->
            viewPage model.session False page

        TransitioningFrom page ->
            viewPage model.session True page

viewPage : Session -> Bool -> Page -> Html Msg
viewPage session isLoading page =
    let
        frame =
            Page.frame isLoading session.user
    in
    case page of
        Article subModel ->
            Article.view session subModel
                |> frame Page.Other
                |> Html.map ArticleMsg
        ...

update ではその時表示中のページの Msg が来たら、そのページの update に渡しています。(間違ったページと Msg の組み合わせを無視するため、最後のパターンでワイルドカードを使っています。そのため本来必要なパターンを書き忘れてもコンパイラーが警告してくれないのが微妙なところです。何か良い方法はないのでしょうか?)

Main.elm
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    updatePage (getPage model.pageState) model

updatePage : Page -> Msg -> Model -> ( Model, Cmd Msg )
updatePage page msg model =
    let
        toPage toModel toMsg subUpdate subMsg subModel =
            let
                ( newModel, newCmd ) =
                    subUpdate subMsg subModel
            in
            ( { model | pageState = Loaded (toModel newModel) }, Cmd.map toMsg newCmd )
    in
        case ( msg, page ) of
            ( ArticleMsg subMsg, Article subModel ) ->
                toPage Article ArticleMsg (Article.update model.session) subMsg subModel
            ...
            ( _, _ ) ->
                ( model, Cmd.none )

ページの初期化

この構成の大きな特徴の一つは、画面遷移前に API からデータを取得することです。これによって、データの欠けた不完全な状態の画面を表示するのを防ぐことができます。また、ローディングのぐるぐるや失敗した場合のエラー画面を統一的に表示できます。さらに、各ページは必要なデータがすでにあることを前提に書けるので、Model がすっきりします。これがないと API で取得してくるデータに Maybe をつける必要が出てきたりしますよね。

Page/BadFoo.elm
type alias Model =
    { foo : Maybe Foo }

init : ( Model, Cmd msg )
init =
    ( { foo = Nothing }
    , Task.attempt FooLoaded Request.Foo.get
    )

-- 以下 view でも update でも `Maybe` を気にしながらコードを書くことに・・・。

elm-spa-example の構成では、各ページの init 関数が Task PageLoadError Model を返します。API コールが成功すれば Ok model が返ってきてページが表示されます。API コールが失敗すれば Err err が返ってきて、エラー画面が表示されます。また Task なので Task.map2/map3/map4/map5 で複数の API を呼んだり、Time.now で現在時刻を取得したりすることもできます。

Page/FooBar.elm
type alias Model =
    { foo : Foo
    , bar : Bar
    , timestamp : Time
    }

init : Task PageLoadError Model
init =
    let
        loadFoo =
            Request.Foo.get
                |> Task.mapError (\_ -> pageLoadError "Foo is currently unavailable.")

        loadBar =
            Request.Bar.get
                |> Task.mapError (\_ -> pageLoadError "Bar is currently unavailable.")

        getTime =
            Time.now
                -- Task.map3 を使うので Task のエラーの型を揃える必要があります。
                |> Task.mapError (\_ -> pageLoadError "Timestamp is currently unavailable")
    in
        Task.map3 Model loadFoo loadBar getTime

Main.elminit 関数をどう呼ぶかは次節で。

ページの切り替え

ルーティングには elm-lang/navigationevancz/url-parser という公式のルーティングライブラリが使われています。Route.elm ではそれらを使って、ページの一覧の Union タイプ(Route)と関連する関数が定義されています。

Route.elm
module Route exposing (Route(..), fromLocation, href, modifyUrl)


type Route
    = Home
    | Login
    | Logout
    | Register
    | Settings
    | Article Article.Slug
    | Profile Username
    | NewArticle
    | EditArticle Article.Slug

href : Route -> Attribute msg

modifyUrl : Route -> Cmd msg

fromLocation : Location -> Maybe Route

href はその名の通り a タグの href 属性を出力するためのもので、URL を手打ちすることなく型安全に URL を作ることができます。

Page/Article.elm
a [ Route.href (Route.Profile author.username) ]
    [ img [ UserPhoto.src author.image ] [] ]

さてリンクがクリックされた時の処理を追ってみましょう。Main.elmmain 関数は以下のようになっています。URL が変更された際に SetRoute (Maybe Route) という Msg が送られるわけです。

Main.elm
main : Program Value Model Msg
main =
    Navigation.programWithFlags (Route.fromLocation >> SetRoute)
        { init = init
        , view = view
        , update = update
        , subscriptions = subscriptions
        }

次は update が呼んでいる updatePage という関数を見てみましょう。SetRoute (Maybe Route) に対しては setRoute という関数を呼んでいます。一緒に並んでいる ArticleLoaded (Result PageLoadError Article.Model) はページに必要なデータ取得完了後の Msg です。

Main.elm
type Msg
    = SetRoute (Maybe Route)
    | ArticleLoaded (Result PageLoadError Article.Model)
    ...

updatePage : Page -> Msg -> Model -> ( Model, Cmd Msg )
updatePage page msg model =
    let
        ...
    in
    case ( msg, page ) of
        ( SetRoute route, _ ) ->
            setRoute route model
        ...
        ( ArticleLoaded (Ok subModel), _ ) ->
            { model | pageState = Loaded (Article subModel) } => Cmd.none

        ( ArticleLoaded (Err error), _ ) ->
            { model | pageState = Loaded (Errored error) } => Cmd.none
        ...

setRoute では URL に対応するルートがなかった場合は Not Found なページを表示します。ルートがあった場合は対応するページの init 関数を呼びます。この時点ではまだ遷移前のページが表示されています(TransitioningFrom)。ページの init 関数は Task PageLoadError Model を返します。この TaskTask.attempt で実行され、結果は上記の ArticleLoaded (Result PageLoadError Article.Model) として update を経て updatePage に返ってきます。ここまで来て晴れてページが切り替わるわけです。

Main.elm
setRoute : Maybe Route -> Model -> ( Model, Cmd Msg )
setRoute maybeRoute model =
    let
        transition toMsg task =
            { model | pageState = TransitioningFrom (getPage model.pageState) }
                => Task.attempt toMsg task

        errored =
            pageErrored model
    in
    case maybeRoute of
        Nothing ->
            { model | pageState = Loaded NotFound } => Cmd.none
        ...
        Just (Route.Article slug) ->
            transition ArticleLoaded (Article.init model.session slug)

グローバルな状態管理

ここまで来るとわかるように、ページが切り替わる時にそれまでのページの Model は破棄されてしまいます。必要なデータは毎回 API を呼んで取り直すというアプローチです。

特定のページに留まらないグローバルなデータを持ちたい場合は Main.elmModel で管理する必要があります。elm-spa-example では Session がそれにあたります。上の Page/Article.elm の例にあるように、(必要があれば)各ページの init, update, view に渡して使います。

ただ、基本的には各ページの Msg ではグローバルな状態を変更することはできません。Main.elmMsg で行う必要があります。なので、各ページの UI の真ん中からグローバルな状態を気軽に変更できないという UI 上の制約ができてしまっています。一応 elm-spa-example のログアウトのようにルーティングを使えばできないことはありませんが、URL を変更する必要があるのでうまくいかないケースもあるでしょう。そういった場合はサーバ側にデータを置いて API で更新・取得するというのも一つの手かもしれません。

おわりに

以上です。あまり長すぎてもアレなためはしょった部分も多いです。他にも関数の命名や UI の再利用など参考になる点が多いので、ぜひソースコードを読んでみてください。Richard Feldman さんによる大規模 Elm アプリの作り方の発表も一緒に見てみると更に良いと思います。

職場のちょっとした管理画面で同じ構成を使ってみていることろなのですが、今のところいい感じに使えています。他にも良い構成があれば教えてください!