SPA
Elm
HistoryAPI

elmでSPAをわかってしまおう

elmコミュニティの方々からSPAのベストプラクティスの提案・ご指摘を受けたので、時間が空いたタイミングで大きく更新をしようと思います。改めて思い直すことで結論などが変わると思われますので少々お待ち下さい (6/10現在)

こちらは、elmによるSPA手法を解説していく記事になります。が、以下の記事をまだ読んでいない方は必ず目を通してください

あなたはelmでSPAをわかってしまいたいのか?

※ 本記事はelmのコードの読み書きにほぼ不自由が無い中級者向けの内容になります。それぐらい、ちゃんとしたSPAの難易度は高いです(私自身もわからないことが多いです。間違いがあれば指摘よろしくお願いします)。ただし、elmに限らず どんなフレームワーク・言語を利用していても役立つ情報かと思われます。


仕様

SPAを説明するにあたって、簡易的な宿泊施設の予約サイト(デモ)を作っていこうと思います。リポジトリはこちら


トップページ

URL Path: /

ヘッダーにはユーザの名前が表示されます。ユーザは既にログイン済みとし、これ以降のすべてのページに同様のヘッダーが存在するとします。

ユーザのオススメの宿泊施設一覧が並びます。宿泊施設のサムネイル(今回は統一です)、名前、概要がセットになります。宿泊施設の説明はどこでもクリックできて、「宿泊施設ページ(プラン一覧)」に遷移します。

スクリーンショット 2019-06-09 10.34.19.png


宿泊施設ページ(プラン一覧)

URL Path: /hotel/:hotelId

トップページに「戻る」リンクがあります。

宿泊施設のプラン一覧が並びます。プランの名前、値段、予約するボタンが並びます。予約ボタンから「宿泊施設予約ページ」に遷移します。

スクリーンショット 2019-06-09 10.34.24.png


宿泊施設予約ページ

URL Path: /hotel/:hotelId/plan/:hotelPlanId/reserve

宿泊施設ページ(プラン一覧)に戻るリンクがあります。

宿泊人数を入力することができます。(今回、エラーハンドリングに手を抜いているので、マイナスの値も入力できます。)

「予約確認ページ」に遷移します。

スクリーンショット 2019-06-09 10.34.33.png


予約確認ページ

URL Path: /hotel/:hotelId/plan/:hotelPlanId/reserve

プランの単価 * 宿泊人数 の金額が出て確認できます。

予約確定ボタンを押すと、「ありがとうございましたページ」に遷移します。

スクリーンショット 2019-06-09 10.34.37.png


ありがとうございましたページ

URL Path: /hotel/:hotelId/plan/:hotelPlanId/reserve

トップへ戻るリンクがあります。

スクリーンショット 2019-06-09 10.34.40.png


Not Foundページ

URL Path: それ以外のパス

のっとふぁうんどなページです。

スクリーンショット 2019-06-09 10.34.52.png


実装

それでは仕様を満たすための実装を順を追って説明します。

※ 中級者向けの記事になりますので、細かい文法や完全なソースコードは載せませんので、コミットログ等を読んで補完をお願いします。


トップページ

まずviewはこちらのようになります。トップページでは、Modelからuserとrecommendの値を使いヘッダーとオススメ宿泊施設の一覧を構築します。

view : Model -> Browser.Document Msg

view model =
let
{ user, recommend } =
model
in
{ title = "URL Interceptor"
, body =
[ headerView user
, section [] <|
[ h1 [] [ text "あなたにオススメの宿" ]
, div [ class "recommend-hotel-list" ] <|
List.map hotelView recommend.hotelList
]
]
}

headerView : User -> Html Msg
headerView user =
header [ class "cmn-header" ]
[ p [] [ text <| "ようこそ " ++ user.name ++ " さん" ]
]

hotelView : Hotel -> Html Msg
hotelView hotel =
a [ class "page-top-hotellist-article__link" ]
[ article [ class "page-top-hotellist-article" ]
[ img [ class "__thumb", src "https://4.bp.blogspot.com/-LR5Lja-lZ4E/WZVgz8oz0zI/AAAAAAABGE8/dA0DAXWkQFIY23wjjILccR7m8KXHSAzzACLcBGAs/s400/building_hotel_small.png" ] []
, h3 [ class "__title" ] [ text hotel.name ]
, p [ class "__description" ]
[ text hotel.description
]
]
]

Modelの型は以下のようになります。

type alias User =

{ name : String
}

