Elm

ElmのSPAとRouting

 ElmでSPA(Single Page Application)を行うときは、NavigationとUrlParserが使われます。今回はこれを試してみたいと思います。最後にサンプルプログラムの画面キャプチャーを載せておきましたので、予め一瞥しておくと理解の助けになるかもしれません。

1.まずはUrlParser

 まずUrlParserをみましょう。UrlParserは /blog/42/cat-herding-techniques のようなURL(path)をElmの値に変換するものです。
http://package.elm-lang.org/packages/evancz/url-parser/latest/UrlParser

type Parser a b

 UrlParserの古いドキュメントには、この定義の型aと型bの理解は難しいので、使い方を覚えることから始めるのが良いとあります。Parserの入力の型や出力の型に依存しているようですが。これ以降はこのガイドに従い深入りしないでおきましょう。これから述べる内容は古いドキュメントでなく、上の新しいドキュメントに沿ってみていきます。
http://package.elm-lang.org/packages/evancz/url-parser/1.0.0/UrlParser

 以下のparsePath関数はUrlParserで提供されているものです。location.pathname と location.search のパースを行い、 location.hash は扱わないとされています。与えられたParserでLocationをパースし、結果をMaybe aで返します。

parsePathの型
parsePath : Parser (a -> a) a -> Location -> Maybe a

 念のためLocationの定義を以下に挙げます。これはNavigationで定義されているもので、UrlParserは主にLocationをパースの対象にします。

Navigation.Locationの型定義
type alias Location = 
    { href : String
    , host : String
    , hostname : String
    , protocol : String
    , origin : String
    , port_ : String
    , pathname : String
    , search : String
    , hash : String
    , username : String
    , password : String
    }

 以下に、ドキュメントに沿ってParserを見ていきます。

 stringは与えられたpathをStringとしてパースします。

string
string : Parser (String -> a) a

parsePath string location
-- /alice/  ==>  Just "alice"
-- /bob     ==>  Just "bob"
-- /42/     ==>  Just "42"

 intは与えられたpathをIntとしてパースします。

int
int : Parser (Int -> a) a

parsePath int location
-- /alice/  ==>  Nothing
-- /bob     ==>  Nothing
-- /42/     ==>  Just 42

 sは与えられたpathをパースして、引数のString文字列にマッチするかをみます。厳密的詳細には踏み込まないで、以下のルールをそのまま受け入れておきたいと思います。

(2018/0405追記)個人的なsの使い方ルール
sは単独で使わない。
parsePath (s "modules") location
-- /mpdules/  ==>  ?不明です

sは</>で他のParserと連結して使ったり、単独で使う場合は以下のようにmapと一緒に使う

map Modules (s "modules") location 
-- /mpdules/  ==>  Just Modules
s
s : String -> Parser a a

s "modules"  -- can parse /modules/
          -- but not /glob/ or /42/ or anything else

 以下の(</>)はParserを連結します。

(</>)
(</>) : Parser a b -> Parser b c -> Parser a c

parsePath (s "blog" </> int) location
-- /blog/35/  ==>  Just 35
-- /blog/42   ==>  Just 42
-- /blog/     ==>  Nothing
-- /42/       ==>  Nothing

parsePath (s "search" </> string) location
-- /search/cats/  ==>  Just "cats"
-- /search/frog   ==>  Just "frog"
-- /search/       ==>  Nothing
-- /cats/         ==>  Nothing

 oneOfは、locationが与えられたときに、parserのリストの中からマッチするparserを選んで適用するものです。つまるところSPAのRoutingを定義するためには、以下の使い方を知っておけばいい気がします。

oneOf
oneOf : List (Parser a b) -> Parser a b

type Route
  = Search String
  | Blog Int
  | User String
  | Comment String Int
  | Modules   -- 2018/04/05追加

route : Parser (Route -> a) a
route =
  oneOf
    [ map Search  (s "search" </> string)
    , map Blog    (s "blog" </> int)
    , map User    (s "user" </> string)
    , map Comment (s "user" </> string </> "comments" </> int)
    , map Modules (s "modules")   -- 2018/04/05追加
    ]

parsePath route location
-- /search/cats           ==>  Just (Search "cats")
-- /search/               ==>  Nothing

-- /blog/42               ==>  Just (Blog 42)
-- /blog/cats             ==>  Nothing

