Runner in the High

技術のことをかくこころみ

ElmでBrowser.elementを使いつつルーティングを自前で作る

一般的にElmでルーティングを行うSPAを作る場合にはBrowser.applicationを使って、組み込みのルーティングの機構を使うことになる*1。しかし、一方でルーティングの仕組みを持たないBrower.elementBrowser.documentでも、ルーティングをJavaScriptサイドで自前実装する方法がある。

elementを使いつつルーティングを自作したいユースケースとして、ReactやVue.jsと統合してElmを使いたいケースが挙げられる。applicationやdocumentを使うと特定のDOMのみにElmアプリケーションをマウントすることができないため、他のフレームワークと共存させることができない。

なお、elm/browserリポジトリにも「Browser.elementでルーティングをするにはどうすればよいか」を説明した詳しいドキュメントがある。 github.com

実装してみる

さて、ではどんな感じでルーティング部分を自作するか。この記事では楽をするべくルーティングの実装にnanorouterhistoryを使う。

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)

内部関数であるonClicklink関数の中で使っている。これはaタグで画面遷移の発火をブロックするためのカスタムのonClickイベントである。これがないとブラウザのロードが走ってしまう。

stringToRouterouteToStringは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