type alias Recommend =
{ hotelList : List Hotel
}

type alias Hotel =
{ hotelId : Int
, name : String
, description : String
}

type alias Model =
{ key : Nav.Key
, url : Url.Url
, user : User
, recommend : Recommend
}

実際にはバックエンドサーバを用意しAPIを叩くことになると思いますが、今回は擬似的に必ず成功する一瞬でレスポンスが返る夢のようなAPIを以下のように用意しました。init時点ではuserとrecommendは空っぽで、Cmd.batchにより先ほど用意したAPIを叩いています。

getUser : Cmd Msg

getUser =
Task.perform GotUser <|
Task.succeed <|
User "えび"

getRecommend : Cmd Msg
getRecommend =
Task.perform GotRecommend <|
Task.succeed <|
Recommend
[ Hotel 1 "さくらちゃんランド" "ヤギ"
, Hotel 2 "にゃんこ宿" "子猫が可愛い癒やしの宿です。"
, Hotel 3 "ワンワンホテル" "大型犬とはしゃげるホテルです。"
]

init : () -> Url.Url -> Nav.Key -> ( Model, Cmd Msg )
init flags url key =
( Model key url (User "") (Recommend [])
, Cmd.batch [ getUser, getRecommend ]
)

Msgとupdateになります。この時点では詳しい説明をしません。なんとなくお決まりの形なんだな。と心に止めておいてください。

type Msg

= LinkClicked Browser.UrlRequest
| UrlChanged Url.Url
| GotUser User
| GotRecommend Recommend

update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
LinkClicked urlRequest ->
case urlRequest of
Browser.Internal url ->
( model, Nav.pushUrl model.key (Url.toString url) )

Browser.External href ->
( model, Nav.load href )

UrlChanged url ->
( { model | url = url }
, Cmd.none
)

GotUser user ->
( { model | user = user }, Cmd.none )

GotRecommend recommend ->
( { model | recommend = recommend }, Cmd.none )

ここまでは、単に動的な書き換えと(疑似)HTTP通信を行うだけのアプリケーションになります。


宿泊施設ページ(プラン一覧)のルーティング追加

それでは、SPAらしくルーティングと新たなページの見た目を作っていきましょう。ルーティングとして、トップページと宿泊施設ページ、そしてNotFoundページが追加されました。また、ルーティングに対応するUrlパーサ関数routeParserとUrlからRouteに変換する関数urlToRouteを追加しました。基本的にはパースするパターンとRouteをマッピングしているだけに過ぎません。パースのどのパターンにもマッチしなければNotFoundとなります。

type alias HotelId =

Int

type Route
= Top
| HotelPage HotelId
| NotFound

routeParser : Parser (Route -> a) a
routeParser =
P.oneOf
[ P.map Top P.top
, P.map HotelPage (P.s "hotel" </> P.int)
]

urlToRoute : Url.Url -> Route
urlToRoute url =
Maybe.withDefault NotFound <| P.parse routeParser url

もし、もっとUrlのパースに詳しくなりたければ、こちらの記事をご覧になると良いでしょう。

こちらが新しいviewです。先ほどのurlToRoute関数を使いRoute型に変換し、それに対応するBrowser.Document Msg型を作ってマッピングしています。一旦、説明が複雑になりすぎないように新しいページの中身は空っぽにしています。

view : Model -> Browser.Document Msg

view model =
let
{ url, user, recommend, hotel } =
model
in
case urlToRoute url of
Top ->
{ title = "トップページ"
, body =
[ headerView user
, section [] <|
[ h1 [] [ text "あなたにオススメの宿" ]
, div [ class "recommend-hotel-list" ] <|
List.map hotelOverviewView recommend.hotelOverviewList
]
]
}

HotelPage _ ->
{ title = hotel.hotelName ++ "プラン一覧"
, body =
[ headerView user
]
}

NotFound ->
{ title = hotel.hotelName ++ "Not found"
, body =
[ headerView user
, p [] [ text "のっとふぁうんど" ]
]
}

新しいページ追加に伴ってModelを一部変更しました。今回は明確にホテル情報取得が失敗するケース(hotelIdに対応する情報がない場合)を考えなければなりません。

type alias HotelOverview =

{ hotelId : HotelId
, hotelName : String
, description : String
}

type alias Hotel =
{ hotelName : String
, planList : List HotelPlan
}

type alias HotelPlanId =
Int

type alias HotelPlan =
{ planId : Int
, planName : String
, accommodationFee : Int
}

