一般的にElmでルーティングを行うSPAを作る場合にはBrowser.application
を使って、組み込みのルーティングの機構を使うことになる*1。しかし、一方でルーティングの仕組みを持たないBrower.element
やBrowser.document
でも、ルーティングをJavaScriptサイドで自前実装する方法がある。
elementを使いつつルーティングを自作したいユースケースとして、ReactやVue.jsと統合してElmを使いたいケースが挙げられる。applicationやdocumentを使うと特定のDOMのみにElmアプリケーションをマウントすることができないため、他のフレームワークと共存させることができない。
なお、elm/browser
のリポジトリにも「Browser.elementでルーティングをするにはどうすればよいか」を説明した詳しいドキュメントがある。
github.com
実装してみる
さて、ではどんな感じでルーティング部分を自作するか。この記事では楽をするべくルーティングの実装にnanorouterとhistoryを使う。
nanorouterはURLのマッチング機構だけをもつ小さなルーティングモジュールである。これを使えばURLのパスやパラメタのパーサをわざわざ正規表現で作る必要がなくなる。また、イベントエミッタ的なインタフェースをしているのでElmのPortがもつPub/Sub的な雰囲気との相性がよい。
historyは言わずとしれたHistory APIの抽象化ライブラリ。これを使っておけばブラウザ間の差異だとかそういうのを気にしなくていい。
ここから紹介する実際のコードは以下のギッハブリポジトリに置いてある。
github.com
index.js
基本的にパスの変更が起きた場合に、JSサイドでは変更をPort経由でElmアプリケーションへ送信したり、Elmアプリケーションから受け取ったパスを用いてhistoryで遷移を実行したりするだけである。
import { Elm } from "./App.elm"
import { createBrowserHistory } from 'history'
const history = createBrowserHistory()
const app = Elm.App.init({
flags: history.location.pathname,
node: document.querySelector('main'),
})
history.listen((location, _) => {
app.ports.onUrlChanged.send(location.pathname)
})
app.ports.replaceInternal.subscribe(path => {
history.push(path)
})
Route.elm
ルーティングに関連するロジックを凝集したのが、このRouteモジュールになる。
port module Route exposing
( Route(..)
, decode
, link
, replace
)
import Html exposing (Html, a, text)
import Html.Attributes exposing (href)
import Html.Events as Events
import Json.Decode as Decode
type Route
= Top
| PageA
| PageB
decode : Decode.Value -> Route
decode value =
value
|> Decode.decodeValue decoder
|> Result.withDefault Top
link : msg -> String -> Html msg
link msg label =
a [ href "#", onClick msg ] [ text label ]
replace : Route -> Cmd msg
replace route =
replaceInternal (routeToString route)
内部関数であるonClick
はlink
関数の中で使っている。これはaタグで画面遷移の発火をブロックするためのカスタムのonClickイベントである。これがないとブラウザのロードが走ってしまう。
stringToRoute
とrouteToString
はRoute型と文字列のパスを突合するためのマッピング関数である。JavaScript側とPortを介して文字列でパス情報がやりとりされるため、このようなマッピングを行う関数が必要になる。
replaceInternal
を公開関数にしていないのは、直接文字列を受け取れてしまうのを避けるためである。あえてreplace
関数でラップして、引数にRoute型を受け取るようにすることで、ルーティングの組み合わせが型から判別できる。文字列だとなんでも指定できる分、使う側からすると前提がなくて難しい。パスにスラッシュがつくのかどうか、なども気にしてなくてはいけなくなる。
onClick : msg -> Html.Attribute msg
onClick msg =
Events.custom "click"
(Decode.succeed
{ message = msg
, stopPropagation = True
, preventDefault = True
}
)
stringToRoute : String -> Decode.Decoder Route
stringToRoute value =
case value of
"/pageA" ->
Decode.succeed PageA
"/pageB" ->
Decode.succeed PageB
_ ->
Decode.succeed Top
routeToString : Route -> String
routeToString route =
case route of
Top ->
"/"
PageA ->
"/pageA"
PageB ->
"/pageB"
decoder : Decode.Decoder Route
decoder =
Decode.string |> Decode.andThen stringToRoute
port replaceInternal : String -> Cmd msg
App.elm
initialModel
関数ではFlagの値をValue型で受け取り、初期のRoute型のデータを計算する。計算されたRoute型のデータをchangeRouteTo
関数に食わせることで、最初のModelが生成される。
基本的には画面遷移は、このModel計算の流れを繰り返すことになる。
port module App exposing (main)
import Browser
import Html exposing (Html, div, text)
import Json.Decode as Decode
import Pages.A as PageA
import Pages.B as PageB
import Route
type Model
= Top
| PageA PageA.Model
| PageB PageB.Model
initialModel : Decode.Value -> ( Model, Cmd Msg )
initialModel flag =
( changeRouteTo (Route.decode flag), Cmd.none )
changeRouteTo : Route.Route -> Model
changeRouteTo route =
case route of
Route.Top ->
Top
Route.PageA ->
PageA PageA.init
Route.PageB ->
PageB PageB.init
Msg型のバリアントふたつはほとんどBrowser.applicationで使うものと近い。
type Msg
= URLChanged Decode.Value
| ChangeURL Route.Route
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
URLChanged nextRoute ->
( changeRouteTo (Route.decode nextRoute), Cmd.none )
ChangeURL route ->
( model, Route.replace route )
上で定義されているURLChanged
はインバウンドなPortの受けメッセージになる。ここはFlagと同じでValue型で受け取る。
受け取ったValue型のデータをデコードしてそこからModel型をつくる流れはinitialModel
関数とほぼ同じであることがわかる。
subscriptions : Model -> Sub Msg
subscriptions _ =
onUrlChanged URLChanged
port onUrlChanged : (Decode.Value -> msg) -> Sub msg
画面遷移のリンクはRouteモジュールで定義したlink
関数で作ることができる。
view : Model -> Html Msg
view model =
case model of
Top ->
div []
[ text "This is Top"
, div [] [ Route.link (ChangeURL Route.PageA) "PageA" ]
, div [] [ Route.link (ChangeURL Route.PageB) "PageB" ]
]
PageA pageModel ->
PageA.view pageModel
PageB pageModel ->
PageB.view pageModel
あえてBrowser.elementを使って自分でElmアプリケーションのルーティングを作ってみると「SPAにおけるルーティングとはなんぞや」というものがすごくクリアに見えてくる。これはReactだろうがVue.jsだろうが同じである。
ルーティングはアプリケーションに対するブラウザからの入力であり、アプリケーションからブラウザに対する出力という副作用である。Elmアプリケーションは、外部から受け取ったデータをもとにModelを都度計算するというひとつの関数のように振る舞う。こういう分かりが生じてくるのが、関数型プログラミングのおもしろさだ。