-- /user/sam/             ==>  Just (User "sam")
-- /user/bob/comments/42  ==>  Just (Comment "bob" 42)
-- /user/tom/comments/35  ==>  Just (Comment "tom" 35)
-- /user/                 ==>  Nothing
-- /modules/              ==>  Just Modules   -- 2018/04/05追加

 ちなみに、parsePath関数の型とroute関数の型を使い合わせると、( parsePath route location )の型は必然的にMaybe Routeに決まります。

 topはpath文字列をまったく消費しません。一般的にParserは与えられた文字列をパース(消費)し、残った文字列を後続のParserに渡します。

top
top : Parser a a

type BlogRoute = Overview | Post Int

blogRoute : Parser (BlogRoute -> a) a
blogRoute =
  oneOf
    [ map Overview top
    , map Post  (s "post" </> int)
    ]

parsePath (s "blog" </> blogRoute) location
-- /blog/         ==>  Just Overview
-- /blog/post/42  ==>  Just (Post 42)

 最後にstringParamは query parameterをパースするものです。

stringParam
stringParam : String -> QueryParser (Maybe String -> a) a

parsePath (s "blog" <?> stringParam "search") location
-- /blog/              ==>  Just (Overview Nothing)
-- /blog/?search=cats  ==>  Just (Overview (Just "cats"))

2.Navigationとサンプルプログラム

 NavigationはブラウザのアドレスバーのURLを変更するためのものです。この変更によってサーバへのリクエストは発行されません。代わりにMsgが発行され、プログラム内のupdate関数で処理を書くことができるようになります。サンプルコードを示してから説明していきます。サンプルは以下のサイトのものを修正して使います。
https://github.com/evancz/url-parser/tree/master/examples

Main.elm
import Html exposing (..)
import Html.Attributes exposing (href)
import Html.Events exposing (onClick)
import Http
import Navigation
import UrlParser as Url exposing ((</>), (<?>), s, int, stringParam, top)


main =
  Navigation.program UrlChange
    { init = init
    , view = view
    , update = update
    , subscriptions = subscriptions
    }



-- MODEL
type alias Model =
  { page : Maybe Route
  }


init : Navigation.Location -> ( Model, Cmd Msg )
init location =
  ( Model (Url.parsePath route location)
  , Cmd.none
  )



-- URL PARSING
type Route
  = Home
  | BlogList (Maybe String)
  | BlogPost Int
  | Modules   -- 2018/04/05追加


route : Url.Parser (Route -> a) a
route =
  Url.oneOf
    [ Url.map Home top
    , Url.map BlogList (Url.s "blog" <?> stringParam "search")
    , Url.map BlogPost (Url.s "blog" </> int)
    , Url.map Modules (Url.s "modules")   -- 2018/04/05追加
    ]



-- UPDATE
type Msg
  = NewUrl String
  | UrlChange Navigation.Location


update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
  case msg of
    NewUrl url ->
      ( model
       ,Navigation.newUrl url
      )

    UrlChange location ->
      let  -- Bebug printのためのブロック
        _ = Debug.log "location=" location
        page0 = Url.parsePath route location
        _ = Debug.log "page=" page0
      in
      ( { model | page = Url.parsePath route location }
      , Cmd.none
      )



-- SUBSCRIPTIONS
subscriptions : Model -> Sub Msg
subscriptions model =
  Sub.none



-- VIEW
view : Model -> Html Msg
view model =
  div []
    [ h1 [] [ text "Links" ]
   -- 2018/04/05追加 "/modules/"
    , ul [] (List.map viewLink [ "/", "/modules/", "/blog/", "/blog/42", "/blog/37", "/blog/?search=cats" ])
    , ul [] (List.map viewLink2 [ "/", "/blog/", "/blog/42", "/blog/37", "/blog/?search=cats" ])
    , h1 [] [ text "各ページの画面です" ]
    , div [] [ viewRoute model.page ]
    ]


viewLink : String -> Html Msg
viewLink url =
  li [] [ button [ onClick (NewUrl url) ] [ text url ] ]

viewLink2 : String -> Html Msg
viewLink2 url =
  li [] [ a [ href ("#"++url) ] [ text url ] ]



viewRoute : Maybe Route -> Html msg
viewRoute maybeRoute =
  case maybeRoute of
    Nothing ->
      h2 [] [ text "404 Page Not Found!"]

    Just route ->
      viewPage route


