一般的に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型を受け取るようにすることで、ルーティングの組み合わせが型から判別できる。文字列だとなんでも指定できる分、使う側からすると前提がなくて難しい。パスにスラッシュがつくのかどうか、なども気にしてなくてはいけなくなる。
-- Internals 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 -- model 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で使うものと近い。
-- update 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
関数とほぼ同じであることがわかる。
-- port subscriptions : Model -> Sub Msg subscriptions _ = onUrlChanged URLChanged port onUrlChanged : (Decode.Value -> msg) -> Sub msg
画面遷移のリンクはRouteモジュールで定義したlink
関数で作ることができる。
-- view 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を都度計算するというひとつの関数のように振る舞う。こういう分かりが生じてくるのが、関数型プログラミングのおもしろさだ。
*1:Browserモジュールが提供している初期化関数の種類に関してはJinjorさんのQiitaの記事が詳しい https://qiita.com/jinjor/items/245959d2da710eda18fa