type alias Model =
{ key : Nav.Key
, url : Url.Url
, user : User
, recommend : Recommend
, hotel : Hotel
}

getHotel : HotelId -> Cmd Msg
getHotel hotelId =
Task.attempt GotHotel <|
case hotelId of
1 ->
Task.succeed <|
Hotel "さくらちゃんランド"
[ HotelPlan 1 "スイートルームプラン" 10000
, HotelPlan 2 "さくらちゃんと戯れプラン" 20000
]

2 ->
Task.succeed <|
Hotel "にゃんこ宿"
[ HotelPlan 3 "にゃんにゃんプラン" 15000 ]

3 ->
Task.succeed <|
Hotel "ワンワンホテル"
[ HotelPlan 4 "ワンワンプラン" 13000 ]

_ ->
Task.fail "Hotel Not Found"

updateの分岐が以下のように増えます。Errを今回は握りつぶしています。本来ならNotFoundページに飛ばすなどのエラーハンドリングが求められます。

        GotHotel result ->

case result of
Ok hotel ->
( { model | hotel = hotel }, Cmd.none )

Err _ ->
( model, Cmd.none )


ルーティングの反映

それでは先ほど作ったルーティングを反映していきましょう。

まずは、init関数はルーティング毎に叩くAPIが異なります。トップページの時は、オススメ宿泊施設一覧、宿泊施設ページ(プラン一覧)ではプラン一覧のAPIを叩いてあげる必要があります。

init : () -> Url.Url -> Nav.Key -> ( Model, Cmd Msg )

init flags url key =
let
commands =
case urlToRoute url of
Top ->
[ getUser, getRecommend ]

HotelPage hotelId ->
[ getUser, getHotel hotelId ]

NotFound ->
[ getUser ]
in
( Model key url (User "") (Recommend []) (Hotel "" [])
, Cmd.batch commands
)

そして、宿泊施設ページ(プラン一覧)ページへのリンクを追加します。

hotelOverviewView : HotelOverview -> Html Msg

hotelOverviewView { hotelId, hotelName, description } =
+ a [ href <| UrlBuilder.absolute [ "hotel", String.fromInt hotelId ] [], class "page-top-hotellist-article__link" ]
[ article [ class "page-top-hotellist-article" ]
[ img [ class "__thumb", src "https://4.bp.blogspot.com/-LR5Lja-lZ4E/WZVgz8oz0zI/AAAAAAABGE8/dA0DAXWkQFIY23wjjILccR7m8KXHSAzzACLcBGAs/s400/building_hotel_small.png" ] []
, h3 [ class "__title" ] [ text hotelName ]
, p [ class "__description" ]
[ text description
]
]
]

そしてこの様に宿泊施設ページのviewを完成させます。

        HotelPage _ ->

let
{ hotelName, planList } =
hotel
in
{ title = hotel.hotelName ++ "プラン一覧"
, body =
[ headerView user
, a [ href "/" ] [ text "戻る" ]
, h1 [] [ text hotelName ]
, section [] <|
List.map
(\plan ->
article []
[ p [] [ text plan.planName ]
]
)
planList
]
}

しかし、ここで問題が発生します。いくつかのプランを切り替えると、最初に見たプランの状態から変わらないという現象が起きます。これはinit関数が最初にページを開いたときにしか走らず、それ以降APIが叩かれないことが問題になります。名前の通りinit関数は初期化時にしかその役割を果たしません。

もう少し状況を詳しく説明するために、今まで説明を避けていたupdate関数のLinkClickedBrowser.Internal(外部リンクはまんまなので、今回説明をしません。)とUrlChangedの説明をします。LinkedClickはリンクがクリックされたときに走ります。UrlChangedは、History APIが積んだUrlを辿ったりリンクを踏んでLinkClikedが走ったあとに走ります。


  1. トップページを開く

  2. initでAPIが叩かれる

  3. トップページの描画が完了する

  4. さくらちゃんランドへのリンククリック


  5. LinkClickedが呼ばれる(Nav.pushUrlにより、Historyに/hotel/:hotelIdが積まれる。)

  6. Historyに今まで無いページUrlが来たため、init関数が呼ばれる


  7. UrlChangedが呼ばれる(modelのurlが書き換わる)

  8. さくらちゃんランドページの描画が完了する

  9. ブラウザバックをする(戻るリンクをクリックの場合、上と同様にLinkClickedが呼ばれる)

  10. History APIから以前いたUrlが取り出される

  11. にゃんこ宿へのリンククリック


  12. LinkClickedが呼ばれる(Nav.pushUrlにより、HistoryAPIに/hotel/:hotelIdが積まれる。)


  13. LinkClickedが呼ばれる(Nav.pushUrlにより、Historyに/hotel/:hotelIdが積まれる。)

  14. Historyを見ると5の工程で既に同じPathが積まれているので、initが走らない


  15. UrlChangedが呼ばれる(modelのurlが書き換わる)

  16. にゃんこ宿の情報が無いため、さくらちゃんランドのページの見た目のままになる