viewPage : Route -> Html msg
viewPage route =
  case route of
    Home ->
      div []
        [ h2 [] [text "Welcomw to My Page!"]
         ,p [] [ text "これはテストページのトップです" ]
        ]

    BlogList Nothing ->
      div []
        [ h2 [] [text "ブログ一覧"]
         ,p [] [ text "これはブログの一覧ページです" ]
        ]

    BlogList (Just search) ->
      div []
        [ h2 [] [text "ブログ検索結果"]
         ,p [] [ text ("これはブログの検索結果("++ Http.encodeUri search ++")ページです") ]
        ]

    BlogPost id ->
      div []
        [ h2 [] [text "ブログ記事表示"]
         ,p [] [ text ("これはブログの記事("++ toString id ++")を表示します") ]
        ]

    Main ->
      div []
        [ h2 [] [text "初期画面"]
         ,p [] [ text "これはプログラムがロードされた初期画面です。" ]
        ]

    Modules ->   -- 2018/04/05追加
      div []
        [ h2 [] [text "Modules"]
         ,p [] [ text "これはプログラムがロードされModulesです。" ]
        ]

 まず以下のmain関数で Navigation.program UrlChange とすることにより、ブラウザのアドレスバーの変更時に、UrlChange Msgが発生することになります。UrlChange Msgはlocation messagesと呼ばれ、urlが変更される度に呼ばれます。これはbackやnewUrlなどのNavigationライブラリでurlを変更した時も発生します。肝の部分なので覚えておいてください。

main
main =
  Navigation.program UrlChange
    { init = init
    , view = view
    , update = update
    , subscriptions = subscriptions
    }

 Modelは以下のように現在のRouteを入れておくだけです。

Model
type alias Model =
  { page : Maybe Route
  }

 init関数を見ましょう。UrlParserの説明で述べたように(Url.parsePath route location)はMaybe Routeであることに注目してください。私の確認環境ですとlocationの初期値はpathname=Main.elmが入りますので、後でRouteの定義にこれを含めておきます。

init
init : Navigation.Location -> ( Model, Cmd Msg )
init location =
  ( Model (Url.parsePath route location)
  , Cmd.none
  )

 型Routeとroute関数の定義です。以下の定義からわかるように、pathがMain.elmの場合にMainを使っています。

route関数
type Route
  = Home
  | BlogList (Maybe String)
  | BlogPost Int
  | Main
  | Modules  -- 2018/04/05追加

route : Url.Parser (Route -> a) a
route =
  Url.oneOf
    [ Url.map Home top
    , Url.map BlogList (Url.s "blog" <?> stringParam "search")
    , Url.map BlogPost (Url.s "blog" </> int)
    , Url.map Main (Url.s "Main.elm")
    , Url.map Modules (Url.s "modules")   -- 2018/04/05追加

    ]

 ここで行っていることは、URLのpath文字列をパースして対応するRoute型の値に変換するルールを記述しています。直感的に理解できることが本質です。routeのParserをキックするのはinit関数と以下のupdate関数です。

update関数
type Msg
  = NewUrl String
  | UrlChange Navigation.Location


update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
  case msg of
    NewUrl url ->
      ( model
       ,Navigation.newUrl url
      )

    UrlChange location ->
      let
        _ = Debug.log "location=" location -- debug print to console
      in
      ( { model | page = Url.parsePath route location }
      , Cmd.none
      )

 Navigation.newUrlの型宣言は以下のようです。これはブラウザのアドレスバーを変更(historyにも追加)します。その結果、上で述べたようにUrlChangeというlocation messageを発生します。

newUrl
Navigation.newUrl : String -> Cmd msg

 ここで全体的な筋書きを述べますと、こうなります。リンクボタンをクリックするとMsgが発生してUpdate関数が呼ばれ、Msg含まれているpathをパースして得られたRoute値をmodelにセットします。modelが更新されたことによりview関数が呼ばれ再描画されます。

view関数
view : Model -> Html Msg
view model =
  div []
    [ h1 [] [ text "Links" ]
    , ul [] (List.map viewLink [ "/", "/blog/", "/blog/42", "/blog/37", "/blog/?search=cats" ])
    , ul [] (List.map viewLink2 [ "/", "/blog/", "/blog/42", "/blog/37", "/blog/?search=cats" ])
    , h1 [] [ text "各ページの画面です" ]
    , div [] [ viewRoute model.page ]
    ]


viewLink : String -> Html Msg
viewLink url =
  li [] [ button [ onClick (NewUrl url) ] [ text url ] ]

