LoginSignup
18
13

More than 5 years have passed since last update.

ElmのSPAとRouting

Last updated at Posted at 2017-12-31

 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で書いた方がすっきりするかな~、とぼんやり思ってしまう。

18
13
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
18
13