説明を踏まえて、update関数を改めて見てみましょう。

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

update msg model =
case msg of
LinkClicked urlRequest ->
case urlRequest of
Browser.Internal url ->
( model, Nav.pushUrl model.key (Url.toString url) )

Browser.External href ->
( model, Nav.load href )

UrlChanged url ->
( { model | url = url }
, Cmd.none
)

この問題を回避するには、init関数で叩いているコマンドをLinkClickedの分岐でも同様に実行してあげることです。

        LinkClicked urlRequest ->

case urlRequest of
Browser.Internal url ->
let
commands =
case urlToRoute url of
Top ->
[ getUser, getRecommend ]

HotelPage hotelId ->
[ getUser, getHotel hotelId ]

NotFound ->
[ getUser ]
in
( model, Cmd.batch <| Nav.pushUrl model.key (Url.toString url) :: commands )

全く同じ内容になるため、以下のように関数に切り出すと良いでしょう。

urlToCommands : Model -> Url.Url -> List (Cmd Msg)

urlToCommands model url =
let
{ isTopLoaded, isHotelPageLoaded } =
model
in
case urlToRoute url of
Top ->
[ getUser, getRecommend ]

HotelPage hotelId ->
[ getUser, getHotel hotelId ]

NotFound ->
[ getUser ]

init : () -> Url.Url -> Nav.Key -> ( Model, Cmd Msg )
init flags url key =
let
initialModel =
Model key url False False (User "") (Recommend []) (Hotel "" [])
in
( initialModel
, Cmd.batch <| urlToCommands initialModel url
)

-- update
LinkClicked urlRequest ->
case urlRequest of
Browser.Internal url ->
( model
, Cmd.batch <|
Nav.pushUrl model.key (Url.toString url)
:: urlToCommands model url
)

Browser.External href ->
( model, Nav.load href )

これで無事バグを回避することができました。


余談

無事丸く問題が収まったように見えますが、もしかしたらSPAが本来解決するべき課題であるパフォーマンスの最大化を少し弱めている結果になります。つまり、これはすべてのリンクをクリックする度にAPIを叩き直すことにより既に訪問したことがあるページのAPIも叩き直してしまいます。ただし、これはその都度最新情報を取り出すことにより予約が埋まってしまっていた、という問題を解決していることに繋がります。そのため以下の事項に十分注意しながらパフォーマンスの最適化を図ると良いでしょう。(とても頭をもたげる問題です...。)


  • 正しく初期化処理が走り、正しくページが表示されるか(最重要)

  • 情報の更新頻度が高いページは、都度APIを叩く

  • 情報の更新頻度が低いページ かつ 仕様ユーザのネットワーク回線やマシンパワーが低いと予想される場合にのみ、一度叩かれたAPIは叩かれないように最適化する


予約ページ

ルーティングに関する重要な課題を解決しましたが、まだ終わりではありません。予約ページに関する仕様を考えてみましょう。記事の「仕様」に戻るとわかりますが予約に関わる、「予約ページ」「予約確認ページ」「ありがとうございましたページ」のPathはすべて同じになります。これはルーティングを設けてしまうと、直接ブラウザのUrl欄に予約確認ページなどのUrlを入力されアクセスされてしまうケースを防ぐためです。このようなケースに出くわしても慌てることはありません。ルーティング以前の状態によりviewを切り替える単純な実装をするだけです。

type ReservePageState

= ReserveContentInput
| ReserveContentConfirm
| Reserved

viewはルーティングの分岐とStateによる二段階の分岐となります。ネストが深くなり気になる方は、関数に切り出すと見通しが良くなるでしょう。注意するべきポイントは、今までaタグのhrefだった部分がbuttonタグのonClickになっています。

        ReservePage hotelId planId ->

