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 : Parser (a -> a) a -> Location -> Maybe a
念のためLocationの定義を以下に挙げます。これはNavigationで定義されているもので、UrlParserは主に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 : Parser (String -> a) a
parsePath string location
-- /alice/ ==> Just "alice"
-- /bob ==> Just "bob"
-- /42/ ==> Just "42"
intは与えられたpathをIntとしてパースします。
int : Parser (Int -> a) a
parsePath int location
-- /alice/ ==> Nothing
-- /bob ==> Nothing
-- /42/ ==> Just 42
sは与えられたpathをパースして、引数のString文字列にマッチするかをみます。厳密的詳細には踏み込まないで、以下のルールをそのまま受け入れておきたいと思います。
sは単独で使わない。
parsePath (s "modules") location
-- /mpdules/ ==> ?不明です
sは</>で他のParserと連結して使ったり、単独で使う場合は以下のようにmapと一緒に使う
map Modules (s "modules") location
-- /mpdules/ ==> Just Modules
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 : 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 : 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 : 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
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 =
Navigation.program UrlChange
{ init = init
, view = view
, update = update
, subscriptions = subscriptions
}
Modelは以下のように現在のRouteを入れておくだけです。
type alias Model =
{ page : Maybe Route
}
init関数を見ましょう。UrlParserの説明で述べたように(Url.parsePath route location)はMaybe Routeであることに注目してください。私の確認環境ですとlocationの初期値はpathname=Main.elmが入りますので、後でRouteの定義にこれを含めておきます。
init : Navigation.Location -> ( Model, Cmd Msg )
init location =
( Model (Url.parsePath route location)
, Cmd.none
)
型Routeとroute関数の定義です。以下の定義からわかるように、pathがMain.elmの場合にMainを使っています。
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関数です。
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を発生します。
Navigation.newUrl : String -> Cmd msg
ここで全体的な筋書きを述べますと、こうなります。リンクボタンをクリックするとMsgが発生してUpdate関数が呼ばれ、Msg含まれているpathをパースして得られたRoute値をmodelにセットします。modelが更新されたことにより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 : 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
"/" ボタン
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> }
"/blog/" ボタン
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> }
"/blog/42/" ボタン
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> }
"/blog/?search=cats" ボタン
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> }
Reactでプログラムの全体を書き、RoutingもReactで書き、個々のComponentだけElmで書いた方がすっきりするかな~、とぼんやり思ってしまう。