viewLink2 : String -> Html Msg
viewLink2 url =
  li [] [ a [ href ("#"++url) ] [ text url ] ]

 ここで説明したいのは、ボタンとaタグのリンクについてです。viewLink urlではボタンを描画します。このボタンをクリックするとMsgのNewUrl urlが発生します。NewUrl urlを受け取ったupdateではNavigation.newUrl urlでアドレスバーを変更しますので、結果的にUrlChange location が発生します。アドレスバーに"#"は含まれません。

 viewLink2 urlではaリンクを描画します。このリンクは"#"を含むハッシュになっていて、クリックするとUrlChange location が発生します。これはクリックによりアドレスバーが変更されるからです。アドレスバーに"#"が含まれますが(ハッシュ)、そのおかげで404エラーは出ません。

 結論的にボタンバージョンのリンクを使うことで、"#"を含まないキレイなアドレスのSPAを実現できることになります。ハッシュ付きのaリンクはどのような使い道があるのだろう?

 以下のviewRoute関数は、Parserのrouteで作られたMaybe Routeで場合分けし、それぞれのページのコンテンツを表示します。ちょっと長い気がしますが、中身は薄いです。

viewRoute
viewRoute : Maybe Route -> Html msg
viewRoute maybeRoute =
  case maybeRoute of
    Nothing ->
      h2 [] [ text "404 Page Not Found!"]

    Just route ->
      viewPage route


viewPage : Route -> Html msg
viewPage route =
  case route of
    Home ->
      div []
        [ h2 [] [text "Welcomw to My Page!"]
         ,p [] [ text "これはテストページのトップです" ]
        ]

    BlogList Nothing ->
      div []
        [ h2 [] [text "ブログ一覧"]
         ,p [] [ text "これはブログの一覧ページです" ]
        ]

    BlogList (Just search) ->
      div []
        [ h2 [] [text "ブログ検索結果"]
         ,p [] [ text ("これはブログの検索結果("++ Http.encodeUri search ++")ページです") ]
        ]

    BlogPost id ->
      div []
        [ h2 [] [text "ブログ記事表示"]
         ,p [] [ text ("これはブログの記事("++ toString id ++")を表示します") ]
        ]

    Main ->
      div []
        [ h2 [] [text "初期画面"]
         ,p [] [ text "これはプログラムがロードされた初期画面です。" ]

    Modules ->  -- 2018/04/05追加
      div []
        [ h2 [] [text "Modules"]
         ,p [] [ text "これはModulesです。" ]
        ]

3.アプリ画面遷移、アドレスバーとlocation

 ここでは今までのプログラムの説明の確認として、それぞれのボタンをクリックしたときの画面キャプチャーを示します。またその時のブラウザのアドレスバーの表示と、locationのデバッグプリントの値を示します。

初期画面

ブラウザのアドレスバー
http://www.mypress.jp:3030/Main.elm

image.png

"/" ボタン

ブラウザのアドレスバーとlocation
http://www.mypress.jp:3030/

location=: { 
    href = "http://www.mypress.jp:3030/", 
    host = "www.mypress.jp:3030", 
    hostname = "www.mypress.jp", 
    protocol = "http:", 
    origin = "http://www.mypress.jp:3030", 
    port_ = "3030", 
    pathname = "/", 
    search = "", 
    hash = "", 
    username = <internal structure>, 
    password = <internal structure> }

image.png

"/blog/" ボタン

ブラウザのアドレスバーとlocation
http://www.mypress.jp:3030/blog/

location=: { 
    href = "http://www.mypress.jp:3030/blog/", 
    host = "www.mypress.jp:3030", 
    hostname = "www.mypress.jp", 
    protocol = "http:", 
    origin = "http://www.mypress.jp:3030", 
    port_ = "3030", 
    pathname = "/blog/", 
    search = "", 
    hash = "", 
    username = <internal structure>, 
    password = <internal structure> }

image.png

"/blog/42/" ボタン

ブラウザのアドレスバーとlocation
http://www.mypress.jp:3030/blog/42

location=: { 
    href = "http://www.mypress.jp:3030/blog/42", 
    host = "www.mypress.jp:3030", 
    hostname = "www.mypress.jp", 
    protocol = "http:", 
    origin = "http://www.mypress.jp:3030", 
    port_ = "3030", 
    pathname = "/blog/42", 
    search = "", 
    hash = "", 
    username = <internal structure>, 
    password = <internal structure> }

image.png

"/blog/?search=cats" ボタン

ブラウザのアドレスバーとlocation
http://www.mypress.jp:3030/blog/?search=cats

location=: { 
    href = "http://www.mypress.jp:3030/blog/?search=cats", 
    host = "www.mypress.jp:3030", 
    hostname = "www.mypress.jp", 
    protocol = "http:", 
    origin = "http://www.mypress.jp:3030", 
    port_ = "3030", 
    pathname = "/blog/", 
    search = "?search=cats", 
    hash = "", 
    username = <internal structure>, 
    password = <internal structure> }

image.png

 Reactでプログラムの全体を書き、RoutingもReactで書き、個々のComponentだけElmで書いた方がすっきりするかな~、とぼんやり思ってしまう。