let
{ hotelName, planName, accommodationFee } =
plan
in
case reservePageState of
ReserveContentInput ->
{ title = planName ++ "のご予約"
, body =
[ headerView user
, a [ href <| UrlBuilder.absolute [ "hotel", String.fromInt hotelId ] [] ] [ text "戻る" ]
, h1 [] [ text <| planName ++ " - " ++ hotelName ]
, section [ class "page-hotel-reserve-content" ]
[ div []
[ input [ type_ "number", value <| String.fromInt numOfPeople, onInput UpdateNumOfPeople ] []
, span [] [ text "人" ]
]
, button [ onClick ToConfirm ] [ text "予約確認へ" ]
]
]
}

ReserveContentConfirm ->
{ title = planName ++ "のご予約の確認"
, body =
[ headerView user
, h1 [] [ text "ご予約の確認" ]
, section [ class "" ]
[ h2 [] [ text <| String.fromInt <| accommodationFee * numOfPeople ]
, button [ onClick ToReserved ] [ text "予約確定" ]
]
]
}

Reserved ->
{ title = "ご予約ありがとうございました。"
, body =
[ headerView user
, h1 [] [ text "ご予約ありがとうございました。" ]
, a [ href "/" ] [ text "トップへ戻る" ]
]
}

特に説明は不要と思われますが、人数の更新とページ遷移のためのStateの更新は以下のようになります。

        UpdateNumOfPeople numOfPeopleText ->

( { model
| numOfPeople =
Maybe.withDefault model.numOfPeople <|
String.toInt numOfPeopleText
}
, Cmd.none
)

ToConfirm ->
( { model | reservePageState = ReserveContentConfirm }, Cmd.none )

ToReserved ->
( { model | reservePageState = Reserved }, Cmd.none )

最後にとても重要なポイントです。予約ページの状態が残ったまま、他の予約ページに遷移すると状態が引き継がれてしまい途中の画面から始まったり、人数が古い予約のまま勝手に確定されてバグの原因となります。そのためプラン一覧から予約に遷移する時はhrefではなく、Msgを発行してからNav.loadを利用して強制的にページロードをさせています。そうすることでModelが初期化され 正しくフォームが最初から表示されます。もちろん手動で初期値をセットしたりHistoryを自分で積むことでModelを保持し続けることができますが、初期化ロジックが各地に点在することになりバグの温床にならないためにもパフォーマンスの最適化は最終手段として、まずは正しい実装を心がけましょう。

button [ onClick <| GoToReserve hotelId planId ] [ text "予約する" ]

GoToReserve hotelId planId ->
let
url =
model.url

reserveURLPath =
UrlBuilder.absolute [ "hotel", String.fromInt hotelId, "plan", String.fromInt planId, "reserve" ] []
in
( model, Nav.load reserveURLPath )


残る課題

今回は解説しませんが、いくつか課題が存在します。例えば実際のHTTP通信が発生する場合は、ローディングが完了するまでスピナーを回す。これは各ページにisTopPageLoadedのようなフラグをもたせ、APIが叩き終えたタイミングでTrueにしてスピナーを消す。などの工夫をすると良いでしょう。他にもフォームから離脱しようとしたときにアラートを出す(portsを利用します)など、ユーザのUXを考えて出来ることはたくさんあります。SPAをするというのは今までのサーバサイドレンダリングのみでやっていたことよりも非常に高度なことなのです。冒頭に載せた記事にもあるように、本当にSPAでやるべきかどうかを熟考してから、今回の記事の知識を行かして実践してみてください。

また口酸っぱく言ってるのですが、SPAをすることとrtfeldmanによるelm-spa-exampleにあるようなモジュール分割をすることは全く別の話になります。これらを同時に行うと確実に破綻するので、是非分けて考えてみてください。ちなみに私はモジュール分割を一度もしたことがありません


まとめ

改めて、SPAの実践がどれほど難しいか感じていただけたでしょうか。正直、私自身もここまで本格的なSPAを始めたのはここ最近です。そのためベストプラクティスが定まっていません。

また、elmのルーティング周りの実装はSPA自体が複雑なことを見積もっても、「楽」とは絶対言えません。仕組み自体はシンプルではありますがHistory API周りは、elmの強みであるてスタビリティの恩恵を受けにくい場所です。そのため、正直 SPAのやりやすさに関しては、別のフレームワークの方が分があるように思えます。しかし、バージョン0.19で大きく改善された箇所であり、これからも改善され続ける見込みがあることと、複雑なビジネスロジックや非同期処理のやりやすさなどを総合的に見ると全然引けを取らないと思います。elmのパワーで複雑なフロントエンド時代を乗り越えていきましょう!それでは、素敵なelmライフを!